]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
core: introduce ConditionFraction= that is true on a certain percantage of the fleet
authorLennart Poettering <lennart@amutable.com>
Thu, 21 May 2026 13:32:24 +0000 (15:32 +0200)
committerLennart Poettering <lennart@poettering.net>
Thu, 21 May 2026 16:19:07 +0000 (18:19 +0200)
man/systemd.unit.xml
src/core/load-fragment-gperf.gperf.in
src/shared/condition.c
src/shared/condition.h
src/test/test-condition.c

index 2f1467b720af460f39127e9003fd21a0431ed762..5c3439d193bfe7a82cd0d850630a45abab632212 100644 (file)
           </listitem>
         </varlistentry>
 
+        <varlistentry>
+          <term><varname>ConditionFraction=</varname></term>
+
+          <listitem><para><varname>ConditionFraction=</varname> may be used to enable a unit on a stable,
+          pseudo-random subset of a fleet of machines. It is primarily useful for staged rollouts: the same
+          unit (or drop-in) is distributed to every machine in a fleet, but only the configured fraction of
+          them will actually have it enabled. The decision is derived locally from the machine ID (see
+          <citerefentry><refentrytitle>machine-id</refentrytitle><manvolnum>5</manvolnum></citerefentry>), so
+          it requires no central coordination and is stable over time: a given machine always lands on the
+          same side of the threshold.</para>
+
+          <para>The argument consists of an optional <replaceable>tag</replaceable> followed by a percentage,
+          separated by whitespace, for example <literal>30%</literal> or
+          <literal>myrollout 30%</literal>. The percentage may include up to two decimal places (e.g.
+          <literal>0.5%</literal>). The condition is satisfied on approximately the configured percentage of
+          all machines; <literal>0%</literal> matches no machine and <literal>100%</literal> matches every
+          machine.</para>
+
+          <para>The optional tag is an arbitrary string (not containing whitespace) that is mixed into the
+          derivation, so that independent rollouts select <emphasis>independent</emphasis> subsets of the
+          fleet. Without it, all untagged <varname>ConditionFraction=</varname> checks would select the very
+          same machines (the same machines would always be picked first). Use distinct tags for unrelated
+          rollouts, and a shared tag to deliberately target the same machines across several units.</para>
+
+          <para>The test may be negated by prepending an exclamation mark, in which case it is satisfied on the
+          complementary fraction of machines (e.g. <literal>!myrollout 30%</literal> matches the other ≈70%).
+          If the machine ID cannot be determined, the condition fails.</para>
+
+          <xi:include href="version-info.xml" xpointer="v261"/>
+          </listitem>
+        </varlistentry>
+
         <varlistentry>
           <term><varname>ConditionKernelCommandLine=</varname></term>
 
           <term><varname>AssertArchitecture=</varname></term>
           <term><varname>AssertVirtualization=</varname></term>
           <term><varname>AssertHost=</varname></term>
+          <term><varname>AssertFraction=</varname></term>
           <term><varname>AssertKernelCommandLine=</varname></term>
           <term><varname>AssertKernelVersion=</varname></term>
           <term><varname>AssertVersion=</varname></term>
index 0e2d679f978052191f07d875af4216dc9334fd54..be074230ce5c1222ca9ef49c9c801129617091c9 100644 (file)
@@ -382,6 +382,7 @@ Unit.ConditionArchitecture,                   config_parse_unit_condition_string
 Unit.ConditionFirmware,                       config_parse_unit_condition_string,                 CONDITION_FIRMWARE,                 offsetof(Unit, conditions)
 Unit.ConditionVirtualization,                 config_parse_unit_condition_string,                 CONDITION_VIRTUALIZATION,           offsetof(Unit, conditions)
 Unit.ConditionHost,                           config_parse_unit_condition_string,                 CONDITION_HOST,                     offsetof(Unit, conditions)
+Unit.ConditionFraction,                       config_parse_unit_condition_string,                 CONDITION_FRACTION,                 offsetof(Unit, conditions)
 Unit.ConditionKernelCommandLine,              config_parse_unit_condition_string,                 CONDITION_KERNEL_COMMAND_LINE,      offsetof(Unit, conditions)
 Unit.ConditionKernelVersion,                  config_parse_unit_condition_string,                 CONDITION_VERSION,                  offsetof(Unit, conditions)
 Unit.ConditionVersion,                        config_parse_unit_condition_string,                 CONDITION_VERSION,                  offsetof(Unit, conditions)
