#!/bin/sh
# keyboot-snap — userland management for keyboot-convention ZFS snapshots
# (ADR 0006). The companion to keyboot-autosnap: that tool is the *cron engine*
# (periodic create + retention per label); this is the *admin front-end* —
# list/inspect, take an on-demand labelled snapshot, prune by policy, hold a
# point-in-time against pruning, and destroy one.
#
# Naming convention (shared with keyboot-autosnap):
#   <dataset>@<YYYY-MM-DD-HHMM>Z-<LABEL>     UTC, explicit Z, 24h
#   e.g.  rpool/ROOT/debian@2026-06-22-1730Z-MANUAL
# UTC sorts lexically == chronologically; `zfs list -o creation` renders the
# local time for display. A point-in-time is taken `zfs snapshot -r` so the whole
# subtree shares one name (same txg) — keyboot's rollback UI groups by it.
#
# Usage:
#   keyboot-snap list    [--label L] [--json] [TARGET ...]
#   keyboot-snap create  [--label L] [--dry-run] [TARGET ...]   (default label MANUAL)
#   keyboot-snap prune   --label L --keep N [--dry-run] [TARGET ...]
#   keyboot-snap hold    <YYYY-MM-DD-HHMMZ-LABEL> [TARGET ...]
#   keyboot-snap release <YYYY-MM-DD-HHMMZ-LABEL> [TARGET ...]
#   keyboot-snap destroy <YYYY-MM-DD-HHMMZ-LABEL> [--confirm] [TARGET ...]
#
#     TARGET   pool or dataset (recursive). Default: every imported pool.
#     A "point-in-time" is the <YYYY-MM-DD-HHMMZ-LABEL> suffix; hold/release/
#     destroy act on `<target>@<suffix>` recursively across the subtree.
# Exit non-zero on any failure.

set -u
PROG=keyboot-snap
HOLD_TAG=keyboot:held

die()  { printf '%s: error: %s\n' "$PROG" "$*" >&2; exit 2; }
warn() { printf '%s: %s\n' "$PROG" "$*" >&2; }
usage() { awk '/^# *Usage:/{f=1} f{ if(!/^#/) exit; print }' "$0" | sed 's/^# \{0,1\}//'; }
run()  { if [ "${DRYRUN:-0}" -eq 1 ]; then printf '[dry-run] %s\n' "$*" >&2; return 0; fi; "$@"; }

# A keyboot-convention snapshot suffix: YYYY-MM-DD-HHMMZ-LABEL (ERE; used with grep -E).
SUFFIX_RE='[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z-[A-Za-z0-9_]+'

[ $# -ge 1 ] || { usage; exit 2; }
CMD="$1"; shift

# zfs is required for every command except help (so --help works pre-install).
case "$CMD" in -h|--help|help) ;; *) command -v zfs >/dev/null 2>&1 || die "zfs not found";; esac

LABEL=; KEEP=; DRYRUN=0; JSON=0; CONFIRM=0; SUFFIX=; TARGETS=""
while [ $# -gt 0 ]; do
    case "$1" in
        --label)   LABEL="${2:?--label needs a value}"; shift 2 ;;
        --label=*) LABEL="${1#*=}"; shift ;;
        --keep)    KEEP="${2:?--keep needs a value}"; shift 2 ;;
        --keep=*)  KEEP="${1#*=}"; shift ;;
        --json)    JSON=1; shift ;;
        --dry-run) DRYRUN=1; shift ;;
        --confirm) CONFIRM=1; shift ;;
        -h|--help) usage; exit 0 ;;
        --)        shift; while [ $# -gt 0 ]; do TARGETS="$TARGETS $1"; shift; done ;;
        -*)        die "unknown option: $1" ;;
        *)
            # First bare arg for hold/release/destroy is the snapshot suffix.
            case "$CMD" in
                hold|release|destroy)
                    if [ -z "$SUFFIX" ]; then SUFFIX="$1"; shift; continue; fi ;;
            esac
            TARGETS="$TARGETS $1"; shift ;;
    esac
