#!/bin/bash

# Overlord management of snapd for package manager actions.
# Implements actions that would be invoked in %pre(un) actions for snapd.
# Derived from the snapd.postrm scriptlet used in the Ubuntu packaging for
# snapd.

set -e
set +x

STATIC_SNAP_TOOLING_DIR="/usr/lib/snapd"

show_help() {
    exec cat <<'EOF'
Usage: snap-mgmt.sh [OPTIONS]

A simple script to cleanup snap installations.

Options:
  --help                           Show this help message and exit
  --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
}

systemctl_stop() {
    unit="$1"

    echo "Stopping unit $unit"
    systemctl stop -q "$unit" || true

    for i in $(seq 10); do
        echo "Waiting until unit $unit is stopped [attempt $i]"
        if ! systemctl is-active -q "$unit"; then
            echo "$unit is stopped."
            break
        fi
        sleep .5
    done

    if echo "$unit" | grep -q '.*\.service' ; then
        # snap services can request KillMode=process, which would result in only
        # the main process getting stopped, however during purge we are doing a
        # full cleanup
        systemctl kill -q "$unit" || true
    fi
}

is_component_mount_unit() {
    systemctl show "$1" -p Where | sed 's#Where=##' | grep -q '\(/snap\|/var/lib/snapd/snap\)/[^/]*/components/mnt/[^/]*/[^/]*'
}

