Rollback¶
punix service rollback STACK flips the live current symlink to gen-(current-1). O(1) — no rebuild, no re-evaluation, no fetch. The conceptual model is in Generations and rollback; this page is the operator's how-to.
The minimal recipe¶
That's it. Output:
Two gen-NNN.json files exist on disk afterwards; current points at the older one. Rollback is "flip the pointer", not "delete forward generations" — you can roll forward by re-deploying.
What it does¶
1. Read manifest: current = N
2. If N is None or N <= 1: [E12] "no previous generation"
3. prev = N - 1
4. verify_pinned_paths(stack, prev, store)
└─ for each path in gen-(prev).store_paths:
require Store.is_valid(path)
└─ any missing path: [E12] with the full list
5. atomic_symlink_switch(gen-(prev).json, current) # the atomic flip
The verify step catches GC contamination: if a path the previous gen needs has been deleted, rollback refuses before flipping — so you don't end up with current pointing at a generation whose binaries are gone.
When rollback fails¶
[E12] no previous generation¶
current points at gen-001.json — there's nothing earlier. Two options:
- Re-deploy from a known-good PCL version.
- Wait for Stage 8 — a planned
service set-current STACK Ncommand will allow flipping to any preserved gen, not justcur-1.
[E12] pinned store path missing¶
error: [E12] stack 'MyStack' gen-001: pinned store path missing:
/store/abc-curl-8.5.0
/store/def-openssl-3.0.0
This usually means GC ran with the wrong root set. Recover by
re-realising: punix build pkgs/
GC walked your store with a keep-set that didn't include this generation. Most likely cause: someone rm -rf'd the store, or you GC'd a deployments-root that wasn't the live one. Recover:
- Re-realise the closure:
punix build pkgs/ --store-root <same as deploy used>. The store paths are content-addressed; rebuilding produces the same paths (so the manifest's pin still matches). - Re-try rollback:
punix service rollback MyStack.
If you can't rebuild (PCL source is gone), the previous gen is unrecoverable. This is why generations and the store live in different directories — losing one doesn't lose the other.
Multi-step rollback¶
v0.6 ships one-step rollback only. To roll back two steps:
punix service rollback MyStack # gen-003 → gen-002
punix service rollback MyStack # gen-002 → gen-001
The intermediate flip is fully atomic; the combined operation is not (another deploy could land between them). Stage 8's planned service set-current STACK N makes multi-step atomic.
Verifying after rollback¶
# What gen is current?
cat <deployments_root>/MyStack/current # → gen-001.json
# (it's a symlink — readlink shows the target name)
# What config files does that gen want on disk?
cat <deployments_root>/MyStack/gen-001.json | jq '.config_files[]'
# Spot-check a sha256
sha256sum /etc/myapp/app.conf
# compare with gen-001.json's config_files[0].sha256
If the disk-file sha256 doesn't match the gen's, someone mutated /etc/myapp/app.conf out-of-band since the deploy. Rollback does not re-write config files in v0.6 (it just flips current; config files were already written at the deploy that produced that gen). The disk-file drift is a known small gap — Stage 8 will add an opt-in "rollback also re-writes config files."
Rollback over SSH¶
Same command shape with --target:
punix service rollback MyStack \
--target ssh://deploy@prod.example.com \
--known-hosts ./ci/prod-known_hosts
The atomic flip runs on the remote via mv (which invokes rename(2)). Same atomicity guarantee as the local case. The verify_pinned_paths step uses the LOCAL store on the deploy host — known cross-host gap recorded in status: limitations.
Conformance¶
- Rollback restores the previous gen exactly.
- Rollback after a normal GC pass succeeds (the GC kept the previous gen's pins).
- A missing pinned path surfaces a located
[E12]error beforecurrentis touched.
All three live in tests/c_e2e/test_conformance_stage4.py (and an SSH variant for .1/.2 in test_conformance_stage5_ssh.py).