nova

Other

Upgrading Nova

Normative. Every step an upgrade needs is on this page; if an upgrade requires something that is not written here, that is a bug — report it rather than improvising. Manual out-of-band repair is not an acceptable upgrade path.

Three questions have to be answerable before the first command runs: what order do I upgrade, how do I prove it worked, and exactly how do I go back. The sections below are in that order.

v0.3.0

Product version v0.3.0
Database schema 19
Platforms linux/amd64
Support epoch 2026-08-11
Support window 2 immediate predecessors, or 180 days since a predecessor's declared support epoch — whichever still admits it

Supported predecessors. An artifact outside this list is untested, which is not the same as rejected: the interop contract is negotiated protocol plus capabilities, and a version string was never it.

Predecessor Kind Schema Support epoch Age-branch deadline
commit:143c459 commit 18 2026-08-08 2027-02-04

Capability profile. Core capabilities are required at registration. Everything else gates a ROLE, not admission: a donor missing one is excluded from that role and from nothing else.

  • corepin-change-log/v1, snapshot/v1
  • assignment-destinationblob-transfer/v1
  • possession-auditaudit-block-hash/v1
  • read-sourceblob-transfer/v1, read-source/v1
  • repair-sourcerepair-stream/v1

Claims this release makes, and the gate that proves each. A claim bound to a gate whose coverage is still a placeholder cannot reach a signed lock.

Claim Gate Runner Coverage
baseline-coordinator-on-schema-19 upgrade-schema-e2e rc-docker derived from an executed run
baseline-donor-interop upgrade-wire-e2e rc-docker derived from an executed run
candidate-baseline-transition upgrade-candidate-e2e rc-docker derived from an executed run
coordinator-upgrade-needs-no-donor-upgrade mixed-fleet-e2e rc-docker derived from an executed run

What the gates do NOT prove. Recorded because the absence of an entry is not a statement, and a reader who sees only what a gate proves will assume the rest.

  • backup-restore-e2e
  • any DECLARED claim. Restorability is not a statement about artifact compatibility, which is what a claim is; it is a statement about the procedure UPGRADING.md tells an operator to run before anything else
  • that a restore succeeds on the operator's own data. It restores what the drill backed up, and a backup nobody has ever restored is the thing this exists to stop being normal
  • anything about a partial or corrupted archive volume: the drill restores a whole snapshot
  • crossversion-e2e
  • any DECLARED claim: baseline-donor-interop is assigned to upgrade-wire-e2e, which runs the protocol against a fresh database. This gate overlaps it heavily but is not the same scope, and re-pointing a reviewed claim at a gate that happens to cover it is how coverage stops meaning anything
  • anything about the schema: every pairing gets a FRESH database migrated by the coordinator side's own binary, so no binary is ever run against a schema it did not produce
  • anything about released artifacts: both sides are built from source
  • donor-backed reads or drain WITH THE BASELINE COORDINATOR: the gate exercises those only when the coordinator is HEAD. Untested here, not known broken — the earlier claim that they were provably broken described the P2-M6 binary, and 143c459 contains the later TLS fix
  • mixed-fleet-e2e
  • anything about donors outside the declared support window: an unsupported donor is untested by definition, which is what unsupported means
  • real Nebula routing, MTU behaviour, NAT traversal or lighthouse failure. The fleet speaks federation mTLS over loopback with placeholder overlay material; federation-deploy-e2e owns the overlay and needs TUN
  • that a donor FETCHES bytes after an assignment. The gate asserts the coordinator's decisions — who is assignable, who is evicted, who holds a role — not the transfer, which upgrade-wire-e2e covers
  • upgrade-candidate-e2e
  • anything about a PUBLISHED release: no registry ref is resolved, no release asset is downloaded, and no signature is verified against a real bundle. That is upgrade-release-e2e, and it cannot run until the release exists
  • multi-coordinator ordering or fencing, which is a Phase 6 concern
  • upgrade-release-e2e — POST-PUBLICATION; runs after the release exists, gates completion state 4 rather than the lock
  • anything at lock time. It runs AFTER publication, so nothing it concludes can appear in the lock it verifies — that document is already signed
  • multi-coordinator ordering or fencing, which is a Phase 6 concern
  • upgrade-schema-e2e
  • donor behaviour: no donor participates
  • that a DOWN migration works — the contract is restore-from-backup, and nothing here exercises a downgrade of the schema itself
  • anything about released artifacts: both coordinators are built from source
  • any range other than the one it ran. The result is about (18, 19], not about old-binary compatibility in general
  • upgrade-wire-e2e
  • anything about the schema: this gate runs against a FRESH database, so it cannot say whether an old binary survives a forward schema
  • anything about released artifacts: it builds from source
  • the reverse direction. A baseline COORDINATOR serving a candidate donor is a different question and belongs to mixed-fleet-e2e

