diff --git a/man/systemd.unit.xml b/man/systemd.unit.xml index 2f1467b720..5c3439d193 100644 --- a/man/systemd.unit.xml +++ b/man/systemd.unit.xml @@ -1467,6 +1467,38 @@ + + ConditionFraction= + + ConditionFraction= 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 + machine-id5), so + it requires no central coordination and is stable over time: a given machine always lands on the + same side of the threshold. + + The argument consists of an optional tag followed by a percentage, + separated by whitespace, for example 30% or + myrollout 30%. The percentage may include up to two decimal places (e.g. + 0.5%). The condition is satisfied on approximately the configured percentage of + all machines; 0% matches no machine and 100% matches every + machine. + + The optional tag is an arbitrary string (not containing whitespace) that is mixed into the + derivation, so that independent rollouts select independent subsets of the + fleet. Without it, all untagged ConditionFraction= 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. + + The test may be negated by prepending an exclamation mark, in which case it is satisfied on the + complementary fraction of machines (e.g. !myrollout 30% matches the other ≈70%). + If the machine ID cannot be determined, the condition fails. + + + + + ConditionKernelCommandLine= @@ -2065,6 +2097,7 @@ AssertArchitecture= AssertVirtualization= AssertHost= + AssertFraction= AssertKernelCommandLine= AssertKernelVersion= AssertVersion= diff --git a/src/core/load-fragment-gperf.gperf.in b/src/core/load-fragment-gperf.gperf.in index 0e2d679f97..be074230ce 100644 --- a/src/core/load-fragment-gperf.gperf.in +++ b/src/core/load-fragment-gperf.gperf.in @@ -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) diff --git a/src/shared/condition.c b/src/shared/condition.c index af5093a6a3..d0e41bbf71 100644 --- a/src/shared/condition.c +++ b/src/shared/condition.c @@ -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", diff --git a/src/shared/condition.h b/src/shared/condition.h index d2274522f4..91af2027df 100644 --- a/src/shared/condition.h +++ b/src/shared/condition.h @@ -9,6 +9,7 @@ typedef enum ConditionType { CONDITION_FIRMWARE, CONDITION_VIRTUALIZATION, CONDITION_HOST, + CONDITION_FRACTION, CONDITION_KERNEL_COMMAND_LINE, CONDITION_VERSION, CONDITION_CREDENTIAL, diff --git a/src/test/test-condition.c b/src/test/test-condition.c index 234041d126..bee1014c1c 100644 --- a/src/test/test-condition.c +++ b/src/test/test-condition.c @@ -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;