From dc3e59f50c556746b4e4971ee9312b9c470cdb46 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 19 Jan 2026 17:15:33 +0000 Subject: [PATCH 1/6] sysupdated: Factor out a common check about job types This introduces no functional changes. Signed-off-by: Philip Withnall --- src/sysupdate/sysupdated.c | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/sysupdate/sysupdated.c b/src/sysupdate/sysupdated.c index 4eeaf3a706..b0b7d4aeff 100644 --- a/src/sysupdate/sysupdated.c +++ b/src/sysupdate/sysupdated.c @@ -220,6 +220,11 @@ static int job_new(JobType type, Target *t, sd_bus_message *msg, JobComplete com return 0; } +/* Is Job in the set of jobs which require Target.busy to be set so they run exclusively? */ +static bool job_requires_busy(Job *j) { + return IN_SET(j->type, JOB_UPDATE, JOB_VACUUM); +} + static int job_parse_child_output(int _fd, sd_json_variant **ret) { _cleanup_close_ int fd = ASSERT_FD(_fd); /* Take ownership of the passed fd */ _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; @@ -332,7 +337,7 @@ static int job_on_exit(sd_event_source *s, const siginfo_t *si, void *userdata) assert(s); assert(si); - if (IN_SET(j->type, JOB_UPDATE, JOB_VACUUM)) { + if (job_requires_busy(j)) { assert(j->target->busy); j->target->busy = false; } @@ -430,7 +435,7 @@ static int job_start(Job *j) { assert(j); - if (IN_SET(j->type, JOB_UPDATE, JOB_VACUUM) && j->target->busy) + if (job_requires_busy(j) && j->target->busy) return log_notice_errno(SYNTHETIC_ERRNO(EBUSY), "Target %s busy, ignoring job.", j->target->name); stdout_fd = memfd_new("sysupdate-stdout"); @@ -547,7 +552,7 @@ static int job_start(Job *j) { j->stdout_fd = TAKE_FD(stdout_fd); - if (IN_SET(j->type, JOB_UPDATE, JOB_VACUUM)) + if (job_requires_busy(j)) j->target->busy = true; return 0; From f0b2ea63f4e6876839a05dd05bc8bc338d8e8abb Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 19 Jan 2026 17:16:54 +0000 Subject: [PATCH 2/6] sysupdated: Add Acquire() and Install() D-Bus methods These mirror the `sysupdate acquire` and `sysupdate update --offline` verbs, which are themselves a split of `sysupdate update` into downloading and installing stages. The existing `sysupdated` `Update()` method is kept for now, for convenience. It might be dropped in future. Signed-off-by: Philip Withnall Helps: https://github.com/systemd/systemd/issues/34814 --- man/org.freedesktop.sysupdate1.xml | 48 +++- src/sysupdate/org.freedesktop.sysupdate1.conf | 8 + src/sysupdate/sysupdated.c | 206 +++++++++++++++++- 3 files changed, 252 insertions(+), 10 deletions(-) diff --git a/man/org.freedesktop.sysupdate1.xml b/man/org.freedesktop.sysupdate1.xml index a34ca18c5c..08fc729c50 100644 --- a/man/org.freedesktop.sysupdate1.xml +++ b/man/org.freedesktop.sysupdate1.xml @@ -125,6 +125,16 @@ node /org/freedesktop/sysupdate1/target/host { out s new_version, out t job_id, out o job_path); + Acquire(in s new_version, + in t flags, + out s new_version, + out t job_id, + out o job_path); + Install(in s new_version, + in t flags, + out s new_version, + out t job_id, + out o job_path); Vacuum(out u instances, out u disabled_transfers); GetAppStream(out as appstream); @@ -165,6 +175,10 @@ node /org/freedesktop/sysupdate1/target/host { + + + + @@ -272,6 +286,26 @@ node /org/freedesktop/sysupdate1/target/host { by the caller. This method pulls both metadata and payload data from the network. Listen for the Manager's JobRemoved() signal to detect when the job is complete. + Acquire() downloads an update for this target, if one is available. If a + new_version is specified, that is the version that gets downloaded. Otherwise, the + latest version is downloaded. Call Install() to install the acquired update. + The flags argument is added for future extensibility. No flags are currently + defined, and the argument is required to be set to 0. This method pulls both + metadata and payload data from the network. + + Install() installs an already-acquired update for this target. If a + new_version is specified, that is the version that gets installed, assuming it has + already been acquired. Otherwise, the latest acquired version is installed. The + flags argument is added for future extensibility. No flags are currently defined, + and the argument is required to be set to 0. + + Unlike all the other methods in this interface, Acquire() and + Install() do not wait for their jobs to complete. Instead, they return the job's + numeric ID and object path as soon as the job begins, so that the caller can listen for progress + updates or cancel the operation. These methods also return the version the target will be updated to, + for cases where no version was specified by the caller. Listen for the Manager's + JobRemoved() signal to detect when the job is complete. + Vacuum() deletes old installed versions of this target to free up space. It returns the number of instances that have been deleted. @@ -347,7 +381,7 @@ node /org/freedesktop/sysupdate1/target/host { preemptive decisions to be made about features that are planned to appear in future releases of the OS. The drop-in will have a filename of 50-systemd-sysupdate-enabled.conf. This method only changes configuration files; to actually apply the changes, clients will need to - call Update(). + call Update() (or Acquire() and Install()). Depending on the exact needs of the client, it can choose to update the system to the latest available version, or it can extend the newest existing installation in-place (by passing in the version returned by GetVersion()). @@ -397,7 +431,8 @@ node /org/freedesktop/sysupdate1/target/host { use the polkit action org.freedesktop.sysupdate1.check. By default, this action is permitted without administrator authentication. - Update() uses the polkit action + Update(), Acquire() and Install() + use the polkit action org.freedesktop.sysupdate1.update when no version is specified. By default, this action is permitted without administrator authentication. When a version is specified, org.freedesktop.sysupdate1.update-to-version is @@ -496,14 +531,15 @@ node /org/freedesktop/sysupdate1/job/_1 { The Type property exposes the type of operation (one of: list, describe, check-new, - update, vacuum, or describe-feature). + update, acquire, install, + vacuum, or describe-feature). The Offline property exposes whether the job is permitted to access the network or not. The Progress property exposes the current progress of the job as a value - between 0 and 100. It is only available for update jobs; for all other jobs - it is always 0. + between 0 and 100. It is only available for update, acquire and + install jobs; for all other jobs it is always 0. @@ -563,6 +599,8 @@ node /org/freedesktop/sysupdate1/job/_1 { Describe(), CheckNew(), Update(), + Acquire(), + Install(), Vacuum(), GetAppStream(), GetVersion(), diff --git a/src/sysupdate/org.freedesktop.sysupdate1.conf b/src/sysupdate/org.freedesktop.sysupdate1.conf index da45c02017..c98acb5736 100644 --- a/src/sysupdate/org.freedesktop.sysupdate1.conf +++ b/src/sysupdate/org.freedesktop.sysupdate1.conf @@ -66,6 +66,14 @@ send_interface="org.freedesktop.sysupdate1.Target" send_member="Update"/> + + + + diff --git a/src/sysupdate/sysupdated.c b/src/sysupdate/sysupdated.c index b0b7d4aeff..57a5cb0457 100644 --- a/src/sysupdate/sysupdated.c +++ b/src/sysupdate/sysupdated.c @@ -101,6 +101,8 @@ typedef enum JobType { JOB_DESCRIBE, JOB_CHECK_NEW, JOB_UPDATE, + JOB_ACQUIRE, + JOB_INSTALL, JOB_VACUUM, JOB_DESCRIBE_FEATURE, _JOB_TYPE_MAX, @@ -121,7 +123,7 @@ struct Job { JobType type; bool offline; - char *version; /* Passed into sysupdate for JOB_DESCRIBE and JOB_UPDATE */ + char *version; /* Passed into sysupdate for JOB_DESCRIBE, JOB_UPDATE, JOB_ACQUIRE and JOB_INSTALL */ char *feature; /* Passed into sysupdate for JOB_DESCRIBE_FEATURE */ unsigned progress_percent; @@ -154,6 +156,8 @@ static const char* const job_type_table[_JOB_TYPE_MAX] = { [JOB_DESCRIBE] = "describe", [JOB_CHECK_NEW] = "check-new", [JOB_UPDATE] = "update", + [JOB_ACQUIRE] = "acquire", + [JOB_INSTALL] = "install", [JOB_VACUUM] = "vacuum", [JOB_DESCRIBE_FEATURE] = "describe-feature", }; @@ -222,7 +226,7 @@ static int job_new(JobType type, Target *t, sd_bus_message *msg, JobComplete com /* Is Job in the set of jobs which require Target.busy to be set so they run exclusively? */ static bool job_requires_busy(Job *j) { - return IN_SET(j->type, JOB_UPDATE, JOB_VACUUM); + return IN_SET(j->type, JOB_UPDATE, JOB_ACQUIRE, JOB_INSTALL, JOB_VACUUM); } static int job_parse_child_output(int _fd, sd_json_variant **ret) { @@ -458,8 +462,8 @@ static int job_start(Job *j) { NULL, /* maybe --verify=no */ NULL, /* maybe --component=, --root=, or --image= */ NULL, /* maybe --offline */ - NULL, /* list, check-new, update, vacuum, features */ - NULL, /* maybe version (for list, update), maybe feature (features) */ + NULL, /* list, check-new, acquire, update, vacuum, features */ + NULL, /* maybe version (for list, acquire, update), maybe feature (features) */ NULL }; size_t k = 2; @@ -484,7 +488,7 @@ static int job_start(Job *j) { if (target_arg) cmd[k++] = target_arg; - if (j->offline) + if (j->offline || j->type == JOB_INSTALL) /* install is implemented as `update --offline` */ cmd[k++] = "--offline"; switch (j->type) { @@ -507,6 +511,16 @@ static int job_start(Job *j) { cmd[k++] = empty_to_null(j->version); break; + case JOB_ACQUIRE: + cmd[k++] = "acquire"; + cmd[k++] = empty_to_null(j->version); + break; + + case JOB_INSTALL: + cmd[k++] = "update"; /* install is implemented as `update --offline` */ + cmd[k++] = empty_to_null(j->version); + break; + case JOB_VACUUM: cmd[k++] = "vacuum"; break; @@ -587,6 +601,8 @@ static int job_method_cancel(sd_bus_message *msg, void *userdata, sd_bus_error * break; case JOB_UPDATE: + case JOB_ACQUIRE: + case JOB_INSTALL: if (j->version) action = "org.freedesktop.sysupdate1.update-to-version"; else @@ -1152,6 +1168,174 @@ static int target_method_update(sd_bus_message *msg, void *userdata, sd_bus_erro return 1; } +static int target_method_acquire_finished_early( + sd_bus_message *msg, + const Job *j, + sd_json_variant *json, + sd_bus_error *error) { + + /* Called when job finishes w/ a successful exit code, but before any work begins. + * This happens when there is no candidate (i.e. we're already up-to-date), or + * specified update is already acquired. */ + return sd_bus_error_setf(error, BUS_ERROR_NO_UPDATE_CANDIDATE, + "Job exited successfully with no work to do, assume already acquired"); +} + +static int target_method_acquire_detach(sd_bus_message *msg, const Job *j) { + int r; + + assert(msg); + assert(j); + + r = sd_bus_reply_method_return(msg, "sto", j->version, j->id, j->object_path); + if (r < 0) + return bus_log_parse_error(r); + + return 0; +} + +static int target_method_acquire(sd_bus_message *msg, void *userdata, sd_bus_error *error) { + Target *t = ASSERT_PTR(userdata); + _cleanup_(job_freep) Job *j = NULL; + const char *version, *action; + uint64_t flags; + int r; + + assert(msg); + + r = sd_bus_message_read(msg, "st", &version, &flags); + if (r < 0) + return r; + + if (flags != 0) + return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Flags must be 0"); + + /* We don’t have a separate polkit action for acquire/install as they are both effectively (part of) + * an update anyway. */ + if (isempty(version)) + action = "org.freedesktop.sysupdate1.update"; + else + action = "org.freedesktop.sysupdate1.update-to-version"; + + const char *details[] = { + "class", target_class_to_string(t->class), + "name", t->name, + "version", version, + NULL + }; + + r = bus_verify_polkit_async( + msg, + action, + details, + &t->manager->polkit_registry, + error); + if (r < 0) + return r; + if (r == 0) + return 1; /* Will call us back */ + + r = job_new(JOB_ACQUIRE, t, msg, target_method_acquire_finished_early, &j); + if (r < 0) + return r; + j->detach_cb = target_method_acquire_detach; + + j->version = strdup(version); + if (!j->version) + return -ENOMEM; + + r = job_start(j); + if (r < 0) + return sd_bus_error_set_errnof(error, r, "Failed to start job: %m"); + TAKE_PTR(j); + + return 1; +} + +static int target_method_install_finished_early( + sd_bus_message *msg, + const Job *j, + sd_json_variant *json, + sd_bus_error *error) { + + /* Called when job finishes w/ a successful exit code, but before any work begins. + * This happens when there is no candidate (i.e. we're already up-to-date), or + * specified update is already installed. */ + return sd_bus_error_setf(error, BUS_ERROR_NO_UPDATE_CANDIDATE, + "Job exited successfully with no work to do, assume already installed"); +} + +static int target_method_install_detach(sd_bus_message *msg, const Job *j) { + int r; + + assert(msg); + assert(j); + + r = sd_bus_reply_method_return(msg, "sto", j->version, j->id, j->object_path); + if (r < 0) + return bus_log_parse_error(r); + + return 0; +} + +static int target_method_install(sd_bus_message *msg, void *userdata, sd_bus_error *error) { + Target *t = ASSERT_PTR(userdata); + _cleanup_(job_freep) Job *j = NULL; + const char *version, *action; + uint64_t flags; + int r; + + assert(msg); + + r = sd_bus_message_read(msg, "st", &version, &flags); + if (r < 0) + return r; + + if (flags != 0) + return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Flags must be 0"); + + /* We don’t have a separate polkit action for acquire/install as they are both effectively (part of) + * an update anyway. */ + if (isempty(version)) + action = "org.freedesktop.sysupdate1.update"; + else + action = "org.freedesktop.sysupdate1.update-to-version"; + + const char *details[] = { + "class", target_class_to_string(t->class), + "name", t->name, + "version", version, + NULL + }; + + r = bus_verify_polkit_async( + msg, + action, + details, + &t->manager->polkit_registry, + error); + if (r < 0) + return r; + if (r == 0) + return 1; /* Will call us back */ + + r = job_new(JOB_INSTALL, t, msg, target_method_install_finished_early, &j); + if (r < 0) + return r; + j->detach_cb = target_method_install_detach; + + j->version = strdup(version); + if (!j->version) + return -ENOMEM; + + r = job_start(j); + if (r < 0) + return sd_bus_error_set_errnof(error, r, "Failed to start job: %m"); + TAKE_PTR(j); + + return 1; +} + static int target_method_vacuum_finish( sd_bus_message *msg, const Job *j, @@ -1591,6 +1775,18 @@ static const sd_bus_vtable target_vtable[] = { target_method_update, SD_BUS_VTABLE_UNPRIVILEGED), + SD_BUS_METHOD_WITH_ARGS("Acquire", + SD_BUS_ARGS("s", new_version, "t", flags), + SD_BUS_RESULT("s", new_version, "t", job_id, "o", job_path), + target_method_acquire, + SD_BUS_VTABLE_UNPRIVILEGED), + + SD_BUS_METHOD_WITH_ARGS("Install", + SD_BUS_ARGS("s", new_version, "t", flags), + SD_BUS_RESULT("s", new_version, "t", job_id, "o", job_path), + target_method_install, + SD_BUS_VTABLE_UNPRIVILEGED), + SD_BUS_METHOD_WITH_ARGS("Vacuum", SD_BUS_NO_ARGS, SD_BUS_RESULT("u", instances, "u", disabled_transfers), From a5ff474a2bf13a95075519d0193f88537f6e979d Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 19 Jan 2026 17:19:39 +0000 Subject: [PATCH 3/6] =?UTF-8?q?updatectl:=20Use=20sysupdated=E2=80=99s=20A?= =?UTF-8?q?cquire()=20and=20Install()=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than calling `Update()`. This should allow us to eventually drop `Update()`. This should just be an internal change, and should not result in any user-visible changes to the behaviour of `updatectl update`. Signed-off-by: Philip Withnall Helps: https://github.com/systemd/systemd/issues/34814 --- src/sysupdate/updatectl.c | 142 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/src/sysupdate/updatectl.c b/src/sysupdate/updatectl.c index 5eae86e4ea..7638bbff79 100644 --- a/src/sysupdate/updatectl.c +++ b/src/sysupdate/updatectl.c @@ -72,6 +72,9 @@ typedef struct Operation { sd_event_source *job_interrupt_source; sd_bus_slot *job_properties_slot; sd_bus_slot *job_finished_slot; + + /* Only used for Acquire()/Install() operations: */ + char *acquired_version; } Operation; static Operation* operation_free(Operation *p) { @@ -86,6 +89,7 @@ static Operation* operation_free(Operation *p) { assert_se(sd_event_exit(p->event, 0) >= 0); free(p->job_path); + free(p->acquired_version); sd_event_source_disable_unref(p->job_interrupt_source); sd_bus_slot_unref(p->job_properties_slot); @@ -804,6 +808,7 @@ static int verb_check(int argc, char **argv, void *userdata) { } #define UPDATE_PROGRESS_FAILED INT_MIN +#define UPDATE_PROGRESS_ACQUIRED (INT_MAX - 1) #define UPDATE_PROGRESS_DONE INT_MAX /* Make sure it doesn't overlap w/ errno values */ assert_cc(UPDATE_PROGRESS_FAILED < -ERRNO_MAX); @@ -853,6 +858,10 @@ static int update_render_progress(sd_event_source *source, void *userdata) { clear_progress_bar_unbuffered(target); fprintf(stderr, "%s: %s %s\n", target, RED_CROSS_MARK(), STRERROR(progress)); total += 100; + } else if (progress == UPDATE_PROGRESS_ACQUIRED) { + clear_progress_bar_unbuffered(target); + fprintf(stderr, "%s: %s Installing\n", target, glyph(GLYPH_DOWNLOAD)); + total += 100; } else if (progress == UPDATE_PROGRESS_DONE) { clear_progress_bar_unbuffered(target); fprintf(stderr, "%s: %s Done\n", target, GREEN_CHECK_MARK()); @@ -920,7 +929,60 @@ static int update_properties_changed(sd_bus_message *m, void *userdata, sd_bus_e return 0; } -static int update_finished(sd_bus_message *m, void *userdata, sd_bus_error *error) { +static int update_install_started(sd_bus_message *reply, void *userdata, sd_bus_error *ret_error) { + _cleanup_(operation_freep) Operation *op = ASSERT_PTR(userdata); + OrderedHashmap *map = ASSERT_PTR(op->userdata); + const sd_bus_error *e; + const char *job_path; + int r; + + assert(reply); + + e = sd_bus_message_get_error(reply); + if (e) { + r = -sd_bus_error_get_errno(e); + + r = ordered_hashmap_replace(map, op->target_id, INT_TO_PTR(r)); + if (r < 0) + log_debug_errno(r, "Failed to update hashmap: %m"); + + return 0; + } + + r = sd_bus_message_read(reply, "sto", NULL, &op->job_id, &job_path); + if (r < 0) + return bus_log_parse_error(r); + r = free_and_strdup_warn(&op->job_path, job_path); + if (r < 0) + return r; + + /* Update this job in the hashmap. */ + r = ordered_hashmap_replace(map, op->target_id, INT_TO_PTR(UPDATE_PROGRESS_ACQUIRED)); + if (r < 0) + log_debug_errno(r, "Failed to update hashmap: %m"); + + /* Register for progress notifications for this Install() D-Bus call; previously + * op->job_properties_slot was registered for progress notifications for the Acquire() D-Bus call. */ + sd_bus_slot_unref(TAKE_PTR(op->job_properties_slot)); + r = sd_bus_match_signal_async( + op->bus, + &op->job_properties_slot, + bus_sysupdate_mgr->destination, + job_path, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + update_properties_changed, + NULL, + op); + if (r < 0) + return log_bus_error(r, NULL, op->target_id, "listen for PropertiesChanged"); + + TAKE_PTR(op); /* update_install_finished/update_interrupted take ownership of the data */ + + return 0; +} + +static int update_install_finished(sd_bus_message *m, void *userdata, sd_bus_error *error) { _cleanup_(operation_freep) Operation *op = ASSERT_PTR(userdata); OrderedHashmap *map = ASSERT_PTR(op->userdata); uint64_t id; @@ -951,6 +1013,64 @@ static int update_finished(sd_bus_message *m, void *userdata, sd_bus_error *erro return 0; } +static int update_acquire_finished(sd_bus_message *m, void *userdata, sd_bus_error *error) { + _cleanup_(operation_freep) Operation *op = ASSERT_PTR(userdata); + OrderedHashmap *map = ASSERT_PTR(op->userdata); + uint64_t id; + int r, status; + + assert(m); + + r = sd_bus_message_read(m, "toi", &id, NULL, &status); + if (r < 0) { + bus_log_parse_error_debug(r); + return 0; + } + + if (id != op->job_id) { + TAKE_PTR(op); + return 0; + } + + if (status == 0) /* success */ + status = UPDATE_PROGRESS_ACQUIRED; + else if (status > 0) /* exit status without errno */ + status = UPDATE_PROGRESS_FAILED; /* i.e. EXIT_FAILURE */ + /* else errno */ + + r = ordered_hashmap_replace(map, op->target_id, INT_TO_PTR(status)); + if (r < 0) + log_debug_errno(r, "Failed to update hashmap: %m"); + + /* Renew the JobRemoved notification for the Install() call instead. */ + sd_bus_slot_unref(op->job_finished_slot); + r = bus_match_signal_async( + op->bus, &op->job_finished_slot, bus_sysupdate_mgr, "JobRemoved", update_install_finished, NULL, op); + if (r < 0) + return log_bus_error(r, NULL, op->target_id, "listen for JobRemoved"); + + /* With the Acquire() call finished, immediately call Install() to deploy the downloaded update. + * This reuses the same Operation struct so the progress reporting continues to be done in the same + * slot in the terminal. */ + r = sd_bus_call_method_async( + op->bus, + NULL, + bus_sysupdate_mgr->destination, + op->target_path, + SYSUPDATE_TARGET_INTERFACE, + "Install", + update_install_started, + op, + "st", + op->acquired_version, + 0LU); + if (r < 0) + return log_bus_error(r, NULL, op->target_id, "call Install"); + TAKE_PTR(op); + + return 0; +} + static int update_interrupted(sd_event_source *source, void *userdata) { /* Since the event loop is exiting, we will never receive the JobRemoved * signal. So, we must free the userdata here. */ @@ -959,6 +1079,8 @@ static int update_interrupted(sd_event_source *source, void *userdata) { OrderedHashmap *map = ASSERT_PTR(op->userdata); int r; + /* This call should work regardless of whether we’re cancelling the Acquire() call or the Install() + * call. */ r = sd_bus_call_method(op->bus, bus_sysupdate_mgr->destination, op->job_path, @@ -977,7 +1099,7 @@ static int update_interrupted(sd_event_source *source, void *userdata) { return 0; } -static int update_started(sd_bus_message *reply, void *userdata, sd_bus_error *ret_error) { +static int update_acquire_started(sd_bus_message *reply, void *userdata, sd_bus_error *ret_error) { _cleanup_(operation_freep) Operation *op = ASSERT_PTR(userdata); OrderedHashmap *map = ASSERT_PTR(op->userdata); const sd_bus_error *e; @@ -1008,6 +1130,12 @@ static int update_started(sd_bus_message *reply, void *userdata, sd_bus_error *r op->job_path = strdup(job_path); if (!op->job_path) return log_oom(); + + /* Store the version for the subsequent Install() call */ + op->acquired_version = strdup(new_version); + if (!op->acquired_version) + return log_oom(); + if (isempty(new_version)) new_version = "latest"; @@ -1047,7 +1175,7 @@ static int update_started(sd_bus_message *reply, void *userdata, sd_bus_error *r if (r < 0) return log_bus_error(r, NULL, op->target_id, "listen for PropertiesChanged"); - TAKE_PTR(op); /* update_finished/update_interrupted take ownership of the data */ + TAKE_PTR(op); /* update_acquire_finished/update_interrupted take ownership of the data */ return 0; } @@ -1094,7 +1222,7 @@ static int do_update(sd_bus *bus, char **targets) { /* Sign up for notification when the associated job finishes */ r = bus_match_signal_async( - op->bus, &op->job_finished_slot, bus_sysupdate_mgr, "JobRemoved", update_finished, NULL, op); + op->bus, &op->job_finished_slot, bus_sysupdate_mgr, "JobRemoved", update_acquire_finished, NULL, op); if (r < 0) return log_bus_error(r, NULL, op->target_id, "listen for JobRemoved"); @@ -1104,14 +1232,14 @@ static int do_update(sd_bus *bus, char **targets) { bus_sysupdate_mgr->destination, target_paths[i], SYSUPDATE_TARGET_INTERFACE, - "Update", - update_started, + "Acquire", + update_acquire_started, op, "st", versions[i], 0LU); if (r < 0) - return log_bus_error(r, NULL, targets[i], "call Update"); + return log_bus_error(r, NULL, targets[i], "call Acquire"); TAKE_PTR(op); remaining++; From b0ca987cd96599ca672716249aa4efe3d80abf72 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 19 Jan 2026 17:11:18 +0000 Subject: [PATCH 4/6] updatectl: Expose partial/pending updates in the updatectl list output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit And expand the tests to catch if any more JSON fields are added to the sysupdated output in future without being added to `updatectl`’s parser. Signed-off-by: Philip Withnall Helps: https://github.com/systemd/systemd/issues/34814 --- src/sysupdate/updatectl.c | 6 ++++++ test/units/TEST-72-SYSUPDATE.sh | 12 +++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/sysupdate/updatectl.c b/src/sysupdate/updatectl.c index 7638bbff79..60523181bc 100644 --- a/src/sysupdate/updatectl.c +++ b/src/sysupdate/updatectl.c @@ -338,6 +338,8 @@ typedef struct DescribeParams { bool newest; bool available; bool installed; + bool partial; + bool pending; bool obsolete; bool protected; bool incomplete; @@ -373,6 +375,8 @@ static int parse_describe(sd_bus_message *reply, Version *ret) { { "newest", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, newest), 0 }, { "available", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, available), 0 }, { "installed", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, installed), 0 }, + { "partial", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, partial), 0 }, + { "pending", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, pending), 0 }, { "obsolete", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, obsolete), 0 }, { "protected", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, protected), 0 }, { "incomplete", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(DescribeParams, incomplete), 0 }, @@ -390,6 +394,8 @@ static int parse_describe(sd_bus_message *reply, Version *ret) { SET_FLAG(p.v.flags, UPDATE_NEWEST, p.newest); SET_FLAG(p.v.flags, UPDATE_AVAILABLE, p.available); SET_FLAG(p.v.flags, UPDATE_INSTALLED, p.installed); + SET_FLAG(p.v.flags, UPDATE_PARTIAL, p.partial); + SET_FLAG(p.v.flags, UPDATE_PENDING, p.pending); SET_FLAG(p.v.flags, UPDATE_OBSOLETE, p.obsolete); SET_FLAG(p.v.flags, UPDATE_PROTECTED, p.protected); SET_FLAG(p.v.flags, UPDATE_INCOMPLETE, p.incomplete); diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index f3b712a6e6..3fe125a26a 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -150,6 +150,12 @@ verify_version_current() { cmp "$WORKDIR/source/dir-$version/bar.txt" "$WORKDIR/dirs/current/bar.txt" } +verify_object_fields() { + local updatectl_output="${1:?}" + + [[ "${updatectl_output}" != *"Unrecognized object field"* ]] || exit 1 +} + for sector_size in "${SECTOR_SIZES[@]}"; do for update_type in monolithic split-offline split; do # Disk size of: @@ -373,9 +379,9 @@ EOF if [[ -x "$SYSUPDATED" ]] && command -v updatectl; then mkdir -p /run/sysupdate.test.d/ cp "$CONFIGDIR/01-first.transfer" /run/sysupdate.test.d/01-first.transfer - updatectl list - updatectl list host - updatectl list host@v6 + verify_object_fields "$(updatectl list 2>&1)" + verify_object_fields "$(updatectl list host 2>&1)" + verify_object_fields "$(updatectl list host@v6 2>&1)" updatectl check rm -r /run/sysupdate.test.d fi From 7537717aed389ce2cf207fc2007c52f8b5e0efc7 Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 19 Jan 2026 18:12:39 +0000 Subject: [PATCH 5/6] test: Expand sysupdate test to test updatectl update This further expands the sysupdate test to run the suite a fourth time, testing that `updatectl update` behaves the same as `sysupdate update` and `sysupdate acquire; sysupdate update --offline`. Signed-off-by: Philip Withnall --- test/units/TEST-72-SYSUPDATE.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index 3fe125a26a..46968d4372 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -21,6 +21,8 @@ if [[ ! -x "$SYSUPDATE" ]]; then exit 77 fi +have_updatectl=$([[ -x "$SYSUPDATED" ]] && command -v updatectl) + # Loopback devices may not be supported. They are used because sfdisk cannot # change the sector size of a file, and we want to test both 512 and 4096 byte # sectors. If loopback devices are not supported, we can only test one sector @@ -112,6 +114,16 @@ update_now() { elif [[ "$update_type" == "split" ]]; then "$SYSUPDATE" --verify=no acquire "$SYSUPDATE" --verify=no update + elif [[ "$update_type" == "updatectl" ]]; then + if $have_updatectl; then + systemctl start systemd-sysupdated + updatectl update + else + # Gracefully fall back to sysupdate + "$SYSUPDATE" --verify=no update + fi + else + exit 1 fi (! "$SYSUPDATE" --verify=no check-new) } @@ -157,7 +169,7 @@ verify_object_fields() { } for sector_size in "${SECTOR_SIZES[@]}"; do -for update_type in monolithic split-offline split; do +for update_type in monolithic split-offline split updatectl; do # Disk size of: # - 1MB for GPT # - 4 partitions of 2048 sectors each @@ -358,7 +370,7 @@ EOF # Create sixth version, update using updatectl and verify it replaced the # correct version new_version "$sector_size" v6 - if [[ -x "$SYSUPDATED" ]] && command -v updatectl; then + if $have_updatectl; then systemctl start systemd-sysupdated "$SYSUPDATE" --verify=no check-new updatectl update @@ -376,7 +388,7 @@ EOF # testing for specific output, but this will at least catch obvious crashes # and allow updatectl to run under the various sanitizers. We create a # component so that updatectl has multiple targets to list. - if [[ -x "$SYSUPDATED" ]] && command -v updatectl; then + if $have_updatectl; then mkdir -p /run/sysupdate.test.d/ cp "$CONFIGDIR/01-first.transfer" /run/sysupdate.test.d/01-first.transfer verify_object_fields "$(updatectl list 2>&1)" From 8b5374d6c215942c494b86b2993746bba7d05e9c Mon Sep 17 00:00:00 2001 From: Philip Withnall Date: Mon, 19 Jan 2026 18:33:27 +0000 Subject: [PATCH 6/6] sysupdated: Drop the Update() D-Bus method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that it’s been replaced by `Acquire()` and `Install()`, there’s no need to expose it as a separate method. Wrappers around `sysupdated` can still expose an ‘update’ convenience verb by calling those two methods. Signed-off-by: Philip Withnall Helps: https://github.com/systemd/systemd/issues/34814 --- man/org.freedesktop.sysupdate1.xml | 34 ++---- src/sysupdate/org.freedesktop.sysupdate1.conf | 4 - src/sysupdate/sysupdated.c | 100 +----------------- 3 files changed, 10 insertions(+), 128 deletions(-) diff --git a/man/org.freedesktop.sysupdate1.xml b/man/org.freedesktop.sysupdate1.xml index 08fc729c50..6c0e86df9f 100644 --- a/man/org.freedesktop.sysupdate1.xml +++ b/man/org.freedesktop.sysupdate1.xml @@ -120,11 +120,6 @@ node /org/freedesktop/sysupdate1/target/host { in t flags, out s json); CheckNew(out s new_version); - Update(in s new_version, - in t flags, - out s new_version, - out t job_id, - out o job_path); Acquire(in s new_version, in t flags, out s new_version, @@ -173,8 +168,6 @@ node /org/freedesktop/sysupdate1/target/host { - - @@ -259,7 +252,8 @@ node /org/freedesktop/sysupdate1/target/host { A boolean indicating whether this version is incomplete, which means that it is missing some file. Note that only installed incomplete versions will be offered by the service; versions that are incomplete on the server-side are completely ignored. Incomplete versions can - be repaired in-place by calling Update() on that version. + be repaired in-place by calling Acquire() and Install() + on that version. @@ -275,17 +269,6 @@ node /org/freedesktop/sysupdate1/target/host { Describe() to query more information about the version returned by this method. - Update() installs an update for this target. If a - new_version is specified, that is the version that gets installed. Otherwise, the - latest version is installed. The flags argument is added for future - extensibility. No flags are currently defined, and the argument is required to be set to - 0. Unlike all the other methods in this interface, Update() - does not wait for its job to complete. Instead, it returns the job's numeric ID and object path as soon - as the job begins, so that the caller can listen for progress updates or cancel the operation. This - method also returns the version the target will be updated to, for cases where no version was specified - by the caller. This method pulls both metadata and payload data from the network. Listen for the - Manager's JobRemoved() signal to detect when the job is complete. - Acquire() downloads an update for this target, if one is available. If a new_version is specified, that is the version that gets downloaded. Otherwise, the latest version is downloaded. Call Install() to install the acquired update. @@ -381,7 +364,7 @@ node /org/freedesktop/sysupdate1/target/host { preemptive decisions to be made about features that are planned to appear in future releases of the OS. The drop-in will have a filename of 50-systemd-sysupdate-enabled.conf. This method only changes configuration files; to actually apply the changes, clients will need to - call Update() (or Acquire() and Install()). + call Acquire() and Install(). Depending on the exact needs of the client, it can choose to update the system to the latest available version, or it can extend the newest existing installation in-place (by passing in the version returned by GetVersion()). @@ -431,7 +414,7 @@ node /org/freedesktop/sysupdate1/target/host { use the polkit action org.freedesktop.sysupdate1.check. By default, this action is permitted without administrator authentication. - Update(), Acquire() and Install() + Acquire() and Install() use the polkit action org.freedesktop.sysupdate1.update when no version is specified. By default, this action is permitted without administrator authentication. When a version is @@ -531,15 +514,15 @@ node /org/freedesktop/sysupdate1/job/_1 { The Type property exposes the type of operation (one of: list, describe, check-new, - update, acquire, install, - vacuum, or describe-feature). + acquire, install, vacuum, or + describe-feature). The Offline property exposes whether the job is permitted to access the network or not. The Progress property exposes the current progress of the job as a value - between 0 and 100. It is only available for update, acquire and - install jobs; for all other jobs it is always 0. + between 0 and 100. It is only available for acquire and install + jobs; for all other jobs it is always 0. @@ -598,7 +581,6 @@ node /org/freedesktop/sysupdate1/job/_1 { List(), Describe(), CheckNew(), - Update(), Acquire(), Install(), Vacuum(), diff --git a/src/sysupdate/org.freedesktop.sysupdate1.conf b/src/sysupdate/org.freedesktop.sysupdate1.conf index c98acb5736..8a44f75f23 100644 --- a/src/sysupdate/org.freedesktop.sysupdate1.conf +++ b/src/sysupdate/org.freedesktop.sysupdate1.conf @@ -62,10 +62,6 @@ send_interface="org.freedesktop.sysupdate1.Target" send_member="CheckNew"/> - - diff --git a/src/sysupdate/sysupdated.c b/src/sysupdate/sysupdated.c index 57a5cb0457..c123842c10 100644 --- a/src/sysupdate/sysupdated.c +++ b/src/sysupdate/sysupdated.c @@ -100,7 +100,6 @@ typedef enum JobType { JOB_LIST, JOB_DESCRIBE, JOB_CHECK_NEW, - JOB_UPDATE, JOB_ACQUIRE, JOB_INSTALL, JOB_VACUUM, @@ -123,7 +122,7 @@ struct Job { JobType type; bool offline; - char *version; /* Passed into sysupdate for JOB_DESCRIBE, JOB_UPDATE, JOB_ACQUIRE and JOB_INSTALL */ + char *version; /* Passed into sysupdate for JOB_DESCRIBE, JOB_ACQUIRE and JOB_INSTALL */ char *feature; /* Passed into sysupdate for JOB_DESCRIBE_FEATURE */ unsigned progress_percent; @@ -155,7 +154,6 @@ static const char* const job_type_table[_JOB_TYPE_MAX] = { [JOB_LIST] = "list", [JOB_DESCRIBE] = "describe", [JOB_CHECK_NEW] = "check-new", - [JOB_UPDATE] = "update", [JOB_ACQUIRE] = "acquire", [JOB_INSTALL] = "install", [JOB_VACUUM] = "vacuum", @@ -226,7 +224,7 @@ static int job_new(JobType type, Target *t, sd_bus_message *msg, JobComplete com /* Is Job in the set of jobs which require Target.busy to be set so they run exclusively? */ static bool job_requires_busy(Job *j) { - return IN_SET(j->type, JOB_UPDATE, JOB_ACQUIRE, JOB_INSTALL, JOB_VACUUM); + return IN_SET(j->type, JOB_ACQUIRE, JOB_INSTALL, JOB_VACUUM); } static int job_parse_child_output(int _fd, sd_json_variant **ret) { @@ -506,11 +504,6 @@ static int job_start(Job *j) { cmd[k++] = "check-new"; break; - case JOB_UPDATE: - cmd[k++] = "update"; - cmd[k++] = empty_to_null(j->version); - break; - case JOB_ACQUIRE: cmd[k++] = "acquire"; cmd[k++] = empty_to_null(j->version); @@ -600,7 +593,6 @@ static int job_method_cancel(sd_bus_message *msg, void *userdata, sd_bus_error * action = "org.freedesktop.sysupdate1.check"; break; - case JOB_UPDATE: case JOB_ACQUIRE: case JOB_INSTALL: if (j->version) @@ -1086,88 +1078,6 @@ static int target_method_check_new(sd_bus_message *msg, void *userdata, sd_bus_e return 1; } -static int target_method_update_finished_early( - sd_bus_message *msg, - const Job *j, - sd_json_variant *json, - sd_bus_error *error) { - - /* Called when job finishes w/ a successful exit code, but before any work begins. - * This happens when there is no candidate (i.e. we're already up-to-date), or - * specified update is already installed. */ - return sd_bus_error_setf(error, BUS_ERROR_NO_UPDATE_CANDIDATE, - "Job exited successfully with no work to do, assume already updated"); -} - -static int target_method_update_detach(sd_bus_message *msg, const Job *j) { - int r; - - assert(msg); - assert(j); - - r = sd_bus_reply_method_return(msg, "sto", j->version, j->id, j->object_path); - if (r < 0) - return bus_log_parse_error(r); - - return 0; -} - -static int target_method_update(sd_bus_message *msg, void *userdata, sd_bus_error *error) { - Target *t = ASSERT_PTR(userdata); - _cleanup_(job_freep) Job *j = NULL; - const char *version, *action; - uint64_t flags; - int r; - - assert(msg); - - r = sd_bus_message_read(msg, "st", &version, &flags); - if (r < 0) - return r; - - if (flags != 0) - return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Flags must be 0"); - - if (isempty(version)) - action = "org.freedesktop.sysupdate1.update"; - else - action = "org.freedesktop.sysupdate1.update-to-version"; - - const char *details[] = { - "class", target_class_to_string(t->class), - "name", t->name, - "version", version, - NULL - }; - - r = bus_verify_polkit_async( - msg, - action, - details, - &t->manager->polkit_registry, - error); - if (r < 0) - return r; - if (r == 0) - return 1; /* Will call us back */ - - r = job_new(JOB_UPDATE, t, msg, target_method_update_finished_early, &j); - if (r < 0) - return r; - j->detach_cb = target_method_update_detach; - - j->version = strdup(version); - if (!j->version) - return -ENOMEM; - - r = job_start(j); - if (r < 0) - return sd_bus_error_set_errnof(error, r, "Failed to start job: %m"); - TAKE_PTR(j); - - return 1; -} - static int target_method_acquire_finished_early( sd_bus_message *msg, const Job *j, @@ -1769,12 +1679,6 @@ static const sd_bus_vtable target_vtable[] = { target_method_check_new, SD_BUS_VTABLE_UNPRIVILEGED), - SD_BUS_METHOD_WITH_ARGS("Update", - SD_BUS_ARGS("s", new_version, "t", flags), - SD_BUS_RESULT("s", new_version, "t", job_id, "o", job_path), - target_method_update, - SD_BUS_VTABLE_UNPRIVILEGED), - SD_BUS_METHOD_WITH_ARGS("Acquire", SD_BUS_ARGS("s", new_version, "t", flags), SD_BUS_RESULT("s", new_version, "t", job_id, "o", job_path),