]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
hostnamed: introduce "machine tags" concept
authorLennart Poettering <lennart@amutable.com>
Wed, 20 May 2026 16:45:12 +0000 (18:45 +0200)
committerLennart Poettering <lennart@amutable.com>
Thu, 21 May 2026 16:30:16 +0000 (18:30 +0200)
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
man/machine-info.xml
man/org.freedesktop.hostname1.xml
src/basic/hostname-util.c
src/basic/hostname-util.h
src/hostname/hostnamed.c
src/test/test-hostname-util.c

index 53c9e64ec49f11864bee9f6078c0be86a3c34b83..252f341bba655f0527c7d562b96c7fae434366a3 100644 (file)
           <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>
 
     <programlisting>PRETTY_HOSTNAME="Lennart's Tablet"
 ICON_NAME=computer-tablet
 CHASSIS=tablet
-DEPLOYMENT=production</programlisting>
+DEPLOYMENT=production
+TAGS=demo:berlin</programlisting>
   </refsect1>
 
   <refsect1>
index 3d98b88ebc17791c75e8bf3fcdb3ee68ed20349c..d5571de207ff63c0e62b9d12056a3cbecd35be3a 100644 (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>
 
index 01434d3d641d2864847a18e6b3174cd946aabac0..baa735ab10c01f60d83e5106a4fa408bf7bed89b 100644 (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;
+}
index 73cd83fbb8d6e891bf05e1ab5636d57a4853caf4..f3d904a1bbf718e141561d9c82b571c7fe638f2b 100644 (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);
index f8c20ff9c75a7db01f9928845d0aef797e514dbc..a39bf5a57eac53f99fb15f1d9c5f7624efea16a0 100644 (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),
index b3e3b1b84a753901c7d3051ec0351cd9a03fffe0..1a3cb1dfff5523915502d319ba9873a80c5bb85e 100644 (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);