Ctrl K
ComunidadGuías…

scripts/deploy-migration-payload.sh

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# deploy-migration-payload.sh — publish a 0.3.5.1 -> 0.4.0 migration OTA
# payload (built by `make start-os-migration-squashfs`, assembled by
# projects/start-os/build/assemble-migration-payload.sh) to a legacy (Haskell)
# 0.3.5.1 registry over ssh.
#
# Usage: ./scripts/deploy-migration-payload.sh [options] <ssh-host> <payload.migration.squashfs>...
#
# How 0.3.5.1's frozen updater pulls an update, and therefore what this script
# sets up on the registry:
#   1. GET /eos/v0/latest?os.version=..&os.arch=<platform> — the Haskell app
#      serves the max os_version DB row for the box's *exact* arch (the
#      -nonfree variants are distinct arches; os.compat is ignored).
#   2. rsync <host>::<version>/<platform>/ (daemon protocol, port 873) into
#      the box's `next` subvolume.
# So per payload we: upload the squashfs, loop-mount it (fstab-persisted — the
# old registries' ad-hoc mounts died on reboot) at /srv/rsync/<version>/<platform>,
# expose a [<version>] rsyncd module, and verify an external pull with the
# updater's exact flags. Only then, as the go-live switch, insert the
# os_version row and verify /eos/v0/latest advertises it.
#
# The advertised version must be plain emver (major.minor.patch[.revision]):
# 0.3.5.1's parser has no prerelease syntax, so 0.4.0-beta.N is advertised as
# 0.4.0 (the default; override with --version). Boxes on other arches are
# untouched until a payload for their arch is deployed.
#
# Redeploying a version that is already live (its os_version row exists)
# requires --replace: the new payload is remounted over the live one,
# superseded payload files for that platform are pruned, and the os_version
# row's headline/notes are updated in place.
#
# Staging and go-live can also be split across two runs: --skip-publish stages
# and verifies but stops before the os_version insert; a later --publish-only
# skips straight to that insert against the already-staged payloads, so the
# squashfs files need not still be on local disk (only their names are read, for
# the version and platform). The external pull is re-verified either way, so
# --publish-only still proves the staging is live before flipping the switch.
#
# Assumes the standard legacy registry droplet: root ssh, the Haskell app as
# registry.service (its Environment supplies RESOURCES_PATH / PG_DATABASE /
# REGISTRY_HOSTNAME), postgres peer auth, and Debian/Ubuntu's rsync.service.

set -euo pipefail

usage() {
    >&2 cat <<EOF
usage: $0 [options] <ssh-host> <payload.migration.squashfs>...

options:
  --version X.Y.Z[.R]  version advertised to 0.3.5.1 boxes (default: payload
                       version with any prerelease suffix stripped)
  --headline TEXT      os_version headline (default: "StartOS <version>")
  --notes TEXT         os_version release notes shown in the update prompt
                       (markdown; default: the pre-update steps of the 0.4.0
                       migration guide)
  --hostname HOST      registry public hostname (default: REGISTRY_HOSTNAME
                       from registry.service on the target)
  --resources DIR      resources dir on the target (default: RESOURCES_PATH
                       from registry.service)
  --skip-publish       stage + verify everything but skip the os_version DB
                       insert (the go-live switch); rerun without this to
                       publish
  --publish-only       skip staging (upload/mount) and go straight to the
                       go-live insert against payloads a prior --skip-publish
                       run already staged; the squashfs files need not exist
                       locally (only their names are read for version/platform)
  --replace            allow redeploying a version that is already published:
                       swap the payload under the live mount and update the
                       existing os_version row's headline/notes
EOF
    exit 1
}

ADVERTISED=
EXPLICIT_VERSION=
HEADLINE=
NOTES=
HOSTNAME_OVERRIDE=
RESOURCES_OVERRIDE=
SKIP_PUBLISH=
PUBLISH_ONLY=
REPLACE=
TARGET=
PAYLOADS=()

