core: introduce ConditionFraction= that is true on a certain percantage of the fleet
This commit is contained in:
committed by
Lennart Poettering
parent
ecaac1c0a2
commit
740a5ce947
@@ -1467,6 +1467,38 @@
|
||||
</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>
|
||||
|
||||
@@ -2065,6 +2097,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ typedef enum ConditionType {
|
||||
CONDITION_FIRMWARE,
|
||||
CONDITION_VIRTUALIZATION,
|
||||
CONDITION_HOST,
|
||||
CONDITION_FRACTION,
|
||||
CONDITION_KERNEL_COMMAND_LINE,
|
||||
CONDITION_VERSION,
|
||||
CONDITION_CREDENTIAL,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user