diff --git a/TODO.md b/TODO.md
index 5c8f92ac42..6e7cc16112 100644
--- a/TODO.md
+++ b/TODO.md
@@ -159,8 +159,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later
- sysupdate: add concept for enabling/disabling specific components explicitly,
just like features.
-- machine-info: add a TAGS concept that can be used to categorize a machine
-
- udev: add a MACHINE_TAGS field, that augments /etc/machine-info configured
tags.
@@ -180,13 +178,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later
- pcrextend: we should measure something when we enter developer mode, by some
definition of developer mode.
-- /etc/machine-info should have a concept of a "role" that we can put a machine
- into, which can be consumed by sysupdate and similar. A role should be
- something we can set once (i.e. the initial setting should be protected by
- polkit and be somewhat loosely access control, and later settings should use a
- different/tougher polkit authorization, so that people can implement a
- no-way-back mechanism)
-
- firstboot: optionally accept credentials at firstboot without authentication
- firstboot/sysinstall: add simple interface for prompting users to enable
@@ -788,11 +779,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later
- sysext: make systemd-{sys,conf}ext-sysroot.service work in the split `/var`
configuration.
-- introduce a concept of /etc/machine-info "TAGS=" field that allows tagging
- machines with zero, one or more roles, states or other forms of
- categorization. Then, add a way of using this in sysupdate to automatically
- enable certain transfers, one for each role.
-
- sd-varlink: add fully async modes of the protocol upgrade stuff
- repart: maybe remove iso9660/eltorito superblock from disk when booting via
diff --git a/man/hostnamectl.xml b/man/hostnamectl.xml
index 8ac18349c6..878837bee1 100644
--- a/man/hostnamectl.xml
+++ b/man/hostnamectl.xml
@@ -165,6 +165,32 @@
+
+
+ tags [TAG…]
+
+ If no argument is given, print the machine tags currently configured, as a
+ colon-separated list. If one or more TAG arguments are provided then the
+ command replaces the configured tags with the specified ones. Each argument may itself be a
+ colon-separated list of tags, so that the tags may be specified either as multiple arguments or as a
+ single colon-separated argument, or any combination thereof. Duplicate tags are removed and the
+ resulting list is sorted before being stored. To remove all tags, invoke the command with a single
+ empty string argument.
+
+ Machine tags are short labels that may be used to classify and group machines for management
+ 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
+ machine-info5 for
+ details. They may also be matched against with the
+ ConditionMachineTag=/AssertMachineTag= unit settings, see
+ systemd.unit5.
+
+
+
+
diff --git a/man/machine-info.xml b/man/machine-info.xml
index 53c9e64ec4..252f341bba 100644
--- a/man/machine-info.xml
+++ b/man/machine-info.xml
@@ -139,6 +139,27 @@
+
+ TAGS=
+
+ A colon-separated list of tags attached to this machine. Tags are short labels that
+ may be used to classify and group machines for management purposes, for example to identify the role
+ a machine plays in a deployment (webserver, database), the
+ fleet or organizational unit it belongs to, or any other administrator-defined attribute. Example:
+ TAGS=webserver:frontend:berlin.
+
+ Each individual tag must be 1…255 characters long and may consist only of the ASCII
+ alphanumeric characters, - and ..
+
+ The configured tags may be matched against with the
+ ConditionMachineTag= and AssertMachineTag= unit settings, see
+ systemd.unit5
+ for details. They may be queried and changed with the tags command of
+ hostnamectl1.
+
+
+
+
HARDWARE_VENDOR=
@@ -195,7 +216,8 @@
PRETTY_HOSTNAME="Lennart's Tablet"
ICON_NAME=computer-tablet
CHASSIS=tablet
-DEPLOYMENT=production
+DEPLOYMENT=production
+TAGS=demo:berlin
diff --git a/man/org.freedesktop.hostname1.xml b/man/org.freedesktop.hostname1.xml
index 3d98b88ebc..d5571de207 100644
--- a/man/org.freedesktop.hostname1.xml
+++ b/man/org.freedesktop.hostname1.xml
@@ -56,6 +56,7 @@ node /org/freedesktop/hostname1 {
in b interactive);
SetLocation(in s location,
in b interactive);
+ SetTags(in as tags);
GetProductUUID(in b interactive,
out ay uuid);
GetHardwareSerial(out s serial);
@@ -73,6 +74,7 @@ node /org/freedesktop/hostname1 {
readonly s Chassis = '...';
readonly s Deployment = '...';
readonly s Location = '...';
+ readonly as Tags = ['...', ...];
@org.freedesktop.DBus.Property.EmitsChangedSignal("const")
readonly s KernelName = '...';
@org.freedesktop.DBus.Property.EmitsChangedSignal("const")
@@ -142,6 +144,8 @@ node /org/freedesktop/hostname1 {
+
+
@@ -168,6 +172,8 @@ node /org/freedesktop/hostname1 {
+
+
@@ -268,6 +274,15 @@ node /org/freedesktop/hostname1 {
configure the chassis type if it could not be auto-detected. Set this property to the empty string to
reenable the automatic detection of the chassis type from firmware information.
+ The Tags property exposes a list of machine tags: short
+ labels that may be used to classify and group machines for management 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 is 1…255 characters long and consists only of the
+ ASCII alphanumeric characters, - and .. The tags are stored as a
+ colon-separated list in the TAGS= field of /etc/machine-info, see
+ machine-info5 for
+ details. If no tags are configured this property is the empty list.
+
Note that systemd-hostnamed starts only on request and terminates after a
short idle period. This effectively means that PropertyChanged messages are not sent
out for changes made directly on the files (as in: administrator edits the files with vi). This is
@@ -365,8 +380,13 @@ node /org/freedesktop/hostname1 {
deployment environment), and Location (physical system location), respectively.
+ SetTags() sets the machine tags exposed by the Tags
+ property. It takes a list of strings, each of which must be a valid machine tag (1…255 ASCII
+ alphanumeric characters, - and .). Passing an empty list removes
+ the TAGS= field from /etc/machine-info.
+
PrettyHostname, IconName, Chassis,
- Deployment, and Location are stored in
+ Deployment, Location, and Tags are stored in
/etc/machine-info. See
machine-info5 for
the semantics of those settings.
@@ -400,8 +420,8 @@ node /org/freedesktop/hostname1 {
org.freedesktop.hostname1.set-hostname. For
SetStaticHostname() and SetPrettyHostname() it is
org.freedesktop.hostname1.set-static-hostname. For
- SetIconName(), SetChassis(), SetDeployment()
- and SetLocation() it is
+ SetIconName(), SetChassis(), SetDeployment(),
+ SetLocation() and SetTags() it is
org.freedesktop.hostname1.set-machine-info.
@@ -505,7 +525,8 @@ node /org/freedesktop/hostname1 {
OperatingSystemImageVersion, HardwareSKU, and
HardwareVersion were added in version 258.
OperatingSystemFancyName was added in version 260.
- GetMachineInfo() was added in version 261.
+ GetMachineInfo(), Tags and
+ SetTags() were added in version 261.
diff --git a/man/systemd-firstboot.xml b/man/systemd-firstboot.xml
index db6f2569a8..252ec73fed 100644
--- a/man/systemd-firstboot.xml
+++ b/man/systemd-firstboot.xml
@@ -67,6 +67,8 @@
The system hostname
+ The machine tags
+
The kernel command line used when installing kernel images
The root user's password and shell
@@ -198,6 +200,27 @@
+
+
+
+ Set the machine tags to the specified colon-separated list. Machine tags are short
+ labels that may be used to classify and group machines for management 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 .. This
+ controls the TAGS= field of
+ machine-info5. The
+ tags may later be queried and changed with the tags command of
+ hostnamectl1, and
+ matched against with the ConditionMachineTag=/AssertMachineTag=
+ unit settings, see
+ systemd.unit5.
+
+ Unlike most other settings, machine tags are not prompted for interactively.
+
+
+
+
@@ -458,6 +481,18 @@
+
+
+ firstboot.machine-tags
+
+ This credential specifies the machine tags to set during first boot, as a
+ colon-separated list, equivalent to the switch described above. The
+ tags are written into the TAGS= field of /etc/machine-info
+ (if that file is not already present), and only have an effect on first boot. If the list contains
+ invalid tags it is ignored in its entirety.
+
+
+
Note that by default the systemd-firstboot.service unit file is set up to
diff --git a/man/systemd.system-credentials.xml b/man/systemd.system-credentials.xml
index fb1377c560..f316961cd7 100644
--- a/man/systemd.system-credentials.xml
+++ b/man/systemd.system-credentials.xml
@@ -91,6 +91,21 @@
+
+ firstboot.machine-tags
+
+ The machine tags to set, as a colon-separated list (e.g.
+ webserver:frontend:berlin). Read by
+ systemd-firstboot1,
+ and only honoured if /etc/machine-info has not been configured before. Written
+ into the TAGS= field of that file, see
+ machine-info5 for
+ details.
+
+
+
+
+
login.issue
diff --git a/man/systemd.unit.xml b/man/systemd.unit.xml
index 5c3439d193..2990e65891 100644
--- a/man/systemd.unit.xml
+++ b/man/systemd.unit.xml
@@ -2058,6 +2058,25 @@
+
+ ConditionMachineTag=
+
+ ConditionMachineTag= may be used to match against the tags
+ assigned to the local machine. Machine tags are short labels that classify and group machines for
+ management purposes; they are configured in the TAGS= field of
+ machine-info5 and
+ may be queried and changed with the tags command of
+ hostnamectl1. The
+ argument is a single tag pattern, which is compared against each of the configured tags using
+ shell-style globbing (*, ?, []). The
+ condition is satisfied if at least one of the configured tags matches the pattern. The test may be
+ negated by prepending an exclamation mark, in which case it is satisfied if none of the configured
+ tags matches.
+
+
+
+
+
ConditionMemoryPressure=
ConditionCPUPressure=
@@ -2126,6 +2145,7 @@
AssertCPUs=
AssertCPUFeature=
AssertOSRelease=
+ AssertMachineTag=
AssertMemoryPressure=
AssertCPUPressure=
AssertIOPressure=
diff --git a/shell-completion/bash/hostnamectl b/shell-completion/bash/hostnamectl
index 0baffeed9c..a4ebe25912 100644
--- a/shell-completion/bash/hostnamectl
+++ b/shell-completion/bash/hostnamectl
@@ -67,7 +67,7 @@ _hostnamectl() {
local -A VERBS=(
[STANDALONE]='status'
[ICONS]='icon-name'
- [NAME]='hostname deployment location'
+ [NAME]='hostname deployment location tags'
[CHASSIS]='chassis'
)
diff --git a/shell-completion/zsh/_hostnamectl b/shell-completion/zsh/_hostnamectl
index 76473937a0..30facfb134 100644
--- a/shell-completion/zsh/_hostnamectl
+++ b/shell-completion/zsh/_hostnamectl
@@ -47,6 +47,11 @@ _hostnamectl_location() {
fi
}
+(( $+functions[_hostnamectl_tags] )) ||
+_hostnamectl_tags() {
+ _message "machine tag"
+}
+
(( $+functions[_hostnamectl_commands] )) ||
_hostnamectl_commands() {
local -a _hostnamectl_cmds
@@ -57,6 +62,7 @@ _hostnamectl_commands() {
"chassis:Get/set chassis type for host"
"deployment:Get/set deployment environment for host"
"location:Get/set location for host"
+ "tags:Get/set machine tags for host"
)
if (( CURRENT == 1 )); then
_describe -t commands 'hostnamectl commands' _hostnamectl_cmds || compadd "$@"
diff --git a/src/basic/hostname-util.c b/src/basic/hostname-util.c
index 01434d3d64..baa735ab10 100644
--- a/src/basic/hostname-util.c
+++ b/src/basic/hostname-util.c
@@ -253,3 +253,72 @@ int machine_spec_valid(const char *s) {
return true;
}
+
+bool machine_tag_is_valid(const char *s) {
+ size_t n = strlen_ptr(s);
+ if (n <= 0 || n >= 256)
+ return false;
+
+ return in_charset(s, ALPHANUMERICAL "-.");
+}
+
+bool machine_tag_list_is_valid(char **l) {
+ size_t n = 0;
+ STRV_FOREACH(i, l) {
+ n++;
+ if (n > MACHINE_TAGS_MAX)
+ return false;
+
+ if (!machine_tag_is_valid(*i))
+ return false;
+ }
+
+ return true;
+}
+
+int machine_tags_from_string(const char *s, bool graceful, char ***ret) {
+ int r;
+
+ assert(ret);
+
+ /* Parses the colon-separated TAGS= machine-info field into a sorted, deduplicated strv. Each tag is
+ * validated: if 'graceful' is true invalid tags are silently dropped, otherwise an invalid tag makes
+ * us fail with -EINVAL. The result is NULL if no (valid) tags remain. */
+
+ if (isempty(s)) {
+ *ret = NULL;
+ return 0;
+ }
+
+ _cleanup_strv_free_ char **l = strv_split(s, ":");
+ if (!l)
+ return -ENOMEM;
+
+ strv_sort_uniq(l);
+
+ if (!graceful) {
+ if (!machine_tag_list_is_valid(l))
+ return -EINVAL;
+
+ *ret = strv_isempty(l) ? NULL : TAKE_PTR(l);
+ return 0;
+ }
+
+ size_t n = 0;
+ _cleanup_strv_free_ char **cleaned = NULL;
+ STRV_FOREACH(i, l) {
+ if (!machine_tag_is_valid(*i))
+ continue;
+
+ n++;
+ if (n > MACHINE_TAGS_MAX)
+ return -E2BIG;
+
+ r = strv_extend(&cleaned, *i);
+ if (r < 0)
+ return r;
+ }
+
+ *ret = TAKE_PTR(cleaned);
+ return 0;
+}
diff --git a/src/basic/hostname-util.h b/src/basic/hostname-util.h
index 73cd83fbb8..f3d904a1bb 100644
--- a/src/basic/hostname-util.h
+++ b/src/basic/hostname-util.h
@@ -47,3 +47,9 @@ int get_pretty_hostname(char **ret);
int machine_spec_valid(const char *s);
int split_user_at_host(const char *s, char **ret_user, char **ret_host);
+
+#define MACHINE_TAGS_MAX 1024U
+
+bool machine_tag_is_valid(const char *s);
+bool machine_tag_list_is_valid(char **l);
+int machine_tags_from_string(const char *s, bool graceful, char ***ret);
diff --git a/src/core/load-fragment-gperf.gperf.in b/src/core/load-fragment-gperf.gperf.in
index be074230ce..60fec56f46 100644
--- a/src/core/load-fragment-gperf.gperf.in
+++ b/src/core/load-fragment-gperf.gperf.in
@@ -398,6 +398,7 @@ Unit.ConditionUser, config_parse_unit_condition_string
Unit.ConditionGroup, config_parse_unit_condition_string, CONDITION_GROUP, offsetof(Unit, conditions)
Unit.ConditionControlGroupController, config_parse_unit_condition_string, CONDITION_CONTROL_GROUP_CONTROLLER, offsetof(Unit, conditions)
Unit.ConditionOSRelease, config_parse_unit_condition_string, CONDITION_OS_RELEASE, offsetof(Unit, conditions)
+Unit.ConditionMachineTag, config_parse_unit_condition_string, CONDITION_MACHINE_TAG, offsetof(Unit, conditions)
Unit.ConditionMemoryPressure, config_parse_unit_condition_string, CONDITION_MEMORY_PRESSURE, offsetof(Unit, conditions)
Unit.ConditionCPUPressure, config_parse_unit_condition_string, CONDITION_CPU_PRESSURE, offsetof(Unit, conditions)
Unit.ConditionIOPressure, config_parse_unit_condition_string, CONDITION_IO_PRESSURE, offsetof(Unit, conditions)
@@ -434,6 +435,7 @@ Unit.AssertUser, config_parse_unit_condition_string
Unit.AssertGroup, config_parse_unit_condition_string, CONDITION_GROUP, offsetof(Unit, asserts)
Unit.AssertControlGroupController, config_parse_unit_condition_string, CONDITION_CONTROL_GROUP_CONTROLLER, offsetof(Unit, asserts)
Unit.AssertOSRelease, config_parse_unit_condition_string, CONDITION_OS_RELEASE, offsetof(Unit, asserts)
+Unit.AssertMachineTag, config_parse_unit_condition_string, CONDITION_MACHINE_TAG, offsetof(Unit, asserts)
Unit.AssertMemoryPressure, config_parse_unit_condition_string, CONDITION_MEMORY_PRESSURE, offsetof(Unit, asserts)
Unit.AssertCPUPressure, config_parse_unit_condition_string, CONDITION_CPU_PRESSURE, offsetof(Unit, asserts)
Unit.AssertIOPressure, config_parse_unit_condition_string, CONDITION_IO_PRESSURE, offsetof(Unit, asserts)
diff --git a/src/firstboot/firstboot.c b/src/firstboot/firstboot.c
index e328c85500..e922abdd63 100644
--- a/src/firstboot/firstboot.c
+++ b/src/firstboot/firstboot.c
@@ -67,6 +67,7 @@ static char *arg_keymap = NULL;
static char *arg_timezone = NULL;
static char *arg_hostname = NULL;
static sd_id128_t arg_machine_id = {};
+static char **arg_machine_tags = NULL;
static char *arg_root_password = NULL;
static char *arg_root_shell = NULL;
static char *arg_kernel_cmdline = NULL;
@@ -98,6 +99,7 @@ STATIC_DESTRUCTOR_REGISTER(arg_locale_messages, freep);
STATIC_DESTRUCTOR_REGISTER(arg_keymap, freep);
STATIC_DESTRUCTOR_REGISTER(arg_timezone, freep);
STATIC_DESTRUCTOR_REGISTER(arg_hostname, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_machine_tags, strv_freep);
STATIC_DESTRUCTOR_REGISTER(arg_root_password, erase_and_freep);
STATIC_DESTRUCTOR_REGISTER(arg_root_shell, freep);
STATIC_DESTRUCTOR_REGISTER(arg_kernel_cmdline, freep);
@@ -778,6 +780,71 @@ static int process_machine_id(int rfd) {
return 0;
}
+static int process_machine_tags(int rfd) {
+ int r;
+
+ assert(rfd >= 0);
+
+ _cleanup_free_ char *f = NULL;
+ _cleanup_close_ int pfd = chase_and_open_parent_at(
+ /* root_fd= */ rfd,
+ /* dir_fd= */ rfd,
+ "/etc/machine-info",
+ CHASE_MKDIR_0755|CHASE_WARN|CHASE_NOFOLLOW,
+ &f);
+ if (pfd < 0)
+ return log_error_errno(pfd, "Failed to chase /etc/machine-info parent: %m");
+
+ r = should_configure(pfd, f);
+ if (r == 0)
+ log_debug("Found /etc/machine-info, assuming machine tags have been configured.");
+ if (r <= 0)
+ return r;
+
+ if (!arg_machine_tags) {
+ _cleanup_free_ char *tags = NULL;
+ r = read_credential("firstboot.machine-tags", (void**) &tags, /* ret_size= */ NULL);
+ if (r < 0)
+ log_debug_errno(r, "Failed to read credential firstboot.machine-tags, ignoring: %m");
+ else {
+ _cleanup_strv_free_ char **l = NULL;
+ r = machine_tags_from_string(tags, /* graceful= */ false, &l);
+ if (r < 0)
+ log_warning_errno(r, "Failed to parse machine tags '%s', ignoring credential: %m", tags);
+ else {
+ strv_free_and_replace(arg_machine_tags, l);
+ log_debug("Acquired machine tags list from credentials.");
+ }
+ }
+ }
+
+ /* NB: We do not prompt for machine tags, at least not for now */
+
+ if (!arg_machine_tags) {
+ log_debug("Initialization of machine tags was not requested, skipping.");
+ return 0;
+ }
+
+ _cleanup_free_ char *j = strv_join(arg_machine_tags, ":");
+ if (!j)
+ return log_oom();
+
+ _cleanup_free_ char *c = strjoin("TAGS=\"", j, "\"\n");
+ if (!c)
+ return log_oom();
+
+ r = write_string_file_at(
+ pfd,
+ "machine-info",
+ c,
+ WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_SYNC|WRITE_STRING_FILE_ATOMIC|WRITE_STRING_FILE_LABEL);
+ if (r < 0)
+ return log_error_errno(r, "Failed to write /etc/machine-info: %m");
+
+ log_info("/etc/machine-info written.");
+ return 0;
+}
+
static int prompt_root_password(int rfd, sd_varlink **mute_console_link) {
const char *msg1, *msg2;
int r;
@@ -1363,6 +1430,16 @@ static int parse_argv(int argc, char *argv[]) {
return log_error_errno(r, "Failed to parse machine id %s.", opts.arg);
break;
+ OPTION_LONG("machine-tags", "TAG[:…]", "Set machine tags"): {
+ _cleanup_strv_free_ char **tags = NULL;
+ r = machine_tags_from_string(opts.arg, /* graceful= */ false, &tags);
+ if (r < 0)
+ return log_error_errno(r, "Failed to parse machine tags '%s': %m", opts.arg);
+
+ strv_free_and_replace(arg_machine_tags, tags);
+ break;
+ }
+
OPTION_LONG("root-password", "PASSWORD", "Set root password from plaintext password"):
r = free_and_strdup_warn(&arg_root_password, opts.arg);
if (r < 0)
@@ -1693,6 +1770,10 @@ static int run(int argc, char *argv[]) {
if (r < 0)
return r;
+ r = process_machine_tags(rfd);
+ if (r < 0)
+ return r;
+
return 0;
}
diff --git a/src/hostname/hostnamectl.c b/src/hostname/hostnamectl.c
index 282a16d83c..d52580fbf5 100644
--- a/src/hostname/hostnamectl.c
+++ b/src/hostname/hostnamectl.c
@@ -9,6 +9,7 @@
#include "sd-json.h"
#include "alloc-util.h"
+#include "ansi-color.h"
#include "build.h"
#include "bus-common-errors.h"
#include "bus-error.h"
@@ -18,6 +19,7 @@
#include "bus-util.h"
#include "errno-util.h"
#include "format-table.h"
+#include "help-util.h"
#include "hostname-setup.h"
#include "hostname-util.h"
#include "log.h"
@@ -26,9 +28,9 @@
#include "os-util.h"
#include "parse-argument.h"
#include "polkit-agent.h"
-#include "pretty-print.h"
#include "runtime-scope.h"
#include "string-util.h"
+#include "strv.h"
#include "time-util.h"
#include "verbs.h"
@@ -49,6 +51,7 @@ typedef struct StatusInfo {
const char *chassis_asset_tag;
const char *deployment;
const char *location;
+ char **tags;
const char *kernel_name;
const char *kernel_release;
const char *os_pretty_name;
@@ -196,6 +199,18 @@ static int print_status_info(StatusInfo *i) {
return table_log_add_error(r);
}
+ if (!strv_isempty(i->tags)) {
+ _cleanup_free_ char *j = strv_join(i->tags, ":");
+ if (!j)
+ return log_oom();
+
+ r = table_add_many(table,
+ TABLE_FIELD, "Tags",
+ TABLE_STRING, j);
+ if (r < 0)
+ return table_log_add_error(r);
+ }
+
if (!sd_id128_is_null(i->machine_id)) {
r = table_add_many(table,
TABLE_FIELD, "Machine ID",
@@ -411,8 +426,14 @@ static int get_one_name(sd_bus *bus, const char* attr, char **ret) {
return 0;
}
+static void status_info_done(StatusInfo *info) {
+ assert(info);
+
+ info->tags = strv_free(info->tags);
+}
+
static int show_all_names(sd_bus *bus) {
- StatusInfo info = {
+ _cleanup_(status_info_done) StatusInfo info = {
.vsock_cid = VMADDR_CID_ANY,
.os_support_end = USEC_INFINITY,
.firmware_date = USEC_INFINITY,
@@ -427,6 +448,7 @@ static int show_all_names(sd_bus *bus) {
{ "ChassisAssetTag", "s", NULL, offsetof(StatusInfo, chassis_asset_tag)},
{ "Deployment", "s", NULL, offsetof(StatusInfo, deployment) },
{ "Location", "s", NULL, offsetof(StatusInfo, location) },
+ { "Tags", "as", NULL, offsetof(StatusInfo, tags) },
{ "KernelName", "s", NULL, offsetof(StatusInfo, kernel_name) },
{ "KernelRelease", "s", NULL, offsetof(StatusInfo, kernel_release) },
{ "OperatingSystemPrettyName", "s", NULL, offsetof(StatusInfo, os_pretty_name) },
@@ -719,14 +741,67 @@ static int verb_get_or_set_location(int argc, char *argv[], uintptr_t _data, voi
set_simple_string(userdata, "location", "SetLocation", argv[1]);
}
-static int help(void) {
- _cleanup_free_ char *link = NULL;
- _cleanup_(table_unrefp) Table *options = NULL, *verbs = NULL;
+VERB(verb_get_or_set_tags, "tags", "[TAG …]", VERB_ANY, VERB_ANY, 0, "Get/set machine tags for host");
+static int verb_get_or_set_tags(int argc, char *argv[], uintptr_t _data, void *userdata) {
+ _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL;
+ sd_bus *bus = ASSERT_PTR(userdata);
int r;
- r = terminal_urlify_man("hostnamectl", "1", &link);
+ if (argc == 1) {
+ _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+
+ _cleanup_free_ char *j = NULL;
+ r = bus_get_property(bus, bus_hostname, "Tags", &error, &reply, "as");
+ if (r < 0) {
+ if (!sd_bus_error_has_name(&error, SD_BUS_ERROR_UNKNOWN_PROPERTY))
+ return log_error_errno(r, "Could not get property: %s", bus_error_message(&error, r));
+
+ /* Old hostnamed didn't know the tags concept, hence such a machine has no tags. */
+ } else {
+ _cleanup_strv_free_ char **l = NULL;
+ r = sd_bus_message_read_strv(reply, &l);
+ if (r < 0)
+ return bus_log_parse_error(r);
+
+ j = strv_join(l, ":");
+ if (!j)
+ return log_oom();
+ }
+
+ printf("%s\n", strempty(j));
+ return 0;
+ }
+
+ _cleanup_strv_free_ char **l = NULL;
+ for (int i = 1; i < argc; i++) {
+ r = strv_split_and_extend(&l, argv[i], ":", /* filter_duplicates= */ true);
+ if (r < 0)
+ return log_oom();
+ }
+
+ strv_sort(l);
+
+ (void) polkit_agent_open_if_enabled(arg_transport, arg_ask_password);
+
+ _cleanup_(sd_bus_message_unrefp) sd_bus_message *m = NULL;
+ r = bus_message_new_method_call(bus, &m, bus_hostname, "SetTags");
if (r < 0)
- return log_oom();
+ return bus_log_create_error(r);
+
+ r = sd_bus_message_append_strv(m, l);
+ if (r < 0)
+ return bus_log_create_error(r);
+
+ r = sd_bus_call(bus, m, /* usec= */ 0, &error, /* ret_reply= */ NULL);
+ if (r < 0)
+ return log_error_errno(r, "Could not set tags: %s", bus_error_message(&error, r));
+
+ return 0;
+}
+
+static int help(void) {
+ _cleanup_(table_unrefp) Table *options = NULL, *verbs = NULL;
+ int r;
r = option_parser_get_help_table(&options);
if (r < 0)
@@ -738,22 +813,20 @@ static int help(void) {
(void) table_sync_column_widths(0, options, verbs);
- printf("%s [OPTIONS...] COMMAND ...\n"
- "\n%sQuery or change system hostname.%s\n"
- "\nCommands:\n",
- program_invocation_short_name,
- ansi_highlight(),
- ansi_normal());
+ help_cmdline("[OPTIONS...] COMMAND ...");
+ help_abstract("Query or change system hostname.");
+
+ help_section("Commands");
r = table_print_or_warn(verbs);
if (r < 0)
return r;
- printf("\nOptions:\n");
+ help_section("Options");
r = table_print_or_warn(options);
if (r < 0)
return r;
- printf("\nSee the %s for details.\n", link);
+ help_man_page_reference("hostnamectl", "1");
return 0;
}
diff --git a/src/hostname/hostnamed.c b/src/hostname/hostnamed.c
index 2292c07661..a39bf5a57e 100644
--- a/src/hostname/hostnamed.c
+++ b/src/hostname/hostnamed.c
@@ -43,7 +43,6 @@
#include "string-util.h"
#include "strv.h"
#include "time-util.h"
-#include "utf8.h"
#include "varlink-io.systemd.Hostname.h"
#include "varlink-io.systemd.service.h"
#include "varlink-util.h"
@@ -58,12 +57,15 @@ typedef enum {
PROP_STATIC_HOSTNAME,
PROP_STATIC_HOSTNAME_SUBSTITUTED_WILDCARDS,
- /* Read from /etc/machine-info */
+ /* Read from /etc/machine-info (with fallbacks) */
PROP_PRETTY_HOSTNAME,
+ _PROP_MACHINE_INFO_SETTABLE_FIRST = PROP_PRETTY_HOSTNAME,
PROP_ICON_NAME,
PROP_CHASSIS,
PROP_DEPLOYMENT,
PROP_LOCATION,
+ PROP_TAGS,
+ _PROP_MACHINE_INFO_SETTABLE_LAST = PROP_TAGS,
PROP_HARDWARE_VENDOR,
PROP_HARDWARE_MODEL,
PROP_HARDWARE_SKU,
@@ -77,6 +79,7 @@ typedef enum {
PROP_OS_SUPPORT_END,
PROP_OS_IMAGE_ID,
PROP_OS_IMAGE_VERSION,
+
_PROP_MAX,
_PROP_INVALID = -EINVAL,
} HostProperty;
@@ -173,6 +176,7 @@ static void context_read_machine_info(Context *c) {
PROP_CHASSIS,
PROP_DEPLOYMENT,
PROP_LOCATION,
+ PROP_TAGS,
PROP_HARDWARE_VENDOR,
PROP_HARDWARE_MODEL,
PROP_HARDWARE_SKU,
@@ -184,6 +188,7 @@ static void context_read_machine_info(Context *c) {
"CHASSIS", &c->data[PROP_CHASSIS],
"DEPLOYMENT", &c->data[PROP_DEPLOYMENT],
"LOCATION", &c->data[PROP_LOCATION],
+ "TAGS", &c->data[PROP_TAGS],
"HARDWARE_VENDOR", &c->data[PROP_HARDWARE_VENDOR],
"HARDWARE_MODEL", &c->data[PROP_HARDWARE_MODEL],
"HARDWARE_SKU", &c->data[PROP_HARDWARE_SKU],
@@ -321,18 +326,6 @@ static int context_acquire_device_tree(Context *c) {
return 1;
}
-static bool string_is_safe_for_dbus(const char *s) {
- assert(s);
-
- /* Do some superficial validation: do not allow CCs and make sure D-Bus won't kick us off the bus
- * because we send invalid UTF-8 data */
-
- if (string_has_cc(s, /* ok= */ NULL))
- return false;
-
- return utf8_is_valid(s);
-}
-
static int get_dmi_property(Context *c, const char *key, char **ret) {
const char *s;
int r;
@@ -348,7 +341,7 @@ static int get_dmi_property(Context *c, const char *key, char **ret) {
if (r < 0)
return r;
- if (!string_is_safe_for_dbus(s))
+ if (!string_is_safe(s, STRING_ALLOW_EMPTY|STRING_ALLOW_BACKSLASHES|STRING_ALLOW_QUOTES|STRING_ALLOW_GLOBS))
return -ENXIO;
return strdup_to(ret, s);
@@ -447,7 +440,7 @@ static int get_sysattr(sd_device *device, const char *key, char **ret) {
if (r < 0)
return log_device_debug_errno(device, r, "Failed to read '%s' attribute: %m", key);
- if (!string_is_safe_for_dbus(s))
+ if (!string_is_safe(s, STRING_ALLOW_EMPTY|STRING_ALLOW_BACKSLASHES|STRING_ALLOW_QUOTES|STRING_ALLOW_GLOBS))
return log_device_debug_errno(device, SYNTHETIC_ERRNO(ENXIO),
"'%s' attribute is not safe for exposing through DBus: %s",
key, s);
@@ -852,11 +845,12 @@ static int context_write_data_static_hostname(Context *c) {
static int context_write_data_machine_info(Context *c) {
_cleanup_(unset_statp) struct stat *s = NULL;
static const char * const name[_PROP_MAX] = {
- [PROP_PRETTY_HOSTNAME] = "PRETTY_HOSTNAME",
- [PROP_ICON_NAME] = "ICON_NAME",
- [PROP_CHASSIS] = "CHASSIS",
- [PROP_DEPLOYMENT] = "DEPLOYMENT",
- [PROP_LOCATION] = "LOCATION",
+ [PROP_PRETTY_HOSTNAME] = "PRETTY_HOSTNAME",
+ [PROP_ICON_NAME] = "ICON_NAME",
+ [PROP_CHASSIS] = "CHASSIS",
+ [PROP_DEPLOYMENT] = "DEPLOYMENT",
+ [PROP_LOCATION] = "LOCATION",
+ [PROP_TAGS] = "TAGS",
};
_cleanup_strv_free_ char **l = NULL;
int r;
@@ -871,7 +865,7 @@ static int context_write_data_machine_info(Context *c) {
if (r < 0 && r != -ENOENT)
return r;
- for (HostProperty p = PROP_PRETTY_HOSTNAME; p <= PROP_LOCATION; p++) {
+ for (HostProperty p = _PROP_MACHINE_INFO_SETTABLE_FIRST; p <= _PROP_MACHINE_INFO_SETTABLE_LAST; p++) {
assert(name[p]);
r = strv_env_assign(&l, name[p], empty_to_null(c->data[p]));
@@ -1175,6 +1169,29 @@ static int property_get_machine_info_field(
return sd_bus_message_append(reply, "s", *(char**) userdata);
}
+static int property_get_tags(
+ sd_bus *bus,
+ const char *path,
+ const char *interface,
+ const char *property,
+ sd_bus_message *reply,
+ void *userdata,
+ sd_bus_error *error) {
+
+ Context *c = ASSERT_PTR(userdata);
+ int r;
+
+ context_read_machine_info(c);
+
+ /* Silently drop any invalid tags that might have been written into the file by hand */
+ _cleanup_strv_free_ char **l = NULL;
+ r = machine_tags_from_string(c->data[PROP_TAGS], /* graceful= */ true, &l);
+ if (r < 0)
+ log_warning_errno(r, "Failed to parse machine tags '%s', ignoring: %m", strnull(c->data[PROP_TAGS]));
+
+ return sd_bus_message_append_strv(reply, l);
+}
+
static int property_get_os_release_field(
sd_bus *bus,
const char *path,
@@ -1573,6 +1590,74 @@ static int method_set_location(sd_bus_message *m, void *userdata, sd_bus_error *
return set_machine_info(userdata, m, PROP_LOCATION, method_set_location, error);
}
+static int method_set_tags(sd_bus_message *m, void *userdata, sd_bus_error *error) {
+ Context *c = ASSERT_PTR(userdata);
+ int r;
+
+ assert(m);
+
+ _cleanup_strv_free_ char **tags = NULL;
+ r = sd_bus_message_read_strv(m, &tags);
+ if (r < 0)
+ return r;
+
+ strv_sort_uniq(tags);
+
+ if (strv_length(tags) > MACHINE_TAGS_MAX)
+ return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Too many machine tags specified.");
+
+ _cleanup_free_ char *j = strv_join(tags, ":");
+ if (!j)
+ return log_oom();
+
+ if (!machine_tag_list_is_valid(tags))
+ return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Invalid tags '%s'", j);
+
+ context_read_machine_info(c);
+
+ if (streq_ptr(empty_to_null(j), empty_to_null(c->data[PROP_TAGS])))
+ return sd_bus_reply_method_return(m, NULL);
+
+ r = bus_verify_polkit_async_full(
+ m,
+ "org.freedesktop.hostname1.set-machine-info",
+ /* details= */ NULL,
+ /* good_user= */ UID_INVALID,
+ /* flags= */ 0,
+ &c->polkit_registry,
+ error);
+ if (r < 0)
+ return r;
+ if (r == 0)
+ return 1; /* No authorization for now, but the async polkit stuff will call us again when it has it */
+
+ if (strv_isempty(tags))
+ c->data[PROP_TAGS] = mfree(c->data[PROP_TAGS]);
+ else
+ free_and_replace(c->data[PROP_TAGS], j);
+
+ r = context_write_data_machine_info(c);
+ if (r < 0) {
+ log_error_errno(r, "Failed to write machine info: %m");
+ if (ERRNO_IS_PRIVILEGE(r))
+ return sd_bus_error_set(error, BUS_ERROR_FILE_IS_PROTECTED, "Not allowed to update /etc/machine-info.");
+ if (r == -EROFS)
+ return sd_bus_error_set(error, BUS_ERROR_READ_ONLY_FILESYSTEM, "/etc/machine-info is in a read-only filesystem.");
+ return sd_bus_error_set_errnof(error, r, "Failed to write machine info: %m");
+ }
+
+ log_info("Changed tags to '%s'", strempty(c->data[PROP_TAGS]));
+
+ (void) sd_bus_emit_properties_changed(
+ sd_bus_message_get_bus(m),
+ "/org/freedesktop/hostname1",
+ "org.freedesktop.hostname1",
+ "Tags",
+ NULL);
+
+ return sd_bus_reply_method_return(m, NULL);
+}
+
static int method_get_product_uuid(sd_bus_message *m, void *userdata, sd_bus_error *error) {
_cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
Context *c = ASSERT_PTR(userdata);
@@ -1657,6 +1742,7 @@ static int method_get_machine_info(sd_bus_message *m, void *userdata, sd_bus_err
{ "CHASSIS", PROP_CHASSIS },
{ "DEPLOYMENT", PROP_DEPLOYMENT },
{ "LOCATION", PROP_LOCATION },
+ { "TAGS", PROP_TAGS },
{ "HARDWARE_VENDOR", PROP_HARDWARE_VENDOR },
{ "HARDWARE_MODEL", PROP_HARDWARE_MODEL },
{ "HARDWARE_SKU", PROP_HARDWARE_SKU },
@@ -1865,6 +1951,7 @@ static const sd_bus_vtable hostname_vtable[] = {
SD_BUS_PROPERTY("Chassis", "s", property_get_chassis, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("Deployment", "s", property_get_machine_info_field, offsetof(Context, data[PROP_DEPLOYMENT]), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("Location", "s", property_get_machine_info_field, offsetof(Context, data[PROP_LOCATION]), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
+ SD_BUS_PROPERTY("Tags", "as", property_get_tags, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("KernelName", "s", property_get_uname_field, offsetof(struct utsname, sysname), SD_BUS_VTABLE_ABSOLUTE_OFFSET|SD_BUS_VTABLE_PROPERTY_CONST),
SD_BUS_PROPERTY("KernelRelease", "s", property_get_uname_field, offsetof(struct utsname, release), SD_BUS_VTABLE_ABSOLUTE_OFFSET|SD_BUS_VTABLE_PROPERTY_CONST),
SD_BUS_PROPERTY("KernelVersion", "s", property_get_uname_field, offsetof(struct utsname, version), SD_BUS_VTABLE_ABSOLUTE_OFFSET|SD_BUS_VTABLE_PROPERTY_CONST),
@@ -1922,6 +2009,11 @@ static const sd_bus_vtable hostname_vtable[] = {
SD_BUS_NO_RESULT,
method_set_location,
SD_BUS_VTABLE_UNPRIVILEGED),
+ SD_BUS_METHOD_WITH_ARGS("SetTags",
+ SD_BUS_ARGS("as", tags),
+ SD_BUS_NO_RESULT,
+ method_set_tags,
+ SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD_WITH_ARGS("GetProductUUID",
SD_BUS_ARGS("b", interactive),
SD_BUS_RESULT("ay", uuid),
diff --git a/src/shared/condition.c b/src/shared/condition.c
index d0e41bbf71..ae33c18ade 100644
--- a/src/shared/condition.c
+++ b/src/shared/condition.c
@@ -35,6 +35,7 @@
#include "glob-util.h"
#include "hmac.h"
#include "hostname-setup.h"
+#include "hostname-util.h"
#include "id128-util.h"
#include "ima-util.h"
#include "initrd-util.h"
@@ -316,6 +317,37 @@ static int condition_test_osrelease(Condition *c, char **env) {
return true;
}
+static int condition_test_machine_tag(Condition *c, char **env) {
+ int r;
+
+ assert(c);
+ assert(c->parameter);
+ assert(c->type == CONDITION_MACHINE_TAG);
+
+ _cleanup_free_ char *tags = NULL;
+ r = parse_env_file(
+ /* f= */ NULL, etc_machine_info(),
+ "TAGS", &tags);
+ if (r < 0 && r != -ENOENT) {
+ log_debug_errno(r, "Failed to read /etc/machine-info, ignoring: %m");
+ return false;
+ }
+
+ /* Silently ignore invalid tags, matching the Tags D-Bus property */
+ _cleanup_strv_free_ char **l = NULL;
+ r = machine_tags_from_string(tags, /* graceful= */ true, &l);
+ if (r < 0) {
+ log_debug_errno(r, "Failed to parse machine tags '%s', ignoring: %m", tags);
+ return false;
+ }
+
+ STRV_FOREACH(i, l)
+ if (fnmatch(c->parameter, *i, /* flags= */ 0) == 0)
+ return true;
+
+ return false;
+}
+
static int condition_test_memory(Condition *c, char **env) {
CompareOperator operator;
uint64_t m, k;
@@ -1344,6 +1376,7 @@ int condition_test(Condition *c, char **env) {
[CONDITION_ENVIRONMENT] = condition_test_environment,
[CONDITION_CPU_FEATURE] = condition_test_cpufeature,
[CONDITION_OS_RELEASE] = condition_test_osrelease,
+ [CONDITION_MACHINE_TAG] = condition_test_machine_tag,
[CONDITION_MEMORY_PRESSURE] = condition_test_psi,
[CONDITION_CPU_PRESSURE] = condition_test_psi,
[CONDITION_IO_PRESSURE] = condition_test_psi,
@@ -1471,6 +1504,7 @@ static const char* const _condition_type_table[_CONDITION_TYPE_MAX] = {
[CONDITION_ENVIRONMENT] = "ConditionEnvironment",
[CONDITION_CPU_FEATURE] = "ConditionCPUFeature",
[CONDITION_OS_RELEASE] = "ConditionOSRelease",
+ [CONDITION_MACHINE_TAG] = "ConditionMachineTag",
[CONDITION_MEMORY_PRESSURE] = "ConditionMemoryPressure",
[CONDITION_CPU_PRESSURE] = "ConditionCPUPressure",
[CONDITION_IO_PRESSURE] = "ConditionIOPressure",
@@ -1528,6 +1562,7 @@ static const char* const _assert_type_table[_CONDITION_TYPE_MAX] = {
[CONDITION_ENVIRONMENT] = "AssertEnvironment",
[CONDITION_CPU_FEATURE] = "AssertCPUFeature",
[CONDITION_OS_RELEASE] = "AssertOSRelease",
+ [CONDITION_MACHINE_TAG] = "AssertMachineTag",
[CONDITION_MEMORY_PRESSURE] = "AssertMemoryPressure",
[CONDITION_CPU_PRESSURE] = "AssertCPUPressure",
[CONDITION_IO_PRESSURE] = "AssertIOPressure",
diff --git a/src/shared/condition.h b/src/shared/condition.h
index 91af2027df..1f5c33284c 100644
--- a/src/shared/condition.h
+++ b/src/shared/condition.h
@@ -21,6 +21,7 @@ typedef enum ConditionType {
CONDITION_ENVIRONMENT,
CONDITION_CPU_FEATURE,
CONDITION_OS_RELEASE,
+ CONDITION_MACHINE_TAG,
CONDITION_MEMORY_PRESSURE,
CONDITION_CPU_PRESSURE,
CONDITION_IO_PRESSURE,
diff --git a/src/test/test-condition.c b/src/test/test-condition.c
index bee1014c1c..4f450ab12d 100644
--- a/src/test/test-condition.c
+++ b/src/test/test-condition.c
@@ -1414,6 +1414,69 @@ TEST(condition_test_os_release) {
condition_free(condition);
}
+TEST(condition_test_machine_tag) {
+ Condition *condition;
+
+ /* etc_machine_info() caches the path on first use, so redirect it before anything reads it and
+ * rewrite the same file (truncating) for each scenario rather than switching paths. */
+ _cleanup_free_ char *saved = NULL;
+ ASSERT_OK(free_and_strdup(&saved, getenv("SYSTEMD_ETC_MACHINE_INFO")));
+
+ _cleanup_(rm_rf_physical_and_freep) char *d = NULL;
+ ASSERT_OK(mkdtemp_malloc(NULL, &d));
+
+ _cleanup_free_ char *f = path_join(d, "machine-info");
+ ASSERT_NOT_NULL(f);
+ ASSERT_OK_ERRNO(setenv("SYSTEMD_ETC_MACHINE_INFO", f, /* overwrite= */ true));
+
+ ASSERT_OK(write_string_file(f, "TAGS=\"webserver:frontend:berlin\"\n",
+ WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE));
+
+ /* Exact match */
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "webserver", /* trigger= */ false, /* negate= */ false)));
+ ASSERT_OK_POSITIVE(condition_test(condition, environ));
+ condition_free(condition);
+
+ /* Glob match */
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "front*", /* trigger= */ false, /* negate= */ false)));
+ ASSERT_OK_POSITIVE(condition_test(condition, environ));
+ condition_free(condition);
+
+ /* No match */
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "database", /* trigger= */ false, /* negate= */ false)));
+ ASSERT_OK_ZERO(condition_test(condition, environ));
+ condition_free(condition);
+
+ /* Negation matches when the tag is absent, ... */
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "database", /* trigger= */ false, /* negate= */ true)));
+ ASSERT_OK_POSITIVE(condition_test(condition, environ));
+ condition_free(condition);
+
+ /* ... and does not match when the tag is present */
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "webserver", /* trigger= */ false, /* negate= */ true)));
+ ASSERT_OK_ZERO(condition_test(condition, environ));
+ condition_free(condition);
+
+ /* Invalid tags in the file are ignored (matching the Tags D-Bus property) */
+ ASSERT_OK(write_string_file(f, "TAGS=\"in valid:good\"\n",
+ WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE));
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "in valid", /* trigger= */ false, /* negate= */ false)));
+ ASSERT_OK_ZERO(condition_test(condition, environ));
+ condition_free(condition);
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "good", /* trigger= */ false, /* negate= */ false)));
+ ASSERT_OK_POSITIVE(condition_test(condition, environ));
+ condition_free(condition);
+
+ /* No tags configured at all → never matches */
+ ASSERT_OK(write_string_file(f, "PRETTY_HOSTNAME=\"x\"\n",
+ WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE));
+ ASSERT_NOT_NULL((condition = condition_new(CONDITION_MACHINE_TAG, "webserver", /* trigger= */ false, /* negate= */ false)));
+ ASSERT_OK_ZERO(condition_test(condition, environ));
+ condition_free(condition);
+
+ ASSERT_OK(set_unset_env("SYSTEMD_ETC_MACHINE_INFO", saved, /* overwrite= */ true));
+}
+
TEST(condition_test_psi) {
Condition *condition;
CGroupMask mask;
diff --git a/src/test/test-hostname-util.c b/src/test/test-hostname-util.c
index b3e3b1b84a..1a3cb1dfff 100644
--- a/src/test/test-hostname-util.c
+++ b/src/test/test-hostname-util.c
@@ -1,7 +1,9 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#include "alloc-util.h"
#include "hostname-util.h"
#include "string-util.h"
+#include "strv.h"
#include "tests.h"
TEST(hostname_is_valid) {
@@ -117,4 +119,66 @@ TEST(split_user_at_host) {
test_split_user_at_host_one("aa@@@bb", "aa", "@@bb", 1);
}
+TEST(machine_tag_is_valid) {
+ assert_se(machine_tag_is_valid("foo"));
+ 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(NULL));
+ assert_se(!machine_tag_is_valid(""));
+ assert_se(!machine_tag_is_valid("foo:bar")); /* colon is the separator */
+ assert_se(!machine_tag_is_valid("foo bar"));
+ assert_se(!machine_tag_is_valid("fööbar")); /* non-ASCII */
+ assert_se(!machine_tag_is_valid("foo/bar"));
+ assert_se(!machine_tag_is_valid("foo_bar"));
+
+ /* Length boundary: 255 characters is fine, 256 is too long */
+ _cleanup_free_ char *max = strrep("a", 255), *over = strrep("a", 256);
+ assert_se(max);
+ assert_se(over);
+ assert_se(machine_tag_is_valid(max));
+ assert_se(!machine_tag_is_valid(over));
+}
+
+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", "b:c")));
+ assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo", "")));
+}
+
+TEST(machine_tags_from_string) {
+ _cleanup_strv_free_ char **l = NULL;
+
+ ASSERT_OK(machine_tags_from_string(NULL, /* graceful= */ false, &l));
+ assert_se(strv_isempty(l));
+ l = strv_free(l);
+
+ ASSERT_OK(machine_tags_from_string("", /* graceful= */ true, &l));
+ assert_se(strv_isempty(l));
+ l = strv_free(l);
+
+ /* Sorted and deduplicated */
+ ASSERT_OK(machine_tags_from_string("foo:bar:foo:baz", /* graceful= */ false, &l));
+ assert_se(strv_equal(l, STRV_MAKE("bar", "baz", "foo")));
+ l = strv_free(l);
+
+ /* Graceful: invalid tags are dropped, valid ones kept (sorted/deduplicated) */
+ ASSERT_OK(machine_tags_from_string("foo:in valid:bar:foo", /* graceful= */ true, &l));
+ assert_se(strv_equal(l, STRV_MAKE("bar", "foo")));
+ l = strv_free(l);
+
+ /* Graceful: all tags invalid → empty list */
+ ASSERT_OK(machine_tags_from_string("in valid:also invalid", /* graceful= */ true, &l));
+ assert_se(strv_isempty(l));
+ l = strv_free(l);
+
+ /* 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);
+}
+
DEFINE_TEST_MAIN(LOG_DEBUG);
diff --git a/test/fuzz/fuzz-unit-file/directives-all.service b/test/fuzz/fuzz-unit-file/directives-all.service
index 9fedef5a63..0d6fadcc96 100644
--- a/test/fuzz/fuzz-unit-file/directives-all.service
+++ b/test/fuzz/fuzz-unit-file/directives-all.service
@@ -24,6 +24,7 @@ AssertHost=
AssertIOPressure=
AssertKernelCommandLine=
AssertKernelVersion=
+AssertMachineTag=
AssertMemoryPressure=
AssertNeedsUpdate=
AssertOSRelease=
@@ -72,6 +73,7 @@ ConditionHost=
ConditionIOPressure=
ConditionKernelCommandLine=
ConditionKernelVersion=
+ConditionMachineTag=
ConditionMemoryPressure=
ConditionNeedsUpdate=
ConditionOSRelease=
diff --git a/test/units/TEST-74-AUX-UTILS.machine-tags.sh b/test/units/TEST-74-AUX-UTILS.machine-tags.sh
new file mode 100755
index 0000000000..5bd421e489
--- /dev/null
+++ b/test/units/TEST-74-AUX-UTILS.machine-tags.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# shellcheck disable=SC1091
+set -eux
+set -o pipefail
+
+# shellcheck source=test/units/util.sh
+. "$(dirname "$0")"/util.sh
+
+if ! command -v hostnamectl >/dev/null; then
+ echo "hostnamectl not found, skipping the test"
+ exit 0
+fi
+
+at_exit() {
+ set +e
+
+ # Restore the original /etc/machine-info (if any) and make hostnamed read it again
+ if [[ -e /tmp/machine-info.bak ]]; then
+ mv /tmp/machine-info.bak /etc/machine-info
+ else
+ rm -f /etc/machine-info
+ fi
+ systemctl stop --job-mode=replace-irreversibly systemd-hostnamed.service
+ systemctl reset-failed systemd-hostnamed.service
+
+ rm -fr "${ROOT:-}"
+}
+
+trap at_exit EXIT
+
+# Read the TAGS= field out of /etc/machine-info (robust against the file being
+# absent and against TAGS= being unset).
+get_tags_from_file() (
+ set +u
+ [[ -f /etc/machine-info ]] && . /etc/machine-info
+ echo "${TAGS:-}"
+)
+
+if [[ -f /etc/machine-info ]]; then
+ cp /etc/machine-info /tmp/machine-info.bak
+fi
+
+# Start from a clean slate, and make sure hostnamed re-reads the (now missing) file.
+rm -f /etc/machine-info
+systemctl stop --job-mode=replace-irreversibly systemd-hostnamed.service || :
+systemctl reset-failed systemd-hostnamed.service || :
+
+# --------------------------------------------------------------------------------------------------
+# hostnamectl tags <-> /etc/machine-info
+# --------------------------------------------------------------------------------------------------
+
+# No tags are configured initially.
+assert_eq "$(hostnamectl tags)" ""
+
+# Set some tags. Multiple arguments, colon-separated arguments, duplicates and arbitrary order are
+# all accepted and normalized into a sorted, deduplicated, colon-separated list.
+hostnamectl tags webserver:frontend frontend berlin
+assert_eq "$(hostnamectl tags)" "berlin:frontend:webserver"
+
+# The very same list must show up in the TAGS= field of /etc/machine-info.
+grep -qE '^TAGS="?berlin:frontend:webserver"?$' /etc/machine-info
+assert_eq "$(get_tags_from_file)" "berlin:frontend:webserver"
+
+# Setting tags again replaces the previous list rather than extending it.
+hostnamectl tags database
+assert_eq "$(hostnamectl tags)" "database"
+assert_eq "$(get_tags_from_file)" "database"
+
+# Invalid tags (only ASCII alphanumerics, '-' and '.' are allowed) are refused, leaving the
+# previously configured tags untouched.
+(! hostnamectl tags "invalid tag")
+(! hostnamectl tags "invalid/tag")
+assert_eq "$(hostnamectl tags)" "database"
+
+# Clearing all tags via a single empty string argument. When TAGS= was the only field, hostnamed
+# removes /etc/machine-info altogether.
+hostnamectl tags ""
+assert_eq "$(hostnamectl tags)" ""
+assert_eq "$(get_tags_from_file)" ""
+
+# --------------------------------------------------------------------------------------------------
+# ConditionMachineTag=/AssertMachineTag= in a transient unit
+# --------------------------------------------------------------------------------------------------
+
+hostnamectl tags alpha:beta:gamma
+assert_eq "$(hostnamectl tags)" "alpha:beta:gamma"
+
+# When the condition matches, the unit is started and 'false' actually runs, so systemd-run fails.
+# When the condition does not match, the unit is skipped (not failed) and systemd-run succeeds.
+(! systemd-run --wait --pipe -p ConditionMachineTag=beta false)
+systemd-run --wait --pipe -p ConditionMachineTag=delta false
+# Globs are matched against each individual tag.
+(! systemd-run --wait --pipe -p ConditionMachineTag='al*' false)
+systemd-run --wait --pipe -p ConditionMachineTag='z*' false
+# Negation inverts the result.
+systemd-run --wait --pipe -p ConditionMachineTag='!beta' false
+(! systemd-run --wait --pipe -p ConditionMachineTag='!delta' false)
+
+# Asserts behave like conditions, except a failing assert puts the unit into a failed state, which
+# systemd-run propagates as a non-zero exit code.
+systemd-run -p AssertMachineTag=beta -p Type=oneshot true
+(! systemd-run -p AssertMachineTag=delta -p Type=oneshot true)
+
+# --------------------------------------------------------------------------------------------------
+# systemd-firstboot --machine-tags= and the firstboot.machine-tags credential
+# --------------------------------------------------------------------------------------------------
+
+if command -v systemd-firstboot >/dev/null; then
+ # The firstboot.machine-tags credential is split on ':', deduplicated and sorted, and written
+ # into /etc/machine-info underneath the target root.
+ ROOT="$(mktemp -d)"
+ systemd-run --wait --pipe --service-type=exec \
+ -p SetCredential=firstboot.machine-tags:webserver:frontend:webserver:berlin \
+ systemd-firstboot --root="$ROOT"
+ grep -qE '^TAGS="?berlin:frontend:webserver"?$' "$ROOT/etc/machine-info"
+
+ # An invalid tag anywhere in the credential causes the whole list to be ignored, so no
+ # machine-info file is written.
+ rm -fr "$ROOT"
+ ROOT="$(mktemp -d)"
+ systemd-run --wait --pipe --service-type=exec \
+ -p SetCredential=firstboot.machine-tags:'good:bad/tag' \
+ systemd-firstboot --root="$ROOT"
+ test ! -e "$ROOT/etc/machine-info"
+
+ # The --machine-tags= switch is normalized the same way and takes precedence over the credential.
+ rm -fr "$ROOT"
+ ROOT="$(mktemp -d)"
+ systemd-run --wait --pipe --service-type=exec \
+ -p SetCredential=firstboot.machine-tags:ignored \
+ systemd-firstboot --root="$ROOT" --machine-tags=database:cache:database
+ grep -qE '^TAGS="?cache:database"?$' "$ROOT/etc/machine-info"
+
+ # An invalid tag passed on the command line is a hard error.
+ rm -fr "$ROOT"
+ ROOT="$(mktemp -d)"
+ (! systemd-firstboot --root="$ROOT" --machine-tags='good:bad/tag')
+fi