while [ $# -gt 0 ]; do
    case "$1" in
        --version)      ADVERTISED="$2"; EXPLICIT_VERSION=1; shift 2 ;;
        --headline)     HEADLINE="$2"; shift 2 ;;
        --notes)        NOTES="$2"; shift 2 ;;
        --hostname)     HOSTNAME_OVERRIDE="$2"; shift 2 ;;
        --resources)    RESOURCES_OVERRIDE="$2"; shift 2 ;;
        --skip-publish) SKIP_PUBLISH=1; shift ;;
        --publish-only) PUBLISH_ONLY=1; shift ;;
        --replace)      REPLACE=1; shift ;;
        -*)             usage ;;
        *)
            if [ -z "$TARGET" ]; then TARGET="$1"; else PAYLOADS+=("$1"); fi
            shift ;;
    esac
done
[ -n "$TARGET" ] && [ "${#PAYLOADS[@]}" -gt 0 ] || usage
# One skips the go-live switch, the other skips everything but it.
[ -z "$SKIP_PUBLISH" ] || [ -z "$PUBLISH_ONLY" ] \
    || { >&2 echo "--skip-publish and --publish-only are contradictory"; exit 1; }

# startos-<version>-<hash>[~env]_<platform>.migration.squashfs
PLATFORMS=()
for payload in "${PAYLOADS[@]}"; do
    # --publish-only stages nothing, so the squashfs need not still be on disk;
    # only its name is parsed below, for the version and platform.
    [ -n "$PUBLISH_ONLY" ] || [ -f "$payload" ] || { >&2 echo "no such file: $payload"; exit 1; }
    base="$(basename "$payload")"
    stem="${base%.migration.squashfs}"
    [ "$stem" != "$base" ] || { >&2 echo "$base: not a .migration.squashfs"; exit 1; }
    # suffix-match against the known set: platforms themselves contain '_'
    platform=
    for p in x86_64-nonfree aarch64-nonfree x86_64 aarch64 raspberrypi; do
        case "$stem" in *_"$p") platform="$p"; break ;; esac
    done
    case "$platform" in
        raspberrypi) >&2 echo "raspberrypi has no in-place migration — reflash required (#3443)"; exit 1 ;;
        "") >&2 echo "$base: unrecognized platform"; exit 1 ;;
    esac
    PLATFORMS+=("$platform")
    verhash="${stem#startos-}"; verhash="${verhash%_"$platform"}"
    version="${verhash%-*}" # drop the trailing -<hash>[~env] segment
    derived="${version%%-*}"
    if [ -n "$EXPLICIT_VERSION" ]; then
        : # --version wins for all payloads
    elif [ -z "$ADVERTISED" ]; then
        ADVERTISED="$derived"
    elif [ "$derived" != "$ADVERTISED" ]; then
        >&2 echo "$base: derives version $derived but earlier payloads derive $ADVERTISED; pass --version to force one"
        exit 1
    fi
