diff --git a/man/hostnamectl.xml b/man/hostnamectl.xml index c120b938c93..4c1d3cf245e 100644 --- a/man/hostnamectl.xml +++ b/man/hostnamectl.xml @@ -189,8 +189,11 @@ purposes, for example to identify the role a machine plays in a deployment, the fleet or organizational unit it belongs to, or any other administrator-defined attribute. Each individual tag must be 1…255 characters long and consist only of ASCII alphanumeric characters, - - and .. The tags are stored in the TAGS= - field of /etc/machine-info; see + -, . and =. A tag may optionally be + parameterized with a value, in the form + key=value, in which case the + same key may not be assigned more than one distinct value. The tags are stored in the + TAGS= field of /etc/machine-info; see machine-info5 for details. They may also be matched against with the ConditionMachineTag=/AssertMachineTag= unit settings, see diff --git a/man/machine-info.xml b/man/machine-info.xml index 252f341bba6..6b2d92e7e5a 100644 --- a/man/machine-info.xml +++ b/man/machine-info.xml @@ -149,7 +149,22 @@ TAGS=webserver:frontend:berlin. Each individual tag must be 1…255 characters long and may consist only of the ASCII - alphanumeric characters, - and .. + alphanumeric characters, -, . and =. The + first character may not be -, . or =, and + the last character may not be - or . (unless it takes the + parameterized form, see below). + + A tag may optionally be parameterized with a value, in the form + key=value. The first + = separates the key from the value; any further = characters + are part of the value. The key (the part before the first =) follows the same + restrictions as an unparameterized tag, in particular it may not be empty and may not end in + - or .. The value (the part after the first + =) may be empty and is otherwise unrestricted within the allowed character set. + Example: TAGS=role=webserver:env=production:berlin. The same key may not be + assigned more than one distinct value: role=webserver:role=database is refused + (but a key may coexist with the corresponding unparameterized tag, e.g. + role:role=webserver). The configured tags may be matched against with the ConditionMachineTag= and AssertMachineTag= unit settings, see @@ -217,7 +232,7 @@ ICON_NAME=computer-tablet CHASSIS=tablet DEPLOYMENT=production -TAGS=demo:berlin +TAGS=demo:berlin:role=webserver diff --git a/man/systemd.unit.xml b/man/systemd.unit.xml index d032d91f3a0..347fc816d09 100644 --- a/man/systemd.unit.xml +++ b/man/systemd.unit.xml @@ -2083,6 +2083,17 @@ negated by prepending an exclamation mark, in which case it is satisfied if none of the configured tags matches. + Tags may be parameterized with a value in the form + key=value; the + = and the value are part of the tag and thus part of the string the pattern is + matched against. Hence ConditionMachineTag=role=webserver matches the tag + role=webserver exactly, ConditionMachineTag=role=* matches any + value assigned to the role key, and ConditionMachineTag=role + (without =) does not match role=webserver. + See + machine-info5 + for the precise syntax of machine tags. + diff --git a/src/basic/hostname-util.c b/src/basic/hostname-util.c index ff23f078633..9410dd05d23 100644 --- a/src/basic/hostname-util.c +++ b/src/basic/hostname-util.c @@ -261,13 +261,26 @@ bool machine_tag_is_valid(const char *s) { if (n <= 0 || n >= 256) return false; - /* Don't allow "-" and "." as first or last char. (This is load-bearing, we want that "+"/"-" can be - * used as prefix for adding/removing tags from the list). */ - if (strchr("-.", s[0]) || - strchr("-.", s[n-1])) + /* Don't allow "-" and "." as first char. (This is load-bearing, we want that "+"/"-" can be used as + * prefix for adding/removing tags from the list). */ + if (strchr("-.=", s[0])) return false; - return in_charset(s, ALPHANUMERICAL "-."); + /* We allow parameterization of tags, with a "=" as separator */ + const char *eq = strchr(s, '='); + if (eq) { + assert(eq > s); + + /* If there is an '=', then make the same restrictions as for the first char on the last char before it */ + if (strchr("-.", eq[-1])) + return false; + } else { + /* If there's no '=', then make the restriction on the very last character */ + if (strchr("-.", s[n-1])) + return false; + } + + return in_charset(s, ALPHANUMERICAL "-.="); } bool machine_tag_list_is_valid(char **l) { @@ -279,6 +292,23 @@ bool machine_tag_list_is_valid(char **l) { if (!machine_tag_is_valid(*i)) return false; + + const char *eq = strchr(*i, '='); + if (!eq) + continue; + + /* Refuse tags with a common part before the '=', that do no also carry the same value. */ + size_t np = eq - *i + 1; + STRV_FOREACH(j, l) { + if (j == i) + break; + + if (streq(*i, *j)) /* Fully identical is OK */ + continue; + + if (strneq(*i, *j, np)) /* Not identical, but same key: refuse */ + return false; + } } return true; @@ -322,6 +352,21 @@ int machine_tags_from_string(const char *s, bool graceful, char ***ret) { if (n > MACHINE_TAGS_MAX) return -E2BIG; + const char *eq = strchr(*i, '='); + if (eq) { + /* Suppress duplicate assignments */ + bool skip = false; + size_t np = eq - *i + 1; + STRV_FOREACH(j, cleaned) + if (strneq(*i, *j, np)) { + skip = true; + break; + } + + if (skip) + continue; + } + r = strv_extend(&cleaned, *i); if (r < 0) return r; diff --git a/src/basic/string-util.c b/src/basic/string-util.c index cfeb2e3917a..0484a03473b 100644 --- a/src/basic/string-util.c +++ b/src/basic/string-util.c @@ -1003,23 +1003,29 @@ char* strrep(const char *s, size_t n) { int split_pair(const char *s, const char *sep, char **ret_first, char **ret_second) { assert(s); assert(!isempty(sep)); - assert(ret_first); - assert(ret_second); const char *x = strstr(s, sep); if (!x) return -EINVAL; - _cleanup_free_ char *a = strndup(s, x - s); - if (!a) - return -ENOMEM; + _cleanup_free_ char *a = NULL; + if (ret_first) { + a = strndup(s, x - s); + if (!a) + return -ENOMEM; + } - _cleanup_free_ char *b = strdup(x + strlen(sep)); - if (!b) - return -ENOMEM; + _cleanup_free_ char *b = NULL; + if (ret_second) { + b = strdup(x + strlen(sep)); + if (!b) + return -ENOMEM; + } - *ret_first = TAKE_PTR(a); - *ret_second = TAKE_PTR(b); + if (ret_first) + *ret_first = TAKE_PTR(a); + if (ret_second) + *ret_second = TAKE_PTR(b); return 0; } diff --git a/src/test/test-condition.c b/src/test/test-condition.c index 4f450ab12d8..a4c57dc1979 100644 --- a/src/test/test-condition.c +++ b/src/test/test-condition.c @@ -1474,6 +1474,69 @@ TEST(condition_test_machine_tag) { ASSERT_OK_ZERO(condition_test(condition, environ)); condition_free(condition); + /* Key/value (parameterized) tags */ + ASSERT_OK(write_string_file(f, "TAGS=\"role=webserver:env=prod:berlin\"\n", + WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE)); + + /* Exact match of a key/value tag */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role=webserver", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + + /* Glob on the value */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role=web*", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + + /* Glob on the key */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "*=prod", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + + /* A bare key does not match an assignment (the "=" is part of the tag) */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_ZERO(condition_test(condition, environ)); + condition_free(condition); + + /* Right key, wrong value → no match */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role=database", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_ZERO(condition_test(condition, environ)); + condition_free(condition); + + /* A plain (non-parameterized) tag still matches alongside key/value tags */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "berlin", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + + /* Negation against a key/value tag */ + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role=database", /* trigger= */ false, /* negate= */ true))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role=webserver", /* trigger= */ false, /* negate= */ true))); + ASSERT_OK_ZERO(condition_test(condition, environ)); + condition_free(condition); + + /* Conflicting values for the same key: graceful parsing keeps the first (lexicographically smallest) + * value and drops the rest, so only "env=prod" remains visible. */ + ASSERT_OK(write_string_file(f, "TAGS=\"env=staging:env=prod\"\n", + WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE)); + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "env=prod", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "env=staging", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_ZERO(condition_test(condition, environ)); + condition_free(condition); + + /* Invalid key/value tags in the file are ignored, valid ones still match */ + ASSERT_OK(write_string_file(f, "TAGS=\"bad-=x:role=good\"\n", + WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE)); + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "bad-=x", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_ZERO(condition_test(condition, environ)); + condition_free(condition); + ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "role=good", /* trigger= */ false, /* negate= */ false))); + ASSERT_OK_POSITIVE(condition_test(condition, environ)); + condition_free(condition); + ASSERT_OK(set_unset_env("SYSTEMD_ETC_MACHINE_INFO", saved, /* overwrite= */ true)); } diff --git a/src/test/test-hostname-util.c b/src/test/test-hostname-util.c index 8ec4034af7f..513c8edc9b8 100644 --- a/src/test/test-hostname-util.c +++ b/src/test/test-hostname-util.c @@ -124,6 +124,15 @@ TEST(machine_tag_is_valid) { assert_se(machine_tag_is_valid("foo-bar.baz")); assert_se(machine_tag_is_valid("Webserver01")); assert_se(machine_tag_is_valid("a")); + assert_se(machine_tag_is_valid("a=")); /* empty value is OK */ + assert_se(machine_tag_is_valid("a=b")); + assert_se(machine_tag_is_valid("foo.bar=")); + assert_se(machine_tag_is_valid("foo.bar-baz=zuziuziuz")); + assert_se(machine_tag_is_valid("foo=bar.baz")); /* "." and "-" are fine inside a value */ + assert_se(machine_tag_is_valid("foo=bar-")); /* even as the very last char of a value */ + assert_se(machine_tag_is_valid("foo=.bar")); /* and as the very first char of a value */ + assert_se(machine_tag_is_valid("foo=bar=")); /* a value may itself contain a "=" */ + assert_se(machine_tag_is_valid("a=b=c")); /* only the first "=" is the separator */ assert_se(!machine_tag_is_valid(NULL)); assert_se(!machine_tag_is_valid("")); @@ -136,6 +145,15 @@ TEST(machine_tag_is_valid) { assert_se(!machine_tag_is_valid("foo-")); assert_se(!machine_tag_is_valid(".foo")); assert_se(!machine_tag_is_valid("foo.")); + assert_se(!machine_tag_is_valid("=b")); + assert_se(!machine_tag_is_valid("=")); + assert_se(!machine_tag_is_valid(".foo=asd")); /* "." not allowed as first char */ + assert_se(!machine_tag_is_valid("foo.=asd")); /* "." not allowed as last char of key */ + assert_se(!machine_tag_is_valid("foo-=asd")); /* "-" not allowed as last char of key */ + assert_se(!machine_tag_is_valid("_foo=asd")); /* "_" is not in the charset */ + assert_se(!machine_tag_is_valid("foo_=sda")); + assert_se(!machine_tag_is_valid("foo=a_b")); /* ... not even in the value */ + assert_se(!machine_tag_is_valid("foo=a:b")); /* colon is the separator, not allowed in a value */ /* Length boundary: 255 characters is fine, 256 is too long */ _cleanup_free_ char *max = strrep("a", 255), *over = strrep("a", 256); @@ -149,9 +167,18 @@ TEST(machine_tag_list_is_valid) { assert_se(machine_tag_list_is_valid(NULL)); /* empty list is valid */ assert_se(machine_tag_list_is_valid(STRV_MAKE("a"))); assert_se(machine_tag_list_is_valid(STRV_MAKE("foo", "bar", "c-d.e"))); + assert_se(machine_tag_list_is_valid(STRV_MAKE("foo=uuu", "bar=qqqq", "c-d.e"))); + assert_se(machine_tag_list_is_valid(STRV_MAKE("foo=aa", "foo=aa"))); /* same key + same value is OK */ + assert_se(machine_tag_list_is_valid(STRV_MAKE("foo", "foo=aa"))); /* bare key and assignment coexist */ + assert_se(machine_tag_list_is_valid(STRV_MAKE("foo=1", "foobar=2"))); /* one key is a prefix of the other */ + assert_se(machine_tag_list_is_valid(STRV_MAKE("ab=1", "a=2"))); /* ... and the other way around */ assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo", "b:c"))); assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo", ""))); + assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo=aa", "foo=b"))); /* same key, different value */ + assert_se(!machine_tag_list_is_valid(STRV_MAKE("a=1", "b=2", "a=3"))); /* ... also when not adjacent */ + assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo=aa", "bar", "foo=aa", "foo=b"))); + assert_se(!machine_tag_list_is_valid(STRV_MAKE("=aa"))); } TEST(machine_tags_from_string) { @@ -183,6 +210,30 @@ TEST(machine_tags_from_string) { /* Fatal: a single invalid tag fails the whole parse */ ASSERT_ERROR(machine_tags_from_string("foo:in valid:bar", /* graceful= */ false, &l), EINVAL); assert_se(!l); + + /* With assignment */ + ASSERT_OK(machine_tags_from_string("foo=aa:bar=aaa:foo2=x:baz", /* graceful= */ false, &l)); + assert_se(strv_equal(l, STRV_MAKE("bar=aaa", "baz", "foo2=x", "foo=aa"))); + l = strv_free(l); + + /* Graceful: a duplicate key is suppressed, keeping the first (i.e. lexicographically smallest) value */ + ASSERT_OK(machine_tags_from_string("foo=zzz:foo=aaa:foo=mmm", /* graceful= */ true, &l)); + assert_se(strv_equal(l, STRV_MAKE("foo=aaa"))); + l = strv_free(l); + + /* Graceful: a bare key and an assignment for the same name are not considered duplicates */ + ASSERT_OK(machine_tags_from_string("foo:foo=aaa", /* graceful= */ true, &l)); + assert_se(strv_equal(l, STRV_MAKE("foo", "foo=aaa"))); + l = strv_free(l); + + /* Graceful: an invalid value is dropped, conflicting keys that remain are deduplicated */ + ASSERT_OK(machine_tags_from_string("foo=a_b:foo=good:foo=zzz", /* graceful= */ true, &l)); + assert_se(strv_equal(l, STRV_MAKE("foo=good"))); + l = strv_free(l); + + /* Fatal: conflicting values for the same key fail the whole parse */ + ASSERT_ERROR(machine_tags_from_string("foo=a:foo=b", /* graceful= */ false, &l), EINVAL); + assert_se(!l); } DEFINE_TEST_MAIN(LOG_DEBUG); diff --git a/src/test/test-string-util.c b/src/test/test-string-util.c index 648b8fa839d..9e8776399bb 100644 --- a/src/test/test-string-util.c +++ b/src/test/test-string-util.c @@ -640,6 +640,23 @@ TEST(split_pair) { ASSERT_OK(split_pair("===", "==", &a, &b)); ASSERT_STREQ(a, ""); ASSERT_STREQ(b, "="); + a = mfree(a); + b = mfree(b); + + /* The output parameters are optional */ + ASSERT_OK(split_pair("foo=bar", "=", NULL, &b)); + ASSERT_NULL(a); + ASSERT_STREQ(b, "bar"); + b = mfree(b); + ASSERT_OK(split_pair("foo=bar", "=", &a, NULL)); + ASSERT_STREQ(a, "foo"); + ASSERT_NULL(b); + a = mfree(a); + ASSERT_OK(split_pair("foo=bar", "=", NULL, NULL)); + ASSERT_NULL(a); + ASSERT_NULL(b); + /* ... but the separator must still be present */ + ASSERT_ERROR(split_pair("foo", "=", NULL, NULL), EINVAL); } TEST(empty_to_null) {