Rollback. migrate down does not exist. Going back means one of two things, and novactl upgrade status prints which one before you start:

  • redeploy the previous binary — the schema stays forward and the named predecessor runs against it;
  • restore from backup — a migration in the range destroyed information, or the range is not compatible with the predecessor.

Three things that changed in P2-M7.3

1. You run a PUBLISHED image, not a local build. docker/docker-compose.yml carries no build: section. Image digests come from a release env you install, separate from docker/.env, and passed LAST so a stale ref in your own file cannot outrank the release you verified. Developers add -f docker/docker-compose.dev.yml and build from source; operators do not.

2. Migrations announce themselves and can refuse. The entrypoint runs migrate auto, which applies a range unattended only when the WHOLE range is old-binary compatible, online, maintenance-free and free of operator procedures. Otherwise it prints the exact command and exits zero, and the coordinator's startup floor refuses to serve against a stale schema. migrate down no longer exists — see Going back.

3. NOVA_VERSION no longer overrides anything. Build identity comes from the linker stamp. An environment variable that can lie about immutable build information has no legitimate use, and the fleet census must not be able to launder a claim through one.


Step 0 — establish trust, before anything from the release runs

The bundle you downloaded is material, not authority. Verify the lock with a command whose issuer and identity are written HERE, not read from the bundle:

cosign verify-blob \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity https://github.com/nova-archive/nova/.github/workflows/release.yml@refs/heads/main \
  --bundle lock.sigstore.json \
  lock.json

Record the digest it authenticated:

LOCK_DIGEST="sha256:$(sha256sum lock.json | cut -d' ' -f1)"

Then check the bootstrap script's hash against the lock BEFORE running it:

python3 -c 'import json,hashlib,sys; \
  p=json.load(open("lock.json"))["payload"]["scripts/nova-release"]; \
  h="sha256:"+hashlib.sha256(open("scripts/nova-release","rb").read()).hexdigest(); \
  sys.exit(0 if p==h else "MISMATCH: "+h+" != "+p)'

Only now may it run:

./scripts/nova-release verify --bundle . --lock-digest "$LOCK_DIGEST"

That verifies every bundle member against the lock, audits the bundle's copy of the signer policy against the script's hard-coded one, and verifies the nova-admin image the lock names for signature and provenance. Nothing from the release executes until all of it passes.


Step 1 — record what you are running

docker compose --env-file docker/.env -f docker/docker-compose.yml ps --format '{{.Service}} {{.Image}}'
./scripts/nova-release exec --bundle . --lock-digest "$LOCK_DIGEST" -- upgrade status

upgrade status works with the database down. It prints binary identity, the compiled-in release catalog, the applied schema, the pending range, the rollback boundary, the fleet census and the last recorded upgrade run.


Step 2 — take a backup, and restore it

A backup that has never passed restore verification does not count as a backup. For some ranges — anything with RestoreToRevert — the backup IS the rollback path, and discovering it was incomplete at that moment is discovering it too late.

Back up all three, together:

# 1. Postgres — the archive's metadata.
docker compose --env-file docker/.env -f docker/docker-compose.yml \
  exec -T postgres pg_dump -U nova -Fc nova > nova.dump

# 2. nova-secrets — the master key, the OIDC seed, the swarm key.
#    Without the master key, a restored database is a catalogue of ciphertext
#    nobody can open.
docker run --rm -v nova_nova-secrets:/s:ro -v "$PWD":/out debian:bookworm-slim \
  tar czf /out/nova-secrets.tar.gz -C /s .

# 3. nova-fedpki — the federation and Nebula CA keys. Without them you cannot
#    issue, rotate or revoke a donor identity, and nothing fails until the
#    first certificate expires.
docker run --rm -v nova_nova-fedpki:/pki:ro -v "$PWD":/out debian:bookworm-slim \
  tar czf /out/nova-fedpki.tar.gz -C /pki .