done
[[ "$ADVERTISED" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] \
    || { >&2 echo "advertised version '$ADVERTISED' is not plain emver (0.3.5.1 cannot parse prerelease tags); pass --version"; exit 1; }
HEADLINE="${HEADLINE:-StartOS $ADVERTISED}"
# 0.3.5.1's OS-update modal renders these notes as markdown (marked + DOMPurify;
# GitHub-style `> [!WARNING]` admonitions are NOT supported) directly above its
# "Begin Update" button. The default carries the pre-update portion of the 0.4.0
# migration guide (projects/start-os/docs/src/update-040.md) plus its "Over the
# Air" tab's what-to-expect and browser-cache steps, with the guide's USB path
# dropped — keep the two in sync.
if [ -z "$NOTES" ]; then
    NOTES="$(cat <<'EOF'
StartOS 0.4.0 is a completely new operating system. This update replaces StartOS in place: it downloads ~3 GB, reboots your server, and migrates your services and data to the new format. It requires StartOS 0.3.5.1.

**This is a major migration, not a routine update.** Complete every preparation step below before tapping "Begin Update". Skipping the service-update or backup steps can result in **permanent data loss**.

**1. Review services that need special handling**

- **Embassy Pages** — retired and replaced by **Start9 Pages**. Embassy Pages will survive the update but will no longer receive updates. Uninstall it, then after the update install Start9 Pages from the marketplace and re-add your content.
- **Ghost** — completely redesigned for 0.4.0 and incompatible with the old version. Before updating, open your Ghost admin UI and use Ghost's built-in **Export** tool to download your content. After the update, install the new Ghost and use its **Import** tool to restore your content.
- **Synapse** — the old Synapse was Tor-only; the new Synapse is clearnet-only. These are different services now, with no migration path.
- **Jam** — Jam's backend (JoinMarket) is being replaced, and Jam is unavailable on 0.4.0 until its new backend matures. Back up your seed, move out any spendable funds (fidelity-bond funds stay locked until expiry), and uninstall Jam before updating.

**2. Prepare for new service addresses**

On 0.4.0, each service no longer has its own `.local` address (e.g. `longexamplepublickey.local`); services are reached on unique ports of your server's main `.local` address (e.g. `server-name.local:4545`). Your old per-service `.local` addresses will no longer exist after the update. If you use a password manager, give your saved passwords clear names now — not just the old `.local` URLs — so you can identify them later and save the new URLs.

**3. Update ALL services**

Update every installed service to its latest version, starting at the base of the dependency tree and working upward — Bitcoin before LND, LND before RTL. Bitcoin may safely remain at 28.x or 29.x, but you MUST update to the latest **minor** version of your selected major version. **This step is required:** services that are not on their latest version may fail to migrate, potentially requiring a rollback to 0.3.5.1 or losing data entirely.

**4. (Recommended) Add an SSH key**

If you haven't already, add an SSH key to your server. If something goes wrong during the migration, SSH access makes it much easier to debug.

**5. Uninstall services you don't use**

Every installed service must be migrated, and each adds to the total migration time. Uninstalling unused services now is much faster than migrating them; you can reinstall fresh on 0.4.0.

**6. Stop all services**

Stop every remaining service and wait for each to fully stop, so no new data is written before your backup.

**7. Create a full system backup**

With all services stopped, create a full system backup covering every service. **Do not skip this step.** Backups made on 0.3.5.1 cannot be restored onto 0.4.0 — this backup is your safety net for returning to 0.3.5.1 if the migration fails.

**What to expect**

After you begin, the update downloads (~3 GB), your server reboots, and the migration runs. It can take **hours**, depending on how much data you have — be patient, and do not power off or unplug your server.

**Your browser will not show you 0.4.0 on its own.** Your server is unreachable for part of the restart, and your browser has this 0.3.5.1 interface cached — reloading this tab will keep showing you the old UI or a "cannot connect" error indefinitely, long after 0.4.0 is up and serving. So check on your server every few minutes by opening its address (the `.local` address you are using right now) in a **new private/incognito window** — a fresh window each time. That bypasses the cache, and is the reliable way to see whether your server is back: first an initialization screen with migration progress, then the 0.4.0 login page. Once a private window loads, bring your normal window over with a hard refresh (`ctrl+shift+R`; on macOS Firefox `cmd+shift+R`, on macOS Safari `cmd+option+E` then `cmd+R`) or by clearing your browser's cache.

When the migration completes: sign in, update **all** of your services from the marketplace before doing anything else (the 0.4.0 versions are repackaged for the new system even when the app version looks the same), start them, and create a fresh backup.

Full guide: https://docs.start9.com/start-os/update-040.html
EOF
)"
fi

echo "== target: $TARGET   advertised version: $ADVERTISED   platforms: ${PLATFORMS[*]}"

[ "$(ssh "$TARGET" id -u)" = 0 ] || { >&2 echo "$TARGET: root ssh required"; exit 1; }