purge() {
    # shellcheck disable=SC1091
    distribution=$(. /etc/os-release; echo "${ID}-${VERSION_ID}")

    if [ "$distribution" = "ubuntu-14.04" ]; then
        # snap.mount.service is a trusty thing
        systemctl_stop snap.mount.service
    fi

    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=""
    components_mounts=""

    for mount in $mounts; do
        if is_component_mount_unit "$mount"; then
            components_mounts="$components_mounts $mount"
        else
            snap_mounts="$snap_mounts $mount"
        fi
    done

    # services from snaps
    services=$(echo "$units" | grep '^snap\..*\.service' | cut -f1 -d ' ')
    # slices from snaps
    slices=$(echo "$units" | grep '^snap\..*\.slice' | cut -f1 -d ' ')

    # component mounts must come first so that they are unmounted before we
    # unmount the snap mounts
    for unit in $services $components_mounts $snap_mounts $slices; do
        # ensure its really a snap mount unit or systemd unit
        if ! grep -q 'What=/var/lib/snapd/snaps/' "/etc/systemd/system/$unit" && ! grep -q 'X-Snappy=yes' "/etc/systemd/system/$unit"; then
            echo "Skipping non-snapd systemd unit $unit"
            continue
        fi

        echo "Stopping $unit"
        systemctl_stop "$unit"

        if echo "$unit" | grep -q '.*\.mount' && ! is_component_mount_unit "$unit"; then
            # 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
            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 "${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 "${global_snaps_mount_dir}/bin/$snap"
                find "${global_snaps_mount_dir}/bin" -maxdepth 1 -name "$snap.*" -delete
                # snap mount dir
                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 "${global_snaps_mount_dir}/$snap" "/var/snap/$snap"; do
                    if [ -d "$d" ]; then
                        rmdir --ignore-fail-on-non-empty "$d"
                    fi
                done
                # udev rules
                find /etc/udev/rules.d -name "*-snap.${snap}.rules" -execdir rm -f "{}" \;
                # dbus policy files
                if [ -d /etc/dbus-1/system.d ]; then
                    find /etc/dbus-1/system.d -name "snap.${snap}.*.conf" -execdir rm -f "{}" \;
                fi
                # modules
                rm -f "/etc/modules-load.d/snap.${snap}.conf"
                rm -f "/etc/modprobe.d/snap.${snap}.conf"
                # timer and socket units
                find /etc/systemd/system -name "snap.${snap}.*.timer" -o -name "snap.${snap}.*.socket" | while read -r f; do
                    systemctl_stop "$(basename "$f")"
                    rm -f "$f"
                done
                # user services, sockets, and timers - we make no attempt to stop any of them.
                # TODO: ask snapd to ask each snapd.session-agent.service to stop snaps
                # user-session services and stop itself.
                find /etc/systemd/user -name "snap.${snap}.*.timer" -o -name "snap.${snap}.*.socket" -o -name "snap.${snap}.*.service" | while read -r f; do
                    rm -f "$f"
                done
            fi
        fi

        echo "Removing $unit"
        rm -f "/etc/systemd/system/$unit"
        rm -f "/etc/systemd/system/multi-user.target.wants/$unit"
        rm -f "/etc/systemd/system/snapd.mounts.target.wants/${unit}"
    done
    # Remove empty ".wants/" directory created by enabling mount units
    rmdir "/etc/systemd/system/snapd.mounts.target.wants" || true
    # Units may have been removed do a reload
    systemctl -q daemon-reload || true

    # Undo any bind mounts to /var/lib/snapd/snap, /snap or /var/snap done by parallel
    # installs or LP:#1668659
    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 ||
                grep -q -e "\(/.*\)$mp $mp .* btrfs .*\(subvol=\1\)\(,.*\)\?\$" /proc/self/mountinfo ; then
            echo "umounting $mp"
            umount -l "$mp" || true
        fi
    done

    # stop snapd services
    for serv in snapd.autoimport.service snapd.seeded.service snapd.apparmor.service snapd.mounts.target snapd.mounts-pre.target; do
        systemctl_stop "$serv"
    done

    # snapd session-agent
    rm -f /etc/systemd/user/snapd.session-agent.socket
    rm -f /etc/systemd/user/snapd.session-agent.service
    rm -f /etc/systemd/user/sockets.target.wants/snapd.session-agent.socket

    # dbus activation configuration
    rm -f /etc/dbus-1/session.d/snapd.session-services.conf
    rm -f /etc/dbus-1/system.d/snapd.system-services.conf

    echo "Discarding preserved snap namespaces"
    # opportunistic as those might not be actually mounted
    if [ -d /run/snapd/ns ]; then
        if [ "$(find /run/snapd/ns/ -name "*.mnt" | wc -l)" -gt 0 ]; then
            for mnt in /run/snapd/ns/*.mnt; do
                umount -l "$mnt" || true
                rm -f "$mnt"
            done
        fi
        find /run/snapd/ns/ \( -name '*.fstab' -o -name '*.user-fstab' -o -name '*.info' \) -delete
        umount -l /run/snapd/ns/ || true
    fi

    echo "Removing downloaded snaps"
    rm -rf /var/lib/snapd/snaps/*

    echo "Removing features exported from snapd to helper tools"
    rm -rf /var/lib/snapd/features

    echo "Final directory cleanup"
    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"
    rm -rf /var/lib/snapd/dbus-1/services/*
    rm -rf /var/lib/snapd/dbus-1/system-services/*
    rm -rf /var/lib/snapd/desktop/applications/*
    rm -rf /var/lib/snapd/environment/*
    rm -rf /var/lib/snapd/seccomp/bpf/*
    rm -rf /var/lib/snapd/device/*
    rm -rf /var/lib/snapd/assertions/*
    rm -rf /var/lib/snapd/cookie/*
    rm -rf /var/lib/snapd/cache/*
    rm -rf /var/lib/snapd/mount/*
    rm -rf /var/lib/snapd/sequence/*
    rm -rf /var/lib/snapd/apparmor/*
    rm -rf /var/lib/snapd/inhibit/*
    rm -rf /var/lib/snapd/cgroup/*
    rm -rf /var/lib/snapd/desktop/*
    rm -f /var/lib/snapd/state.json
    rm -f /var/lib/snapd/state.lock
    rm -f /var/lib/snapd/system-key

    echo "Removing snapd catalog cache"
    rm -rf /var/cache/snapd/*

    if test -d /etc/apparmor.d; then
        # Remove auto-generated rules for snap-confine from the 'core' snap
        echo "Removing extra snap-confine apparmor rules"
        # shellcheck disable=SC2046
        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-tooling-dir=*)
            STATIC_SNAP_TOOLING_DIR=${1#*=}
            shift
            ;;
        --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, 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
