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) {