# Updater

Safe in-app updates: **Settings → Update & changelog**. The web app never runs an update. It verifies
a signed bundle, records the request, and shows status. A root agent on the VPS host does the work.

```text
Release machine            Web app (container)                 Host agent (systemd, every minute)
───────────────            ───────────────────                 ─────────────────────────────────
build + push image         upload bundle (settings.manage      claim request (artisan updater:claim)
updater:bundle  ──zip──▶   + password) → verify Ed25519   ──▶  verify signature again (openssl, own key)
(secret key, offline)      → install request (newer only)      backup DB (pg_dump) → docker pull @digest
                           → rollback request                  → migrate → up -d → health check /up
                           ◀── status + log (updater:report) ◀ → auto switch back on failure
```

## Why this is safe

- **Signed, not uploaded code.** A bundle is `manifest.json` + `manifest.sig`. The manifest pins the
  image by digest (`repo@sha256:…`), so the signature covers exactly the software that will run.
- **Trust anchor outside the UI.** The public key sits in `.env` (`UPDATE_PUBLIC_KEY`) and in a
  root-owned file on the host. A compromised admin account cannot change either one.
- **Two independent checks.** The app verifies on upload. The agent verifies again with its own copy of
  the key and the allowed repository, so a compromised app container cannot push an unsigned image.
- **No silent downgrade.** Install accepts only a newer version. Going back is Rollback, and the agent
  only rolls back to an image that already ran on this host (`/var/lib/dataplatform-updater/known-good-images`).
- **Recoverable.** Every run makes a `pg_dump` first, then health-checks. On failure it switches back
  to the previous image. At most one request is in flight, enforced by a unique index. Everything is
  audit-logged.

## One-time setup

1. **Release keys**, on the release machine only:
   `php artisan updater:keygen`. Store the SECRET in a password manager. Never commit it or copy it
   to the server.
2. **App `.env`** (production):
    ```
    UPDATE_PUBLIC_KEY=<base64 public key>
    UPDATE_IMAGE_REPOSITORY=ghcr.io/<org>/dataplatform
    APP_IMAGE=ghcr.io/<org>/dataplatform@sha256:<current digest>
    ```
    Production compose must run `app`, `horizon`, and `scheduler` from `image: ${APP_IMAGE}`. The image
    must be built with `APP_VERSION=<x.y.z>`.
3. **Host agent** (as root):
    ```
    apt install -y jq openssl curl
    install -m 0755 deploy/updater/dataplatform-updater.sh /usr/local/sbin/dataplatform-updater
    install -d -m 0700 /etc/dataplatform-updater
    # paste the PEM printed by updater:keygen
    install -m 0644 release.pub /etc/dataplatform-updater/release.pub
    cat > /etc/dataplatform-updater/updater.env <<'ENV'
    APP_DIR=/opt/dataplatform
    IMAGE_REPOSITORY=ghcr.io/<org>/dataplatform
    HEALTH_URL=http://127.0.0.1:8081/up
    ENV
    cp deploy/updater/dataplatform-updater.{service,timer} /etc/systemd/system/
    systemctl daemon-reload && systemctl enable --now dataplatform-updater.timer
    ```

## Releasing a version

```
docker build --build-arg APP_VERSION=1.4.0 -t ghcr.io/<org>/dataplatform:1.4.0 . && docker push ...
# take the digest printed by push
php artisan updater:bundle 1.4.0 ghcr.io/<org>/dataplatform@sha256:<digest> --key-file=secret.key --notes="..."
```

Then upload `dataplatform-1.4.0.zip` in Settings and press **Install**.

## Rules for migrations (required for safe rollback)

Rollback switches **code only**. Migrations must be _expand/contract_. A release may only add columns
or tables, or make them nullable. Columns are dropped or renamed only in a later release, once no
running version reads them. That way the previous image keeps working on the newer schema.

If a schema change must be undone, restore the matching backup by hand:
`docker compose exec -T postgres pg_restore -U <user> -d <db> --clean < /var/backups/dataplatform/pre-update-<id>-*.dump`.
Take a fresh backup first.