Then verify the restore, on a scratch host or a scratch Postgres — never over the live one:

./scripts/backup_restore_e2e.sh   # the same drill, against a synthetic deployment

Against YOUR backup, check the same properties the drill checks: the schema version matches, a donor's registration returns under the SAME id, the master key is byte-identical, the secrets keep mode 0600, the federation CA's fingerprint is unchanged, the restored authority can still ISSUE, and a certificate issued before the backup still verifies against it. Missing any one of those means the backup is not a rollback path.


Step 3 — preflight

./scripts/nova-release exec --bundle . --lock-digest "$LOCK_DIGEST" -- \
  upgrade check --lock /release/lock.json --intent /release/intent.json \
                --expect-lock-digest "$LOCK_DIGEST"

Run from the TARGET admin image: an N−1 deployment cannot evaluate an N target, because it does not know what N requires. It fetches nothing.

It prints the rollback boundary FIRST, then seven checks. Each carries a requirement (required/optional) and an outcome (passed/failed/skipped), because "disk headroom could not be checked" must not read the same as "disk headroom is fine". A required check that could not run BLOCKS until you acknowledge it by id:

... upgrade check ... --acknowledge disk-headroom

Acknowledgement waives a SKIP and nothing else. A check that FAILED is a measurement, and there is no flag for disbelieving one.

An unsupported donor never blocks. Untested is not dead, and blocking on it would make the support window a weapon rather than a statement. A donor with no compatible protocol does block, and must be drained and replaced while the old protocol still exists.


Step 4 — install the release env

sudo ./scripts/nova-release install-env \
  --bundle . --lock-digest "$LOCK_DIGEST" --dest /etc/nova/release.env

Atomic: Compose may read that file at any moment, and a half-written one is a deployment pointed at half a release.


Step 5 — apply the migrations, explicitly

docker compose -f docker/docker-compose.yml \
  --env-file docker/.env --env-file /etc/nova/release.env \
  --profile upgrade run --rm migrate apply --to <target schema> \
  --expect-release-lock "$LOCK_DIGEST"

--to is mandatory. An unbounded apply can apply a set nobody reviewed; the target IS the reviewed decision, and the matrix above names it.

The apply takes an advisory lock, RE-READS the applied version under it, computes (applied, target] there, and refuses a migration set it cannot account for. Pass --expect-from <n> to make it refuse if the schema moved between preflight and now.

If the range carries operator procedures, it refuses and prints each with an id. Do them, then re-run with --acknowledge <id> for each. An instruction nobody read is an instruction nobody followed.

Every apply is journalled to /var/lib/nova/upgrade before it starts, because migration 0019 creates the very table that records upgrade runs — the first upgrade from schema 18 has nowhere in the database to say it began. If the apply fails, that journal is the record. Back it up with the rest.


Step 6 — bring up the released topology

docker compose -f docker/docker-compose.yml \
               -f deploy/operator/compose.federation.yaml \
               --env-file docker/.env --env-file /etc/nova/release.env \
               --profile prod --profile federation up -d

Order matters: the release env is LAST.


Step 7 — prove it worked

./scripts/nova-release verify-planes --bundle . --lock-digest "$LOCK_DIGEST" \
  --report-dir /var/lib/nova/upgrade/reports

One run id across the host plane and the four in-image planes, with per-plane reports written to a mounted directory. The planes do not collapse into one process: host image inspection needs a Docker socket, the doctor plane needs another container's network namespace, and nova-admin runs --rm.

Then watch one control interval before touching any donor.


Step 8 — roll donors forward, in bounded batches

Two parties. You drain and authorize; the volunteer applies; you confirm and undrain. The volunteer's script holds no coordinator authority and cannot reconfigure the federation.

For each donor, in batches small enough to keep the replication floor:

# 1. Convert their bundle (once per release). Preserves node id, both
#    certificates, the Kubo repo and every volume.
novactl node convert-bundle --bundle ./invites/<name> \
  --lock lock.json --intent intent.json --expect-lock-digest "$LOCK_DIGEST"

# 2. Authorize what they may run.
novactl node rollout authorize --id <node-uuid> \
  --lock lock.json --intent intent.json --expect-lock-digest "$LOCK_DIGEST"