done

# Default targets = every imported pool.
default_targets() {
    if [ -n "${TARGETS# }" ]; then printf '%s' "$TARGETS"; return; fi
    zpool list -H -o name 2>/dev/null
}

# keyboot-convention snapshots under the targets as TAB rows
# (name<TAB>used<TAB>referenced<TAB>creation), filtered by $LABEL if set,
# sorted oldest-first (UTC names sort chronologically).
list_rows() {
    _tab="$(printf '\t')"
    for t in $(default_targets); do
        zfs list -H -o name,used,referenced,creation -t snapshot -r "$t" 2>/dev/null
    done | { while IFS="$_tab" read -r nm us rf cr; do
        case "$nm" in *@*) sfx="${nm#*@}" ;; *) continue ;; esac
        printf '%s' "$sfx" | grep -qE "^${SUFFIX_RE}\$" || continue
        [ -z "$LABEL" ] || [ "${sfx##*-}" = "$LABEL" ] || continue
        printf '%s\t%s\t%s\t%s\n' "$nm" "$us" "$rf" "$cr"
    done; } | sort -t@ -k2
}

cmd_list() {
    _tab="$(printf '\t')"
    _rows="$(list_rows)"
    if [ "$JSON" -eq 1 ]; then
        printf '['
        _first=1
        printf '%s\n' "$_rows" | while IFS="$_tab" read -r nm us rf cr; do
            [ -n "$nm" ] || continue
            _suf="${nm#*@}"; _ds="${nm%@*}"; _lbl="${_suf##*-}"
            [ "$_first" -eq 1 ] || printf ','; _first=0
            printf '{"snapshot":"%s","dataset":"%s","suffix":"%s","label":"%s","used":"%s","referenced":"%s","creation":"%s"}' \
                "$nm" "$_ds" "$_suf" "$_lbl" "$us" "$rf" "$cr"
        done
        printf ']\n'
        return 0
    fi
    [ -n "$_rows" ] || { warn "no keyboot-convention snapshots found"; return 0; }
    # The snapshot NAME already carries the UTC timestamp (human-readable);
    # LABEL is its suffix tail. zfs's epoch 'creation' isn't shown (redundant).
    printf '%-50s %8s %8s  %s\n' "SNAPSHOT (name = dataset@UTC-LABEL)" "USED" "REFER" "LABEL"
    printf '%s\n' "$_rows" | while IFS="$_tab" read -r nm us rf cr; do
        [ -n "$nm" ] || continue
        _sfx="${nm#*@}"
        printf '%-50s %8s %8s  %s\n' "$nm" "$us" "$rf" "${_sfx##*-}"
    done
}

cmd_create() {
    [ -n "$LABEL" ] || LABEL=MANUAL
    case "$LABEL" in *[!A-Za-z0-9_]*) die "label must be [A-Za-z0-9_]";; esac
    _stamp="$(date -u +%Y-%m-%d-%H%MZ)" || die "date failed"
    _suf="${_stamp}-${LABEL}"
    _rc=0
    for t in $(default_targets); do
        zfs list -H -o name "$t" >/dev/null 2>&1 || { warn "no such dataset: $t"; _rc=1; continue; }
        if run zfs snapshot -r "${t}@${_suf}"; then
            [ "$DRYRUN" -eq 1 ] || printf '%s: created %s@%s (recursive)\n' "$PROG" "$t" "$_suf" >&2
        else warn "snapshot ${t}@${_suf} FAILED"; _rc=1; fi
    done
    return "$_rc"
}

