|
| 1 | +# Backing up and restoring Windshift |
| 2 | + |
| 3 | +`backup.sh` is a conservative host-side workflow for the stock Docker Compose deployments in this repository. |
| 4 | +It is not a general-purpose Docker or database recovery tool. |
| 5 | +It backs up the complete configured `/data` tree and, for the supported PostgreSQL shape, a custom-format logical dump. |
| 6 | + |
| 7 | +The helper image is pinned to the Alpine digest used by `deploy/coding-agent/Dockerfile`. |
| 8 | +It has no network, a read-only root filesystem, and no-new-privileges. |
| 9 | +The backup helper receives Windshift volumes read-only. |
| 10 | +The restore helper receives a writable data volume only after all preflight checks and a local rollback snapshot have completed. |
| 11 | + |
| 12 | +## Supported scope |
| 13 | + |
| 14 | +The workflow requires exactly one Compose `windshift` container using the stock `/windshift` entrypoint and exactly one persistent mount at the configured data path. |
| 15 | +Additional mounts below that path are rejected because deleting a mounted child can leave both restore and rollback incomplete. |
| 16 | +The backup directory and rollback root must be outside that mount's host source so an archive cannot include or delete itself. |
| 17 | +Replicas are rejected. |
| 18 | +It derives a stable, domain-separated hash from the Compose project and service labels and uses a system-wide mode-700 lock directory under `/var/tmp`. |
| 19 | + |
| 20 | +SQLite is supported only when `DB_PATH` is below `/data`. |
| 21 | +An empty `DB_PATH` is rejected because Windshift would otherwise fall back to a path outside the mounted data tree. |
| 22 | +The database path is recorded in the manifest and must exist as a nonempty regular archive entry. |
| 23 | +The script also checks `ATTACHMENT_PATH`, `PLUGIN_DIR`, `AI_PROMPTS_DIR`, `LLM_PROVIDERS_FILE`, and trimmed comma-separated `PLUGIN_DIRS` when configured. |
| 24 | +An empty `PLUGIN_DIR` is rejected because Windshift would otherwise fall back to a relative path outside the data mount. |
| 25 | +SSH host keys and SSH-enabled deployments are outside this workflow because the image-relative default path is not guaranteed to be under `/data`. |
| 26 | +Any extra host mounts or persisted paths outside `/data` are out of scope and cause a safe failure when they are configured through these variables. |
| 27 | + |
| 28 | +PostgreSQL is supported only when all of the following are true: |
| 29 | + |
| 30 | +- `DB_TYPE=postgres` and `POSTGRES_CONNECTION_STRING` is unset. |
| 31 | +- `POSTGRES_HOST` exactly equals the selected Compose service. |
| 32 | +- `POSTGRES_PORT` is unset or 5432. |
| 33 | +- `POSTGRES_USER` and `POSTGRES_DB` exactly match that service's container environment. |
| 34 | +- Exactly one PostgreSQL service container exists. |
| 35 | + |
| 36 | +The automatic service candidates are `postgres` and `db`. |
| 37 | +Use `--postgres-service NAME` for another stock-style service. |
| 38 | +On restore, that explicit option overrides the manifest value. |
| 39 | +External databases, connection strings, non-default ports, custom database selection, and replicas are deliberately unsupported. |
| 40 | +Any command-line use of `-postgres-connection-string`, `--postgres-connection-string`, `-pg-conn`, or `--pg-conn` is also out of scope and fails closed. |
| 41 | +The path override flags `-db`, `--db`, `-attachment-path`, `--attachment-path`, `-llm-providers`, `--llm-providers`, `-ai-prompts-dir`, and `--ai-prompts-dir` are out of scope too. |
| 42 | +Environment values containing CR or LF are rejected because this workflow cannot safely fingerprint or archive them. |
| 43 | +Use a database-specific procedure for those deployments. |
| 44 | + |
| 45 | +Use the same PostgreSQL major version for backup and restore. |
| 46 | +Restore only into the same Windshift image and schema generation that produced the backup unless a separately verified migration plan says otherwise. |
| 47 | + |
| 48 | +## Create a backup |
| 49 | + |
| 50 | +Run from the Compose directory: |
| 51 | + |
| 52 | + cd deploy |
| 53 | + |
| 54 | + ./backup.sh backup /srv/windshift-backups/windshift-$(date -u +%Y%m%dT%H%M%SZ) |
| 55 | + |
| 56 | +The backup path must be an absolute, normalized host path without a colon because it is passed to Docker's `-v` syntax. |
| 57 | +The created directory is mode 700. |
| 58 | +It contains `data.tar.gz`, `manifest.env`, and `checksums.sha256`. |
| 59 | +PostgreSQL backups also contain `database.dump`. |
| 60 | + |
| 61 | +The script takes an atomic lock before stopping the app or creating backup files. |
| 62 | +If a lock exists, do not run another operation. |
| 63 | +First confirm that no backup or restore is running, then remove only the reported stale lock directory. |
| 64 | + |
| 65 | +SHA-256 checksums detect accidental corruption and incomplete copies. |
| 66 | +They do not authenticate a backup. |
| 67 | +Use access-controlled, authenticated, encrypted storage and transport. |
| 68 | + |
| 69 | +The manifest stores a domain-separated SHA-256 fingerprint of the effective `SSO_SECRET`, or `SESSION_SECRET` when no `SSO_SECRET` is present. |
| 70 | +This is a compatibility check, not a password verifier. |
| 71 | +Weak secrets therefore still produce easily guessable fingerprints. |
| 72 | + |
| 73 | +Pass `--include-sso-secret` only for an explicitly encrypted secret-recovery copy. |
| 74 | +It writes the raw effective secret as mode-600 `sso-secret` inside the mode-700 backup and includes it in the checksum file. |
| 75 | +This artifact is highly sensitive and must never be logged or copied to ordinary backup media. |
| 76 | + |
| 77 | +## Restore |
| 78 | + |
| 79 | +Run from the same Compose directory: |
| 80 | + |
| 81 | + cd deploy |
| 82 | + |
| 83 | + ./backup.sh restore --force /srv/windshift-backups/windshift-20260829T120000Z |
| 84 | + |
| 85 | +Before stopping Windshift, restore rejects symlinked or non-regular input artifacts, copies approved artifacts into a private staging directory, and verifies only that staged snapshot thereafter. |
| 86 | +The helper checks the staged archive in the same image that performs extraction. |
| 87 | +It accepts only regular files and directories, rejects absolute and parent paths, and rejects symbolic links, hard links, devices, and FIFOs. |
| 88 | + |
| 89 | +Restore compares the live secret fingerprint before any state change. |
| 90 | +On mismatch it fails closed and never writes a secret into Compose. |
| 91 | +Configure the source secret in Compose or the secret manager first. |
| 92 | + |
| 93 | +After the app stops, the script creates a mode-700 rollback directory under `--rollback-root` or `WINDSHIFT_BACKUP_ROLLBACK_ROOT`. |
| 94 | +The default is disk-backed `/var/tmp`. |
| 95 | +That directory must already exist and be writable. |
| 96 | +Choose a local, private filesystem with space for a full compressed `/data` copy, staging copy, and, for PostgreSQL, a second logical dump. |
| 97 | +Do not put it on volatile `/tmp` unless its loss during reboot is acceptable. |
| 98 | + |
| 99 | +For PostgreSQL the rollback contains both `/data` and a pre-restore database dump. |
| 100 | +The script writes SHA-256 checksums for retained rollback artifacts and structurally validates custom-format PostgreSQL dumps with `pg_restore --list` before relying on them. |
| 101 | +The target database restore uses `pg_restore --single-transaction --exit-on-error`. |
| 102 | +It cleans and restores archive objects, not necessarily unrelated database objects. |
| 103 | + |
| 104 | +The rollback is retained until the started application is healthy. |
| 105 | +When Docker exposes a health check, the script waits for it for up to `WINDSHIFT_BACKUP_HEALTH_TIMEOUT` seconds, default 90. |
| 106 | +Without a Docker health status, it executes `/windshift healthcheck` through Compose until the configured deadline. |
| 107 | +Increase `WINDSHIFT_BACKUP_HEALTH_TIMEOUT` before restore when startup or migrations can legitimately take longer than 90 seconds. |
| 108 | +If the target is unhealthy, it restores both rollback data and PostgreSQL before trying the old state. |
| 109 | +If PostgreSQL state is uncertain because of an interruption or failed database command, Windshift remains stopped and the error retains both rollback and staged paths. |
| 110 | +The recovery lock also remains in place because a detached server-side `pg_restore` may still be running. |
| 111 | +Remove that lock only after confirming that no backup, restore, or PostgreSQL restore process is active and after resolving the database state. |
| 112 | +If Windshift was already stopped before restore, the script cannot perform a health check or automatic start. |
| 113 | +It reports success with the retained rollback path so the operator can validate a manual start before removing it. |
| 114 | + |
| 115 | +The command uses a 60-second Compose stop timeout. |
| 116 | +An interrupted command, host reboot, or Docker failure can still leave the service stopped. |
| 117 | +Read the printed recovery paths before restarting it. |
| 118 | +On a confirmed healthy restore, cleanup failures only warn and retain the private rollback or stage instead of changing the restored state. |
| 119 | +The ephemeral backup helper streams its tar archive to a host-created mode-600 file, so it does not need to chown host files. |
| 120 | +The Docker-free contract test does not prove behavior against a real Docker daemon, image, filesystem driver, or PostgreSQL server. |
| 121 | + |
| 122 | +## Manual emergency recovery |
| 123 | + |
| 124 | +This is a destructive emergency procedure. |
| 125 | +Leave Windshift stopped and first copy every reported stage and rollback directory to immutable, access-controlled storage. |
| 126 | +Use only the same Windshift image/schema generation and the same PostgreSQL major version. |
| 127 | + |
| 128 | +Before stopping or deleting anything, validate the retained checksums and data archive in a separate preflight. |
| 129 | +Use the SHA-256 tool available on the host and the exact pinned helper image shown below: |
| 130 | + |
| 131 | + cd deploy |
| 132 | + (cd "$ROLLBACK_DIR" && sha256sum -c checksums.sha256) |
| 133 | + docker run --rm --network none --read-only --security-opt no-new-privileges -v "$ROLLBACK_DIR:/backup:ro" alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d sh -ec ' |
| 134 | + test -s /backup/data.tar.gz |
| 135 | + tar -tzf /backup/data.tar.gz >/dev/null |
| 136 | + tar -tvzf /backup/data.tar.gz >/dev/null |
| 137 | + tar -tzf /backup/data.tar.gz | awk '\''{ |
| 138 | + path = $0 |
| 139 | + if (path == "./") key = "." |
| 140 | + else { |
| 141 | + if (substr(path, 1, 2) != "./") exit 1 |
| 142 | + path = substr(path, 3) |
| 143 | + if (path == "" || path ~ /(^|\/)\.($|\/)/ || path ~ /(^|\/)\.\.($|\/)/ || path ~ /\/\//) exit 1 |
| 144 | + sub(/\/$/, "", path) |
| 145 | + if (path == "") exit 1 |
| 146 | + key = path |
| 147 | + } |
| 148 | + if (seen[key]++) exit 1 |
| 149 | + count++ |
| 150 | + } END { if (!count) exit 1 }'\'' |
| 151 | + if tar -tvzf /backup/data.tar.gz | grep -Eqv "^[-d]"; then exit 1; fi |
| 152 | + if tar -tvzf /backup/data.tar.gz | grep -E "^-.* -> "; then exit 1; fi |
| 153 | + ' |
| 154 | + |
| 155 | +If the host provides `shasum` instead, replace the checksum command with `shasum -a 256 -c checksums.sha256`. |
| 156 | +Do not continue unless every command succeeds. |
| 157 | + |
| 158 | +For PostgreSQL, validate the retained database dump before changing `/data`. |
| 159 | +Set `POSTGRES_SERVICE` to the confirmed service name and keep `remote_dump` for the restore step below: |
| 160 | + |
| 161 | + POSTGRES_SERVICE=postgres |
| 162 | + db_id=$(docker compose ps --all -q "$POSTGRES_SERVICE") |
| 163 | + [ "$(printf '%s\n' "$db_id" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 ] || exit 1 |
| 164 | + remote_dump=/tmp/windshift-manual-rollback-$$.dump |
| 165 | + docker cp "$ROLLBACK_DIR/database-before.dump" "$db_id:$remote_dump" |
| 166 | + docker compose exec -T "$POSTGRES_SERVICE" sh -ec 'pg_restore --list "$1" >/dev/null' sh "$remote_dump" |
| 167 | + |
| 168 | +For `/data`, resolve exactly one Windshift container before stopping it: |
| 169 | + |
| 170 | + cd deploy |
| 171 | + windshift_id=$(docker compose ps --all -q windshift) |
| 172 | + [ "$(printf '%s\n' "$windshift_id" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 ] || exit 1 |
| 173 | + mount_destinations=$(docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$windshift_id") |
| 174 | + [ "$(printf '%s\n' "$mount_destinations" | grep -Fxc /data)" = 1 ] || exit 1 |
| 175 | + if printf '%s\n' "$mount_destinations" | grep -Eq '^/data/'; then exit 1; fi |
| 176 | + docker compose stop --timeout 60 windshift || exit 1 |
| 177 | + [ "$(docker compose ps --all -q windshift)" = "$windshift_id" ] || exit 1 |
| 178 | + [ "$(docker inspect -f '{{.State.Running}}' "$windshift_id")" = false ] || exit 1 |
| 179 | + docker run --rm --network none --read-only --security-opt no-new-privileges --volumes-from "$windshift_id" -v "$ROLLBACK_DIR:/backup:ro" alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d sh -ec 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +; tar -C /data -xzf /backup/data.tar.gz' |
| 180 | + |
| 181 | +For PostgreSQL, restore the already validated remote dump only after confirming the target database again: |
| 182 | + |
| 183 | + docker compose exec -T "$POSTGRES_SERVICE" sh -ec 'pg_restore --clean --if-exists --no-owner --no-privileges --single-transaction --exit-on-error -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$1"' sh "$remote_dump" |
| 184 | + docker compose exec -T "$POSTGRES_SERVICE" rm -f "$remote_dump" |
| 185 | + |
| 186 | +The PostgreSQL rollback cleans objects listed in its dump but may leave unrelated objects created by a newer schema. |
| 187 | +Remove any container path printed as `remote=` after the database state has been recovered and the retained dump has been preserved elsewhere. |
| 188 | +Only then start Windshift and run its health check. |
0 commit comments