#!/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] ... # # 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= — 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 ::// (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//, # expose a [] 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 < ... 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 ") --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--[~env]_.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 -[~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."