File 0002-cmd-snap-mgmt-support-for-migrating-snap-mount-direc.patch of Package snapd

From 54bfff17095921bfb684d6d61bbb2b4d3c8d5402 Mon Sep 17 00:00:00 2001
Message-ID: <54bfff17095921bfb684d6d61bbb2b4d3c8d5402.1768215470.git.maciej.borzecki@canonical.com>
From: Maciej Borzecki <maciej.borzecki@canonical.com>
Date: Mon, 1 Dec 2025 07:59:26 +0100
Subject: [PATCH] cmd/snap-mgmt: support for migrating snap mount directory
 (#16063)

* cmd: define path to snapd tooling directory at build time

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* syscheck: account for distributions which support migration of /snap to /var/lib/snapd/snap

Account for distributions which support migration of /snap to
/var/lib/snapd/snap. Currently, this applies only to flavors of
openSUSE.

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* syscheck/dirs: fix typo in license header

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* cmd/snap-mgmt/snap-mgmt: add support for migration of snap mount directory

Add support for migrating snap mount directory.

Related: SNAPDENG-35406

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* tests/main/migrate-snap-mount-dir: spread test

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* tests/main/mount-dir-detect-check: account for openSUSE supporting both mount locations

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* cmd/snap-mgmt/snap-mgmt: update --purge to not require snap mount directory

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* tests/lib/reset: stop passing --snap-mount-directory to purge

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

* cmd/snap-mgmt/snap-mgmt: tweak help output

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>

---------

Signed-off-by: Maciej Borzecki <maciej.borzecki@canonical.com>
---
 cmd/Makefile.am                             |   3 +-
 cmd/configure.ac                            |   4 +
 cmd/snap-mgmt/snap-mgmt.sh.in               | 430 ++++++++++++++++++--
 syscheck/dirs.go                            |  11 +-
 syscheck/dirs_test.go                       |  44 +-
 tests/lib/reset.sh                          |   1 -
 tests/main/migrate-snap-mount-dir/task.yaml | 208 ++++++++++
 tests/main/mount-dir-detect-check/task.yaml |  21 +-
 8 files changed, 669 insertions(+), 53 deletions(-)
 create mode 100644 tests/main/migrate-snap-mount-dir/task.yaml

diff --git a/cmd/Makefile.am b/cmd/Makefile.am
index ad48c1558137221036978189f40028c38fbbd196..4c5e70dc4f9e94cbb07596e755130e6adad526d2 100644
--- a/cmd/Makefile.am
+++ b/cmd/Makefile.am
@@ -434,7 +434,8 @@ snap-mgmt/$(am__dirstamp):
 EXTRA_DIST += snap-mgmt/snap-mgmt.sh.in
 
 snap-mgmt/snap-mgmt: snap-mgmt/snap-mgmt.sh.in Makefile snap-mgmt/$(am__dirstamp)
-	sed -e 's,[@]STATIC_SNAP_MOUNT_DIR[@],$(STATIC_SNAP_MOUNT_DIR),' <$< >$@
+	sed \
+		-e 's,[@]STATIC_SNAP_TOOLING_DIR[@],$(STATIC_SNAP_TOOLING_DIR),' <$< >$@
 
 if SELINUX
 ##
diff --git a/cmd/configure.ac b/cmd/configure.ac
index 233976750f08bf12c7f60108c92b31aafc1e3ac2..f02ef7cd08ea79f90dd2a264df3a7b9174c21b00 100644
--- a/cmd/configure.ac
+++ b/cmd/configure.ac
@@ -160,6 +160,10 @@ AC_ARG_WITH([snap-mount-dir],
 AC_SUBST(STATIC_SNAP_MOUNT_DIR)
 AC_DEFINE_UNQUOTED([STATIC_SNAP_MOUNT_DIR], "${STATIC_SNAP_MOUNT_DIR}", [Static location of the snap mount points])
 
+STATIC_SNAP_TOOLING_DIR="${libexecdir%/snapd}/snapd"
+AC_SUBST(STATIC_SNAP_TOOLING_DIR)
+AC_DEFINE_UNQUOTED([STATIC_SNAP_TOOLING_DIR], "${STATIC_SNAP_TOOLING_DIR}", [Static location of the snapd tooling directory])
+
 SNAP_MOUNT_DIR_SYSTEMD_UNIT="$(systemd-escape -p "$STATIC_SNAP_MOUNT_DIR")"
 AC_SUBST([SNAP_MOUNT_DIR_SYSTEMD_UNIT])
 AC_DEFINE_UNQUOTED([SNAP_MOUNT_DIR_SYSTEMD_UNIT], "${SNAP_MOUNT_DIR_SYSTEMD_UNIT}", [Systemd unit name for snap mount points location])
diff --git a/cmd/snap-mgmt/snap-mgmt.sh.in b/cmd/snap-mgmt/snap-mgmt.sh.in
index 4c9f4494bce7f85620bda8e4fdeaae36a8a1daeb..470676ae983afd78c69353faa370d855b2aee4b8 100755
--- a/cmd/snap-mgmt/snap-mgmt.sh.in
+++ b/cmd/snap-mgmt/snap-mgmt.sh.in
@@ -8,7 +8,7 @@
 set -e
 set +x
 
-STATIC_SNAP_MOUNT_DIR="@STATIC_SNAP_MOUNT_DIR@"
+STATIC_SNAP_TOOLING_DIR="@STATIC_SNAP_TOOLING_DIR@"
 
 show_help() {
     exec cat <<'EOF'
@@ -16,15 +16,18 @@ Usage: snap-mgmt.sh [OPTIONS]
 
 A simple script to cleanup snap installations.
 
-optional arguments:
+Options:
   --help                           Show this help message and exit
-  --snap-mount-dir=<path>          Provide a path to be used as $STATIC_SNAP_MOUNT_DIR
-  --purge                          Purge all data from $STATIC_SNAP_MOUNT_DIR
+  --snap-tooling-dir=<path>        Override default path to snapd tooling dir
+  --force                          Force operation (relevant for mount directory migration)
+
+Actions:
+  --purge                          Purge all snaps and their data
+  --migrate-mount-dir              Migrate mount directory from /snap to /var/lib/snapd/snap
+  --check-mount-dir-migration      Check system state required to run mount directory migration
 EOF
 }
 
-SNAP_UNIT_PREFIX="$(systemd-escape -p ${STATIC_SNAP_MOUNT_DIR})"
-
 systemctl_stop() {
     unit="$1"
 
@@ -49,7 +52,7 @@ systemctl_stop() {
 }
 
 is_component_mount_unit() {
-    systemctl show "$1" -p Where | sed 's#Where=##' | grep -q "${SNAP_MOUNT_DIR}/"'[^/]*/components/mnt/[^/]*/[^/]*'
+    systemctl show "$1" -p Where | sed 's#Where=##' | grep -q '\(/snap\|/var/lib/snapd/snap\)/[^/]*/components/mnt/[^/]*/[^/]*'
 }
 
 purge() {
@@ -61,8 +64,8 @@ purge() {
         systemctl_stop snap.mount.service
     fi
 
-    units=$(systemctl list-unit-files --no-legend --full | grep -vF snap.mount.service || true)
-    mounts=$(echo "$units" | grep "^${SNAP_UNIT_PREFIX}[-.].*\\.mount" | cut -f1 -d ' ')
+    units=$(systemctl list-unit-files --no-legend --full 'snap.*' 'snap-*.mount' 'var-lib-snapd-snap-*.mount' | grep -vF snap.mount.service || true)
+    mounts=$(echo "$units" | grep -e "^snap-.*\\.mount" -e "^var-lib-snapd-snap-.*\\.mount" | cut -f1 -d ' ')
 
     # *.snap and *.comp mount points
     snap_mounts=""
@@ -94,36 +97,44 @@ purge() {
         systemctl_stop "$unit"
 
         if echo "$unit" | grep -q '.*\.mount' && ! is_component_mount_unit "$unit"; then
-            # Transform ${STATIC_SNAP_MOUNT_DIR}/core/3440 -> core/3440 removing any
+            # Transform /var/lib/snapd/snap/core/3440 -> core/3440 removing any
             # extra / preceding snap name, eg:
             #  /var/lib/snapd/snap/core/3440  -> core/3440
             #  /snap/core/3440                -> core/3440
             #  /snap/core//3440               -> core/3440
             # NOTE: we could have used `systemctl show $unit -p Where --value`
             # but systemd 204 shipped with Ubuntu 14.04 does not support this
-            snap_rev=$(systemctl show "$unit" -p Where | sed -e 's#Where=##' -e "s#$STATIC_SNAP_MOUNT_DIR##" -e 's#^/*##')
-            snap=$(echo "$snap_rev" |cut -f1 -d/)
-            rev=$(echo "$snap_rev" |cut -f2 -d/)
+            systemctl_where="$(systemctl show "$unit" -p Where)"
+            #  Where=/var/lib/snapd/snap/core/3440  -> core/3440
+            snap_rev="${systemctl_where#Where=*/snap/}"
+            # core/3440 -> core
+            snap="${snap_rev%/*}"
+            # core/3440 -> 3440
+            rev="${snap_rev#*/}"
+            # Transform:
+            # Where=/var/lib/snapd/snap/core/3440 -> /var/lib/snapd/snap
+            snap_mountpoint="${systemctl_where#Where=}"
+            global_snaps_mount_dir="$(dirname "$(dirname "$snap_mountpoint")")"
             if [ -n "$snap" ]; then
                 echo "Removing snap $snap"
                 # aliases
-                if [ -d "${STATIC_SNAP_MOUNT_DIR}/bin" ]; then
-                    find "${STATIC_SNAP_MOUNT_DIR}/bin" -maxdepth 1 -lname "$snap" -delete
-                    find "${STATIC_SNAP_MOUNT_DIR}/bin" -maxdepth 1 -lname "$snap.*" -delete
+                if [ -d "${global_snaps_mount_dir}/bin" ]; then
+                    find "${global_snaps_mount_dir}/bin" -maxdepth 1 -lname "$snap" -delete
+                    find "${global_snaps_mount_dir}/bin" -maxdepth 1 -lname "$snap.*" -delete
                 fi
                 # generated binaries
-                rm -f "${STATIC_SNAP_MOUNT_DIR}/bin/$snap"
-                rm -f "${STATIC_SNAP_MOUNT_DIR}/bin/$snap".*
+                rm -f "${global_snaps_mount_dir}/bin/$snap"
+                find "${global_snaps_mount_dir}/bin" -maxdepth 1 -name "$snap.*" -delete
                 # snap mount dir
-                umount -l "${STATIC_SNAP_MOUNT_DIR}/$snap/$rev" 2> /dev/null || true
-                rm -rf "${STATIC_SNAP_MOUNT_DIR:?}/$snap/$rev"
-                rm -f "${STATIC_SNAP_MOUNT_DIR}/$snap/current"
+                umount -l "$snap_mountpoint" 2> /dev/null || true
+                rm -rf "$snap_mountpoint"
+                rm -f "${global_snaps_mount_dir}/$snap/current"
                 # snap data dir
                 rm -rf "/var/snap/$snap/$rev"
                 rm -rf "/var/snap/$snap/common"
                 rm -f "/var/snap/$snap/current"
                 # opportunistic remove (may fail if there are still revisions left)
-                for d in "${STATIC_SNAP_MOUNT_DIR}/$snap" "/var/snap/$snap"; do
+                for d in "${global_snaps_mount_dir}/$snap" "/var/snap/$snap"; do
                     if [ -d "$d" ]; then
                         rmdir --ignore-fail-on-non-empty "$d"
                     fi
@@ -161,9 +172,9 @@ purge() {
     # Units may have been removed do a reload
     systemctl -q daemon-reload || true
 
-    # Undo any bind mounts to ${STATIC_SNAP_MOUNT_DIR} or /var/snap done by parallel
+    # Undo any bind mounts to /var/lib/snapd/snap, /snap or /var/snap done by parallel
     # installs or LP:#1668659
-    for mp in "$STATIC_SNAP_MOUNT_DIR" /var/snap; do
+    for mp in /snap /var/lib/snapd/snap /var/snap; do
         # btrfs bind mounts actually include subvolume in the filesystem-path
         # https://www.mail-archive.com/linux-btrfs@vger.kernel.org/msg51810.html
         if grep -q " $mp $mp " /proc/self/mountinfo ||
@@ -207,7 +218,15 @@ purge() {
     rm -rf /var/lib/snapd/features
 
     echo "Final directory cleanup"
-    rm -rf "${STATIC_SNAP_MOUNT_DIR}"
+    for snap_mount_dir in /snap /var/lib/snapd/snap; do
+        if [ -L "$snap_mount_dir" ]; then
+            continue
+        fi
+
+        if [ -d "$snap_mount_dir" ]; then
+            rm -rf "$snap_mount_dir"
+        fi
+    done
     rm -rf /var/snap
 
     echo "Removing leftover snap shared state data"
@@ -235,28 +254,377 @@ purge() {
         # Remove auto-generated rules for snap-confine from the 'core' snap
         echo "Removing extra snap-confine apparmor rules"
         # shellcheck disable=SC2046
-        rm -f /etc/apparmor.d/$(echo "$SNAP_UNIT_PREFIX" | tr '-' '.').core.*.usr.lib.snapd.snap-confine
+        for snap_unit_prefix in snap var-lib-snapd-snap; do
+            rm -f /etc/apparmor.d/$(echo "$snap_unit_prefix" | tr '-' '.').core.*.usr.lib.snapd.snap-confine
+        done
     fi
 }
 
+ensure_snap_apps_stopped() {
+    apps_or_services="$(find /sys/fs/cgroup/ -name 'snap.*.*.scope' -o -name 'snap.*.*.service')"
+    if [ -n "$apps_or_services" ]; then
+        (
+            echo "Found active snap services or applications in the following cgroups:"
+            for n in $apps_or_services; do
+                # transform snap.foo.bar.service into foo
+                sn="$(basename "$n")"
+                sn="${sn#snap.}"
+                sn="${sn%%.*}"
+                echo "- $n"
+                echo "  likely owned by snap: '$sn'"
+                echo "  PIDs: $(paste -s -d' ' "$n/cgroup.procs")"
+            done
+        ) >&2
+        return 1
+    fi
+    return 0
+}
+
+discard_mount_namespaces() {
+    if [ ! -d /run/snapd/ns ]; then
+        return
+    fi
+
+    tooldir="$1"
+    if [ -z "$tooldir" ]; then
+        tooldir="$STATIC_SNAP_TOOLING_DIR"
+    fi
+
+    echo "Discarding snap mount namespaces"
+    find /run/snapd/ns/ -name '*.mnt' | while read -r mntns; do
+        snname="$(basename "${mntns%.mnt}")"
+        echo "  ..discarding mount namespace of snap $snname"
+        "$tooldir"/snap-discard-ns "$snname"
+    done
+}
+
+_patch_service_unit_mount_dependencies() {
+    mount_from_nopref="$1"
+    mount_to_nopref="$2"
+    svc_unit="$3"
+    tmpdir="$4"
+
+    # strip out snap. and <svc>.service, leaving the snap name only
+    snap_name=${svc_unit#snap.}
+    snap_name=${snap_name%%.*}
+
+    old_mount_p="$(systemd-escape -p "$mount_from_nopref/$snap_name/")"
+    new_mount_p="$(systemd-escape -p "$mount_to_nopref/$snap_name/")"
+
+    # given e.g. /snap/test-snapd-service/
+    # systemd-escape produces: snap-test\x2dsnapd\x2dservice
+    # but we need to convert to: snap-test\\x2dsnapd\\x2dservice
+    # match entries like:
+    # Requires=snap-foo-
+    # After=snap-foo
+    old_mount_p_escaped="${old_mount_p//\\/\\\\}"
+    new_mount_p_escaped="${new_mount_p//\\/\\\\}"
+    if ! grep -F -q "=${old_mount_p_escaped}" "/etc/systemd/system/$svc_unit" ; then
+        echo "Service unit $svc_unit already patched"
+        return
+    fi
+
+    echo "Updating service unit $svc_unit"
+    cp -v "/etc/systemd/system/$svc_unit" "$tmpdir/backup/etc/systemd/system/$svc_unit"
+
+    sed -i \
+        -e "s#^Requires=${old_mount_p_escaped}-#Requires=${new_mount_p_escaped}-#" \
+        -e "s#^After=${old_mount_p_escaped}-#After=${new_mount_p_escaped}-#" \
+        "/etc/systemd/system/$svc_unit"
+}
+
+_mount_dir_migrate() {
+    mount_from="$1"
+    mount_from_nopref="${mount_from#/}"
+    mount_to="$2"
+    mount_to_nopref="${mount_to#/}"
+
+    # TODO error when $mount_from is a prefix of $mount_to
+
+    set -x
+    mount_from_escaped="$(systemd-escape -p "${mount_from_nopref}")"
+    mount_to_escaped="$(systemd-escape -p "${mount_to_nopref}")"
+
+    echo "Attempting migration of snap mount location:"
+    echo "  $mount_from -> $mount_to"
+
+    if ! ensure_snap_apps_stopped; then
+        echo "Please ensure all snap services or applications are stopped before attempting migration." >&2
+        exit 1
+    fi
+
+    service_units=$(systemctl list-unit-files --no-legend --full 'snap.*.service' | cut -f1 -d' '|| true)
+    mount_units=$(systemctl list-unit-files --no-legend --full "${mount_from_escaped}-*.mount" | cut -f1 -d' ' || true)
+
+    echo "-- services"
+    echo "$service_units"
+    echo "-- mounts"
+    echo "$mount_units"
+
+    tmpdir="$(mktemp -d -t snap-mgmt-migrate.XXXXX)"
+    mkdir -p "$tmpdir/backup/etc/systemd/system"
+    echo "Backup saved to $tmpdir"
+
+    # stop service units first, the services could theoretically reach out to
+    # snapd while stopping
+    echo "Stopping snap services..."
+    for unit in $service_units; do
+        echo "Stopping service $unit"
+        systemctl stop "$unit"
+    done
+
+    echo "Stopping snapd..."
+    systemctl stop snapd.socket snapd.service
+
+    echo "Checking for snap applications or services that are still alive"
+    if ! ensure_snap_apps_stopped; then
+        echo "Found running snap services or applications."  >&2
+        exit 1
+    fi
+
+    discard_mount_namespaces "$STATIC_SNAP_TOOLING_DIR"
+
+    new_units=()
+    if [ -z "$mount_units" ]; then
+        echo "All mount units already migrated or no snap mounts"
+    else
+        echo "Stopping mount units..."
+        for unit in $mount_units; do
+            echo "Stopping mount unit $unit"
+            systemctl stop "$unit"
+        done
+
+        # make a backup copy of the whole snap mount dir, all units should be
+        # stopped now, so this should be a tree of empty directories and current
+        # symlinks
+        cp -av "/$mount_from_nopref" "$tmpdir/backup/"
+
+        echo "Updating mount units"
+        # note, we're only iterating over mount units which still have the old name
+        for unit in $mount_units; do
+            # e.g. /snap/foo/123
+            # assumes systemd 246+
+            where_mount="$(systemctl show -p Where --value "$unit")"
+            # e.g. /snap/foo
+            snap_where_mount="$(dirname "$where_mount")"
+
+            # some units may have been patched already
+            echo "Updating mount unit $unit"
+            # new unit name
+            nn="${mount_to_escaped}-${unit#"$mount_from_escaped"-}"
+            new_units+=("$nn")
+            echo "  ..new unit name $nn"
+            # new mount point location
+            n_where_mount="$mount_to/${where_mount#"$mount_from"}"
+            # new snap mount location
+            n_snap_where_mount="$(dirname "$n_where_mount")"
+            echo "  ..new mount path $n_where_mount"
+
+            # make a backup copy
+            mkdir -p "$tmpdir/backup/etc/systemd/system"
+            cp -v "/etc/systemd/system/$unit" "$tmpdir/backup/etc/systemd/system/"
+
+            echo "  ..patching $unit"
+            # Where=/snap/foo/123 -> Where=/var/lib/snapd/snap/foo/123
+            sed -e "s#Where=/${mount_from_nopref}/#Where=/${mount_to_nopref}/#" \
+                "/etc/systemd/system/$unit" > "/etc/systemd/system/$unit.swp"
+            echo "  ..renaming to $nn"
+            mv -v "/etc/systemd/system/$unit.swp" "/etc/systemd/system/$nn"
+            rm -v "/etc/systemd/system/$unit"
+
+            if [ -L "/etc/systemd/system/multi-user.target.wants/$unit" ]; then
+                # enabled units appear as symlinks
+                rm -v "/etc/systemd/system/multi-user.target.wants/$unit"
+                ln -sv "/etc/systemd/system/$nn" "/etc/systemd/system/multi-user.target.wants/$nn"
+            fi
+
+            # ensure mount point exists
+            mkdir -p "$n_where_mount"
+
+            # recreate current symlink
+            if [ -L "$snap_where_mount/current" ] && [ ! -L "$n_snap_where_mount/current" ]; then
+                cp -av "$snap_where_mount/current" "$n_snap_where_mount/current"
+            fi
+        done
+    fi
+
+    if [ -z "$service_units" ]; then
+        echo "No snap service units"
+    else
+        for svc_unit in $service_units; do
+            if ! [[ "$svc_unit" = *.service ]]; then
+                continue
+            fi
+            _patch_service_unit_mount_dependencies "$mount_from_nopref" "$mount_to_nopref" "$svc_unit" "$tmpdir"
+        done
+    fi
+
+    if [ -d "$mount_from/bin" ] && [ ! -L "$mount_from" ]; then
+        cp -av "$mount_from/bin" "$mount_to/bin"
+    fi
+    if [ -f "$mount_from/README" ] && [ ! -L "$mount_from" ]; then
+        cp -av "$mount_from/README" "$mount_to/README"
+    fi
+
+    # TODO: restore SELinux context of created files and directories?
+
+    echo "Reloading systemd"
+    systemctl daemon-reload
+
+    echo "Starting new mount units"
+    for new_unit in "${new_units[@]}"; do
+        systemctl start "$new_unit"
+    done
+
+    if [ -d "$mount_from" ]; then
+        echo "Renaming old snap mount directory $mount_from to $mount_from.old"
+        if mountpoint "$mount_from"; then
+            echo "Found $mount_from to be a mount point, likely from parallel instances"
+            umount -l "$mount_from"
+        fi
+        mv -v "$mount_from" "${mount_from}.old"
+    fi
+
+    if [ "$mount_from" = "/snap" ] && [ ! -L "$mount_from" ]; then
+        echo "Restoring ability to run classic snaps"
+        ln -sv "$mount_to" "$mount_from"
+    fi
+
+    systemctl start snapd.socket snapd.service
+}
+
+# check whether migration is needed, returns 0 when needed
+_mount_dir_paths_check() {
+    if [ -e /var/lib/snapd/snap ] && {
+           [ -L /snap ] || [ ! -e /snap ]
+       }; then
+        echo "Snap mount directory migration already completed or not needed" >&2
+        return 1
+    fi
+    return 0
+}
+
+mount_dir_migrate() {
+    force="$1"
+
+    if ! _mount_dir_paths_check; then
+        if [ "$force" = "yes" ]; then
+            echo "WARNING: Forcing mount directory migration" >&2
+        else
+            exit 0
+        fi
+    fi
+
+    echo "#############################################"
+    echo "####### snap mount directory migration ######"
+    echo "#############################################"
+    echo
+    echo "Please report any issues in the snapcraft forum at: https://forum.snapcraft.io/"
+    echo
+    
+    _mount_dir_migrate "/snap" "/var/lib/snapd/snap"
+
+    echo "######################################################"
+    echo "####### snap mount directory migration complete ######"
+    echo "######################################################"
+    echo
+    echo "You may start using snaps again."
+    echo "System reboot is recommended."
+    echo
+    echo "A copy of old snap mount directory was saved as /snap.old"
+    echo "Remove it manually by executing:"
+    echo "   rm -rf /snap.old"
+    echo
+}
+
+mount_dir_migrate_check() {
+    bad=0
+    not_needed=0
+    echo "Checking snap mount directories presence..."
+    if ! _mount_dir_paths_check ; then
+        not_needed=1
+    fi
+
+    echo
+    echo "Checking snap applications and services..."
+    if ! ensure_snap_apps_stopped; then
+        echo "Found running snap services or applications."
+        bad=1
+    fi
+
+    echo
+    if [ "$bad" != 0 ]; then
+        echo "Mount directory migration cannot continue."
+        exit 1
+    elif [ "$not_needed" = 0 ]; then
+        echo "System is ready for migration."
+    fi
+}
+
+
+force=no
+action=""
 while [ -n "$1" ]; do
     case "$1" in
         --help)
             show_help
             exit
             ;;
-        --snap-mount-dir=*)
-            STATIC_SNAP_MOUNT_DIR=${1#*=}
-            SNAP_UNIT_PREFIX=$(systemd-escape -p "$STATIC_SNAP_MOUNT_DIR")
+        --snap-tooling-dir=*)
+            STATIC_SNAP_TOOLING_DIR=${1#*=}
             shift
             ;;
         --purge)
-            purge
+            if [ -n "$action" ]; then
+                echo "Cannot request purge when already doing '$action'" >&2
+                exit 1
+            fi
+            action=purge
+            shift
+            ;;
+        --migrate-mount-dir)
+            if [ -n "$action" ]; then
+                echo "Cannot request mount directory migration when already doing '$action'" >&2
+                exit 1
+            fi
+            action=migrate-mount-dir
+            shift
+            ;;
+        --check-mount-dir-migration)
+            if [ -n "$action" ]; then
+                echo "Cannot request mount directory migration check when already doing '$action'" >&2
+                exit 1
+            fi
+            action=check-mount-dir-migration
+            shift
+            ;;
+        --force)
+            force=yes
             shift
             ;;
         *)
-            echo "Unknown command: $1"
+                echo "Unknown command: $1, see --help" >&2
             exit 1
             ;;
     esac
 done
+
+case "$action" in
+    purge)
+        purge
+        ;;
+    migrate-mount-dir)
+        mount_dir_migrate "$force"
+        ;;
+    check-mount-dir-migration)
+        mount_dir_migrate_check
+        ;;
+    *)
+        if [ -z "$action" ]; then
+            echo "No action specified, see --help" >&2
+        else
+            echo "Unknown action '$action'" >&2
+        fi
+        exit 1
+        ;;
+esac
diff --git a/syscheck/dirs.go b/syscheck/dirs.go
index 017913cb7f14d53e682b51f3cdd512fbe9b17f28..70921897e072eb5ebce953a4dcb460d50e7db1e9 100644
--- a/syscheck/dirs.go
+++ b/syscheck/dirs.go
@@ -1,7 +1,7 @@
 // -*- Mode: Go; indent-tabs-mode: t -*-
 
 /*
- f* Copyright (C) 2025 Canonical Ltd
+ * Copyright (C) 2025 Canonical Ltd
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 3 as
@@ -15,7 +15,7 @@
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  *
-*/
+ */
 
 package syscheck
 
