deploy: never let a non-main ref change flake inputs (guard + fail-closed on regression) #255
No reviewers
Labels
No labels
backlog
bug
enhancement
in-progress
review
todo
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
ymrtech/nix-config!255
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/deploy-non-main-lock-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #253.
A
workflow_dispatchon a non-main ref deploys?rev=<ref head>, whoseflake.lockis a copy of the ref's base — so every input main has moved since is silently reverted on the target host. Seen live: a PR-branch deploy on public revertedclientsv@244dc35→@92d02cdand killed the bandwidth collector that had just shipped, with the run green.The rule, now enforced
maingithub.sha— unchangeddeploy-lock/<ref>so each host job can fetch it by revEmergency escape hatch:
allow_input_lock_change=trueon the dispatch (documented as emergency-only; it deploys the ref's own lock with a loud warning).Why classification uses the merge base, not main
Comparing straight to main would confuse "stale copy of the base" (normal, reconciled away) with "the ref intends this change". So it diffs the ref against
merge-base(origin/main, HEAD)— what the ref itself did — and only that decides allow/refuse.The merged lock is then re-verified before anything deploys: every input main pins must come out at main's rev, and every node reference must still resolve.
Fail-closed by design. If the guard cannot prove "no existing input moved" (can't clone/fetch/push), it fails the run instead of regressing a host silently. That means a provisioning dispatch now fails rather than silently downgrading inputs when git.ymrtech.com is unreachable — deliberate, and the
allow_input_lock_change=truebypass is the escape hatch. Worth a look if you'd rather degrade differently.Where it lives
One new
deploy-revjob (runs-on: [ci], runs on both refs so no host job ever sees a skipped dependency); the four host jobs gainedneeds: [scope, deploy-rev]and use${{ needs.deploy-rev.outputs.rev }}instead ofgithub.sha.deployed.jsonlnow records that resolvedrevalongside the dispatchedsha.Verification
{added:[],removed:[],changed:[]}→ reconcile;clientsvgoes92d02cd → 244dc35(main's pin) ✓clientsv→changed:[clientsv]→ refuse ✓added:[newdep]→ allowed, node kept at its own rev, everything else from main ✓removed:[comfyui-nix]→ allowed, not resurrected from main ✓$m|has(.key)re-binds.to$m(→has(null), a hard error on every non-main deploy), input values can be a bare node string or[node,...], andflake.lock's top-levelrootis a node name (a string) — the root node itself lives in.nodes.yamlparses; the guard script passesbash -nboth raw and after${{ }}substitution; job graph,needsand the 6 resolved-rev uses verified.hostsvalue, so the guard executes while the scope job aborts the run and nothing deploys.A workflow_dispatch on a non-main ref (PR preview, provision dispatch) runs `nixos-rebuild switch --flake git+...#<host>?rev=${{ github.sha }}`, and that sha is the ref's head — whose flake.lock is a copy of the ref's BASE. Every input main has moved since is then silently reverted on the target host. Seen live: a PR-branch deploy on public reverted clientsv@244dc35 to @92d02cd, killing the per-peer bandwidth collector that had just shipped. The branch never touched flake.lock; it just carried a stale copy. Nothing was red — the health gate only checks critical services. New `deploy-rev` job resolves the rev every host job deploys, and is where the rule now lives: existing flake inputs come from main, always. - main ref → github.sha, unchanged. - non-main, no input changed → the ref's TREE with main's pins, pushed to deploy-lock/<ref> so each host job can fetch it by rev (provenance and the ?rev= contract are preserved). - non-main, ADDS an input → allowed: adding is a legitimate rare change, and the new node survives. - non-main, REMOVES an input → allowed: cleanup of something unused cannot regress anything still in use, and the reconcile does not resurrect it. - non-main, CHANGES an input → FAIL. Existing inputs are owned by the weekly flake-update pipeline on main. Classification compares the ref against its merge base (what the REF did), never against main (which would confuse a stale copy with intent). The merged lock is then re-verified: every input main pins must come out at main's rev, and every node reference must still resolve. Fail-closed by design: if the guard cannot prove "no existing input moved" it fails the run rather than regressing a host silently. The only bypass is an explicit allow_input_lock_change=true dispatch input (emergency only). The four programs were extracted from this file and exercised against the real locks from the incident, plus negative controls (a bumped input must be refused, a regressed pin and a dangling node reference must each be caught).b462021dff3c488621a5Rebased onto main (now
3c48862) — #250/#251 landed in the same file, and the single conflict was giga'sneeds:(they want it afterdeploy-vpn, the guard wantsdeploy-rev); resolved to[scope, deploy-rev, deploy-vpn], keeping theiralways() && needs.scope.outputs.giga_check == '1'and their DNS-quiesce comment intact. Re-validated after the rebase: YAML,bash -n, job graph, inputs, 6 resolved-rev uses, and the four guard programs re-run against the incident locks.End-to-end, deploy-free. A throwaway branch forked before the clientsv bump (its lock pinned
clientsv@92d02cd; main244dc35; the branch itself never touchedflake.lock— the incident's exact shape) was dispatched with an invalidhostsvalue:deploy-lock/guard-test/stale, whoseclientsvpin is244dc35(main's), with all 30 shared nodes at main's pin, and the reconciled commit carries the guard's own workflow.Both throwaway branches were then deleted.
Three real bugs were caught and fixed before this PR:
$m|has(.key)re-binds.to$m(→has(null), a hard error on every non-main deploy); input values may be a bare node string or[node,...]; andflake.lock's top-levelrootis a node name (a string) — the root node itself lives in.nodes, so my.root.inputsclause errored on every lock.One thing worth your call: the guard is fail-closed, so a provisioning dispatch (
hosts=vpnon a device branch) now fails rather than silently downgrading inputs if git.ymrtech.com is unreachable. Theallow_input_lock_change=truebypass is the escape hatch. Say the word if you'd rather it degrade differently for provisioning.Also worth noting for review: the guard only reconciles — it does not stop a merge on main from carrying a reverted
flake.lock(that is #255's sibling problem: main-level lock reverts still need the weekly watchdog or a PR-level check).