# Values that can contain spaces (MARKETPLACE_NAME) would split wrong here, but
# the three we read are a path, a db name, and a hostname.
REG_ENV="$(ssh "$TARGET" systemctl show registry.service -p Environment --value | tr ' ' '\n' | tr -d "'")"
reg_env() { echo "$REG_ENV" | grep "^$1=" | head -n1 | cut -d= -f2-; }
RESOURCES="${RESOURCES_OVERRIDE:-$(reg_env RESOURCES_PATH)}"
PG_DB="$(reg_env PG_DATABASE)"
REG_HOST="${HOSTNAME_OVERRIDE:-$(reg_env REGISTRY_HOSTNAME)}"
[ -n "$RESOURCES" ] && [ -n "$PG_DB" ] && [ -n "$REG_HOST" ] \
    || { >&2 echo "$TARGET: could not read RESOURCES_PATH/PG_DATABASE/REGISTRY_HOSTNAME from registry.service — is this a legacy registry? (--resources/--hostname to override)"; exit 1; }
echo "== registry: $REG_HOST   resources: $RESOURCES   db: $PG_DB"

# ADVERTISED/platform are format-validated above, safe to inline into SQL
for platform in "${PLATFORMS[@]}"; do
    published="$(ssh "$TARGET" "cd /tmp && sudo -u postgres psql -tA -d $(printf '%q' "$PG_DB") -c \"select count(*) from os_version where number='$ADVERTISED' and arch='$platform'\"")"
    if [ "$published" != 0 ]; then
        [ -n "$REPLACE" ] || { >&2 echo "$ADVERTISED/$platform is already published (os_version row exists); pass --replace to redeploy over it"; exit 1; }
        echo "== $ADVERTISED/$platform is already live — replacing (--replace)"
    fi
done

# --publish-only trusts a prior --skip-publish run for everything below, which is
# the whole of what touches the local squashfs files; the external pull further
# down re-proves the staging is live before the go-live switch.
if [ -n "$PUBLISH_ONLY" ]; then
    echo "== skipping upload/mount (--publish-only); re-verifying the staged payloads instead"
fi
for i in "${!PAYLOADS[@]}"; do
    [ -z "$PUBLISH_ONLY" ] || break
    payload="${PAYLOADS[$i]}" platform="${PLATFORMS[$i]}"
    base="$(basename "$payload")"

    size="$(wc -c < "$payload")"
    avail="$(ssh "$TARGET" df --output=avail -B1 "$RESOURCES" | tail -1 | tr -d ' ')"
    [ "$avail" -gt "$size" ] || { >&2 echo "$TARGET: $RESOURCES has $avail bytes free, payload is $size"; exit 1; }

    echo "== uploading $base"
    ssh "$TARGET" mkdir -p "$RESOURCES/eos/$ADVERTISED"
    rsync -a --partial --info=progress2 "$payload" "$TARGET:$RESOURCES/eos/$ADVERTISED/"
    local_sum="$(sha256sum "$payload" | cut -d' ' -f1)"
    remote_sum="$(ssh "$TARGET" sha256sum "$RESOURCES/eos/$ADVERTISED/$base" | cut -d' ' -f1)"
    [ "$local_sum" = "$remote_sum" ] || { >&2 echo "sha256 mismatch after upload: $local_sum != $remote_sum"; exit 1; }

    echo "== mounting at /srv/rsync/$ADVERTISED/$platform"
    # printf %q: ssh joins args into one remote shell string, so quote for it
    ssh "$TARGET" "bash -es -- $(printf '%q ' "$ADVERTISED" "$platform" "$RESOURCES/eos/$ADVERTISED/$base")" <<'REMOTE'
set -euo pipefail
VERSION="$1" PLATFORM="$2" SQFS="$3"
MNT="/srv/rsync/$VERSION/$PLATFORM"
mkdir -p "$MNT"
# a replaced payload keeps the old inode under the live loop mount; remount
if mountpoint -q "$MNT"; then umount "$MNT"; fi
awk -v mnt="$MNT" '$2 != mnt' /etc/fstab > /etc/fstab.new && mv /etc/fstab.new /etc/fstab
echo "$SQFS $MNT squashfs loop,ro,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount "$MNT"
test -f "$MNT/.startos-migration" && test -f "$MNT/usr/lib/startos/migration-boot" \
    || { >&2 echo "$SQFS does not look like a migration payload (missing sentinel/migration-boot)"; exit 1; }