@@ -53,6 +53,11 @@ var (
 		"manjaro",
 		"manjaro-arm",
 	}
+
+	// distributions which support migration from /snap to /var/lib/snapd/snap
+	migratedAltDirDistros = []string{
+		"opensuse", // openSUSE Tumbleweed, Slowroll, Leap, but not SLE
+	}
 )
 
 func checkSnapMountDir() error {
@@ -62,6 +67,8 @@ func checkSnapMountDir() error {
 
 	smd := dirs.StripRootDir(dirs.SnapMountDir)
 	switch {
+	case release.DistroLike(migratedAltDirDistros...) && smd == dirs.AltSnapMountDir:
+		// some distributions support migration from /snap -> /var/lib/snapd/snap/
 	case release.DistroLike(defaultDirDistros...) && smd != dirs.DefaultSnapMountDir:
 		fallthrough
 	case release.DistroLike(altDirDistros...) && smd != dirs.AltSnapMountDir:
diff --git a/syscheck/dirs_test.go b/syscheck/dirs_test.go
index d6a1c29502ce35935bccf921d5d64601ae95b1b2..597fc6ab9f54e2c711594d41e97c1b9b224b0217 100644
--- a/syscheck/dirs_test.go
+++ b/syscheck/dirs_test.go
@@ -72,20 +72,24 @@ func (s *dirsSuite) TestUndetermined(c *C) {
 
 var (
 	known = []struct {
-		ID           string
-		IDLike       []string
-		canonicalDir bool
+		ID                string
+		IDLike            []string
+		canonicalDir      bool
+		supportsMigration bool
 	}{
-		{"fedora", nil, false},
-		{"rhel", []string{"fedora"}, false},
-		{"centos", []string{"fedora"}, false},
-		{"ubuntu", []string{"debian"}, true},
-		{"debian", nil, true},
-		{"suse", nil, true},
-		{"yocto", nil, true},
-		{"arch", []string{"archlinux"}, false},
-		{"archlinux", nil, false},
-		{"altlinux", nil, false},
+		{"fedora", nil, false, false},
+		{"rhel", []string{"fedora"}, false, false},
+		{"centos", []string{"fedora"}, false, false},
+		{"ubuntu", []string{"debian"}, true, false},
+		{"debian", nil, true, false},
+		{"suse", nil, true, false}, // SLE, not to be confused with openSUSE
+		{"opensuse-leap", []string{"opensuse"}, true, true},
+		{"opensuse-tumbleweed", []string{"opensuse"}, true, true},
+		{"opensuse-slowroll", []string{"opensuse"}, true, true},
+		{"yocto", nil, true, false},
+		{"arch", []string{"archlinux"}, false, false},
+		{"archlinux", nil, false, false},
+		{"altlinux", nil, false, false},
 	}
 
 	knownSpecial = []struct {
@@ -101,10 +105,10 @@ func (s *dirsSuite) TestMountDirKnownDistroHappy(c *C) {
 
 	for _, tc := range known {
 		c.Logf("happy case %+v", tc)
-		func() {
+		tf := func(canonicalDir bool) {
 			defer release.MockReleaseInfo(&release.OS{ID: tc.ID, IDLike: tc.IDLike})()
 			d := c.MkDir()
-			if tc.canonicalDir {
+			if canonicalDir {
 				dirstest.MustMockCanonicalSnapMountDir(d)
 			} else {
 				dirstest.MustMockAltSnapMountDir(d)
@@ -112,7 +116,11 @@ func (s *dirsSuite) TestMountDirKnownDistroHappy(c *C) {
 			dirs.SetRootDir(d)
 
 			c.Check(syscheck.CheckSnapMountDir(), IsNil)
-		}()
+		}
+		tf(tc.canonicalDir)
+		if tc.supportsMigration {
+			tf(!tc.canonicalDir)
+		}
 	}
 }
 
@@ -122,6 +130,10 @@ func (s *dirsSuite) TestMountDirKnownDistroMismatch(c *C) {
 	for _, tc := range known {
 		c.Logf("mismatch case %+v", tc)
 		func() {
+			if tc.supportsMigration {
+				c.Logf("distro supports migration skipping test case: %+v", tc)
+				return
+			}
 			defer release.MockReleaseInfo(&release.OS{ID: tc.ID, IDLike: tc.IDLike})()
 			d := c.MkDir()
 			// do the complete opposite so that the mount directory does not
diff --git a/tests/lib/reset.sh b/tests/lib/reset.sh
index eedc253d5d3c913280f9500b697ba4ec9e25e503..1ef87290d2c32a75d6d876bad7daf193728abef5 100755
--- a/tests/lib/reset.sh
+++ b/tests/lib/reset.sh
@@ -28,7 +28,6 @@ reset_classic() {
             # We don't know if snap-mgmt was built, so call the *.in file
             # directly and pass arguments that will override the placeholders
             sh -x "${SPREAD_PATH}/cmd/snap-mgmt/snap-mgmt.sh.in" \
-                --snap-mount-dir="$SNAP_MOUNT_DIR" \
                 --purge
             # The script above doesn't remove the snapd directory as this
             # is normally done by the rpm packaging system.
diff --git a/tests/main/migrate-snap-mount-dir/task.yaml b/tests/main/migrate-snap-mount-dir/task.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..35aad0a7b1886dbbd1f61cfc343fc57b774a756c
--- /dev/null
+++ b/tests/main/migrate-snap-mount-dir/task.yaml
@@ -0,0 +1,208 @@
+summary: Execute snap mount directory migration
+details: |
+    Execute migration of snap mount directory. Ensure that snaps continue to
+    work after.
+
+systems:
+    - opensuse-*
+
+environment:
+    PARALLEL_INSTANCES/parallel: true
+    PARALLEL_INSTANCES/no_parallel: false
+
+prepare: |
+    snap set system experimental.user-daemons=true
+    # although state reset should clean that up
+    tests.cleanup defer snap unset system experimental.user-daemons
+
+    "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24
+    # install twice
+    "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24
+    
+    "$TESTSTOOLS"/snaps-state install-local test-snapd-service
+    "$TESTSTOOLS"/snaps-state install-local test-snapd-user-service
+    tests.cleanup defer snap remove --purge test-snapd-user-service
+
+    if [ "$PARALLEL_INSTANCES" = "true" ]; then
+        snap set system experimental.parallel-instances=true
+        tests.cleanup defer snap unset system experimental.parallel-instances
+
+        "$TESTSTOOLS"/snaps-state install-local-as test-snapd-service test-snapd-service_foo
+        tests.cleanup defer snap remove --purge test-snapd-service_foo
+    fi
+
+execute: |
+    if [ "$SPREAD_REBOOT" = 0 ]; then
+        test-snapd-sh-core24.sh -c true
+        systemctl is-active snap.test-snapd-service.test-snapd-service.service
+        not snapd.tool exec snap-mgmt --check-mount-dir-migration |& tee check-migrate.log
+        not snapd.tool exec snap-mgmt --migrate-mount-dir |& tee migrate.log
+
+        # we're showing the prompt
+        MATCH 'Please report any issues in the snapcraft forum' < migrate.log
+        # and also failing due to running applications
+        MATCH "Please ensure all snap services or applications are stopped" < migrate.log
+        MATCH "/system.slice/snap.test-snapd-service.test-snapd-service.service" < migrate.log
+        MATCH "likely owned by snap: 'test-snapd-service'" < migrate.log
+
+        MATCH "/system.slice/snap.test-snapd-service.test-snapd-service.service" < check-migrate.log
+        MATCH "likely owned by snap: 'test-snapd-service'" < check-migrate.log
+        if [ "$PARALLEL_INSTANCES" = "true" ]; then
+            MATCH "likely owned by snap: 'test-snapd-service_foo'" < check-migrate.log
+        fi
+        MATCH "Mount directory migration cannot continue." < check-migrate.log
+        NOMATCH "System is ready for migration." < check-migrate.log
+
+        # stop the services
+        snap stop test-snapd-service
+        if [ "$PARALLEL_INSTANCES" = "true" ]; then
+            snap stop test-snapd-service_foo
+        fi
+
+        # but start the application
+        test-snapd-sh-core24.sh -c 'exec sleep infinity' &
+        
+        not snapd.tool exec snap-mgmt --check-mount-dir-migration |& tee check-migrate.log
+        not snapd.tool exec snap-mgmt --migrate-mount-dir |& tee migrate.log
+        MATCH "Please ensure all snap services or applications are stopped" < migrate.log
+        MATCH "/app.slice/snap.test-snapd-sh-core24.sh" < migrate.log
+        MATCH "likely owned by snap: 'test-snapd-sh-core24'" < migrate.log
+        NOMATCH "test-snapd-service" < migrate.log
+
+        MATCH "/app.slice/snap.test-snapd-sh-core24.sh" < check-migrate.log
+        MATCH "likely owned by snap: 'test-snapd-sh-core24'" < check-migrate.log
+        MATCH "Mount directory migration cannot continue." < check-migrate.log
+
+        # kill whole process group
+        kill -9 $!
+        wait $! || true
+
+        # still blocked by user services
+        not snapd.tool exec snap-mgmt --check-mount-dir-migration |& tee check-migrate.log
+        not snapd.tool exec snap-mgmt --migrate-mount-dir |& tee migrate.log
+        MATCH "Please ensure all snap services or applications are stopped" < migrate.log
+        MATCH "/app.slice/snap.test-snapd-user-service.test-snapd-user-service.service" < migrate.log
+        MATCH "likely owned by snap: 'test-snapd-user-service'" < migrate.log
+        # no matches for process which was blocking previously snap
+        NOMATCH "test-snapd-sh-core24" < migrate.log
+        MATCH "Mount directory migration cannot continue." < check-migrate.log
+
+        snap stop test-snapd-user-service
+        
+        # now we're good
+        snapd.tool exec snap-mgmt --check-mount-dir-migration |& tee check-migrate.log
+        NOMATCH "Mount directory migration cannot continue." < check-migrate.log
+        MATCH "System is ready for migration." < check-migrate.log
+
+        # and can execute actual migration
+        snapd.tool exec snap-mgmt --migrate-mount-dir |& tee migrate.log
+        
+        snap list | NOMATCH broken
+        
+        test-snapd-sh-core24.sh -c true
+        
+        # XXX should this be done automatically?
+        snap start test-snapd-service
+        
+        snap services test-snapd-service | MATCH ' active'
+        if [ "$PARALLEL_INSTANCES" = "true" ]; then
+            snap start test-snapd-service_foo
+            snap services test-snapd-service_foo | MATCH ' active'
+        fi
+        
+        # both revisions are present
+        test -f /var/lib/snapd/snap/test-snapd-sh-core24/x1/meta/snap.yaml
+        test -f /var/lib/snapd/snap/test-snapd-sh-core24/x2/meta/snap.yaml
+        # current is correct
+        test "$(readlink /var/lib/snapd/snap/test-snapd-sh-core24/current)" = "x2"
+        # mount units are active
+        systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-sh-core24/x1).mount"
+        systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-sh-core24/x2).mount"
+        
+        # same for the service snap
+        test -f /var/lib/snapd/snap/test-snapd-service/x1/meta/snap.yaml
+        test "$(readlink /var/lib/snapd/snap/test-snapd-service/current)" = "x1"
+        # mount unit is active
+        systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-service/x1).mount"
+
+        if [ "$PARALLEL_INSTANCES" = "true" ]; then
+            # and parallel instance of test-snapd-service
+            test -f /var/lib/snapd/snap/test-snapd-service_foo/x1/meta/snap.yaml
+            test "$(readlink /var/lib/snapd/snap/test-snapd-service_foo/current)" = "x1"
+            # mount unit is active
+            systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-service_foo/x1).mount"
+        fi
+        
+        # /snap is preserved as symlink
+        test -L /snap
+        test "$(readlink /snap)" = "/var/lib/snapd/snap"
+
+        echo "A backup copy of old mount directory exists"
+        test -d /snap.old
+        
+        # we can install another revision of a snap
+        "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24
+        
+        test -f /var/lib/snapd/snap/test-snapd-sh-core24/x3/meta/snap.yaml
+        # and remove the old revision
+        snap remove test-snapd-sh-core24 --revision=x2
+        
+        not test -f /var/lib/snapd/snap/test-snapd-sh-core24/x2/meta/snap.yaml
+        
+        # path is correctly reported by the snapd tooling
+        snap debug paths | MATCH 'SNAPD_MOUNT=/var/lib/snapd/snap$'
+
+        # stop all services
+        snap stop test-snapd-service
+        echo "Attempting another migration should be a noop"
+        snapd.tool exec snap-mgmt --migrate-mount-dir |& tee migrate-already-done.log
+        MATCH "Snap mount directory migration already completed or not needed" < migrate-already-done.log
+        snapd.tool exec snap-mgmt --check-mount-dir-migration |& tee check-migrate.log
+        MATCH "Snap mount directory migration already completed or not needed" < check-migrate.log
+
+        echo "Reboot to assert post reboot state"
+        REBOOT
+        
+    elif [ "$SPREAD_REBOOT" = 1 ]; then
+        echo "After reboot..."
+        echo "No snaps are broken"
+        snap list | NOMATCH broken
+        echo "Mount units are active"
+        systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-sh-core24/x3).mount"
+        systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-service/x1).mount"
+
+        echo "Snaps continue to work"
+        snap services test-snapd-service | MATCH ' active'
+
+        if [ "$PARALLEL_INSTANCES" = "true" ]; then
+            systemctl is-active "$(systemd-escape -p var/lib/snapd/snap/test-snapd-service_foo/x1).mount"
+            snap services test-snapd-service_foo | MATCH ' active'
+        fi
+
+        test-snapd-sh-core24.sh -c true
+
+        snapd.tool exec snap-mgmt --migrate-mount-dir |& tee migrate.log
+        MATCH "Snap mount directory migration already completed or not needed" < migrate.log
+        NOMATCH "Forcing mount directory migration" < migrate.log
+
+        echo "It is possible to remove old mount directory"
+        rm -rfv /snap.old
+
+        snap stop test-snapd-service
+        if [ "$PARALLEL_INSTANCES" = "true" ]; then
+            snap stop test-snapd-service_foo
+        fi
+        snap stop test-snapd-user-service
+
+        echo "Forcing migration does not modify files"
+        mod_time_before="$(stat --format '%Y' /etc/systemd/system/snap.test-snapd-service.test-snapd-service.service)"
+        snapd.tool exec snap-mgmt --migrate-mount-dir --force |& tee migrate.log
+        MATCH "Snap mount directory migration already completed or not needed" < migrate.log
+        MATCH "Forcing mount directory migration" < migrate.log
+
+        mod_time_after="$(stat --format '%Y' /etc/systemd/system/snap.test-snapd-service.test-snapd-service.service)"
+        test "$mod_time_after" = "$mod_time_before"
+
+        # snapd is automatically started at the end of the process
+        snap list | NOMATCH broken
+    fi
diff --git a/tests/main/mount-dir-detect-check/task.yaml b/tests/main/mount-dir-detect-check/task.yaml
index 414ac39112daa262e0ed48df584eb4acdf55dacd..712e32f13f397bf25b26dffa21494115f2ccfe7c 100644
--- a/tests/main/mount-dir-detect-check/task.yaml
+++ b/tests/main/mount-dir-detect-check/task.yaml
@@ -61,13 +61,30 @@ execute: |
   SNAP_MOUNT_DIR="$(os.paths snap-mount-dir)"
   baddir="$(cat mock-mount-dir)"
 
-  echo "No snaps can be installed if detection found unexpected snap mount directory"
-  "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24 2>&1 | MATCH "unexpected snap mount directory"
+  if ! os.query is-opensuse; then
+      echo "No snaps can be installed if detection found unexpected snap mount directory"
+      "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24 2>&1 | MATCH "unexpected snap mount directory"
+  else
+      # on openSUSE, we support mount dir migration so either location works
+      "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24
+
+      tests.systemd stop-unit snapd.service
+      # purge all snaps so that the alt mount directory can be purged
+      snapd.tool exec snap-mgmt --purge
+  fi
 
   echo "After restoring the correct snap mount directory"
   rm -rf "$baddir"
   mkdir -p "$SNAP_MOUNT_DIR"
   systemctl restart snapd.service
+  # previous steps may have purged the state
+  snap wait system seed.loaded
 
   echo "Snaps can be installed again"
   "$TESTSTOOLS"/snaps-state install-local test-snapd-sh-core24
+
+  test -d "$SNAP_MOUNT_DIR"
+  test ! -d "$baddir"
+
+  echo "And still work"
+  test-snapd-sh-core24.sh -c true
-- 
2.52.0

openSUSE Build Service is sponsored by