hostnamed: introduce "machine tags" concept

For management purposes it's useful to be able to "tag" a machine with
various labels. Let's add a field for that to /etc/machine-info and make
it settable.

Fixes: #38591
This commit is contained in:
Lennart Poettering
2026-05-21 18:30:16 +02:00
parent 5e82a8d6ee
commit 1b94530b14
6 changed files with 299 additions and 13 deletions
+23 -1
View File
@@ -139,6 +139,27 @@
<xi:include href="version-info.xml" xpointer="v216"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>TAGS=</varname></term>
<listitem><para>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 (<literal>webserver</literal>, <literal>database</literal>), the
fleet or organizational unit it belongs to, or any other administrator-defined attribute. Example:
<literal>TAGS=webserver:frontend:berlin</literal>.</para>
<para>Each individual tag must be 1…255 characters long and may consist only of the ASCII
alphanumeric characters, <literal>-</literal> and <literal>.</literal>.</para>
<para>The configured tags may be matched against with the
<varname>ConditionMachineTag=</varname> and <varname>AssertMachineTag=</varname> unit settings, see
<citerefentry><refentrytitle>systemd.unit</refentrytitle><manvolnum>5</manvolnum></citerefentry>
for details. They may be queried and changed with the <command>tags</command> command of
<citerefentry><refentrytitle>hostnamectl</refentrytitle><manvolnum>1</manvolnum></citerefentry>.</para>
<xi:include href="version-info.xml" xpointer="v261"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>HARDWARE_VENDOR=</varname></term>
@@ -195,7 +216,8 @@
<programlisting>PRETTY_HOSTNAME="Lennart's Tablet"
ICON_NAME=computer-tablet
CHASSIS=tablet
DEPLOYMENT=production</programlisting>
DEPLOYMENT=production
TAGS=demo:berlin</programlisting>
</refsect1>
<refsect1>
+25 -4
View File
@@ -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 {
<variablelist class="dbus-method" generated="True" extra-ref="SetLocation()"/>
<variablelist class="dbus-method" generated="True" extra-ref="SetTags()"/>
<variablelist class="dbus-method" generated="True" extra-ref="GetProductUUID()"/>
<variablelist class="dbus-method" generated="True" extra-ref="GetHardwareSerial()"/>
@@ -168,6 +172,8 @@ node /org/freedesktop/hostname1 {
<variablelist class="dbus-property" generated="True" extra-ref="Location"/>
<variablelist class="dbus-property" generated="True" extra-ref="Tags"/>
<variablelist class="dbus-property" generated="True" extra-ref="KernelName"/>
<variablelist class="dbus-property" generated="True" extra-ref="KernelRelease"/>
@@ -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.</para>
<para>The <varname>Tags</varname> property exposes a list of <emphasis>machine tags</emphasis>: 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, <literal>-</literal> and <literal>.</literal>. The tags are stored as a
colon-separated list in the <varname>TAGS=</varname> field of <filename>/etc/machine-info</filename>, see
<citerefentry><refentrytitle>machine-info</refentrytitle><manvolnum>5</manvolnum></citerefentry> for
details. If no tags are configured this property is the empty list.</para>
<para>Note that <filename>systemd-hostnamed</filename> starts only on request and terminates after a
short idle period. This effectively means that <function>PropertyChanged</function> 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 <varname>Location</varname> (physical system location), respectively.
</para>
<para><function>SetTags()</function> sets the machine tags exposed by the <varname>Tags</varname>
property. It takes a list of strings, each of which must be a valid machine tag (1…255 ASCII
alphanumeric characters, <literal>-</literal> and <literal>.</literal>). Passing an empty list removes
the <varname>TAGS=</varname> field from <filename>/etc/machine-info</filename>.</para>
<para><varname>PrettyHostname</varname>, <varname>IconName</varname>, <varname>Chassis</varname>,
<varname>Deployment</varname>, and <varname>Location</varname> are stored in
<varname>Deployment</varname>, <varname>Location</varname>, and <varname>Tags</varname> are stored in
<filename>/etc/machine-info</filename>. See
<citerefentry><refentrytitle>machine-info</refentrytitle><manvolnum>5</manvolnum></citerefentry> for
the semantics of those settings.</para>
@@ -400,8 +420,8 @@ node /org/freedesktop/hostname1 {
<interfacename>org.freedesktop.hostname1.set-hostname</interfacename>. For
<function>SetStaticHostname()</function> and <function>SetPrettyHostname()</function> it is
<interfacename>org.freedesktop.hostname1.set-static-hostname</interfacename>. For
<function>SetIconName()</function>, <function>SetChassis()</function>, <function>SetDeployment()</function>
and <function>SetLocation()</function> it is
<function>SetIconName()</function>, <function>SetChassis()</function>, <function>SetDeployment()</function>,
<function>SetLocation()</function> and <function>SetTags()</function> it is
<interfacename>org.freedesktop.hostname1.set-machine-info</interfacename>.</para>
</refsect2>
</refsect1>
@@ -505,7 +525,8 @@ node /org/freedesktop/hostname1 {
<varname>OperatingSystemImageVersion</varname>, <varname>HardwareSKU</varname>, and
<varname>HardwareVersion</varname> were added in version 258.</para>
<para><varname>OperatingSystemFancyName</varname> was added in version 260.</para>
<para><function>GetMachineInfo()</function> was added in version 261.</para>
<para><function>GetMachineInfo()</function>, <varname>Tags</varname> and
<function>SetTags()</function> were added in version 261.</para>
</refsect2>
</refsect1>
+69
View File
@@ -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;
}
+6
View File
@@ -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);
+112 -8
View File
@@ -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],
@@ -840,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;
@@ -859,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]));
@@ -1163,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,
@@ -1561,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);
@@ -1645,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 },
@@ -1853,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),
@@ -1910,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),
+64
View File
@@ -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);