From f8f92584db8ca9abd470c90a20d2b1a3aebfffaa Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Tue, 2 Jun 2026 11:40:57 +0100 Subject: [PATCH 1/8] sysupdate: Improve empty table when printing features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than just printing out the table header and then exiting, print “No features” similarly to what `loginctl` or `storagectl` do. --- src/sysupdate/sysupdate.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 01b4a03f4d..48e1445de2 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -1975,7 +1975,16 @@ static int verb_features(int argc, char *argv[], uintptr_t _data, void *userdata return table_log_add_error(r); } - return table_print_with_pager(table, arg_json_format_flags, arg_pager_flags, arg_legend); + r = table_print_with_pager(table, arg_json_format_flags, arg_pager_flags, arg_legend); + if (r < 0) + return r; + + if (arg_legend) { + if (table_isempty(table)) + log_info("No features."); + else + printf("\n%zu features listed.\n", table_get_rows(table) - 1); + } } else { _cleanup_(sd_json_variant_unrefp) sd_json_variant *json = NULL; _cleanup_strv_free_ char **features = NULL; From 8085a05f867ea394b57bbc2c4bf11248fb618321 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Tue, 2 Jun 2026 15:49:18 +0100 Subject: [PATCH 2/8] sysupdate: Factor out core of `features` verb This will be used in the following commit to add a varlink interface for it. This introduces no functional changes. --- src/sysupdate/sysupdate.c | 55 ++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 48e1445de2..19fd136931 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -1703,6 +1703,37 @@ static int context_install( return 1; } +static int context_get_transfers_for_feature(Context *context, const char *feature_id, char ***ret_transfers) { + int r; + + assert(context); + assert(feature_id); + + FOREACH_ARRAY(tr, context->transfers, context->n_transfers) { + Transfer *t = *tr; + + if (!strv_contains(t->features, feature_id) && !strv_contains(t->requisite_features, feature_id)) + continue; + + r = strv_extend(ret_transfers, t->id); + if (r < 0) + return log_oom(); + } + + FOREACH_ARRAY(tr, context->disabled_transfers, context->n_disabled_transfers) { + Transfer *t = *tr; + + if (!strv_contains(t->features, feature_id) && !strv_contains(t->requisite_features, feature_id)) + continue; + + r = strv_extend(ret_transfers, t->id); + if (r < 0) + return log_oom(); + } + + return 0; +} + static JSON_DISPATCH_ENUM_DEFINE(dispatch_target_class, TargetClass, target_class_from_string); static int dispatch_target_identifier(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { @@ -1869,27 +1900,9 @@ static int verb_features(int argc, char *argv[], uintptr_t _data, void *userdata if (!table) return log_oom(); - FOREACH_ARRAY(tr, context.transfers, context.n_transfers) { - Transfer *t = *tr; - - if (!strv_contains(t->features, f->id) && !strv_contains(t->requisite_features, f->id)) - continue; - - r = strv_extend(&transfers, t->id); - if (r < 0) - return log_oom(); - } - - FOREACH_ARRAY(tr, context.disabled_transfers, context.n_disabled_transfers) { - Transfer *t = *tr; - - if (!strv_contains(t->features, f->id) && !strv_contains(t->requisite_features, f->id)) - continue; - - r = strv_extend(&transfers, t->id); - if (r < 0) - return log_oom(); - } + r = context_get_transfers_for_feature(&context, f->id, &transfers); + if (r < 0) + return r; r = table_add_many(table, TABLE_FIELD, "Name", From 3e34ccf6df88069fb4f06eae29538cd7583a4b40 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 1 Jun 2026 18:35:54 +0100 Subject: [PATCH 3/8] sysupdate: Add varlink ListFeatures() method And add integration tests for it using `jq`. --- src/shared/varlink-io.systemd.SysUpdate.c | 26 +++++++ src/sysupdate/sysupdate.c | 88 ++++++++++++++++++++++- test/units/TEST-72-SYSUPDATE.sh | 19 ++++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/shared/varlink-io.systemd.SysUpdate.c b/src/shared/varlink-io.systemd.SysUpdate.c index e80142f9c5..94a7180a50 100644 --- a/src/shared/varlink-io.systemd.SysUpdate.c +++ b/src/shared/varlink-io.systemd.SysUpdate.c @@ -25,6 +25,28 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_FIELD_COMMENT("Name of the target, unique within a class."), SD_VARLINK_DEFINE_FIELD(name, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); +static SD_VARLINK_DEFINE_STRUCT_TYPE( + Feature, + SD_VARLINK_FIELD_COMMENT("Identifier for the feature."), + SD_VARLINK_DEFINE_FIELD(id, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("A short human readable description of the feature."), + SD_VARLINK_DEFINE_FIELD(description, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("A URL to documentation for the feature."), + SD_VARLINK_DEFINE_FIELD(documentation, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("A URL to an AppStream catalog XML file describing the feature."), + SD_VARLINK_DEFINE_FIELD(appstream, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Whether the feature is enabled."), + SD_VARLINK_DEFINE_FIELD(isEnabled, SD_VARLINK_BOOL, 0), + SD_VARLINK_FIELD_COMMENT("Array of IDs of the transfers (including currently disabled ones) which are controlled by this feature."), + SD_VARLINK_DEFINE_FIELD(transfers, SD_VARLINK_STRING, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY)); + +static SD_VARLINK_DEFINE_METHOD( + ListFeatures, + SD_VARLINK_FIELD_COMMENT("Target to list features for."), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(target, TargetIdentifier, 0), + SD_VARLINK_FIELD_COMMENT("The configured features."), + SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(features, Feature, SD_VARLINK_ARRAY)); + static SD_VARLINK_DEFINE_METHOD( CheckNew, SD_VARLINK_FIELD_COMMENT("Target to check for updates for."), @@ -42,6 +64,8 @@ SD_VARLINK_DEFINE_INTERFACE( SD_VARLINK_INTERFACE_COMMENT("APIs to manage system updates"), /* Methods */ + SD_VARLINK_SYMBOL_COMMENT("Show optional features"), + &vl_method_ListFeatures, SD_VARLINK_SYMBOL_COMMENT("Check if there’s a new version available"), &vl_method_CheckNew, @@ -50,6 +74,8 @@ SD_VARLINK_DEFINE_INTERFACE( &vl_type_TargetClass, SD_VARLINK_SYMBOL_COMMENT("Identifier for a component of the system (i.e. the host itself, a sysext, a confext, etc.) that can be updated by systemd-sysupdate(8)."), &vl_type_TargetIdentifier, + SD_VARLINK_SYMBOL_COMMENT("Type containing a configured sysupdate feature."), + &vl_type_Feature, /* Errors */ SD_VARLINK_SYMBOL_COMMENT("Error indicating that no update is currently available to update to."), diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 19fd136931..9ed5d8db46 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -2241,6 +2241,91 @@ static int verb_enable_feature(int argc, char *argv[], uintptr_t _data, void *us } } +static int feature_to_json(Context *context, const Feature *f, sd_json_variant **ret) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + _cleanup_strv_free_ char **transfers = NULL; + int r; + + assert(context); + assert(f); + assert(ret); + + r = context_get_transfers_for_feature(context, f->id, &transfers); + if (r < 0) + return r; + + r = sd_json_variant_merge_objectbo( + &v, + SD_JSON_BUILD_PAIR_STRING("id", f->id), + JSON_BUILD_PAIR_STRING_NON_EMPTY("description", f->description), + JSON_BUILD_PAIR_STRING_NON_EMPTY("documentation", f->documentation), + JSON_BUILD_PAIR_STRING_NON_EMPTY("appstream", f->appstream), + SD_JSON_BUILD_PAIR_BOOLEAN("isEnabled", f->enabled), + JSON_BUILD_PAIR_STRV_NON_EMPTY("transfers", transfers)); + if (r < 0) + return log_oom(); + + *ret = TAKE_PTR(v); + return 1; +} + +static int vl_method_list_features(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + _cleanup_(context_done) Context context = CONTEXT_NULL; + Feature *f; + int r; + + assert(link); + + static const sd_json_dispatch_field dispatch_table[] = { + { "target", SD_JSON_VARIANT_OBJECT, dispatch_target_identifier, voffsetof(context, target_identifier), SD_JSON_MANDATORY }, + {}, + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &context); + if (r != 0) + return r; + + /* Listing features doesn’t require a polkit check */ + + if (getenv_bool("SYSTEMD_SYSUPDATE_NO_VERIFY") > 0) + context.verify = 0; + + /* ListFeatures is always online */ + context.offline = false; + + r = context_load_online_from_target( + &context, + PROCESS_IMAGE_READ_ONLY, + READ_DEFINITIONS_REQUIRES_ANY_TRANSFERS); + if (r == -ENOENT) + return sd_varlink_error(link, "io.systemd.SysUpdate.NoSuchTarget", NULL); + if (r < 0) + return r; + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *l = NULL; + + HASHMAP_FOREACH(f, context.features) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + + r = feature_to_json(&context, f, &v); + if (r < 0) + return r; + + r = sd_json_variant_append_array(&l, v); + if (r < 0) + return r; + } + + if (!l) { + r = sd_json_variant_new_array(&l, NULL, 0); + if (r < 0) + return r; + } + + return sd_varlink_replybo(link, + SD_JSON_BUILD_PAIR_VARIANT("features", l)); +} + VERB_NOARG(verb_check_new, "check-new", "Check if there's a new version available"); static int verb_check_new(int argc, char *argv[], uintptr_t _data, void *userdata) { @@ -3182,7 +3267,8 @@ static int vl_server(void) { r = sd_varlink_server_bind_method_many( varlink_server, - "io.systemd.SysUpdate.CheckNew", vl_method_check_new); + "io.systemd.SysUpdate.CheckNew", vl_method_check_new, + "io.systemd.SysUpdate.ListFeatures", vl_method_list_features); if (r < 0) return log_error_errno(r, "Failed to bind Varlink method: %m"); diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index d3a8a4bc61..6e3e3603b0 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -461,8 +461,16 @@ EOF verify_version_current "$blockdev" "$sector_size" v5 2 # Now let's try enabling an optional feature - "$SYSUPDATE" features | grep "optional" - "$SYSUPDATE" features optional | grep "99-optional" + if [[ "$client" == "sysupdate-cli" ]]; then + "$SYSUPDATE" features | grep "optional" + "$SYSUPDATE" features optional | grep "99-optional" + elif [[ "$client" == "varlink" ]]; then + [[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListFeatures '{"target":{"class":"host"}}' | jq -r '.features[] | select(.id=="optional") | .description') == "Optional Feature" ]] + varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListFeatures '{"target":{"class":"host"}}' | jq -r '.features[] | select(.id=="optional") | .transfers' | grep "99-optional" + else + exit 1 + fi + test ! -f "$WORKDIR/xbootldr/EFI/Linux/uki_v5.efi.extra.d/optional.efi" mkdir "$CONFIGDIR/optional.feature.d" echo -e "[Feature]\nEnabled=true" > "$CONFIGDIR/optional.feature.d/enable.conf" @@ -674,8 +682,15 @@ Path=$WORKDIR/blobs MatchPattern=tiny-@v.bin InstancesMax=1 EOF + "$SYSUPDATE" --verify=no update cmp "$WORKDIR/source/tiny-v1.bin" "$WORKDIR/blobs/tiny-v1.bin" + +# Test that listing features when none are configured gives an empty list. +"$SYSUPDATE" features |& grep "No features." >/dev/null +[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListFeatures '{"target":{"class":"host"}}' | jq -r '.features') == "[]" ]] + +# Cleanup rm "$CONFIGDIR/01-tiny-url.transfer" rm "$WORKDIR/source/tiny-v1.bin" rm "$WORKDIR/source/SHA256SUMS" From 8cb753139020d146d1eff0102105f82a96cfb634 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Tue, 2 Jun 2026 15:56:28 +0100 Subject: [PATCH 4/8] sysupdate: Add varlink ListTargets() method And add integration tests for it using `jq`. --- src/shared/varlink-io.systemd.SysUpdate.c | 14 ++ src/sysupdate/sysupdate-target.c | 32 ++++ src/sysupdate/sysupdate-target.h | 4 + src/sysupdate/sysupdate.c | 205 +++++++++++++++++++++- test/units/TEST-72-SYSUPDATE.sh | 7 + 5 files changed, 261 insertions(+), 1 deletion(-) diff --git a/src/shared/varlink-io.systemd.SysUpdate.c b/src/shared/varlink-io.systemd.SysUpdate.c index 94a7180a50..d2a4b61a5d 100644 --- a/src/shared/varlink-io.systemd.SysUpdate.c +++ b/src/shared/varlink-io.systemd.SysUpdate.c @@ -25,6 +25,11 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_FIELD_COMMENT("Name of the target, unique within a class."), SD_VARLINK_DEFINE_FIELD(name, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); +static SD_VARLINK_DEFINE_STRUCT_TYPE( + Target, + SD_VARLINK_FIELD_COMMENT("Identifier for the target."), + SD_VARLINK_DEFINE_FIELD_BY_TYPE(id, TargetIdentifier, 0)); + static SD_VARLINK_DEFINE_STRUCT_TYPE( Feature, SD_VARLINK_FIELD_COMMENT("Identifier for the feature."), @@ -55,6 +60,11 @@ static SD_VARLINK_DEFINE_METHOD( SD_VARLINK_FIELD_COMMENT("The version string for the new version which is available."), SD_VARLINK_DEFINE_OUTPUT(available, SD_VARLINK_STRING, 0)); +static SD_VARLINK_DEFINE_METHOD( + ListTargets, + SD_VARLINK_FIELD_COMMENT("The configured targets."), + SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(targets, Target, SD_VARLINK_ARRAY)); + static SD_VARLINK_DEFINE_ERROR(NoUpdateNeeded); static SD_VARLINK_DEFINE_ERROR(NoSuchTarget); @@ -68,12 +78,16 @@ SD_VARLINK_DEFINE_INTERFACE( &vl_method_ListFeatures, SD_VARLINK_SYMBOL_COMMENT("Check if there’s a new version available"), &vl_method_CheckNew, + SD_VARLINK_SYMBOL_COMMENT("Show targets"), + &vl_method_ListTargets, /* Types */ SD_VARLINK_SYMBOL_COMMENT("Class of a Target."), &vl_type_TargetClass, SD_VARLINK_SYMBOL_COMMENT("Identifier for a component of the system (i.e. the host itself, a sysext, a confext, etc.) that can be updated by systemd-sysupdate(8)."), &vl_type_TargetIdentifier, + SD_VARLINK_SYMBOL_COMMENT("Type containing a configured sysupdate target."), + &vl_type_Target, SD_VARLINK_SYMBOL_COMMENT("Type containing a configured sysupdate feature."), &vl_type_Feature, diff --git a/src/sysupdate/sysupdate-target.c b/src/sysupdate/sysupdate-target.c index ef132960f3..8559026927 100644 --- a/src/sysupdate/sysupdate-target.c +++ b/src/sysupdate/sysupdate-target.c @@ -1,5 +1,7 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include "alloc-util.h" +#include "macro.h" #include "string-table.h" #include "sysupdate-target.h" @@ -14,9 +16,39 @@ static const char* const target_class_table[_TARGET_CLASS_MAX] = { DEFINE_STRING_TABLE_LOOKUP(target_class, TargetClass); +int target_identifier_new(TargetClass class, const char *name, TargetIdentifier **ret) { + _cleanup_(target_identifier_freep) TargetIdentifier *t = NULL; + + assert(name); + assert(ret); + + t = new(TargetIdentifier, 1); + if (!t) + return -ENOMEM; + + *t = (TargetIdentifier) { + .class = class, + .name = strdup(name), + }; + + if (!t->name) + return -ENOMEM; + + *ret = TAKE_PTR(t); + return 0; +} + void target_identifier_done(TargetIdentifier *t) { assert(t); t->class = _TARGET_CLASS_INVALID; t->name = mfree(t->name); } + +TargetIdentifier *target_identifier_free(TargetIdentifier *t) { + if (!t) + return NULL; + + target_identifier_done(t); + return mfree(t); +} diff --git a/src/sysupdate/sysupdate-target.h b/src/sysupdate/sysupdate-target.h index 9c0a13bf4a..5c8d93e809 100644 --- a/src/sysupdate/sysupdate-target.h +++ b/src/sysupdate/sysupdate-target.h @@ -29,4 +29,8 @@ typedef struct TargetIdentifier { char *name; } TargetIdentifier; +int target_identifier_new(TargetClass class, const char *name, TargetIdentifier **ret); void target_identifier_done(TargetIdentifier *t); +TargetIdentifier *target_identifier_free(TargetIdentifier *t); + +DEFINE_TRIVIAL_CLEANUP_FUNC(TargetIdentifier*, target_identifier_free); diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 9ed5d8db46..cd9325ee57 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -38,6 +38,8 @@ #include "path-util.h" #include "pretty-print.h" #include "runtime-scope.h" +#include "set.h" +#include "siphash24.h" #include "sort-util.h" #include "stdio-util.h" #include "string-util.h" @@ -1268,6 +1270,145 @@ static int context_load_paths_from_image(Context *context, Image *image) { } } +static void target_identifier_hash_func(const TargetIdentifier *t, struct siphash *state) { + assert(t); + + siphash24_compress_typesafe(t->class, state); + siphash24_compress_string(t->name, state); +} + +static int target_identifier_compare_func(const TargetIdentifier *x, const TargetIdentifier *y) { + int r; + + assert(x); + assert(y); + + r = CMP(x->class, y->class); + if (r != 0) + return r; + + return strcmp(x->name, y->name); +} + +DEFINE_PRIVATE_HASH_OPS_WITH_VALUE_DESTRUCTOR(target_identifier_hash_ops, + TargetIdentifier, target_identifier_hash_func, target_identifier_compare_func, + TargetIdentifier, target_identifier_free); + +static int enumerate_image_class(RuntimeScope runtime_scope, TargetClass class, Set **targets) { + _cleanup_hashmap_free_ Hashmap *images = NULL; + Image *image; + int r; + + r = image_discover(runtime_scope, (ImageClass) class, NULL, &images); + if (r < 0) + return r; + + HASHMAP_FOREACH(image, images) { + _cleanup_(target_identifier_freep) TargetIdentifier *t = NULL; + bool have = false; + _cleanup_(context_done) Context image_context = CONTEXT_NULL; + + if (image_is_host(image)) + continue; /* We already enroll the host ourselves */ + + if (!image_type_can_sysupdate(image->type)) + continue; + + r = context_load_paths_from_image(&image_context, image); + if (r < 0) + return r; + + /* Load the components in a separate Context specific to the given Image before + * committing to loading that state to the main Context. */ + r = context_load_offline( + &image_context, + PROCESS_IMAGE_READ_ONLY, + /* read_definitions_flags= */ 0); + if (r < 0) + return r; + + r = context_list_components(&image_context, /* ret_component_names= */ NULL, &have); + if (r < 0) + return r; + if (!have) { + log_debug("Skipping %s because it has no default component", image->path); + continue; + } + + r = target_identifier_new(class, image->name, &t); + if (r < 0) + return r; + + r = set_ensure_consume(targets, &target_identifier_hash_ops, TAKE_PTR(t)); + if (r < 0) + return r; + } + + return 0; +} + +static int context_enumerate_components(Context *context, Set **targets) { + _cleanup_strv_free_ char **component_names = NULL; + bool have_default_component; + int r; + + assert(context); + + r = context_list_components(context, &component_names, &have_default_component); + if (r < 0) + return r; + + if (have_default_component) { + _cleanup_(target_identifier_freep) TargetIdentifier *t = NULL; + + r = target_identifier_new(TARGET_HOST, "host", &t); + if (r < 0) + return r; + + r = set_ensure_consume(targets, &target_identifier_hash_ops, TAKE_PTR(t)); + if (r < 0) + return r; + } + + STRV_FOREACH(component, component_names) { + _cleanup_(target_identifier_freep) TargetIdentifier *t = NULL; + + r = target_identifier_new(TARGET_COMPONENT, *component, &t); + if (r < 0) + return r; + + r = set_ensure_consume(targets, &target_identifier_hash_ops, TAKE_PTR(t)); + if (r < 0) + return r; + } + + return 0; +} + +static int context_enumerate_targets(Context *context, Set **targets) { + static const TargetClass discoverable_classes[] = { + TARGET_MACHINE, + TARGET_PORTABLE, + TARGET_SYSEXT, + TARGET_CONFEXT, + }; + int r; + + assert(context); + + FOREACH_ARRAY(class, discoverable_classes, ELEMENTSOF(discoverable_classes)) { + r = enumerate_image_class(RUNTIME_SCOPE_SYSTEM, *class, targets); + if (r < 0) + return r; + } + + r = context_enumerate_components(context, targets); + if (r < 0) + return r; + + return 0; +} + /* Load a Context to point to the target given by the TargetIdentifier. The TargetIdentifier will have been * syntactically validated by dispatch_target_identifier(), but might still point to components which don’t * exist, images which the user isn’t privileged to access, etc. This function validates the TargetIdentifier @@ -2877,6 +3018,67 @@ static int verb_components(int argc, char *argv[], uintptr_t _data, void *userda return 0; } +static int vl_method_list_targets(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + _cleanup_(context_done) Context context = CONTEXT_NULL; + _cleanup_set_free_ Set *targets = NULL; + int r; + + assert(link); + + r = sd_varlink_dispatch(link, parameters, NULL, NULL); + if (r != 0) + return r; + + /* Listing targets doesn’t require a polkit check */ + + if (getenv_bool("SYSTEMD_SYSUPDATE_NO_VERIFY") > 0) + context.verify = 0; + + /* ListTargets is always offline */ + context.offline = true; + + r = context_load_offline( + &context, + PROCESS_IMAGE_READ_ONLY, + /* read_definitions_flags= */ 0); + if (r < 0) + return r; + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *l = NULL; + + r = context_enumerate_targets(&context, &targets); + if (r < 0) + return r; + + /* Sort to ensure consistent ordering */ + size_t n; + _cleanup_free_ TargetIdentifier **sorted = NULL; + r = set_dump_sorted(targets, (void***) &sorted, &n); + if (r < 0) + return log_oom(); + + FOREACH_ARRAY(p, sorted, n) { + TargetIdentifier *target_identifier = *p; + const char *name = (target_identifier->class != TARGET_HOST) ? target_identifier->name : NULL; + + r = sd_json_variant_append_arraybo(&l, + SD_JSON_BUILD_PAIR_OBJECT("id", + JSON_BUILD_PAIR_ENUM("class", target_class_to_string(target_identifier->class)), + SD_JSON_BUILD_PAIR_STRING("name", name))); + if (r < 0) + return r; + } + + if (!l) { + r = sd_json_variant_new_array(&l, NULL, 0); + if (r < 0) + return r; + } + + return sd_varlink_replybo(link, + SD_JSON_BUILD_PAIR_VARIANT("targets", l)); +} + VERB(verb_enable_component, "enable-component", "COMPONENT…", 1, VERB_ANY, 0, "Enable component"); VERB(verb_enable_component, "disable-component", "COMPONENT…", 1, VERB_ANY, 0, @@ -3268,7 +3470,8 @@ static int vl_server(void) { r = sd_varlink_server_bind_method_many( varlink_server, "io.systemd.SysUpdate.CheckNew", vl_method_check_new, - "io.systemd.SysUpdate.ListFeatures", vl_method_list_features); + "io.systemd.SysUpdate.ListFeatures", vl_method_list_features, + "io.systemd.SysUpdate.ListTargets", vl_method_list_targets); if (r < 0) return log_error_errno(r, "Failed to bind Varlink method: %m"); diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index 6e3e3603b0..abca295d7d 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -647,8 +647,11 @@ MatchPattern=some-component_@v CurrentSymlink=some-component EOF "$SYSUPDATE" --json=short components | grep -F '{"default":false,"components":["some-component"]}' >/dev/null +varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -e '.targets | all(.id.class != "host")' >/dev/null mkdir /run/sysupdate.d "$SYSUPDATE" --json=short components | grep -F '{"default":false,"components":["some-component"]}' >/dev/null +varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -e '.targets | all(.id.class != "host")' >/dev/null +[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -r '.targets[0].id.name') == "some-component" ]] # Regression test for https://github.com/systemd/systemd/issues/42330 — the # 'pending'/'reboot' verbs and the '--reboot' switch compare the newest installed @@ -1441,4 +1444,8 @@ cmp "$WORKDIR/source/pack-v2" "$WORKDIR/blobs/pack-v2" rm -rf "$CONFIGDIR/01-manifest-yes-and-retry.transfer" \ "$WORKDIR/source/pack-v1" "$WORKDIR/source/pack-v2" +# Test that listing components/targets when none are configured gives an empty list. +"$SYSUPDATE" components |& grep "No components defined." >/dev/null +[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -r '.targets') == "[]" ]] + touch /testok From 8c166bc35e0cf1b79ebdda32a9dc396c39ce90c4 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 22 Jun 2026 20:20:20 +0100 Subject: [PATCH 5/8] sysupdate: Downgrade an info to a debug log message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since we now enumerate all targets on a varlink call (to validate the requested target), this message gets printed multiple times in the log. It’s not really necessary, so downgrade it to a debug message. --- src/sysupdate/sysupdate.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index cd9325ee57..01c5c2dd56 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -469,7 +469,7 @@ static int context_load_installed_instances(Context *c) { assert(c); - log_info("Discovering installed instances%s", glyph(GLYPH_ELLIPSIS)); + log_debug("Discovering installed instances%s", glyph(GLYPH_ELLIPSIS)); FOREACH_ARRAY(tr, c->transfers, c->n_transfers) { Transfer *t = *tr; From f6cd12613788cb28363fc683957e9ce6583e0e39 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 22 Jun 2026 20:22:16 +0100 Subject: [PATCH 6/8] test: Remove a redundant exit call `[[ blah ]] || exit 1` is equivalent to `[[ blah ]]`. Fixes: b0ca987cd96 --- test/units/TEST-72-SYSUPDATE.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index abca295d7d..8e0c464c79 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -249,7 +249,7 @@ verify_version_current() { verify_object_fields() { local updatectl_output="${1:?}" - [[ "${updatectl_output}" != *"Unrecognized object field"* ]] || exit 1 + [[ "${updatectl_output}" != *"Unrecognized object field"* ]] } for sector_size in "${SECTOR_SIZES[@]}"; do From a8a88fc3ba995de1b220f775ad03d164efeef1f7 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Thu, 9 Jul 2026 13:55:01 +0100 Subject: [PATCH 7/8] sysupdate: Run ListFeatures in offline mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Historically, the ListFeatures API (in both D-Bus and now varlink) was run without an `--offline` flag. This appears like it’s an oversight, but actually `verb_features()` always unconditionally loaded in offline mode. In any case, there doesn’t appear to be a reason for the context to be online (i.e. for it to check sources for available updates). Features are defined in local config files and are loaded by `read_features()`, which is called in both offline and online mode. When the varlink ListFeatures API was added, it was put into online mode in order to match the lack of `--offline` argument in the existing D-Bus API implementation. Change both of them to be explicitly in offline mode. Signed-off-by: Philip Withnall --- src/sysupdate/sysupdate.c | 51 ++++++++++++++++++++++++++++++-------- src/sysupdate/sysupdated.c | 2 +- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 01c5c2dd56..6270dc707c 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -1409,14 +1409,7 @@ static int context_enumerate_targets(Context *context, Set **targets) { return 0; } -/* Load a Context to point to the target given by the TargetIdentifier. The TargetIdentifier will have been - * syntactically validated by dispatch_target_identifier(), but might still point to components which don’t - * exist, images which the user isn’t privileged to access, etc. This function validates the TargetIdentifier - * against an enumerated list of known targets, which are safe to update without additional permissions. */ -static int context_load_online_from_target( - Context *context, - ProcessImageFlags process_image_flags, - ReadDefinitionsFlags read_definitions_flags) { +static int context_load_paths_from_target(Context *context) { int r; assert(context); @@ -1507,9 +1500,45 @@ static int context_load_online_from_target( assert_not_reached(); } + return 0; +} + +/* Load a Context to point to the target given by the TargetIdentifier. The TargetIdentifier will have been + * syntactically validated by dispatch_target_identifier(), but might still point to components which don’t + * exist, images which the user isn’t privileged to access, etc. This function validates the TargetIdentifier + * against an enumerated list of known targets, which are safe to update without additional permissions. */ +static int context_load_online_from_target( + Context *context, + ProcessImageFlags process_image_flags, + ReadDefinitionsFlags read_definitions_flags) { + int r; + + assert(context); + assert(context->target_identifier.class != _TARGET_CLASS_INVALID); + + r = context_load_paths_from_target(context); + if (r < 0) + return r; + return context_load_online(context, process_image_flags, read_definitions_flags); } +static int context_load_offline_from_target( + Context *context, + ProcessImageFlags process_image_flags, + ReadDefinitionsFlags read_definitions_flags) { + int r; + + assert(context); + assert(context->target_identifier.class != _TARGET_CLASS_INVALID); + + r = context_load_paths_from_target(context); + if (r < 0) + return r; + + return context_load_offline(context, process_image_flags, read_definitions_flags); +} + static int context_on_acquire_progress(const Transfer *t, const Instance *inst, unsigned percentage) { const Context *c = ASSERT_PTR(t->context); size_t i, n = c->n_transfers; @@ -2431,10 +2460,10 @@ static int vl_method_list_features(sd_varlink *link, sd_json_variant *parameters if (getenv_bool("SYSTEMD_SYSUPDATE_NO_VERIFY") > 0) context.verify = 0; - /* ListFeatures is always online */ - context.offline = false; + /* ListFeatures is always offline */ + context.offline = true; - r = context_load_online_from_target( + r = context_load_offline_from_target( &context, PROCESS_IMAGE_READ_ONLY, READ_DEFINITIONS_REQUIRES_ANY_TRANSFERS); diff --git a/src/sysupdate/sysupdated.c b/src/sysupdate/sysupdated.c index bc5a2439b5..d2a8297d82 100644 --- a/src/sysupdate/sysupdated.c +++ b/src/sysupdate/sysupdated.c @@ -1380,7 +1380,7 @@ static int target_method_list_features(sd_bus_message *msg, void *userdata, sd_b if (flags != 0) return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Flags must be 0"); - r = sysupdate_run_simple(&json, t, "features", NULL); + r = sysupdate_run_simple(&json, t, "--offline", "features", NULL); if (r < 0) return r; From 75a247d4de5a16ec950eea9f9bc805386386bf13 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 13 Jul 2026 15:55:41 +0100 Subject: [PATCH 8/8] sysupdate: Allow multiple documentation URLs for a feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the varlink API for Feature structs to allow multiple documentation URLs for them, to match what systemd already does for units etc. This is a deviation from what the sysupdated D-Bus API allows and, for the moment, from what’s supported internally by sysupdate. Internally it continues to support 0-1 URLs for now. But by defining the API as a strv, multiple URLs can be supported in future without API breaks. --- src/shared/varlink-io.systemd.SysUpdate.c | 4 ++-- src/sysupdate/sysupdate.c | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/shared/varlink-io.systemd.SysUpdate.c b/src/shared/varlink-io.systemd.SysUpdate.c index d2a4b61a5d..0fca89adf0 100644 --- a/src/shared/varlink-io.systemd.SysUpdate.c +++ b/src/shared/varlink-io.systemd.SysUpdate.c @@ -36,8 +36,8 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_DEFINE_FIELD(id, SD_VARLINK_STRING, 0), SD_VARLINK_FIELD_COMMENT("A short human readable description of the feature."), SD_VARLINK_DEFINE_FIELD(description, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), - SD_VARLINK_FIELD_COMMENT("A URL to documentation for the feature."), - SD_VARLINK_DEFINE_FIELD(documentation, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("URLs to documentation for the feature."), + SD_VARLINK_DEFINE_FIELD(documentation, SD_VARLINK_STRING, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), SD_VARLINK_FIELD_COMMENT("A URL to an AppStream catalog XML file describing the feature."), SD_VARLINK_DEFINE_FIELD(appstream, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("Whether the feature is enabled."), diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 6270dc707c..ee74c2c31f 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -2414,6 +2414,7 @@ static int verb_enable_feature(int argc, char *argv[], uintptr_t _data, void *us static int feature_to_json(Context *context, const Feature *f, sd_json_variant **ret) { _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; _cleanup_strv_free_ char **transfers = NULL; + const char *documentation_strv[2] = { NULL, }; int r; assert(context); @@ -2424,11 +2425,15 @@ static int feature_to_json(Context *context, const Feature *f, sd_json_variant * if (r < 0) return r; + /* FIXME: Long term we’d like to support an array of documentation, but currently the D-Bus interface + * doesn’t support that and neither do the internals of sysupdate. So just expose 0 or 1 URLs for now. */ + documentation_strv[0] = f->documentation; + r = sd_json_variant_merge_objectbo( &v, SD_JSON_BUILD_PAIR_STRING("id", f->id), JSON_BUILD_PAIR_STRING_NON_EMPTY("description", f->description), - JSON_BUILD_PAIR_STRING_NON_EMPTY("documentation", f->documentation), + JSON_BUILD_PAIR_STRV_NON_EMPTY("documentation", (char **) documentation_strv), JSON_BUILD_PAIR_STRING_NON_EMPTY("appstream", f->appstream), SD_JSON_BUILD_PAIR_BOOLEAN("isEnabled", f->enabled), JSON_BUILD_PAIR_STRV_NON_EMPTY("transfers", transfers));