cmd_prune() {
    [ -n "$LABEL" ] || die "prune needs --label"
    case "$KEEP" in ''|*[!0-9]*) die "prune needs --keep N (non-negative integer)";; esac
    _rc=0
    for t in $(default_targets); do
        # This target's own anchor snapshots of this label, oldest first.
        _snaps="$(zfs list -H -o name -t snapshot -d 1 "$t" 2>/dev/null \
            | grep -E "^${t}@[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z-${LABEL}\$" | sort)"
        [ -n "$_snaps" ] || continue
        _total="$(printf '%s\n' "$_snaps" | wc -l | tr -d ' ')"
        [ "$_total" -gt "$KEEP" ] || continue
        _drop=$(( _total - KEEP ))
        # `for` (not `… | while`) so a destroy failure escapes to set _rc; a
        # pipeline subshell would swallow it. ZFS snapshot names carry no
        # whitespace/glob chars, so word-splitting is safe.
        for s in $(printf '%s\n' "$_snaps" | head -n "$_drop"); do
            [ -n "$s" ] || continue
            if zfs holds -H "$s" 2>/dev/null | grep -q "$HOLD_TAG"; then
                warn "skip held $s (release first)"; continue
            fi
            if run zfs destroy -r "$s"; then
                [ "$DRYRUN" -eq 1 ] || printf '%s: pruned %s\n' "$PROG" "$s" >&2
            else warn "destroy $s FAILED"; _rc=1; fi
        done
    done
    return "$_rc"
}

# hold/release/destroy operate on a point-in-time suffix across the subtree.
require_suffix() {
    [ -n "$SUFFIX" ] || die "$CMD needs a snapshot suffix (YYYY-MM-DD-HHMMZ-LABEL)"
    printf '%s' "$SUFFIX" | grep -qE "^${SUFFIX_RE}\$" || die "bad suffix: $SUFFIX"
}
each_member() {  # $1 = action: hold|release|destroy
    _act="$1"; _rc=0
    for t in $(default_targets); do
        # Every dataset in the subtree that has this point-in-time.
        zfs list -H -o name -t snapshot -r "$t" 2>/dev/null \
            | grep -E "@${SUFFIX}\$" | while IFS= read -r s; do
            [ -n "$s" ] || continue
            case "$_act" in
                hold)    run zfs hold "$HOLD_TAG" "$s" 2>/dev/null && printf '%s: held %s\n' "$PROG" "$s" >&2 || warn "hold $s failed (already held?)" ;;
                release) run zfs release "$HOLD_TAG" "$s" 2>/dev/null && printf '%s: released %s\n' "$PROG" "$s" >&2 || warn "release $s failed (not held?)" ;;
            esac
        done
    done
    return "$_rc"
}

cmd_hold()    { require_suffix; each_member hold; }
cmd_release() { require_suffix; each_member release; }
cmd_destroy() {
    require_suffix
    if [ "$CONFIRM" -ne 1 ] && [ "$DRYRUN" -ne 1 ]; then
        warn "would destroy the '$SUFFIX' point-in-time recursively across:"
        for t in $(default_targets); do printf '  %s@%s\n' "$t" "$SUFFIX" >&2; done
        die "pass --confirm to destroy (or --dry-run)"
    fi
    _rc=0
    for t in $(default_targets); do
        zfs list -H -o name -t snapshot "${t}@${SUFFIX}" >/dev/null 2>&1 || continue
        if zfs holds -H "${t}@${SUFFIX}" 2>/dev/null | grep -q "$HOLD_TAG"; then
            warn "refusing held ${t}@${SUFFIX} (keyboot-snap release it first)"; _rc=1; continue
        fi
        if run zfs destroy -r "${t}@${SUFFIX}"; then
            [ "$DRYRUN" -eq 1 ] || printf '%s: destroyed %s@%s\n' "$PROG" "$t" "$SUFFIX" >&2
        else warn "destroy ${t}@${SUFFIX} FAILED"; _rc=1; fi
    done
    return "$_rc"
}

case "$CMD" in
    list)    cmd_list ;;
    create)  cmd_create ;;
    prune)   cmd_prune ;;
    hold)    cmd_hold ;;
    release) cmd_release ;;
    destroy) cmd_destroy ;;
    -h|--help|help) usage ;;
    *) die "unknown command: $CMD (try --help)" ;;
esac
