to be used in case the unit names do not match the image name as described in the <command>attach</command>.</para>
</varlistentry>
+ <varlistentry>
+ <term><command>reattach</command> <replaceable>IMAGE</replaceable> [<replaceable>PREFIX…</replaceable>]</term>
+
+ <listitem><para>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 <literal>_</literal>
+ 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 <command>attach</command> command.</para></listitem>
+
+ <para>If <option>--now</option> and/or <option>--enable</option> 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 <command>attach</command> case.</para>
+ </varlistentry>
+
<varlistentry>
<term><command>inspect</command> <replaceable>IMAGE</replaceable> [<replaceable>PREFIX…</replaceable>]</term>
<varlistentry>
<term><option>--now</option></term>
- <listitem><para>Immediately start/stop the portable service after attaching/before detaching.</para></listitem>
+ <listitem><para>Immediately start/stop/restart the portable service after attaching/before
+ detaching/after upgrading.</para></listitem>
</varlistentry>
<varlistentry>
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'
)
send_interface="org.freedesktop.portable1.Manager"
send_member="DetachImage"/>
+ <allow send_destination="org.freedesktop.portable1"
+ send_interface="org.freedesktop.portable1.Manager"
+ send_member="ReattachImage"/>
+
<allow send_destination="org.freedesktop.portable1"
send_interface="org.freedesktop.portable1.Manager"
send_member="RemoveImage"/>
send_interface="org.freedesktop.portable1.Image"
send_member="Detach"/>
+ <allow send_destination="org.freedesktop.portable1"
+ send_interface="org.freedesktop.portable1.Image"
+ send_member="Reattach"/>
+
<allow send_destination="org.freedesktop.portable1"
send_interface="org.freedesktop.portable1.Image"
send_member="Remove"/>
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);
}
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);
PORTABLE_PREFER_COPY = 1 << 0,
PORTABLE_PREFER_SYMLINK = 1 << 1,
PORTABLE_RUNTIME = 1 << 2,
+ PORTABLE_REATTACH = 1 << 3,
} PortableFlags;
typedef enum PortableChangeType {
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;
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;
"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));
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;
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_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(bus, path, true, wait);
+ (void) maybe_start_stop_restart(bus, path, "RestartUnit", wait);
}
}
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);
}
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;
_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;
(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);
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;
" 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"
{ "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 },
{}
};
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);
}
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),
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;
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);
}
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);
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,
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),
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);
--- /dev/null
+../TEST-01-BASIC/Makefile
\ No newline at end of file
--- /dev/null
+#!/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
--- /dev/null
+[Unit]
+Description=TEST-58-PORTABLE
+
+[Service]
+ExecStartPre=rm -f /failed /testok
+ExecStart=/usr/lib/systemd/tests/testdata/units/%N.sh
+Type=oneshot
--- /dev/null
+#!/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