diff --git a/Makefile-daemon.am b/Makefile-daemon.am index 0d564e28fe..b9b8d1a554 100644 --- a/Makefile-daemon.am +++ b/Makefile-daemon.am @@ -39,6 +39,7 @@ librpmostreed_la_SOURCES = \ src/daemon/rpmostreed-transaction-monitor.c \ src/daemon/rpmostreed-transaction-types.h \ src/daemon/rpmostreed-transaction-types.c \ + src/daemon/rpmostreed-transaction-livefs.c \ src/daemon/rpmostree-package-variants.h \ src/daemon/rpmostree-package-variants.c \ src/daemon/rpmostreed-os.h \ @@ -51,6 +52,7 @@ librpmostreed_la_CFLAGS = \ $(AM_CFLAGS) \ $(PKGDEP_RPMOSTREE_CFLAGS) \ -DG_LOG_DOMAIN=\"rpm-ostreed\" \ + -D_RPMOSTREE_EXTERN= \ -I$(srcdir)/src/daemon \ -I$(srcdir)/src/lib \ -I$(srcdir)/src/libpriv \ diff --git a/Makefile-rpm-ostree.am b/Makefile-rpm-ostree.am index 10228d16f1..1ecb6d2215 100644 --- a/Makefile-rpm-ostree.am +++ b/Makefile-rpm-ostree.am @@ -30,6 +30,7 @@ rpm_ostree_SOURCES = src/app/main.c \ src/app/rpmostree-builtin-rebase.c \ src/app/rpmostree-builtin-cleanup.c \ src/app/rpmostree-builtin-initramfs.c \ + src/app/rpmostree-builtin-livefs.c \ src/app/rpmostree-pkg-builtins.c \ src/app/rpmostree-builtin-status.c \ src/app/rpmostree-builtin-ex.c \ diff --git a/src/app/rpmostree-builtin-ex.c b/src/app/rpmostree-builtin-ex.c index 623eb370de..5e86125755 100644 --- a/src/app/rpmostree-builtin-ex.c +++ b/src/app/rpmostree-builtin-ex.c @@ -25,6 +25,8 @@ #include "rpmostree-rpm-util.h" static RpmOstreeCommand ex_subcommands[] = { + { "livefs", RPM_OSTREE_BUILTIN_FLAG_REQUIRES_ROOT, + rpmostree_ex_builtin_livefs }, { "unpack", RPM_OSTREE_BUILTIN_FLAG_LOCAL_CMD, rpmostree_ex_builtin_unpack }, { "container", RPM_OSTREE_BUILTIN_FLAG_LOCAL_CMD, diff --git a/src/app/rpmostree-builtin-livefs.c b/src/app/rpmostree-builtin-livefs.c new file mode 100644 index 0000000000..fd0c7e4a7a --- /dev/null +++ b/src/app/rpmostree-builtin-livefs.c @@ -0,0 +1,101 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- + * + * Copyright (C) 2017 Colin Walters + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation; either version 2 of the licence or (at + * your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General + * Public License along with this library; if not, write to the + * Free Software Foundation, Inc., 59 Temple Place, Suite 330, + * Boston, MA 02111-1307, USA. + */ + +#include "config.h" + +#include +#include + +#include "rpmostree-ex-builtins.h" +#include "rpmostree-libbuiltin.h" +#include "rpmostree-dbus-helpers.h" + +#include + +static gboolean opt_dry_run; +static gboolean opt_allow_replace; +static gboolean opt_accept_partial; + +static GOptionEntry option_entries[] = { + { "dry-run", 'n', 0, G_OPTION_ARG_NONE, &opt_dry_run, "Only perform analysis, do not make changes", NULL }, + { "allow-replace", 0, 0, G_OPTION_ARG_NONE, &opt_allow_replace, "Allow replacing existing files", NULL }, + { "accept-partial", 0, 0, G_OPTION_ARG_NONE, &opt_accept_partial, "Continue even on partial updates", NULL }, + { NULL } +}; + +static GVariant * +get_args_variant (void) +{ + GVariantDict dict; + + g_variant_dict_init (&dict, NULL); + g_variant_dict_insert (&dict, "dry-run", "b", opt_dry_run); + g_variant_dict_insert (&dict, "replace", "b", opt_allow_replace); + g_variant_dict_insert (&dict, "partial", "b", opt_accept_partial); + + return g_variant_dict_end (&dict); +} + +int +rpmostree_ex_builtin_livefs (int argc, + char **argv, + RpmOstreeCommandInvocation *invocation, + GCancellable *cancellable, + GError **error) +{ + int exit_status = EXIT_FAILURE; + g_autoptr(GOptionContext) context = g_option_context_new ("- Apply pending deployment changes to booted deployment"); + glnx_unref_object RPMOSTreeOS *os_proxy = NULL; + glnx_unref_object RPMOSTreeSysroot *sysroot_proxy = NULL; + g_autofree char *transaction_address = NULL; + + if (!rpmostree_option_context_parse (context, + option_entries, + &argc, &argv, + invocation, + cancellable, + &sysroot_proxy, + error)) + goto out; + + if (!rpmostree_load_os_proxy (sysroot_proxy, NULL, + cancellable, &os_proxy, error)) + goto out; + + if (!rpmostree_os_call_live_fs_sync (os_proxy, + get_args_variant (), + &transaction_address, + cancellable, + error)) + goto out; + + if (!rpmostree_transaction_get_response_sync (sysroot_proxy, + transaction_address, + cancellable, + error)) + goto out; + + exit_status = EXIT_SUCCESS; +out: + /* Does nothing if using the message bus. */ + rpmostree_cleanup_peer (); + + return exit_status; +} diff --git a/src/app/rpmostree-builtin-status.c b/src/app/rpmostree-builtin-status.c index 9ca1851ae7..6ea66ad14a 100644 --- a/src/app/rpmostree-builtin-status.c +++ b/src/app/rpmostree-builtin-status.c @@ -177,13 +177,16 @@ status_generic (RPMOSTreeSysroot *sysroot_proxy, const gchar *checksum; const gchar *version_string; const gchar *unlocked; + const gchar *live_inprogress; + const gchar *live_replaced; gboolean gpg_enabled; gboolean regenerate_initramfs; guint64 t = 0; int serial; gboolean is_booted; const gboolean was_first = first; - const guint max_key_len = strlen ("PendingBaseVersion"); + /* Add the long keys here */ + const guint max_key_len = MAX (strlen ("PendingBaseVersion"), strlen ("InterruptedLiveCommit")); g_autoptr(GVariant) signatures = NULL; g_autofree char *timestamp_string = NULL; @@ -265,10 +268,43 @@ status_generic (RPMOSTreeSysroot *sysroot_proxy, print_kv ("Timestamp", max_key_len, timestamp_string); } + if (!g_variant_dict_lookup (dict, "live-inprogress", "&s", &live_inprogress)) + live_inprogress = NULL; + if (!g_variant_dict_lookup (dict, "live-replaced", "&s", &live_replaced)) + live_replaced = NULL; + const gboolean have_live_changes = live_inprogress || live_replaced; + if (is_locally_assembled) - print_kv ("BaseCommit", max_key_len, base_checksum); + { + if (have_live_changes) + print_kv ("BootedBaseCommit", max_key_len, base_checksum); + else + print_kv ("BaseCommit", max_key_len, base_checksum); + } else - print_kv ("Commit", max_key_len, checksum); + { + if (have_live_changes) + print_kv ("BootedCommit", max_key_len, checksum); + else + print_kv ("Commit", max_key_len, checksum); + } + + if (live_inprogress) + { + if (is_booted) + g_print ("%s%s", red_prefix, bold_prefix); + print_kv ("InterruptedLiveCommit", max_key_len, live_inprogress); + if (is_booted) + g_print ("%s%s", bold_suffix, red_suffix); + } + if (live_replaced) + { + if (is_booted) + g_print ("%s%s", red_prefix, bold_prefix); + print_kv ("LiveCommit", max_key_len, live_replaced); + if (is_booted) + g_print ("%s%s", bold_suffix, red_suffix); + } /* Show any difference between the baseref vs head, but only for the booted commit, and only if there isn't a pending deployment. Otherwise diff --git a/src/app/rpmostree-ex-builtins.h b/src/app/rpmostree-ex-builtins.h index 9b57006529..792ff613c5 100644 --- a/src/app/rpmostree-ex-builtins.h +++ b/src/app/rpmostree-ex-builtins.h @@ -26,7 +26,14 @@ G_BEGIN_DECLS -gboolean rpmostree_ex_builtin_unpack (int argc, char **argv, RpmOstreeCommandInvocation *invocation, GCancellable *cancellable, GError **error); +#define BUILTINPROTO(name) gboolean rpmostree_ex_builtin_ ## name (int argc, char **argv, \ + RpmOstreeCommandInvocation *invocation, \ + GCancellable *cancellable, GError **error) + +BUILTINPROTO(unpack); +BUILTINPROTO(livefs); + +#undef BUILTINPROTO G_END_DECLS diff --git a/src/daemon/org.projectatomic.rpmostree1.xml b/src/daemon/org.projectatomic.rpmostree1.xml index 1f6d4cfc0e..53a51f7f67 100644 --- a/src/daemon/org.projectatomic.rpmostree1.xml +++ b/src/daemon/org.projectatomic.rpmostree1.xml @@ -224,6 +224,11 @@ + + + + + diff --git a/src/daemon/rpmostreed-deployment-utils.c b/src/daemon/rpmostreed-deployment-utils.c index 53c179f737..cc2dd05f43 100644 --- a/src/daemon/rpmostreed-deployment-utils.c +++ b/src/daemon/rpmostreed-deployment-utils.c @@ -21,6 +21,7 @@ #include "rpmostreed-deployment-utils.h" #include "rpmostree-origin.h" #include "rpmostree-util.h" +#include "rpmostreed-utils.h" #include @@ -157,7 +158,8 @@ variant_add_commit_details (GVariantDict *dict, } GVariant * -rpmostreed_deployment_generate_variant (OstreeDeployment *deployment, +rpmostreed_deployment_generate_variant (OstreeSysroot *sysroot, + OstreeDeployment *deployment, const char *booted_id, OstreeRepo *repo, GError **error) @@ -178,6 +180,8 @@ rpmostreed_deployment_generate_variant (OstreeDeployment *deployment, gint serial = ostree_deployment_get_deployserial (deployment); gboolean gpg_enabled = FALSE; gboolean is_layered = FALSE; + g_autofree char *live_inprogress = NULL; + g_autofree char *live_replaced = NULL; g_auto(GStrv) layered_pkgs = NULL; if (!ostree_repo_load_variant (repo, @@ -247,6 +251,16 @@ rpmostreed_deployment_generate_variant (OstreeDeployment *deployment, variant_add_commit_details (&dict, "pending-base-", pending_base_commit); } + if (!rpmostreed_deployment_get_live_status (sysroot, deployment, -1, + &live_inprogress, &live_replaced, + error)) + return NULL; + + if (live_inprogress) + g_variant_dict_insert (&dict, "live-inprogress", "s", live_inprogress); + if (live_replaced) + g_variant_dict_insert (&dict, "live-replaced", "s", live_replaced); + g_variant_dict_insert (&dict, "origin", "s", refspec); g_autofree char **requested_pkgs = diff --git a/src/daemon/rpmostreed-deployment-utils.h b/src/daemon/rpmostreed-deployment-utils.h index da004650f3..ee2cab0eee 100644 --- a/src/daemon/rpmostreed-deployment-utils.h +++ b/src/daemon/rpmostreed-deployment-utils.h @@ -30,10 +30,11 @@ OstreeDeployment * GVariant * rpmostreed_deployment_generate_blank_variant (void); -GVariant * rpmostreed_deployment_generate_variant (OstreeDeployment *deployment, +GVariant * rpmostreed_deployment_generate_variant (OstreeSysroot *sysroot, + OstreeDeployment *deployment, const char *booted_id, OstreeRepo *repo, - GError **error); + GError **error); GVariant * rpmostreed_commit_generate_cached_details_variant (OstreeDeployment *deployment, OstreeRepo *repo, diff --git a/src/daemon/rpmostreed-os.c b/src/daemon/rpmostreed-os.c index 23cf7941cf..2e79631980 100644 --- a/src/daemon/rpmostreed-os.c +++ b/src/daemon/rpmostreed-os.c @@ -900,6 +900,73 @@ os_handle_cleanup (RPMOSTreeOS *interface, return TRUE; } +static RpmOstreeTransactionLiveFsFlags +livefs_flags_from_options (GVariant *options) +{ + RpmOstreeTransactionLiveFsFlags ret = 0; + GVariantDict options_dict; + gboolean opt = FALSE; + + g_variant_dict_init (&options_dict, options); + if (g_variant_dict_lookup (&options_dict, "dry-run", "b", &opt) && opt) + ret |= RPMOSTREE_TRANSACTION_LIVEFS_FLAG_DRY_RUN; + if (g_variant_dict_lookup (&options_dict, "replace", "b", &opt) && opt) + ret |= RPMOSTREE_TRANSACTION_LIVEFS_FLAG_ALLOW_REPLACE; + if (g_variant_dict_lookup (&options_dict, "partial", "b", &opt) && opt) + ret |= RPMOSTREE_TRANSACTION_LIVEFS_FLAG_IGNORE_NON_USR; + + g_variant_dict_clear (&options_dict); + + return ret; +} + +static gboolean +os_handle_live_fs (RPMOSTreeOS *interface, + GDBusMethodInvocation *invocation, + GVariant *arg_options) +{ + RpmostreedOS *self = RPMOSTREED_OS (interface); + glnx_unref_object RpmostreedTransaction *transaction = NULL; + glnx_unref_object OstreeSysroot *ot_sysroot = NULL; + g_autoptr(GCancellable) cancellable = g_cancellable_new (); + GError *local_error = NULL; + + transaction = merge_compatible_txn (self, invocation); + if (transaction) + goto out; + + if (!rpmostreed_sysroot_load_state (rpmostreed_sysroot_get (), + cancellable, + &ot_sysroot, + NULL, + &local_error)) + goto out; + + transaction = rpmostreed_transaction_new_livefs (invocation, + ot_sysroot, + livefs_flags_from_options (arg_options), + cancellable, + &local_error); + if (transaction == NULL) + goto out; + + rpmostreed_transaction_monitor_add (self->transaction_monitor, transaction); + +out: + if (local_error != NULL) + { + g_dbus_method_invocation_take_error (invocation, local_error); + } + else + { + const char *client_address; + client_address = rpmostreed_transaction_get_client_address (transaction); + rpmostree_os_complete_live_fs (interface, invocation, client_address); + } + + return TRUE; +} + static gboolean os_handle_get_cached_rebase_rpm_diff (RPMOSTreeOS *interface, GDBusMethodInvocation *invocation, @@ -1198,7 +1265,7 @@ rpmostreed_os_load_internals (RpmostreedOS *self, GError **error) booted = ostree_sysroot_get_booted_deployment (ot_sysroot); if (booted && g_strcmp0 (ostree_deployment_get_osname (booted), name) == 0) { - booted_variant = rpmostreed_deployment_generate_variant (booted, booted_id, ot_repo, error); + booted_variant = rpmostreed_deployment_generate_variant (ot_sysroot, booted, booted_id, ot_repo, error); if (!booted_variant) return FALSE; booted_id = rpmostreed_deployment_generate_id (booted); @@ -1209,7 +1276,8 @@ rpmostreed_os_load_internals (RpmostreedOS *self, GError **error) { if (g_strcmp0 (ostree_deployment_get_osname (deployments->pdata[i]), name) == 0) { - default_variant = rpmostreed_deployment_generate_variant (deployments->pdata[i], + default_variant = rpmostreed_deployment_generate_variant (ot_sysroot, + deployments->pdata[i], booted_id, ot_repo, error); if (default_variant == NULL) @@ -1223,8 +1291,8 @@ rpmostreed_os_load_internals (RpmostreedOS *self, GError **error) rollback_index = rpmostreed_rollback_deployment_index (name, ot_sysroot, NULL); if (rollback_index >= 0) { - rollback_variant = rpmostreed_deployment_generate_variant (deployments->pdata[rollback_index], booted_id, - ot_repo, error); + rollback_variant = rpmostreed_deployment_generate_variant (ot_sysroot, deployments->pdata[rollback_index], booted_id, + ot_repo, error); if (!rollback_variant) return FALSE; } @@ -1286,6 +1354,7 @@ rpmostreed_os_iface_init (RPMOSTreeOSIface *iface) iface->handle_pkg_change = os_handle_pkg_change; iface->handle_set_initramfs_state = os_handle_set_initramfs_state; iface->handle_cleanup = os_handle_cleanup; + iface->handle_live_fs = os_handle_live_fs; iface->handle_get_cached_rebase_rpm_diff = os_handle_get_cached_rebase_rpm_diff; iface->handle_download_rebase_rpm_diff = os_handle_download_rebase_rpm_diff; iface->handle_get_cached_deploy_rpm_diff = os_handle_get_cached_deploy_rpm_diff; diff --git a/src/daemon/rpmostreed-sysroot.c b/src/daemon/rpmostreed-sysroot.c index 3435f7991c..25d40830cd 100644 --- a/src/daemon/rpmostreed-sysroot.c +++ b/src/daemon/rpmostreed-sysroot.c @@ -495,7 +495,7 @@ sysroot_populate_deployments_unlocked (RpmostreedSysroot *self, OstreeDeployment *deployment = deployments->pdata[i]; const char *deployment_os; - variant = rpmostreed_deployment_generate_variant (deployment, booted_id, self->repo, error); + variant = rpmostreed_deployment_generate_variant (self->ot_sysroot, deployment, booted_id, self->repo, error); if (!variant) goto out; g_variant_builder_add_value (&builder, variant); diff --git a/src/daemon/rpmostreed-transaction-livefs.c b/src/daemon/rpmostreed-transaction-livefs.c new file mode 100644 index 0000000000..e611afced7 --- /dev/null +++ b/src/daemon/rpmostreed-transaction-livefs.c @@ -0,0 +1,513 @@ +/* + * Copyright (C) 2015,2017 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "config.h" +#include "ostree.h" + +#include +#include +#include +#include + +#include "rpmostreed-transaction-types.h" +#include "rpmostreed-transaction.h" +#include "rpmostreed-deployment-utils.h" +#include "rpmostreed-sysroot.h" +#include "rpmostree-sysroot-upgrader.h" +#include "rpmostree-util.h" +#include "rpmostree-db.h" +#include "rpmostree-output.h" +#include "rpmostree-core.h" +#include "rpmostreed-utils.h" + +typedef struct { + RpmostreedTransaction parent; + RpmOstreeTransactionLiveFsFlags flags; +} LiveFsTransaction; + +typedef RpmostreedTransactionClass LiveFsTransactionClass; + +GType livefs_transaction_get_type (void); + +G_DEFINE_TYPE (LiveFsTransaction, + livefs_transaction, + RPMOSTREED_TYPE_TRANSACTION) + +static void +livefs_transaction_finalize (GObject *object) +{ + G_GNUC_UNUSED LiveFsTransaction *self; + + self = (LiveFsTransaction *) object; + + G_OBJECT_CLASS (livefs_transaction_parent_class)->finalize (object); +} + +typedef enum { + COMMIT_DIFF_FLAGS_ETC = (1<< 0), /* Change in /usr/etc */ + COMMIT_DIFF_FLAGS_BOOT = (1<< 1), /* Change in /boot */ + COMMIT_DIFF_FLAGS_ROOTFS = (1 << 2), /* Change in / */ + COMMIT_DIFF_FLAGS_REPLACEMENT = (1 << 3) /* Files in /usr were replaced */ +} CommitDiffFlags; + +static gboolean +path_is_boot (const char *path) +{ + return g_str_has_prefix (path, "/boot/") || + g_str_has_prefix (path, "/usr/lib/ostree-boot/"); +} + +static gboolean +path_is_usretc (const char *path) +{ + return g_str_has_prefix (path, "/usr/etc/"); +} + +static gboolean +path_is_rpmdb (const char *path) +{ + return g_str_has_prefix (path, "/usr/share/rpm/"); +} + +static gboolean +path_is_rootfs (const char *path) +{ + return !g_str_has_prefix (path, "/usr/"); +} + +static void +update_diff_for_path (const char *path, + gboolean is_addition, + CommitDiffFlags *inout_flags, + guint *inout_changed) +{ + /* We expect the rpmdb to change */ + if (path_is_rpmdb (path)) + ; + else if (path_is_usretc (path)) + (*inout_flags) |= COMMIT_DIFF_FLAGS_ETC; + else if (path_is_boot (path)) + (*inout_flags) |= COMMIT_DIFF_FLAGS_BOOT; + else if (path_is_rootfs (path)) + (*inout_flags) |= COMMIT_DIFF_FLAGS_ROOTFS; + else if (inout_changed) + (*inout_changed)++; +} + +static gboolean +analyze_commit_diff (OstreeRepo *repo, + const char *from_rev, + const char *to_rev, + CommitDiffFlags *out_flags, + guint *out_n_replaced, + GCancellable *cancellable, + GError **error) +{ + CommitDiffFlags ret_flags = 0; + guint ret_replaced = 0; + + /* Shouldn't happen, but might as well fast path it in case */ + if (strcmp (from_rev, to_rev) == 0) + { + *out_flags = ret_flags; + *out_n_replaced = ret_replaced; + return TRUE; + } + + /* Read the "from" and "to" commits */ + glnx_unref_object GFile *from_tree = NULL; + if (!ostree_repo_read_commit (repo, from_rev, &from_tree, NULL, + cancellable, error)) + return FALSE; + glnx_unref_object GFile *to_tree = NULL; + if (!ostree_repo_read_commit (repo, to_rev, &to_tree, NULL, + cancellable, error)) + return FALSE; + + /* Diff the two commits */ + g_autoptr(GPtrArray) modified = g_ptr_array_new_with_free_func ((GDestroyNotify) ostree_diff_item_unref); + g_autoptr(GPtrArray) removed = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref); + g_autoptr(GPtrArray) added = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref); + if (!ostree_diff_dirs (0, from_tree, to_tree, + modified, removed, added, + cancellable, error)) + return FALSE; + + /* Analyze the differences */ + for (guint i = 0; i < removed->len; i++) + { + GFile *gfpath = removed->pdata[i]; + const char *path = gs_file_get_path_cached (gfpath); + + update_diff_for_path (path, FALSE, &ret_flags, &ret_replaced); + } + + for (guint i = 0; i < modified->len; i++) + { + OstreeDiffItem *diffitem = modified->pdata[i]; + const char *path = gs_file_get_path_cached (diffitem->src); + + update_diff_for_path (path, FALSE, &ret_flags, &ret_replaced); + } + + for (guint i = 0; i < added->len; i++) + { + GFile *added_f = added->pdata[i]; + const char *path = gs_file_get_path_cached (added_f); + + update_diff_for_path (path, TRUE, &ret_flags, NULL); + } + + *out_flags = ret_flags; + *out_n_replaced = ret_replaced; + return TRUE; +} + +/* We want to ensure the rollback deployment matches our booted checksum. If it + * doesn't, we'll push a new one, and GC the previous one(s). + */ +static OstreeDeployment * +get_rollback_deployment (OstreeSysroot *self, + OstreeDeployment *booted) +{ + const char *osname = ostree_deployment_get_osname (booted); + int booted_idx = ostree_deployment_get_index (booted); + g_autoptr(GPtrArray) deployments = ostree_sysroot_get_deployments (self); + const char *booted_csum = ostree_deployment_get_csum (booted); + guint i; + + for (i = 0; i < deployments->len; i++) + { + OstreeDeployment *deployment = deployments->pdata[i]; + int idx = ostree_deployment_get_index (deployment); + + /* Is this for another osname? Skip it. */ + if (strcmp (ostree_deployment_get_osname (deployment), osname) != 0) + continue; + + /* Before or at booted? Skip. */ + if (idx <= booted_idx) + continue; + + /* Does it match our checksum? */ + const char *csum = ostree_deployment_get_csum (deployment); + if (strcmp (csum, booted_csum) == 0) + return g_object_ref (deployment); + } + + return NULL; +} + +static gboolean +prepare_rollback_deployment (OstreeSysroot *sysroot, + OstreeDeployment *booted_deployment, + GCancellable *cancellable, + GError **error) +{ + glnx_unref_object OstreeDeployment *new_deployment = NULL; + OstreeBootconfigParser *original_bootconfig = ostree_deployment_get_bootconfig (booted_deployment); + glnx_unref_object OstreeBootconfigParser *new_bootconfig = ostree_bootconfig_parser_clone (original_bootconfig); + + /* Ensure we have a clean slate */ + if (!ostree_sysroot_prepare_cleanup (sysroot, cancellable, error)) + return g_prefix_error (error, "Performing initial cleanup: "), FALSE; + + g_print ("Preparing new rollback matching currently booted deployment\n"); + + if (!ostree_sysroot_deploy_tree (sysroot, + ostree_deployment_get_osname (booted_deployment), + ostree_deployment_get_csum (booted_deployment), + ostree_deployment_get_origin (booted_deployment), + booted_deployment, + NULL, + &new_deployment, + cancellable, error)) + return FALSE; + + /* Inherit kernel arguments */ + ostree_deployment_set_bootconfig (new_deployment, new_bootconfig); + + if (!rpmostree_write_deployment (sysroot, new_deployment, booted_deployment, + TRUE, cancellable, error)) + return FALSE; + + return TRUE; +} + +static gboolean +livefs_transaction_execute (RpmostreedTransaction *transaction, + GCancellable *cancellable, + GError **error) +{ + LiveFsTransaction *self = (LiveFsTransaction *) transaction; + static const char orig_rpmdb_path[] = "usr/share/rpm.rpmostree-orig"; + + /* Initial setup - load sysroot, repo, and booted deployment */ + OstreeSysroot *sysroot = rpmostreed_transaction_get_sysroot (transaction); + glnx_unref_object OstreeRepo *repo = NULL; + if (!ostree_sysroot_get_repo (sysroot, &repo, cancellable, error)) + return FALSE; + OstreeDeployment *booted_deployment = ostree_sysroot_get_booted_deployment (sysroot); + if (!booted_deployment) + return glnx_throw (error, "Not currently booted into an OSTree system"); + + /* Overlayfs doesn't support mutation of the lowerdir. And broadly speaking, + * our medium term goal here is to obviate most of the unlock usage. + */ + OstreeDeploymentUnlockedState unlockstate = ostree_deployment_get_unlocked (booted_deployment); + if (unlockstate != OSTREE_DEPLOYMENT_UNLOCKED_NONE) + return glnx_throw (error, "livefs is incompatible with unlocked state"); + + /* Find the source for /etc - either booted or pending, but down below we + require pending */ + OstreeDeployment *origin_merge_deployment = + rpmostreed_get_origin_merge_deployment (sysroot, + ostree_deployment_get_osname (booted_deployment)); + g_assert (origin_merge_deployment); + const char *booted_csum = ostree_deployment_get_csum (booted_deployment); + const char *target_csum = ostree_deployment_get_csum (origin_merge_deployment); + + /* Require a pending deployment to use as a source - perhaps in the future we + * handle direct live overlays. + */ + if (origin_merge_deployment == booted_deployment) + return glnx_throw (error, "No pending deployment"); + + /* Open a fd for the booted deployment */ + g_autofree char *deployment_path = ostree_sysroot_get_deployment_dirpath (sysroot, booted_deployment); + glnx_fd_close int deployment_dfd = -1; + if (!glnx_opendirat (ostree_sysroot_get_fd (sysroot), deployment_path, TRUE, + &deployment_dfd, error)) + return FALSE; + + /* Find out whether we already have a live overlay */ + g_autofree char *live_inprogress = NULL; + g_autofree char *live_replaced = NULL; + if (!rpmostreed_deployment_get_live_status (sysroot, booted_deployment, -1, + &live_inprogress, &live_replaced, + error)) + return FALSE; + if (live_inprogress != NULL) + { + if (strcmp (live_inprogress, target_csum) == 0) + g_print ("Note: Resuming interrupted overlay of %s\n", target_csum); + } + if (live_replaced != NULL) + { + if (strcmp (live_replaced, booted_csum) == 0) + return glnx_throw (error, "Current overlay is already %s", booted_csum); + g_print ("Note: Adding to previous overlay: %s\n", live_replaced); + } + + /* Look at the difference between the two commits - we could also walk the + * filesystem, but doing it at the ostree level is potentially faster, since + * we know when two directories are the same. + */ + guint n_replaced; + CommitDiffFlags diff_analysis_flags = 0; + if (!analyze_commit_diff (repo, booted_csum, + target_csum, + &diff_analysis_flags, + &n_replaced, + cancellable, error)) + return FALSE; + + /* And gather the RPM level changes */ + g_autoptr(GPtrArray) removed_pkgs = NULL; + g_autoptr(GPtrArray) added_pkgs = NULL; + g_autoptr(GPtrArray) modified_pkgs_old = NULL; + g_autoptr(GPtrArray) modified_pkgs_new = NULL; + if (!rpm_ostree_db_diff (repo, booted_csum, target_csum, + &removed_pkgs, &added_pkgs, &modified_pkgs_old, &modified_pkgs_new, + cancellable, error)) + return FALSE; + g_assert (modified_pkgs_old->len == modified_pkgs_new->len); + + /* Print out the results of the two diffs */ + g_print ("Packages:\n modified: %u\n removed: %u\n added: %u\n", + modified_pkgs_new->len, removed_pkgs->len, added_pkgs->len); + if (diff_analysis_flags == 0) + { + if ((self->flags & RPMOSTREE_TRANSACTION_LIVEFS_FLAG_DRY_RUN) > 0) + { + g_print ("livefs would be safe!\n"); + return TRUE; + } + } + else + { + if (diff_analysis_flags & COMMIT_DIFF_FLAGS_ETC) + { + g_print ("warning: livefs would change configuration in /etc\n"); + } + if (diff_analysis_flags & COMMIT_DIFF_FLAGS_REPLACEMENT) + { + g_print ("warning: livefs would replace %u files\n", n_replaced); + } + if (diff_analysis_flags & COMMIT_DIFF_FLAGS_ROOTFS) + { + g_print ("warning: livefs would add or replace content in /\n"); + } + if (diff_analysis_flags & COMMIT_DIFF_FLAGS_BOOT) + { + g_print ("warning: livefs would add or replace kernel/initramfs\n"); + } + + /* Ensure these messages are out so they don't mix with an error from above */ + fflush (stdout); + + if ((self->flags & RPMOSTREE_TRANSACTION_LIVEFS_FLAG_DRY_RUN) > 0) + return TRUE; + else if ((diff_analysis_flags & COMMIT_DIFF_FLAGS_REPLACEMENT) > 0 + && (self->flags & RPMOSTREE_TRANSACTION_LIVEFS_FLAG_ALLOW_REPLACE) == 0) + return glnx_throw (error, "livefs update would replace files in /usr, and replacement not enabled"); + else if ((diff_analysis_flags & ~COMMIT_DIFF_FLAGS_REPLACEMENT) > 0 + && (self->flags & RPMOSTREE_TRANSACTION_LIVEFS_FLAG_IGNORE_NON_USR) == 0) + return glnx_throw (error, "livefs update would perform changes beyond new files in /usr, and partial updates not enabled"); + } + + /* Ensure that we have a rollback deployment that matches our booted checksum, + * so that if something goes wrong, the user can get to it. If we have an + * older rollback, that gets GC'd. + */ + OstreeDeployment *rollback_deployment = get_rollback_deployment (sysroot, booted_deployment); + if (!rollback_deployment) + { + if (!prepare_rollback_deployment (sysroot, booted_deployment, cancellable, error)) + return g_prefix_error (error, "Preparing rollback: "), FALSE; + } + + /* Reload this, the sysroot may have changed it */ + booted_deployment = ostree_sysroot_get_booted_deployment (sysroot); + + /* Crack open our booted root */ + if (!ostree_sysroot_deployment_set_mutable (sysroot, booted_deployment, TRUE, + cancellable, error)) + return g_prefix_error (error, "Setting deployment mutable: "), FALSE; + /* This xattr says which commit we're in the process of overlaying */ + if (fsetxattr (deployment_dfd, RPMOSTREE_LIVE_INPROGRESS_XATTR, target_csum, OSTREE_SHA256_STRING_LEN, 0) < 0) + return glnx_throw_errno_prefix (error, "Setting %s", RPMOSTREE_LIVE_INPROGRESS_XATTR); + + rpmostree_output_task_begin ("Overlaying /usr"); + + /* Note we only overlay /usr. Note this should implicitly skip the rpmdb since + * we won't overlay new files right now, but we should *explicitly* skip it + * which would require some sort of skip support in checkout. + */ + { OstreeRepoCheckoutAtOptions usr_checkout_opts = { .mode = OSTREE_REPO_CHECKOUT_MODE_NONE, + .overwrite_mode = OSTREE_REPO_CHECKOUT_OVERWRITE_ADD_FILES, + .no_copy_fallback = TRUE, + .subpath = "/usr" }; + if (!ostree_repo_checkout_at (repo, &usr_checkout_opts, deployment_dfd, "usr", + target_csum, cancellable, error)) + return FALSE; + } + + /* Start replacing the rpmdb. First, ensure the temporary dir for the new + version doesn't exist */ + if (!glnx_shutil_rm_rf_at (deployment_dfd, orig_rpmdb_path, cancellable, error)) + return FALSE; + /* Check out the new rpmdb */ + { OstreeRepoCheckoutAtOptions rpmdb_checkout_opts = { .mode = OSTREE_REPO_CHECKOUT_MODE_NONE, + .overwrite_mode = OSTREE_REPO_CHECKOUT_OVERWRITE_ADD_FILES, + .no_copy_fallback = TRUE, + .subpath = "/usr/share/rpm" }; + + + if (!ostree_repo_checkout_at (repo, &rpmdb_checkout_opts, deployment_dfd, orig_rpmdb_path, + target_csum, cancellable, error)) + return FALSE; + } + /* Now, RENAME_EXCHANGE the two */ + if (glnx_renameat2_exchange (deployment_dfd, "usr/share/rpm", deployment_dfd, orig_rpmdb_path) < 0) + return glnx_throw_errno_prefix (error, "%s", "rename(..., RENAME_EXCHANGE) for rpmdb"); + /* And nuke the old one */ + if (!glnx_shutil_rm_rf_at (deployment_dfd, orig_rpmdb_path, cancellable, error)) + return FALSE; + + /* Mark the current deployment as having completed a live overlay */ + if (fsetxattr (deployment_dfd, RPMOSTREE_LIVE_REPLACED_XATTR, target_csum, OSTREE_SHA256_STRING_LEN, 0) < 0) + return glnx_throw_errno_prefix (error, "Setting %s", RPMOSTREE_LIVE_REPLACED_XATTR); + + /* And remove the in-progress xattr */ + if (fremovexattr (deployment_dfd, RPMOSTREE_LIVE_INPROGRESS_XATTR) < 0) + { + /* If this somehow happens...hm, well, let's ignore it */ + if (errno == ENODATA) + sd_journal_print (LOG_WARNING, "Got ENODATA removing xattr '%s'?", RPMOSTREE_LIVE_INPROGRESS_XATTR); + else + return glnx_throw_errno (error); + } + + /* Seal the root back up again */ + if (!ostree_sysroot_deployment_set_mutable (sysroot, booted_deployment, FALSE, + cancellable, error)) + return g_prefix_error (error, "Setting deployment mutable: "), FALSE; + + rpmostree_output_task_end ("done"); + + /* We use this to notify ourselves of changes, which is a bit silly, but it + * keeps things consistent if `ostree admin` is invoked directly. + */ + if (!rpmostree_sysroot_bump_mtime (sysroot, error)) + return FALSE; + + return TRUE; +} + +static void +livefs_transaction_class_init (LiveFsTransactionClass *class) +{ + GObjectClass *object_class; + + object_class = G_OBJECT_CLASS (class); + object_class->finalize = livefs_transaction_finalize; + + class->execute = livefs_transaction_execute; +} + +static void +livefs_transaction_init (LiveFsTransaction *self) +{ +} + +RpmostreedTransaction * +rpmostreed_transaction_new_livefs (GDBusMethodInvocation *invocation, + OstreeSysroot *sysroot, + RpmOstreeTransactionLiveFsFlags flags, + GCancellable *cancellable, + GError **error) +{ + LiveFsTransaction *self; + + g_return_val_if_fail (G_IS_DBUS_METHOD_INVOCATION (invocation), NULL); + g_return_val_if_fail (OSTREE_IS_SYSROOT (sysroot), NULL); + + self = g_initable_new (livefs_transaction_get_type (), + cancellable, error, + "invocation", invocation, + "sysroot-path", gs_file_get_path_cached (ostree_sysroot_get_path (sysroot)), + NULL); + + if (self != NULL) + { + self->flags = flags; + } + + return (RpmostreedTransaction *) self; +} diff --git a/src/daemon/rpmostreed-transaction-types.c b/src/daemon/rpmostreed-transaction-types.c index bca42cf0b6..e20d2f8922 100644 --- a/src/daemon/rpmostreed-transaction-types.c +++ b/src/daemon/rpmostreed-transaction-types.c @@ -27,6 +27,7 @@ #include "rpmostreed-sysroot.h" #include "rpmostree-sysroot-upgrader.h" #include "rpmostree-util.h" +#include "rpmostree-output.h" #include "rpmostree-core.h" #include "rpmostree-unpacker.h" #include "rpmostreed-utils.h" @@ -1085,11 +1086,14 @@ remove_directory_content_if_exists (int dfd, } /* This is a bit like ostree_sysroot_simple_write_deployment() */ -static GPtrArray * +/* TODO: dedup this with rpmostree_write_deployment() */ +static gboolean get_filtered_deployments (OstreeSysroot *sysroot, const char *osname, - gboolean cleanup_pending, - gboolean cleanup_rollback) + gboolean cleanup_pending, + gboolean cleanup_rollback, + GPtrArray **out_deployments, + GError **error) { g_autoptr(GPtrArray) deployments = ostree_sysroot_get_deployments (sysroot); g_autoptr(GPtrArray) new_deployments = g_ptr_array_new_with_free_func (g_object_unref); @@ -1098,6 +1102,11 @@ get_filtered_deployments (OstreeSysroot *sysroot, booted_deployment = ostree_sysroot_get_booted_deployment (sysroot); + gboolean booted_is_live; + if (!rpmostreed_deployment_is_live (sysroot, booted_deployment, -1, + &booted_is_live, error)) + return FALSE; + for (guint i = 0; i < deployments->len; i++) { OstreeDeployment *deployment = deployments->pdata[i]; @@ -1124,15 +1133,21 @@ get_filtered_deployments (OstreeSysroot *sysroot, continue; if (found_booted && cleanup_rollback) + { + /* Maybe we should have a --force? */ + if (booted_is_live) + return glnx_throw (error, "Livefs active; refusing cleanup of rollback deployment"); + } continue; /* Otherwise, add it */ g_ptr_array_add (new_deployments, g_object_ref (deployment)); } - if (new_deployments->len == deployments->len) - return NULL; - return g_steal_pointer (&new_deployments); + /* Only return the value if they're changed */ + if (new_deployments->len != deployments->len) + *out_deployments = g_steal_pointer (&new_deployments); + return TRUE; } static gboolean @@ -1152,9 +1167,14 @@ cleanup_transaction_execute (RpmostreedTransaction *transaction, if (cleanup_pending || cleanup_rollback) { - g_autoptr(GPtrArray) new_deployments = get_filtered_deployments (sysroot, self->osname, - cleanup_pending, - cleanup_rollback); + g_autoptr(GPtrArray) new_deployments = NULL; + if (!get_filtered_deployments (sysroot, self->osname, + cleanup_pending, + cleanup_rollback, + &new_deployments, + error)) + return FALSE; + if (new_deployments) { /* TODO - expose the skip cleanup flag in libostree, use it here */ diff --git a/src/daemon/rpmostreed-transaction-types.h b/src/daemon/rpmostreed-transaction-types.h index c6822fc984..e3f5f4efec 100644 --- a/src/daemon/rpmostreed-transaction-types.h +++ b/src/daemon/rpmostreed-transaction-types.h @@ -94,3 +94,16 @@ rpmostreed_transaction_new_cleanup (GDBusMethodInvocation *invocation, RpmOstreeTransactionCleanupFlags flags, GCancellable *cancellable, GError **error); + +typedef enum { + RPMOSTREE_TRANSACTION_LIVEFS_FLAG_DRY_RUN = (1 << 0), + RPMOSTREE_TRANSACTION_LIVEFS_FLAG_ALLOW_REPLACE = (1 << 1), + RPMOSTREE_TRANSACTION_LIVEFS_FLAG_IGNORE_NON_USR = (1 << 2) +} RpmOstreeTransactionLiveFsFlags; + +RpmostreedTransaction * +rpmostreed_transaction_new_livefs (GDBusMethodInvocation *invocation, + OstreeSysroot *sysroot, + RpmOstreeTransactionLiveFsFlags flags, + GCancellable *cancellable, + GError **error); diff --git a/src/daemon/rpmostreed-utils.c b/src/daemon/rpmostreed-utils.c index 70b54923fc..813c9242eb 100644 --- a/src/daemon/rpmostreed-utils.c +++ b/src/daemon/rpmostreed-utils.c @@ -293,6 +293,15 @@ rpmostree_write_deployment (OstreeSysroot *sysroot, added_new = TRUE; } + /* By default, if we're pushing a rollback, we GC the last one. However, + * for new pending deployments, in the case where we have a livefs active, + * we retain the rollback. Query the booted state here. + */ + gboolean booted_is_live; + if (!rpmostreed_deployment_is_live (sysroot, booted_deployment, -1, + &booted_is_live, error)) + return FALSE; + for (guint i = 0; i < deployments->len; i++) { OstreeDeployment *deployment = deployments->pdata[i]; @@ -308,9 +317,13 @@ rpmostree_write_deployment (OstreeSysroot *sysroot, /* Retain deployment if: * - The deployment is for another osname * - We're pushing a rollback and this is a pending deployment + * - We're pushing a pending, the booted deployment is live, and this is a rollback * - It's the merge or booted deployment */ - if (!osname_matches || (pushing_rollback && before_booted) || is_merge_or_booted) + if (!osname_matches + || (pushing_rollback && before_booted) + || (!pushing_rollback && booted_is_live && !before_booted) + || is_merge_or_booted) g_ptr_array_add (new_deployments, g_object_ref (deployment)); /* Insert new rollback right after the booted */ @@ -746,3 +759,91 @@ rpmostreed_parse_revision (const char *revision, out: return ret; } + +/* Load the value of an xattr which we expect to be a SHA256 string. + */ +static gboolean +get_checksum_xattr (int fd, const char *xattr, + char **out_value, + GError **error) +{ + char xattr_csum[OSTREE_SHA256_STRING_LEN+1]; + ssize_t len; + + *out_value = NULL; + + if ((len = fgetxattr (fd, xattr, xattr_csum, sizeof (xattr_csum))) < 0) + { + /* Did something happen other than not finding the xattr? */ + if (errno != ENODATA) + return glnx_throw_errno (error); + } + else if (len == OSTREE_SHA256_STRING_LEN) + { + *out_value = g_strndup (xattr_csum, len); + } + else + sd_journal_print (LOG_WARNING, "Unexpected length %" PRIu64 " for %s", + len, xattr); + + return TRUE; +} + +/* Load the checksums that describe the "livefs" state of the given + * deployment. + */ +gboolean +rpmostreed_deployment_get_live_status (OstreeSysroot *sysroot, + OstreeDeployment *deployment, + int deployment_dfd, + char **out_inprogress_checksum, + char **out_livereplaced_checksum, + GError **error) +{ + g_autofree char *ret_inprogress_checksum = NULL; + g_autofree char *ret_livereplaced_checksum = NULL; + glnx_fd_close int owned_dfd = -1; + + /* Allow providing -1 if the caller doesn't have a fd already */ + if (deployment_dfd == -1) + { + g_autofree char *deployment_path = NULL; + deployment_path = ostree_sysroot_get_deployment_dirpath (sysroot, deployment); + if (!glnx_opendirat (ostree_sysroot_get_fd (sysroot), deployment_path, TRUE, + &owned_dfd, error)) + return FALSE; + deployment_dfd = owned_dfd; + } + + if (!get_checksum_xattr (deployment_dfd, RPMOSTREE_LIVE_INPROGRESS_XATTR, + &ret_inprogress_checksum, error)) + return FALSE; + + if (!get_checksum_xattr (deployment_dfd, RPMOSTREE_LIVE_REPLACED_XATTR, + &ret_livereplaced_checksum, error)) + return FALSE; + + *out_inprogress_checksum = g_steal_pointer (&ret_inprogress_checksum); + *out_livereplaced_checksum = g_steal_pointer (&ret_livereplaced_checksum); + return TRUE; +} + +/* Set @out_is_live to %TRUE if the deployment is live-modified */ +gboolean +rpmostreed_deployment_is_live (OstreeSysroot *sysroot, + OstreeDeployment *deployment, + int deployment_dfd, + gboolean *out_is_live, + GError **error) +{ + g_autofree char *inprogress_checksum = NULL; + g_autofree char *livereplaced_checksum = NULL; + + if (!rpmostreed_deployment_get_live_status (sysroot, deployment, deployment_dfd, + &inprogress_checksum, &livereplaced_checksum, + error)) + return FALSE; + + *out_is_live = (inprogress_checksum != NULL || livereplaced_checksum != NULL); + return TRUE; +} diff --git a/src/daemon/rpmostreed-utils.h b/src/daemon/rpmostreed-utils.h index f88c0a25ce..b5f41ba322 100644 --- a/src/daemon/rpmostreed-utils.h +++ b/src/daemon/rpmostreed-utils.h @@ -94,3 +94,20 @@ gboolean rpmostreed_parse_revision (const char *revision, char **out_checksum, char **out_version, GError **error); + + +#define RPMOSTREE_LIVE_INPROGRESS_XATTR "user.rpmostree-live-inprogress" +#define RPMOSTREE_LIVE_REPLACED_XATTR "user.rpmostree-live-replaced" + +gboolean rpmostreed_deployment_get_live_status (OstreeSysroot *sysroot, + OstreeDeployment *deployment, + int deployment_dfd, + char **out_inprogress_checksum, + char **out_livereplaced_checksum, + GError **error); + +gboolean rpmostreed_deployment_is_live (OstreeSysroot *sysroot, + OstreeDeployment *deployment, + int deployment_dfd, + gboolean *out_is_live, + GError **error); diff --git a/tests/vmcheck/test-livefs.sh b/tests/vmcheck/test-livefs.sh new file mode 100755 index 0000000000..833240f524 --- /dev/null +++ b/tests/vmcheck/test-livefs.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# +# Copyright (C) 2017 Red Hat Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. + +set -e + +. ${commondir}/libtest.sh +. ${commondir}/libvm.sh + +set -x + +vm_send_test_repo + +vm_assert_layered_pkg foo absent + +vm_rpmostree install /tmp/vmcheck/repo/packages/x86_64/foo-1.0-1.x86_64.rpm +echo "ok install foo locally" + +if vm_cmd rpm -q foo; then + assert_not_reached "have foo?" +fi +vm_rpmostree ex livefs -n > livefs-analysis.txt +assert_file_has_content livefs-analysis.txt 'livefs would be safe' + +vm_rpmostree ex livefs +vm_cmd rpm -q foo > foo-rpmq.txt +assert_file_has_content foo-rpmq.txt foo-1.0-1 + +echo "ok livefs" + +if vm_rpmostree cleanup -r 2>err.txt; then + assert_not_reached "cleaned up rollback?" +fi +assert_file_has_content err.txt 'refusing cleanup of rollback deployment' + +echo "ok livefs rollback"