@@ -417,6 +418,7 @@ Unit.AssertFirstBoot,                         config_parse_unit_condition_string
 Unit.AssertArchitecture,                      config_parse_unit_condition_string,                 CONDITION_ARCHITECTURE,             offsetof(Unit, asserts)
 Unit.AssertVirtualization,                    config_parse_unit_condition_string,                 CONDITION_VIRTUALIZATION,           offsetof(Unit, asserts)
 Unit.AssertHost,                              config_parse_unit_condition_string,                 CONDITION_HOST,                     offsetof(Unit, asserts)
+Unit.AssertFraction,                          config_parse_unit_condition_string,                 CONDITION_FRACTION,                 offsetof(Unit, asserts)
 Unit.AssertKernelCommandLine,                 config_parse_unit_condition_string,                 CONDITION_KERNEL_COMMAND_LINE,      offsetof(Unit, asserts)
 Unit.AssertKernelVersion,                     config_parse_unit_condition_string,                 CONDITION_VERSION,                  offsetof(Unit, asserts)
 Unit.AssertVersion,                           config_parse_unit_condition_string,                 CONDITION_VERSION,                  offsetof(Unit, asserts)
index af5093a6a3132121b54f95d3619c7032d68d7e57..d0e41bbf716886c95214fb0d85e220cad0e1446a 100644 (file)
@@ -33,6 +33,7 @@
 #include "fileio.h"
 #include "fs-util.h"
 #include "glob-util.h"
+#include "hmac.h"
 #include "hostname-setup.h"
 #include "id128-util.h"
 #include "ima-util.h"
@@ -62,6 +63,7 @@
 #include "tomoyo-util.h"
 #include "tpm2-util.h"
 #include "uid-classification.h"
+#include "unaligned.h"
 #include "user-util.h"
 #include "virt.h"
 
@@ -690,6 +692,82 @@ static int condition_test_host(Condition *c, char **env) {
         return true;
 }
 