# same-platform payloads with another hash are orphaned now that fstab points here
find "${SQFS%/*}" -maxdepth 1 -name "*_$PLATFORM.migration.squashfs" ! -name "${SQFS##*/}" -print -delete
REMOTE
done

echo "== exposing rsyncd module [$ADVERTISED]"
# rsyncd rereads its conf per connection, so no restart on module changes
ssh "$TARGET" "bash -es -- $(printf '%q ' "$ADVERTISED")" <<'REMOTE'
set -euo pipefail
VERSION="$1"
grep -qxF "[$VERSION]" /etc/rsyncd.conf \
    || printf '\n[%s]\npath = /srv/rsync/%s\nread only = yes\n' "$VERSION" "$VERSION" >> /etc/rsyncd.conf
systemctl enable --now rsync
REMOTE

for platform in "${PLATFORMS[@]}"; do
    echo "== verifying external pull of ${REG_HOST}::$ADVERTISED/$platform/"
    tmp="$(mktemp -d)"
    trap 'rm -rf "$tmp"' EXIT
    # exactly the frozen updater's flags (core/helpers/src/rsync.rs @ v0.3.5.1)
    rsync --delete --force -actAXH --no-inc-recursive \
        "${REG_HOST}::$ADVERTISED/$platform/usr/lib/startos/" "$tmp/"
    test -f "$tmp/migration-boot" || { >&2 echo "pull verification failed: no migration-boot"; exit 1; }
    rm -rf "$tmp"
done

if [ -n "$SKIP_PUBLISH" ]; then
    echo "== staged and verified; skipping os_version insert (--skip-publish). Rerun without it to go live."
    exit 0
fi

for platform in "${PLATFORMS[@]}"; do
    echo "== publishing os_version $ADVERTISED/$platform (go-live)"
    ssh "$TARGET" "bash -es -- $(printf '%q ' "$PG_DB" "$ADVERTISED" "$platform" "$HEADLINE" "$NOTES" "$REPLACE")" <<'REMOTE'
set -euo pipefail
DB="$1" VERSION="$2" PLATFORM="$3" HEADLINE="${4//\'/\'\'}" NOTES="${5//\'/\'\'}" REPLACE="$6"
cd /tmp # postgres user can't read root's cwd
if [ "$(sudo -u postgres psql -tA -d "$DB" -c "select count(*) from os_version where number='$VERSION' and arch='$PLATFORM'")" != 0 ]; then
    if [ -n "$REPLACE" ]; then
        echo "os_version $VERSION/$PLATFORM already present — updating in place (--replace)"
        sudo -u postgres psql -d "$DB" -c "update os_version set updated_at=now(), headline='$HEADLINE', release_notes='$NOTES' \
            where number='$VERSION' and arch='$PLATFORM'"
    else
        echo "os_version $VERSION/$PLATFORM already present — skipping insert"
    fi
    exit 0
fi
sudo -u postgres psql -d "$DB" -c "insert into os_version (id, created_at, updated_at, number, headline, release_notes, arch) \
    values (nextval('os_version_id_seq'), now(), now(), '$VERSION', '$HEADLINE', '$NOTES', '$PLATFORM')"
REMOTE

    latest="$(curl -fsSk "https://$REG_HOST/eos/v0/latest?os.version=0.3.5.1&os.arch=$platform")"
    echo "$latest" | grep -qF "\"version\":\"$ADVERTISED\"" \
        || { >&2 echo "/eos/v0/latest for $platform did not advertise $ADVERTISED: $latest"; exit 1; }
    echo "== $platform live: $latest"
done

echo "== done. 0.3.5.1 boxes on ${PLATFORMS[*]} pointed at $REG_HOST will now be offered $ADVERTISED."