diff --git a/man/portablectl.xml b/man/portablectl.xml
index 3653207d72..bd0c9e0573 100644
--- a/man/portablectl.xml
+++ b/man/portablectl.xml
@@ -155,6 +155,20 @@
to be used in case the unit names do not match the image name as described in the attach.
+
+ reattach IMAGE [PREFIX…]
+
+ Detaches an existing portable service image from the host, and immediately attaches it again.
+ This is useful in case the image was replaced. Running units are not stopped during the process. Partial matching,
+ to allow for different versions in the image name, is allowed: only the part before the first _
+ character has to match. If the new image doesn't exist, the existing one will not be detached. The parameters
+ follow the same syntax as the attach command.
+
+ If and/or are passed, the portable service(s) are
+ immediately stopped if removed, started and/or enabled if added, or restarted if updated. Prefixes are also
+ accepted, in the same way as described in the attach case.
+
+
inspect IMAGE [PREFIX…]
@@ -328,7 +342,8 @@
- Immediately start/stop the portable service after attaching/before detaching.
+ Immediately start/stop/restart the portable service after attaching/before
+ detaching/after upgrading.
diff --git a/shell-completion/bash/portablectl b/shell-completion/bash/portablectl
index fe3d925d78..e18d04c7ea 100644
--- a/shell-completion/bash/portablectl
+++ b/shell-completion/bash/portablectl
@@ -40,7 +40,7 @@ _portablectl() {
local -A VERBS=(
[STANDALONE]='list'
- [IMAGE]='attach detach inspect is-attached set-limit'
+ [IMAGE]='attach detach reattach inspect is-attached set-limit'
[IMAGES]='remove'
[IMAGE_WITH_BOOL]='read-only'
)
diff --git a/src/portable/org.freedesktop.portable1.conf b/src/portable/org.freedesktop.portable1.conf
index 1343e1d544..5a09f5c2d9 100644
--- a/src/portable/org.freedesktop.portable1.conf
+++ b/src/portable/org.freedesktop.portable1.conf
@@ -61,6 +61,10 @@
send_interface="org.freedesktop.portable1.Manager"
send_member="DetachImage"/>
+
+
@@ -99,6 +103,10 @@
send_interface="org.freedesktop.portable1.Image"
send_member="Detach"/>
+
+
diff --git a/src/portable/portable.c b/src/portable/portable.c
index 77e8d21287..0a3b1e8fde 100644
--- a/src/portable/portable.c
+++ b/src/portable/portable.c
@@ -1003,13 +1003,13 @@ int portable_attach(
r = unit_file_exists(UNIT_FILE_SYSTEM, &paths, item->name);
if (r < 0)
return sd_bus_error_set_errnof(error, r, "Failed to determine whether unit '%s' exists on the host: %m", item->name);
- if (r > 0)
+ if (!FLAGS_SET(flags, PORTABLE_REATTACH) && r > 0)
return sd_bus_error_setf(error, BUS_ERROR_UNIT_EXISTS, "Unit file '%s' exists on the host already, refusing.", item->name);
r = unit_file_is_active(bus, item->name, error);
if (r < 0)
return r;
- if (r > 0)
+ if (!FLAGS_SET(flags, PORTABLE_REATTACH) && r > 0)
return sd_bus_error_setf(error, BUS_ERROR_UNIT_EXISTS, "Unit file '%s' is active already, refusing.", item->name);
}
@@ -1193,7 +1193,7 @@ int portable_detach(
r = unit_file_is_active(bus, de->d_name, error);
if (r < 0)
return r;
- if (r > 0)
+ if (!FLAGS_SET(flags, PORTABLE_REATTACH) && r > 0)
return sd_bus_error_setf(error, BUS_ERROR_UNIT_EXISTS, "Unit file '%s' is active, can't detach.", de->d_name);
r = set_put_strdup(&unit_files, de->d_name);
diff --git a/src/portable/portable.h b/src/portable/portable.h
index 00ff80c38a..724634feb8 100644
--- a/src/portable/portable.h
+++ b/src/portable/portable.h
@@ -21,6 +21,7 @@ typedef enum PortableFlags {
PORTABLE_PREFER_COPY = 1 << 0,
PORTABLE_PREFER_SYMLINK = 1 << 1,
PORTABLE_RUNTIME = 1 << 2,
+ PORTABLE_REATTACH = 1 << 3,
} PortableFlags;
typedef enum PortableChangeType {
diff --git a/src/portable/portablectl.c b/src/portable/portablectl.c
index edfd74d540..278dae0e1b 100644
--- a/src/portable/portablectl.c
+++ b/src/portable/portablectl.c
@@ -45,6 +45,10 @@ static bool arg_enable = false;
static bool arg_now = false;
static bool arg_no_block = false;
+static bool is_portable_managed(const char *unit) {
+ return ENDSWITH_SET(unit, ".service", ".target", ".socket", ".path", ".timer");
+}
+
static int determine_image(const char *image, bool permit_non_existing, char **ret) {
int r;
@@ -436,12 +440,14 @@ static int maybe_enable_disable(sd_bus *bus, const char *path, bool enable) {
return 0;
}
-static int maybe_start_stop(sd_bus *bus, const char *path, bool start, BusWaitForJobs *wait) {
+static int maybe_start_stop_restart(sd_bus *bus, const char *path, const char *method, BusWaitForJobs *wait) {
_cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
_cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL;
char *name = (char *)basename(path), *job = NULL;
int r;
+ assert(STR_IN_SET(method, "StartUnit", "StopUnit", "RestartUnit"));
+
if (!arg_now)
return 0;
@@ -450,13 +456,13 @@ static int maybe_start_stop(sd_bus *bus, const char *path, bool start, BusWaitFo
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",
- start ? "StartUnit" : "StopUnit",
+ method,
&error,
&reply,
"ss", name, "replace");
if (r < 0)
- return log_error_errno(r, "Failed to %s the portable service %s: %s",
- start ? "start" : "stop",
+ return log_error_errno(r, "Failed to call %s on the portable service %s: %s",
+ method,
path,
bus_error_message(&error, r));
@@ -465,13 +471,13 @@ static int maybe_start_stop(sd_bus *bus, const char *path, bool start, BusWaitFo
return bus_log_parse_error(r);
if (!arg_quiet)
- log_info("Queued %s to %s portable service %s.", job, start ? "start" : "stop", name);
+ log_info("Queued %s to call %s on portable service %s.", job, method, name);
if (wait) {
r = bus_wait_for_jobs_add(wait, job);
if (r < 0)
- return log_error_errno(r, "Failed to watch %s job for %s %s: %m",
- job, start ? "starting" : "stopping", name);
+ return log_error_errno(r, "Failed to watch %s job to call %s on %s: %m",
+ job, method, name);
}
return 0;
@@ -506,9 +512,83 @@ static int maybe_enable_start(sd_bus *bus, sd_bus_message *reply) {
if (r == 0)
break;
- if (STR_IN_SET(type, "symlink", "copy") && ENDSWITH_SET(path, ".service", ".target", ".socket")) {
+ if (STR_IN_SET(type, "symlink", "copy") && is_portable_managed(path)) {
(void) maybe_enable_disable(bus, path, true);
- (void) maybe_start_stop(bus, path, true, wait);
+ (void) maybe_start_stop_restart(bus, path, "StartUnit", wait);
+ }
+ }
+
+ r = sd_bus_message_exit_container(reply);
+ if (r < 0)
+ return r;
+
+ if (!arg_no_block) {
+ r = bus_wait_for_jobs(wait, arg_quiet, NULL);
+ if (r < 0)
+ return r;
+ }
+
+ return 0;
+}
+
+static int maybe_stop_enable_restart(sd_bus *bus, sd_bus_message *reply) {
+ _cleanup_(bus_wait_for_jobs_freep) BusWaitForJobs *wait = NULL;
+ int r;
+
+ if (!arg_enable && !arg_now)
+ return 0;
+
+ if (!arg_no_block) {
+ r = bus_wait_for_jobs_new(bus, &wait);
+ if (r < 0)
+ return log_error_errno(r, "Could not watch jobs: %m");
+ }
+
+ r = sd_bus_message_rewind(reply, true);
+ if (r < 0)
+ return r;
+
+ /* First we get a list of units that were definitely removed, not just re-attached,
+ * so we can also stop them if the user asked us to. */
+ r = sd_bus_message_enter_container(reply, 'a', "(sss)");
+ if (r < 0)
+ return bus_log_parse_error(r);
+
+ for (;;) {
+ char *type, *path, *source;
+
+ r = sd_bus_message_read(reply, "(sss)", &type, &path, &source);
+ if (r < 0)
+ return bus_log_parse_error(r);
+ if (r == 0)
+ break;
+
+ if (streq(type, "unlink") && is_portable_managed(path))
+ (void) maybe_start_stop_restart(bus, path, "StopUnit", wait);
+ }
+
+ r = sd_bus_message_exit_container(reply);
+ if (r < 0)
+ return r;
+
+ /* Then we get a list of units that were either added or changed, so that we can
+ * enable them and/or restart them if the user asked us to. */
+ r = sd_bus_message_enter_container(reply, 'a', "(sss)");
+ if (r < 0)
+ return bus_log_parse_error(r);
+
+ for (;;) {
+ char *type, *path, *source;
+
+ r = sd_bus_message_read(reply, "(sss)", &type, &path, &source);
+ if (r < 0)
+ return bus_log_parse_error(r);
+ if (r == 0)
+ break;
+
+ if (STR_IN_SET(type, "symlink", "copy") && is_portable_managed(path)) {
+ (void) maybe_enable_disable(bus, path, true);
+ (void) maybe_start_stop_restart(bus, path, "RestartUnit", wait);
}
}
@@ -588,7 +668,7 @@ static int maybe_stop_disable(sd_bus *bus, char *image, char *argv[]) {
if (r < 0)
return bus_log_parse_error(r);
- (void) maybe_start_stop(bus, name, false, wait);
+ (void) maybe_start_stop_restart(bus, name, "StopUnit", wait);
(void) maybe_enable_disable(bus, name, false);
}
@@ -604,7 +684,7 @@ static int maybe_stop_disable(sd_bus *bus, char *image, char *argv[]) {
return 0;
}
-static int attach_image(int argc, char *argv[], void *userdata) {
+static int attach_reattach_image(int argc, char *argv[], const char *method) {
_cleanup_(sd_bus_message_unrefp) sd_bus_message *m = NULL, *reply = NULL;
_cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL;
_cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL;
@@ -612,6 +692,9 @@ static int attach_image(int argc, char *argv[], void *userdata) {
_cleanup_free_ char *image = NULL;
int r;
+ assert(method);
+ assert(STR_IN_SET(method, "AttachImage", "ReattachImage"));
+
r = determine_image(argv[1], false, &image);
if (r < 0)
return r;
@@ -626,7 +709,7 @@ static int attach_image(int argc, char *argv[], void *userdata) {
(void) polkit_agent_open_if_enabled(arg_transport, arg_ask_password);
- r = bus_message_new_method_call(bus, &m, bus_portable_mgr, "AttachImage");
+ r = bus_message_new_method_call(bus, &m, bus_portable_mgr, method);
if (r < 0)
return bus_log_create_error(r);
@@ -644,17 +727,31 @@ static int attach_image(int argc, char *argv[], void *userdata) {
r = sd_bus_call(bus, m, 0, &error, &reply);
if (r < 0)
- return log_error_errno(r, "Failed to attach image: %s", bus_error_message(&error, r));
+ return log_error_errno(r, "%s failed: %s", method, bus_error_message(&error, r));
(void) maybe_reload(&bus);
print_changes(reply);
- (void) maybe_enable_start(bus, reply);
+ if (streq(method, "AttachImage"))
+ (void) maybe_enable_start(bus, reply);
+ else {
+ /* ReattachImage returns 2 lists - removed units first, and changed/added second */
+ print_changes(reply);
+ (void) maybe_stop_enable_restart(bus, reply);
+ }
return 0;
}
+static int attach_image(int argc, char *argv[], void *userdata) {
+ return attach_reattach_image(argc, argv, "AttachImage");
+}
+
+static int reattach_image(int argc, char *argv[], void *userdata) {
+ return attach_reattach_image(argc, argv, "ReattachImage");
+}
+
static int detach_image(int argc, char *argv[], void *userdata) {
_cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
_cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL;
@@ -920,6 +1017,8 @@ static int help(int argc, char *argv[], void *userdata) {
" Attach the specified portable service image\n"
" detach NAME|PATH [PREFIX...]\n"
" Detach the specified portable service image\n"
+ " reattach NAME|PATH [PREFIX...]\n"
+ " Reattach the specified portable service image\n"
" inspect NAME|PATH [PREFIX...]\n"
" Show details of specified portable service image\n"
" is-attached NAME|PATH Query if portable service image is attached\n"
@@ -1108,6 +1207,7 @@ static int run(int argc, char *argv[]) {
{ "read-only", 2, 3, 0, read_only_image },
{ "remove", 2, VERB_ANY, 0, remove_image },
{ "set-limit", 3, 3, 0, set_limit },
+ { "reattach", 2, VERB_ANY, 0, reattach_image },
{}
};
diff --git a/src/portable/portabled-bus.c b/src/portable/portabled-bus.c
index 20a33dc671..c31ab092b4 100644
--- a/src/portable/portabled-bus.c
+++ b/src/portable/portabled-bus.c
@@ -299,6 +299,10 @@ finish:
return r;
}
+static int method_reattach_image(sd_bus_message *message, void *userdata, sd_bus_error *error) {
+ return redirect_method_to_image(userdata, message, error, bus_image_common_reattach);
+}
+
static int method_remove_image(sd_bus_message *message, void *userdata, sd_bus_error *error) {
return redirect_method_to_image(userdata, message, error, bus_image_common_remove);
}
@@ -362,6 +366,7 @@ const sd_bus_vtable manager_vtable[] = {
SD_BUS_METHOD("GetImageState", "s", "s", method_get_image_state, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("AttachImage", "sassbs", "a(sss)", method_attach_image, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("DetachImage", "sb", "a(sss)", method_detach_image, SD_BUS_VTABLE_UNPRIVILEGED),
+ SD_BUS_METHOD("ReattachImage", "sassbs", "a(sss)a(sss)", method_reattach_image, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("RemoveImage", "s", NULL, method_remove_image, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("MarkImageReadOnly", "sb", NULL, method_mark_image_read_only, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("SetImageLimit", "st", NULL, method_set_image_limit, SD_BUS_VTABLE_UNPRIVILEGED),
@@ -369,18 +374,13 @@ const sd_bus_vtable manager_vtable[] = {
SD_BUS_VTABLE_END
};
-int reply_portable_changes(sd_bus_message *m, const PortableChange *changes, size_t n_changes) {
- _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+static int reply_portable_compose_message(sd_bus_message *reply, const PortableChange *changes, size_t n_changes) {
size_t i;
int r;
- assert(m);
+ assert(reply);
assert(changes || n_changes == 0);
- r = sd_bus_message_new_method_return(m, &reply);
- if (r < 0)
- return r;
-
r = sd_bus_message_open_container(reply, 'a', "(sss)");
if (r < 0)
return r;
@@ -398,5 +398,49 @@ int reply_portable_changes(sd_bus_message *m, const PortableChange *changes, siz
if (r < 0)
return r;
+ return 0;
+}
+
+int reply_portable_changes(sd_bus_message *m, const PortableChange *changes, size_t n_changes) {
+ _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+ int r;
+
+ assert(m);
+
+ r = sd_bus_message_new_method_return(m, &reply);
+ if (r < 0)
+ return r;
+
+ r = reply_portable_compose_message(reply, changes, n_changes);
+ if (r < 0)
+ return r;
+
+ return sd_bus_send(NULL, reply, NULL);
+}
+
+int reply_portable_changes_pair(
+ sd_bus_message *m,
+ const PortableChange *changes_first,
+ size_t n_changes_first,
+ const PortableChange *changes_second,
+ size_t n_changes_second) {
+
+ _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL;
+ int r;
+
+ assert(m);
+
+ r = sd_bus_message_new_method_return(m, &reply);
+ if (r < 0)
+ return r;
+
+ r = reply_portable_compose_message(reply, changes_first, n_changes_first);
+ if (r < 0)
+ return r;
+
+ r = reply_portable_compose_message(reply, changes_second, n_changes_second);
+ if (r < 0)
+ return r;
+
return sd_bus_send(NULL, reply, NULL);
}
diff --git a/src/portable/portabled-bus.h b/src/portable/portabled-bus.h
index e8e4c3a600..7da366c1c3 100644
--- a/src/portable/portabled-bus.h
+++ b/src/portable/portabled-bus.h
@@ -8,3 +8,4 @@
extern const sd_bus_vtable manager_vtable[];
int reply_portable_changes(sd_bus_message *m, const PortableChange *changes, size_t n_changes);
+int reply_portable_changes_pair(sd_bus_message *m, const PortableChange *changes_first, size_t n_changes_first, const PortableChange *changes_second, size_t n_changes_second);
diff --git a/src/portable/portabled-image-bus.c b/src/portable/portabled-image-bus.c
index babdf4197f..630648ba3c 100644
--- a/src/portable/portabled-image-bus.c
+++ b/src/portable/portabled-image-bus.c
@@ -422,6 +422,186 @@ static int bus_image_method_remove(sd_bus_message *message, void *userdata, sd_b
return bus_image_common_remove(NULL, message, NULL, userdata, error);
}
+/* Given two PortableChange arrays, return a new array that has all elements of the first that are
+ * not also present in the second, comparing the basename of the path values. */
+static int normalize_portable_changes(
+ const PortableChange *changes_attached,
+ size_t n_changes_attached,
+ const PortableChange *changes_detached,
+ size_t n_changes_detached,
+ PortableChange **ret_changes,
+ size_t *ret_n_changes) {
+
+ PortableChange *changes = NULL;
+ size_t n_changes = 0;
+ int r = 0;
+
+ assert(ret_n_changes);
+ assert(ret_changes);
+
+ if (n_changes_detached == 0)
+ return 0; /* Nothing to do */
+
+ changes = new0(PortableChange, n_changes_attached + n_changes_detached);
+ if (!changes)
+ return -ENOMEM;
+
+ /* Corner case: only detached, nothing attached */
+ if (n_changes_attached == 0) {
+ memcpy(changes, changes_detached, sizeof(PortableChange) * n_changes_detached);
+ *ret_changes = TAKE_PTR(changes);
+ *ret_n_changes = n_changes_detached;
+
+ return 0;
+ }
+
+ for (size_t i = 0; i < n_changes_detached; ++i) {
+ bool found = false;
+
+ for (size_t j = 0; j < n_changes_attached; ++j)
+ if (streq(basename(changes_detached[i].path), basename(changes_attached[j].path))) {
+ found = true;
+ break;
+ }
+
+ if (!found) {
+ _cleanup_free_ char *path = NULL, *source = NULL;
+
+ path = strdup(changes_detached[i].path);
+ if (!path) {
+ r = -ENOMEM;
+ goto fail;
+ }
+
+ if (changes_detached[i].source) {
+ source = strdup(changes_detached[i].source);
+ if (!source) {
+ r = -ENOMEM;
+ goto fail;
+ }
+ }
+
+ changes[n_changes++] = (PortableChange) {
+ .type = changes_detached[i].type,
+ .path = TAKE_PTR(path),
+ .source = TAKE_PTR(source),
+ };
+ }
+ }
+
+ *ret_n_changes = n_changes;
+ *ret_changes = TAKE_PTR(changes);
+
+ return 0;
+
+fail:
+ portable_changes_free(changes, n_changes);
+ return r;
+}
+
+int bus_image_common_reattach(
+ Manager *m,
+ sd_bus_message *message,
+ const char *name_or_path,
+ Image *image,
+ sd_bus_error *error) {
+
+ PortableChange *changes_detached = NULL, *changes_attached = NULL, *changes_gone = NULL;
+ size_t n_changes_detached = 0, n_changes_attached = 0, n_changes_gone = 0;
+ _cleanup_strv_free_ char **matches = NULL;
+ PortableFlags flags = PORTABLE_REATTACH;
+ const char *profile, *copy_mode;
+ int runtime, r;
+
+ assert(message);
+ assert(name_or_path || image);
+
+ if (!m) {
+ assert(image);
+ m = image->userdata;
+ }
+
+ r = sd_bus_message_read_strv(message, &matches);
+ if (r < 0)
+ return r;
+
+ r = sd_bus_message_read(message, "sbs", &profile, &runtime, ©_mode);
+ if (r < 0)
+ return r;
+
+ if (streq(copy_mode, "symlink"))
+ flags |= PORTABLE_PREFER_SYMLINK;
+ else if (streq(copy_mode, "copy"))
+ flags |= PORTABLE_PREFER_COPY;
+ else if (!isempty(copy_mode))
+ return sd_bus_reply_method_errorf(message, SD_BUS_ERROR_INVALID_ARGS, "Unknown copy mode '%s'", copy_mode);
+
+ if (runtime)
+ flags |= PORTABLE_RUNTIME;
+
+ r = bus_image_acquire(m,
+ message,
+ name_or_path,
+ image,
+ BUS_IMAGE_AUTHENTICATE_ALL,
+ "org.freedesktop.portable1.attach-images",
+ &image,
+ error);
+ if (r < 0)
+ return r;
+ if (r == 0) /* Will call us back */
+ return 1;
+
+ r = portable_detach(
+ sd_bus_message_get_bus(message),
+ image->path,
+ flags,
+ &changes_detached,
+ &n_changes_detached,
+ error);
+ if (r < 0)
+ goto finish;
+
+ r = portable_attach(
+ sd_bus_message_get_bus(message),
+ image->path,
+ matches,
+ profile,
+ flags,
+ &changes_attached,
+ &n_changes_attached,
+ error);
+ if (r < 0)
+ goto finish;
+
+ /* We want to return the list of units really removed by the detach,
+ * and not added again by the attach */
+ r = normalize_portable_changes(changes_attached, n_changes_attached,
+ changes_detached, n_changes_detached,
+ &changes_gone, &n_changes_gone);
+ if (r < 0)
+ goto finish;
+
+ /* First, return the units that are gone (so that the caller can stop them)
+ * Then, return the units that are changed/added (so that the caller can
+ * start/restart/enable them) */
+ r = reply_portable_changes_pair(message,
+ changes_gone, n_changes_gone,
+ changes_attached, n_changes_attached);
+ if (r < 0)
+ goto finish;
+
+finish:
+ portable_changes_free(changes_detached, n_changes_detached);
+ portable_changes_free(changes_attached, n_changes_attached);
+ portable_changes_free(changes_gone, n_changes_gone);
+ return r;
+}
+
+static int bus_image_method_reattach(sd_bus_message *message, void *userdata, sd_bus_error *error) {
+ return bus_image_common_reattach(NULL, message, NULL, userdata, error);
+}
+
int bus_image_common_mark_read_only(
Manager *m,
sd_bus_message *message,
@@ -532,6 +712,7 @@ const sd_bus_vtable image_vtable[] = {
SD_BUS_METHOD("GetState", NULL, "s", bus_image_method_get_state, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Attach", "assbs", "a(sss)", bus_image_method_attach, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Detach", "b", "a(sss)", bus_image_method_detach, SD_BUS_VTABLE_UNPRIVILEGED),
+ SD_BUS_METHOD("Reattach", "assbs", "a(sss)a(sss)", bus_image_method_reattach, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("Remove", NULL, NULL, bus_image_method_remove, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("MarkReadOnly", "b", NULL, bus_image_method_mark_read_only, SD_BUS_VTABLE_UNPRIVILEGED),
SD_BUS_METHOD("SetLimit", "t", NULL, bus_image_method_set_limit, SD_BUS_VTABLE_UNPRIVILEGED),
diff --git a/src/portable/portabled-image-bus.h b/src/portable/portabled-image-bus.h
index 50f1d935b1..e5faf43d90 100644
--- a/src/portable/portabled-image-bus.h
+++ b/src/portable/portabled-image-bus.h
@@ -10,6 +10,7 @@ int bus_image_common_get_os_release(Manager *m, sd_bus_message *message, const c
int bus_image_common_get_metadata(Manager *m, sd_bus_message *message, const char *name_or_path, Image *image, sd_bus_error *error);
int bus_image_common_attach(Manager *m, sd_bus_message *message, const char *name_or_path, Image *image, sd_bus_error *error);
int bus_image_common_remove(Manager *m, sd_bus_message *message, const char *name_or_path, Image *image, sd_bus_error *error);
+int bus_image_common_reattach(Manager *m, sd_bus_message *message, const char *name_or_path, Image *image, sd_bus_error *error);
int bus_image_common_mark_read_only(Manager *m, sd_bus_message *message, const char *name_or_path, Image *image, sd_bus_error *error);
int bus_image_common_set_limit(Manager *m, sd_bus_message *message, const char *name_or_path, Image *image, sd_bus_error *error);
diff --git a/test/TEST-58-PORTABLE/Makefile b/test/TEST-58-PORTABLE/Makefile
new file mode 120000
index 0000000000..e9f93b1104
--- /dev/null
+++ b/test/TEST-58-PORTABLE/Makefile
@@ -0,0 +1 @@
+../TEST-01-BASIC/Makefile
\ No newline at end of file
diff --git a/test/TEST-58-PORTABLE/test.sh b/test/TEST-58-PORTABLE/test.sh
new file mode 100755
index 0000000000..98e697962e
--- /dev/null
+++ b/test/TEST-58-PORTABLE/test.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*-
+# ex: ts=8 sw=4 sts=4 et filetype=sh
+set -e
+TEST_DESCRIPTION="test systemd-portabled"
+IMAGE_NAME="portabled"
+TEST_NO_NSPAWN=1
+TEST_INSTALL_VERITY_MINIMAL=1
+
+. $TEST_BASE_DIR/test-functions
+
+# Need loop devices for mounting images
+test_append_files() {
+ (
+ instmods loop =block
+ instmods squashfs =squashfs
+ instmods dm_verity =md
+ install_dmevent
+ generate_module_dependencies
+ inst_binary losetup
+ inst_binary mksquashfs
+ inst_binary unsquashfs
+ install_verity_minimal
+ )
+}
+
+do_test "$@" 58
diff --git a/test/units/testsuite-58.service b/test/units/testsuite-58.service
new file mode 100644
index 0000000000..47deba7d53
--- /dev/null
+++ b/test/units/testsuite-58.service
@@ -0,0 +1,7 @@
+[Unit]
+Description=TEST-58-PORTABLE
+
+[Service]
+ExecStartPre=rm -f /failed /testok
+ExecStart=/usr/lib/systemd/tests/testdata/units/%N.sh
+Type=oneshot
diff --git a/test/units/testsuite-58.sh b/test/units/testsuite-58.sh
new file mode 100755
index 0000000000..b5b05b42d9
--- /dev/null
+++ b/test/units/testsuite-58.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+# -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*-
+# ex: ts=8 sw=4 sts=4 et filetype=sh
+set -ex
+set -o pipefail
+
+export SYSTEMD_LOG_LEVEL=debug
+
+portablectl attach --now --runtime /usr/share/minimal_0.raw app0
+
+systemctl is-active app0.service
+systemctl is-active app0-foo.service
+set +o pipefail
+set +e
+systemctl is-active app0-bar.service && exit 1
+set -e
+set -o pipefail
+
+portablectl reattach --now --runtime /usr/share/minimal_1.raw app0
+
+systemctl is-active app0.service
+systemctl is-active app0-bar.service
+set +o pipefail
+set +e
+systemctl is-active app0-foo.service && exit 1
+set -e
+set -o pipefail
+
+portablectl list | grep -q -F "minimal_1"
+
+portablectl detach --now --runtime /usr/share/minimal_1.raw app0
+
+portablectl list | grep -q -F "No images."
+
+# portablectl also works with directory paths rather than images
+
+unsquashfs -dest /tmp/minimal_0 /usr/share/minimal_0.raw
+unsquashfs -dest /tmp/minimal_1 /usr/share/minimal_1.raw
+
+portablectl attach --copy=symlink --now --runtime /tmp/minimal_0 app0
+
+systemctl is-active app0.service
+systemctl is-active app0-foo.service
+set +o pipefail
+set +e
+systemctl is-active app0-bar.service && exit 1
+set -e
+set -o pipefail
+
+portablectl reattach --now --enable --runtime /tmp/minimal_1 app0
+
+systemctl is-active app0.service
+systemctl is-active app0-bar.service
+set +o pipefail
+set +e
+systemctl is-active app0-foo.service && exit 1
+set -e
+set -o pipefail
+
+portablectl list | grep -q -F "minimal_1"
+
+portablectl detach --now --enable --runtime /tmp/minimal_1 app0
+
+portablectl list | grep -q -F "No images."
+
+echo OK > /testok
+
+exit 0