# 3. Drain.
novactl node drain --id <node-uuid>

Send the volunteer the converted bundle and the donor-lock digest that convert-bundle printed. They run:

./donor-update.sh check
./donor-update.sh apply --expect-lock-digest sha256:<the digest you sent>

It writes .env before recreating — Compose reads it at up, so persisting afterwards would restart the old digest and report success — never removes a volume, and waits for the node to report healthy.

Then confirm and release:

novactl node list                      # the VERSION column and the reported digests
novactl node undrain --id <node-uuid>

Let repair debt settle before the next batch.


Going back

migrate down does not exist. The schema is forward-only, and a down migration would destroy information the forward one committed to. Going back is one of two operations, and upgrade status names which one before you start:

  • redeploy the previous binary — the schema stays forward and the named predecessor runs against it. Evidenced for (18, 19] by scripts/upgrade_schema_e2e.sh.
  • restore from backup — a migration in the range destroyed information, or the range is not compatible with the predecessor. This is Step 2's backup, and it is why Step 2 says to restore it first.

Donor rollback is per component and conditional. The donor topology holds node state, a persistent Kubo repo and Nebula config; reverting image refs is safe only where the donor lock declares evidence for that component. Where it does not, donor-update.sh rollback stops, writes a recovery report, and changes nothing — starting older software against state newer software wrote can corrupt the replicas the rollback was meant to protect.


The fleet policy

  • Unknown is not unsupported. A donor that reports no version blocks nothing. The interop contract is negotiated protocol plus capabilities.
  • A version string never gates anything. It is an unauthenticated self-report; it informs the census and does nothing else.
  • A digest mismatch is a supply-chain warning. The donor keeps its replicas, keeps receiving work its capabilities support, and keeps counting toward durability. Investigate it; do not let the system act on it.
  • Nonconformance advises. A donor outside the core profile or the support window receives a deprecation message on its heartbeat. Nothing is withheld.
  • Retirement is operator-initiated. Unsupported status alone never triggers replacement. Drain the donor, let the replacement finish, then decommission — in that order, so the replica is re-homed before the holder goes.


P2-M7.3 — Upgrade & release lifecycle

What changed. The three behaviour changes are at the top of this page, and the whole procedure above replaces the old ad-hoc sequence. In short: you run a published image, migrations announce themselves and can refuse, and NOVA_VERSION no longer overrides the build stamp.

Database migration: 0019. Additive — two new tables (upgrade_runs, upgrade_events) and ten nullable columns on nodes, plus a backfill of effective_capabilities from advertised_capabilities that touches only donor-scale rows. (18, 19] is old-binary compatible, evidenced rather than inspected: scripts/upgrade_schema_e2e.sh starts the coordinator built at commit 143c459 against schema 19 and confirms it serves a database-backed query path. Going back across this boundary is a redeploy, not a restore.

The range is auto-appliable, so migrate auto will apply it on start unless you have set NOVA_MIGRATE_ON_START=false.

If you do not use federation. Install the release env, apply 0019, recreate. Nothing else applies to you.

If you have donors. Coordinator first, then donors in bounded batches, using Step 8's two-party sequence. A donor on the previous build keeps working: the interop contract is negotiated protocol plus capabilities, and this release changes neither. Convert each bundle once with novactl node convert-bundle — it preserves the node id, both certificates, the Kubo repo and every volume.

One recovery you may need. If a donor has been offline long enough to be evicted, it now returns by itself: this release accepts an evicted node's heartbeat when it presents its registered certificate, restores it to active with a forced snapshot, and credits none of its old replicas until they are re-assigned and acknowledged. Previously such a donor was stranded permanently and the only remedy was re-enrollment. Nothing to do; it recovers on its next heartbeat, and the reactivation is written to audit_log.

Not yet available. There is no published release to install: the release workflow has never run. Two compatibility gates — upgrade-release-e2e and mixed-fleet-e2e — carry placeholder coverage, and a release candidate is impossible while either does. Until then this page describes the procedure and the tooling, both of which work today against a locally built deployment.


P2-M7.2 — Federation productization

What changed. Federation went from a set of manual steps to one supported path: federation init, federation doctor, node invite. Existing federations are adopted, not replaced.

No database migration. Schema is unchanged.

If you do not use federation

