diff --git a/man/systemd-sysupdate.xml b/man/systemd-sysupdate.xml
index f7f1521a5d..778ff2e793 100644
--- a/man/systemd-sysupdate.xml
+++ b/man/systemd-sysupdate.xml
@@ -106,6 +106,16 @@
+
+ FEATURE
+
+ If invoked without an argument, enumerates optional features and shows a summarizing
+ table, including which features are enabled or disabled. If a feature argument is specified, shows
+ details about the specific feature, including the transfers that are controlled by the feature.
+
+
+
+
diff --git a/src/sysupdate/sysupdate-transfer.c b/src/sysupdate/sysupdate-transfer.c
index 350a12ee0f..1143c3cf63 100644
--- a/src/sysupdate/sysupdate-transfer.c
+++ b/src/sysupdate/sysupdate-transfer.c
@@ -45,11 +45,16 @@ Transfer* transfer_free(Transfer *t) {
t->temporary_path = rm_rf_subvolume_and_free(t->temporary_path);
+ free(t->id);
+
free(t->min_version);
strv_free(t->protected_versions);
free(t->current_symlink);
free(t->final_path);
+ strv_free(t->features);
+ strv_free(t->requisite_features);
+
strv_free(t->changelog);
strv_free(t->appstream);
@@ -520,6 +525,7 @@ int transfer_read_definition(Transfer *t, const char *path, const char **dirs, H
};
_cleanup_free_ char *filename = NULL;
+ char *e;
int r;
assert(path);
@@ -545,6 +551,10 @@ int transfer_read_definition(Transfer *t, const char *path, const char **dirs, H
if (r < 0)
return r;
+ e = ASSERT_PTR(endswith(filename, ".transfer") ?: endswith(filename, ".conf"));
+ *e = 0; /* Remove the file extension */
+ t->id = TAKE_PTR(filename);
+
t->enabled = transfer_decide_if_enabled(t, known_features);
if (!RESOURCE_IS_SOURCE(t->source.type))
diff --git a/src/sysupdate/sysupdate-transfer.h b/src/sysupdate/sysupdate-transfer.h
index 841161846f..505c573d63 100644
--- a/src/sysupdate/sysupdate-transfer.h
+++ b/src/sysupdate/sysupdate-transfer.h
@@ -15,11 +15,15 @@ typedef struct Transfer Transfer;
#include "sysupdate.h"
struct Transfer {
+ char *id;
+
char *min_version;
char **protected_versions;
char *current_symlink;
bool verify;
+ char **features;
+ char **requisite_features;
bool enabled;
Resource source, target;
diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c
index 0cdcb34a58..6b97af111b 100644
--- a/src/sysupdate/sysupdate.c
+++ b/src/sysupdate/sysupdate.c
@@ -1201,6 +1201,140 @@ static int verb_list(int argc, char **argv, void *userdata) {
}
}
+static int verb_features(int argc, char **argv, void *userdata) {
+ _cleanup_(loop_device_unrefp) LoopDevice *loop_device = NULL;
+ _cleanup_(umount_and_rmdir_and_freep) char *mounted_dir = NULL;
+ _cleanup_(context_freep) Context* context = NULL;
+ _cleanup_(table_unrefp) Table *table = NULL;
+ const char *feature_id;
+ Feature *f;
+ int r;
+
+ assert(argc <= 2);
+ feature_id = argc >= 2 ? argv[1] : NULL;
+
+ r = process_image(/* ro= */ true, &mounted_dir, &loop_device);
+ if (r < 0)
+ return r;
+
+ r = context_make_offline(&context, loop_device ? loop_device->node : NULL);
+ if (r < 0)
+ return r;
+
+ if (feature_id) {
+ _cleanup_strv_free_ char **transfers = NULL;
+
+ f = hashmap_get(context->features, feature_id);
+ if (!f)
+ return log_error_errno(SYNTHETIC_ERRNO(ENOENT),
+ "Optional feature not found: %s",
+ feature_id);
+
+ table = table_new_vertical();
+ 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 = table_add_many(table,
+ TABLE_FIELD, "Name",
+ TABLE_STRING, f->id,
+ TABLE_FIELD, "Enabled",
+ TABLE_BOOLEAN, f->enabled);
+ if (r < 0)
+ return table_log_add_error(r);
+
+ if (f->description) {
+ r = table_add_many(table, TABLE_FIELD, "Description", TABLE_STRING, f->description);
+ if (r < 0)
+ return table_log_add_error(r);
+ }
+
+ if (f->documentation) {
+ r = table_add_many(table,
+ TABLE_FIELD, "Documentation",
+ TABLE_STRING, f->documentation,
+ TABLE_SET_URL, f->documentation);
+ if (r < 0)
+ return table_log_add_error(r);
+ }
+
+ if (f->appstream) {
+ r = table_add_many(table,
+ TABLE_FIELD, "AppStream",
+ TABLE_STRING, f->appstream,
+ TABLE_SET_URL, f->appstream);
+ if (r < 0)
+ return table_log_add_error(r);
+ }
+
+ if (!strv_isempty(transfers)) {
+ r = table_add_many(table, TABLE_FIELD, "Transfers", TABLE_STRV_WRAPPED, transfers);
+ if (r < 0)
+ return table_log_add_error(r);
+ }
+
+ return table_print_with_pager(table, arg_json_format_flags, arg_pager_flags, arg_legend);
+ } else if (FLAGS_SET(arg_json_format_flags, SD_JSON_FORMAT_OFF)) {
+ table = table_new("", "feature", "description", "documentation");
+ if (!table)
+ return log_oom();
+
+ HASHMAP_FOREACH(f, context->features) {
+ r = table_add_many(table,
+ TABLE_BOOLEAN_CHECKMARK, f->enabled,
+ TABLE_SET_COLOR, ansi_highlight_green_red(f->enabled),
+ TABLE_STRING, f->id,
+ TABLE_STRING, f->description,
+ TABLE_STRING, f->documentation,
+ TABLE_SET_URL, f->documentation);
+ if (r < 0)
+ return table_log_add_error(r);
+ }
+
+ return table_print_with_pager(table, arg_json_format_flags, arg_pager_flags, arg_legend);
+ } else {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *json = NULL;
+ _cleanup_strv_free_ char **features = NULL;
+
+ HASHMAP_FOREACH(f, context->features) {
+ r = strv_extend(&features, f->id);
+ if (r < 0)
+ return log_oom();
+ }
+
+ r = sd_json_buildo(&json, SD_JSON_BUILD_PAIR_STRV("features", features));
+ if (r < 0)
+ return log_error_errno(r, "Failed to create JSON: %m");
+
+ r = sd_json_variant_dump(json, arg_json_format_flags, stdout, NULL);
+ if (r < 0)
+ return log_error_errno(r, "Failed to print JSON: %m");
+ }
+
+ return 0;
+}
+
static int verb_check_new(int argc, char **argv, void *userdata) {
_cleanup_(loop_device_unrefp) LoopDevice *loop_device = NULL;
_cleanup_(umount_and_rmdir_and_freep) char *mounted_dir = NULL;
@@ -1517,6 +1651,7 @@ static int verb_help(int argc, char **argv, void *userdata) {
"\n%5$sUpdate OS images.%6$s\n"
"\n%3$sCommands:%4$s\n"
" list [VERSION] Show installed and available versions\n"
+ " features [FEATURE] Show optional features\n"
" check-new Check if there's a new version available\n"
" update [VERSION] Install new version now\n"
" vacuum Make room, by deleting old versions\n"
@@ -1729,8 +1864,9 @@ static int sysupdate_main(int argc, char *argv[]) {
static const Verb verbs[] = {
{ "list", VERB_ANY, 2, VERB_DEFAULT, verb_list },
{ "components", VERB_ANY, 1, 0, verb_components },
+ { "features", VERB_ANY, 2, 0, verb_features },
{ "check-new", VERB_ANY, 1, 0, verb_check_new },
- { "update", VERB_ANY, 2, 0, verb_update },
+ { "update", VERB_ANY, 2, 0, verb_update },
{ "vacuum", VERB_ANY, 1, 0, verb_vacuum },
{ "reboot", 1, 1, 0, verb_pending_or_reboot },
{ "pending", 1, 1, 0, verb_pending_or_reboot },
diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh
index 0598ef2c39..e5c62c635e 100755
--- a/test/units/TEST-72-SYSUPDATE.sh
+++ b/test/units/TEST-72-SYSUPDATE.sh
@@ -303,6 +303,8 @@ 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"
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"