+static uint32_t condition_fraction_value(sd_id128_t machine_id, const char *text) {
+        /* Maps (machine ID, text) to a value uniformly distributed over [0, UINT32_MAX], via
+         * HMAC-SHA256 keyed by the machine ID over 'text'. The machine ID keys the hash, so each
+         * machine lands at a stable but unpredictable point; 'text' selects the subset, so that
+         * distinct rollouts (different texts) pick independent subsets of a fleet. */
+        assert(text);
+
+        uint8_t res[SHA256_DIGEST_SIZE];
+        hmac_sha256(&machine_id, sizeof(machine_id), text, strlen(text), res);
+
+        return unaligned_read_le32(res);
+}
+
+static int condition_test_fraction(Condition *c, char **env) {
+        int r;
+
+        assert(c);
+        assert(c->parameter);
+        assert(c->type == CONDITION_FRACTION);
+
+        /* Expected syntax: "[TAG ]PERCENT". The percentage is mandatory; the optional leading tag is used as
+         * a hash salt, so that distinct staged rollouts select independent subsets of a fleet. The condition
+         * is true for approximately PERCENT of all machines — on a given machine when its derived value
+         * falls below the threshold. This is useful for staged rollouts: hand the same unit to a whole fleet
+         * and only a fraction of it will be enabled, with each machine deciding locally and stably (no
+         * coordination required). */
+
+        const char *p = c->parameter;
+        _cleanup_free_ char *first = NULL, *second = NULL;
+
+        r = extract_first_word(&p, &first, /* separators=*/ NULL, /* flags= */ 0);
+        if (r < 0)
+                return log_debug_errno(r, "Failed to parse ConditionFraction=%s: %m", c->parameter);
+        if (r == 0)
+                return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "ConditionFraction= value is empty.");
+
+        r = extract_first_word(&p, &second, /* separators= */ NULL, /* flags= */ 0);
+        if (r < 0)
+                return log_debug_errno(r, "Failed to parse ConditionFraction=%s: %m", c->parameter);
+
+        const char *tag, *percent;
+        if (r == 0) {
+                /* A single field: it's the percentage, with no tag. */
+                tag = NULL;
+                percent = first;
+        } else if (isempty(p)) {
+                /* Two fields: TAG followed by PERCENT. */
+                tag = first;
+                percent = second;
+        } else
+                return log_debug_errno(SYNTHETIC_ERRNO(EINVAL),
+                                       "ConditionFraction=%s has trailing garbage.", c->parameter);
+
+        int permyriad = parse_permyriad(percent);
+        if (permyriad < 0)
+                return log_debug_errno(permyriad, "Failed to parse percentage in ConditionFraction=%s: %m", c->parameter);
+        if (permyriad == 0)
+                return false;             /* 0% → matches no machine */
+        if (permyriad >= 10000)
+                return true;              /* 100% → matches every machine */
+
+        /* Implicitly prefix the tag with a fixed namespace string, so that an absent or empty tag
+         * still yields a well-defined, non-trivial value (never the HMAC of the empty string), and
+         * so this value space is domain-separated from other uses of the machine ID. */
+        _cleanup_free_ char *text = strjoin("systemd-fraction-", strempty(tag));
+        if (!text)
+                return log_oom_debug();
+
+        sd_id128_t mid;
+        r = sd_id128_get_machine(&mid);
+        if (r < 0)
+                return log_debug_errno(r, "Failed to get machine ID for ConditionFraction=: %m");
+
+        return condition_fraction_value(mid, text) < UINT32_SCALE_FROM_PERMYRIAD(permyriad);
+}
+
 static int condition_test_ac_power(Condition *c, char **env) {
         int r;
 
@@ -1252,6 +1330,7 @@ int condition_test(Condition *c, char **env) {
                 [CONDITION_SECURITY]                 = condition_test_security,
                 [CONDITION_CAPABILITY]               = condition_test_capability,
                 [CONDITION_HOST]                     = condition_test_host,
+                [CONDITION_FRACTION]                 = condition_test_fraction,
                 [CONDITION_AC_POWER]                 = condition_test_ac_power,
                 [CONDITION_ARCHITECTURE]             = condition_test_architecture,
                 [CONDITION_FIRMWARE]                 = condition_test_firmware,
@@ -1364,6 +1443,7 @@ static const char* const _condition_type_table[_CONDITION_TYPE_MAX] = {
         [CONDITION_FIRMWARE]                 = "ConditionFirmware",
         [CONDITION_VIRTUALIZATION]           = "ConditionVirtualization",
         [CONDITION_HOST]                     = "ConditionHost",
+        [CONDITION_FRACTION]                 = "ConditionFraction",
         [CONDITION_KERNEL_COMMAND_LINE]      = "ConditionKernelCommandLine",
         [CONDITION_VERSION]                  = "ConditionVersion",
         [CONDITION_CREDENTIAL]               = "ConditionCredential",
@@ -1420,6 +1500,7 @@ static const char* const _assert_type_table[_CONDITION_TYPE_MAX] = {
         [CONDITION_FIRMWARE]                 = "AssertFirmware",
         [CONDITION_VIRTUALIZATION]           = "AssertVirtualization",
         [CONDITION_HOST]                     = "AssertHost",
+        [CONDITION_FRACTION]                 = "AssertFraction",
         [CONDITION_KERNEL_COMMAND_LINE]      = "AssertKernelCommandLine",
         [CONDITION_VERSION]                  = "AssertVersion",
         [CONDITION_CREDENTIAL]               = "AssertCredential",
index d2274522f4f3ba098eacdf589258f751d32d1e52..91af2027df6159baf539cd3b213f369147a4d2d2 100644 (file)
@@ -9,6 +9,7 @@ typedef enum ConditionType {
         CONDITION_FIRMWARE,
         CONDITION_VIRTUALIZATION,
         CONDITION_HOST,
+        CONDITION_FRACTION,
         CONDITION_KERNEL_COMMAND_LINE,
         CONDITION_VERSION,
         CONDITION_CREDENTIAL,
index 234041d1268a13b4515ae5b806adf48e6d85e010..bee1014c1cbf2d44f212fdbef44a2683e4ed5348 100644 (file)
@@ -34,6 +34,7 @@
 #include "rm-rf.h"
 #include "selinux-util.h"
 #include "smack-util.h"
+#include "stdio-util.h"
 #include "string-util.h"
 #include "strv.h"
 #include "tests.h"
@@ -243,6 +244,85 @@ TEST(condition_test_host) {
         condition_free(condition);
 }
 
+TEST(condition_test_fraction) {
+        Condition *condition;
+        int r;
+
+        /* The 0%/100% boundaries are deterministic and short-circuit before the machine ID is even
+         * read, so these run everywhere. */
+        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, "0%", /* trigger= */ false, /* negate= */ false)));
+        ASSERT_OK_ZERO(condition_test(condition, environ));
+        condition_free(condition);
+
+        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, "100%", /* trigger= */ false, /* negate= */ false)));
+        ASSERT_OK_POSITIVE(condition_test(condition, environ));
+        condition_free(condition);
+
+        /* A tag does not change the boundary behaviour. */
+        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, "sometag 0%", /* trigger= */ false, /* negate= */ false)));
+        ASSERT_OK_ZERO(condition_test(condition, environ));
+        condition_free(condition);
+
+        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, "sometag 100%", /* trigger= */ false, /* negate= */ false)));
+        ASSERT_OK_POSITIVE(condition_test(condition, environ));
+        condition_free(condition);
+
+        /* Negation flips the boundaries. */
+        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, "100%", /* trigger= */ false, /* negate= */ true)));
+        ASSERT_OK_ZERO(condition_test(condition, environ));
+        condition_free(condition);
+
+        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, "0%", /* trigger= */ false, /* negate= */ true)));
+        ASSERT_OK_POSITIVE(condition_test(condition, environ));
+        condition_free(condition);
+
+        /* Malformed values must propagate an error rather than silently passing or failing. */
+        FOREACH_STRING(bad,
+                       "",                  /* empty */
+                       "abc",               /* not a number */
+                       "30",                /* missing percent sign */
+                       "30 %",              /* percent token is just '%' */
+                       "150%",              /* out of range */
+                       "tag 30% extra",     /* trailing garbage */
+                       "a b 30%") {         /* unquoted multi-word tag → trailing garbage */
+                ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, bad, /* trigger= */ false, /* negate= */ false)));
+                ASSERT_FAIL(condition_test(condition, environ));
+                condition_free(condition);
+        }
+
+        sd_id128_t id;
+        r = sd_id128_get_machine(&id);
+        if (ERRNO_IS_NEG_MACHINE_ID_UNSET(r))
+                return (void) log_tests_skipped("/etc/machine-id missing");
+        ASSERT_OK(r);
+
+        /* Distribution check: for a fixed machine ID, varying the tag spreads results uniformly, so
+         * about PERCENT of the tags match. This is the statistical dual of the production scenario,
+         * where the tag is fixed and the machine ID varies across a fleet, and it also pins down the
+         * direction of the comparison (a higher percentage matches more, not fewer). The result is
+         * deterministic for a given machine ID, and the slack is many standard deviations wide, so
+         * this does not flake. */
+        static const unsigned percentages[] = { 1, 20, 50, 80 };
+        FOREACH_ELEMENT(pct, percentages) {
+                const unsigned n = 10000, slack = 3 * n / 100;  /* ±3 percentage points */
+                unsigned match = 0;
+
+                for (unsigned i = 0; i < n; i++) {
+                        char param[64];
+                        xsprintf(param, "tag-%u %u%%", i, *pct);
+
+                        ASSERT_NOT_NULL((condition = condition_new(CONDITION_FRACTION, param, /* trigger= */ false, /* negate= */ false)));
+                        r = ASSERT_OK(condition_test(condition, environ));
+                        match += r > 0;
+                        condition_free(condition);
+                }
+
+                unsigned expected = *pct * n / 100;
+                ASSERT_TRUE(match + slack >= expected);
+                ASSERT_TRUE(expected + slack >= match);
+        }
+}
+
 TEST(condition_test_architecture) {
         Condition *condition;
         const char *sa;