Nothing to do. Pull the release and recreate as usual — the base Compose file gained one volume declaration and no service changes.

If you have a hand-built federation

Your CA, your donors and your swarm key are all preserved. Nothing re-enrolls.

1. Collect your existing PKI into deploy/operator/import/:

File What it is
federation-ca.crt / federation-ca.key your federation CA
coordinator-federation.crt / .key the coordinator's identity
nebula-ca.crt / nebula-ca.key your Nebula CA
swarm.key your Kubo swarm key

Copy them — do not move them. Nova reads from this directory and never writes to it; it is mounted read-only.

Your existing swarm.key is never regenerated. A new one would silently cut every current donor off from your storage network while leaving them looking perfectly healthy — registered, up, and unable to exchange anything.

If your hand-built setup genuinely never had some of these (a Nebula CA, for instance), leave them out rather than inventing them. Adoption creates what is missing and preserves what is not.

2. Adopt:

docker compose --env-file docker/.env \
               -f docker/docker-compose.yml \
               -f deploy/operator/compose.federation.yaml \
               --profile federation run --rm nova-admin \
  federation init \
    --adopt-from /import \
    --overlay-cidr 10.42.0.0/24 \
    --operator-overlay-ip 10.42.0.1 \
    --lighthouse-public 203.0.113.7:4242 \
    --hostname nova.example.org

Use the values your current federation already uses. If they disagree with your existing certificates, the command stops and shows the difference rather than overwriting anything.

Expected: a non-zero adopted: count, and created: covering only what your hand-built setup lacked — typically the coordinator client identity and the repair signing key, which earlier documentation never mentioned.

3. Recreate with the overlay:

docker compose --env-file docker/.env \
               -f docker/docker-compose.yml \
               -f deploy/operator/compose.federation.yaml \
               --profile prod --profile federation up -d

4. Verify:

docker compose --env-file docker/.env \
               -f docker/docker-compose.yml \
               -f deploy/operator/compose.federation.yaml \
               --profile federation run --rm nova-doctor \
  federation doctor --live

All checks should pass. Then confirm your donors are still there and unchanged:

docker compose exec coordinator novactl node list

Their NODE_ID values must be the same as before. If any donor has a new id, stop — something re-enrolled rather than adopting, and that should not happen.

Port change

The federation listener is 9443. Some older notes said 8443, which is the public HTTPS port. If you set federation.listen_addr by hand to an :8443 address, federation init rewrites it; verify with federation doctor.

Behaviour change: the coordinator waits instead of failing

Previously, a missing overlay interface stopped the coordinator from booting. It now starts, serves normally, and waits up to interface_wait_seconds (default 120) for the interface before binding the federation listener. On timeout it stays up with federation not ready.

If you prefer the old behaviour, set interface_wait_seconds: -1.

You will now see this line at startup, which is normal:

federation.listener.waiting  iface=nebula1 listen=10.42.0.1:9443

Behaviour change: donors no longer need a restart

A first-boot donor used to register but not serve until restarted. That is fixed. Donors on the new image start serving immediately after registering. No action needed — existing donors keep working on the old image and pick this up whenever they next update.

Rollback

Safe. No migration ran, so the previous release can be redeployed directly. The federation material init created is additive: an older coordinator ignores what it does not know about. Your CA and donor identities are untouched either way.


Upgrading donors

Donors update after the coordinator, in small batches, never all at once.

For each donor:

  1. Confirm the federation stays above its replication floor if this donor disappears (novactl node list).
  2. If the update will take more than a moment, drain first: sh novactl node drain --id <node-id>
  3. Give the donor the exact digest to run. Never a moving tag.
  4. The donor verifies and restarts — see the donor quickstart.
  5. Confirm they return: registration, immediate heartbeat, and node.source.started.
  6. novactl node undrain --id <node-id>.
  7. Wait for repair debt to settle before the next batch.

An update must never look like a new enrollment. The donor keeps their federation certificate, Nebula certificate, node id, Kubo repository, swarm key and stored data. If a donor comes back with a new NODE_ID, something is wrong.

Do not auto-update

Do not point Watchtower or similar at Nova images. A broken or compromised release reaching every donor simultaneously defeats the diversity that makes the federation survivable in the first place. Update a few, watch, continue.

Source: docs/UPGRADING.md