Skip to main content

cloud/reconciler/headscale/
policy_migration.rs

1//! Migrate a headscale coordinator from `policy.mode: file` to
2//! `policy.mode: database` (R861-T2).
3//!
4//! ## Why
5//!
6//! Under `file` mode the ACL policy is `acls.yaml` on the coordinator's local
7//! disk, and litestream replicates only `headscale.db` — so policy is failover
8//! state nobody carries. Under `database` mode the policy is a row in
9//! `headscale.db`, which litestream already replicates, and the file stops
10//! mattering at all. That is the whole point of the flip.
11//!
12//! ## What was verified before any of this was written
13//!
14//! R861-T2's load-bearing assumption was that the pinned headscale **v0.23.0**
15//! (`crate::mesh::HEADSCALE_VERSION`) accepts, in `database` mode, the same
16//! HuJSON document `file` mode reads off disk. **It holds**, read from that
17//! version's own source rather than recalled:
18//!
19//! - `hscontrol/app.go` `loadACLPolicy` — file mode does
20//!   `policy.LoadACLPolicyFromPath`, which is `io.ReadAll` followed by
21//!   `LoadACLPolicyFromBytes`; database mode does `LoadACLPolicyFromBytes` on
22//!   the stored row. Same function, same document, no second format.
23//! - `hscontrol/grpcv1.go` `SetPolicy` — parses the request body with that
24//!   same `LoadACLPolicyFromBytes` before storing it.
25//! - `hscontrol/grpcv1.go` `GetPolicy` — database mode returns the stored
26//!   row's `Data` verbatim; file mode returns the file's bytes as-is.
27//! - `hscontrol/types/config.go` — `policy.mode` is exactly `"file"` or
28//!   `"database"`, defaulting to `"file"` when the key is absent, and
29//!   `policy.path` is read only in file mode.
30//!
31//! So [`crate::mesh::HeadscaleClient::get_policy`] / `set_policy` work
32//! unchanged under either mode.
33//!
34//! ## Three findings that reshape the migration
35//!
36//! **1. The ticket's stated order is impossible, and this module inverts it.**
37//! R861-T2 specified "push the policy through `set_policy`, verify, *then*
38//! flip the mode". `SetPolicy` returns `ErrPolicyUpdateIsDisabled` *before
39//! looking at the payload* unless `cfg.Policy.Mode == database`
40//! (`hscontrol/grpcv1.go`). A file-mode coordinator cannot be pushed to at all,
41//! so the config flip has to come FIRST. See [`next_step`].
42//!
43//! **2. The window the inverted order opens is safe here, and the reason is
44//! specific rather than general.** Between the flip and the push, a
45//! database-mode coordinator with no policy row is not an error: `loadACLPolicy`
46//! maps `ErrPolicyNotFound` to a nil `*ACLPolicy`, and
47//! `(*ACLPolicy).CompileFilterRules` on nil returns `tailcfg.FilterAllowAll`
48//! (`hscontrol/policy/acls.go`). us-west-001's live `acls.yaml` is the
49//! permissive default `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
50//! (measured 2026-09-04), so for THIS fleet the window is semantically a no-op.
51//! On a coordinator with a restrictive policy the same window is a silent
52//! widening to allow-all, which is why [`next_step`] still drives the push
53//! immediately and why [`MigrationPlan::widens_before_push`] reports it.
54//!
55//! **3. `acls.yaml` is the journal, which is why it is deleted last.** Nothing
56//! here writes a state file: until the coordinator serves an equivalent policy
57//! from its database, `acls.yaml` is still on disk and the whole migration is
58//! re-derivable from it. Re-running after any interruption re-observes and
59//! resumes. Delete it earlier and a half-completed run loses the policy.
60//!
61//! ## What this module does NOT do
62//!
63//! It does not touch a live coordinator. [`next_step`] is a pure function of an
64//! [`Observation`], and the steps that must run on the box come back as data
65//! ([`Step::on_box_commands`]) for whatever rail the operator uses. The one
66//! step that can be driven from here is the policy push, and
67//! [`apply_push`] takes an explicit [`Execution`] so a dry run cannot write by
68//! omission.
69//!
70//! ## The rail that does (R861-T3)
71//!
72//! `yah mesh migrate-policy <machine> [--execute]`
73//! (`app/yah/cli/src/mesh.rs::handle_migrate_policy`) is the one production
74//! caller. It observes over SSH + the headscale API, prints the next step and
75//! the rehearsed remainder, and performs exactly one step per invocation — but
76//! only under `--execute`, which is the sole place [`Execution::Live`] is
77//! constructed outside tests. It also refuses, before doing anything, when the
78//! carried policy is not [`is_permissive`]: the flip-then-push window is a
79//! widening to allow-all, and that is a no-op only while the policy being
80//! carried across it is itself allow-all.
81//!
82//! @yah:ticket(R861-T3, "Execute the headscale file-to-database policy migration against the live fleet")
83//! @yah:status(review)
84//! @yah:at(2026-09-05T01:54:53Z)
85//! @yah:assignee(agent:bundle-anthropic-ashguard)
86//! @yah:parent(R861)
87//! @yah:gotcha("ORDER IS LOAD-BEARING AND COUNTERINTUITIVE: config-flip to mode=database and restart FIRST, then push the policy. headscale v0.23.0's SetPolicy returns ErrPolicyUpdateIsDisabled BEFORE it reads the payload unless mode is already database, so the intuitive push-then-flip is silently impossible. The state machine in policy_migration.rs already encodes the correct order (next_step matches PolicyMode::File first and returns Step::FlipConfigToDatabaseMode; PushCarriedPolicy is only reachable after the Database arm). Do not \"fix\" it to push first.")
88//! @yah:gotcha("RE-CHECK THE FAIL-OPEN WINDOW BEFORE EXECUTING — the safety argument is fleet-state-dependent and may have expired. Between the mode flip and the policy push, headscale has no policy row, and a missing row compiles to FilterAllowAll. That is harmless ONLY because the live acls.yaml is currently the permissive default, making the gap state and the current effective state both allow-all. If real ACL rules have been declared since, the same window becomes a fail-OPEN interval — a security exposure, not an outage. Read the live acls.yaml and confirm it is still permissive as the first action of this ticket.")
89//! @yah:handoff("PHASE A — THE OPERATOR'S \"do I need to ship new builds first?\" QUESTION, ANSWERED FROM CODE. (1) NO BUILD/SHIP IS REQUIRED TO RUN THE MIGRATION. Both rails originate on this machine. The API half is HTTPS from a LOCAL yah: HeadscaleClient holds only base_url + api_key and PUT/GETs {base_url}/api/v1/policy with bearer auth (oss/yubaba/crates/cloud/src/mesh.rs:131-160, :380, :421), resolved from the vault's headscale-api-key + mesh-url via from_vault_or_env (:151). The on-box half is SSH (`ssh <target> bash -s`, the same transport app/yah/cli/src/rollout/apply.rs:155 uses for installs), target read from [connect].ssh in .yah/infra/machines/us-west-001.toml:71 = debian@15.204.89.240. Nothing on the appliance needs new bytes. (2) THE RENDERER FLIP DOES NEED A SHIPPED ARTIFACT, AND ONLY FOR NEW-COORDINATOR STAND-UP. Two of the three emit sites run ON THE BOX, inside the yubaba daemon: generate_remote_headscale_config is called at oss/yubaba/crates/yubaba/src/lib.rs:4705 inside the `headscale_deploy` handler (route registered lib.rs:1933) and generate_bootstrap_headscale_config at :4916 inside `headscale_bootstrap` (route :1934) — both `std::fs::write(dir.join(\"config.yaml\"))`. Appliance yubaba binaries come only from cdn.yah.dev's signed release manifest via scripts/roll-node.sh (its header: \"THE INPUT IS THE PUBLISHED MANIFEST, NEVER A LOCAL BUILD\"), so for `yah mesh promote` / `yah mesh bootstrap` to stand a coordinator up in database mode the sequence is scripts/publish-yubaba-release.sh then a roll. The third site, cloud::mesh::generate_headscale_config (mesh.rs:561), is called only from the LOCAL CLI (app/yah/cli/src/mesh.rs:389 in handle_start, :934 in handle_promote_abort), so `cargo xtask install` is all that one needs. (3) NO CLOBBER RISK ON THE EXISTING BOX, verified rather than assumed: leader.rs::start_headscale (oss/yubaba/crates/yubaba/src/leader.rs:358-400) materializes the noise key and deploys the appliance spec — it does NOT write config.yaml — so a raft leadership change or a kamaji redeploy will not revert a flipped config. Only an operator-driven POST /headscale/deploy or /headscale/bootstrap rewrites it. (4) VERSION/COMPAT IS CLEAN: `/var/lib/yah-cloud/headscale/headscale version` on us-west-001 returns v0.23.0, exactly the pinned version constant in cloud/src/mesh.rs:628 and yubaba/src/lib.rs:555.")
90//! @yah:handoff("PHASE C — TARGETS AND THE FAIL-OPEN GATE, RE-MEASURED. THE TARGET LIST IS ONE BOX, NOT TWO: us-west-003 CARRIES NO HEADSCALE. Enumerated from .yah/infra/machines/ (9 declared) and cross-checked against the mesh itself (`yah mesh status` reports coordinator = us-west-001, 10 nodes, 9 online), then probed read-only over SSH on every reachable one: us-west-001 has /var/lib/yah-cloud/headscale/{config.yaml,acls.yaml,headscale.db,headscale binary}; us-south-001, us-east-001, us-west-003, us-west-011, us-west-013, us-west-014 all answer \"No such file or directory\" for that directory and `systemctl is-active headscale` = inactive; us-west-015 is the fleet's Mac (no systemctl, no directory); us-west-002 is unreachable (SSH to 100.64.0.4 timed out — it is the known-offline WSL box, [connect].ssh points at its mesh address). There is no camp-local coordinator either (~/.local/share/yah/mesh does not exist). So exactly one box migrates. THE FAIL-OPEN PRECONDITION STILL HOLDS: us-west-001's /var/lib/yah-cloud/headscale/acls.yaml is 77 bytes, mtime Jun 22, and is byte-for-byte the permissive default {\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]} — semantically equal to cloud::mesh::DEFAULT_ACL_POLICY. No real ACL rules anywhere; the flip-then-push window is a no-op for this fleet and the migration's safety argument is intact. Its config.yaml lines 32-34 still read `policy:` / `  mode: file` / `  path: /var/lib/yah-cloud/headscale/acls.yaml`.")
91//! @yah:gotcha("DISCOVERED AND FIXED IN-TICKET — `sudo systemctl restart headscale` IS THE WRONG RESTART FOR THE ONLY LIVE COORDINATOR, AND IT FAILS SILENTLY. Measured on us-west-001 2026-09-04: `systemctl is-active headscale` = inactive and `is-enabled` = disabled, while `headscale serve --config /var/lib/yah-cloud/headscale/config.yaml` IS running as pid 515991 whose PPID is /usr/local/bin/kamaji (started 23:11, listening on *:443 and *:80, https://cloud.mesh.yah.dev/key?v=138 returns 200). That is leader.rs::start_headscale's kamaji path (oss/yubaba/crates/yubaba/src/leader.rs:381 does `systemctl disable --now headscale` before deploying the appliance under kamaji). So the systemctl restart R861-T2's Step::on_box_commands emitted would have forked a SECOND headscale against the same :443 — it fails to bind while the kamaji-supervised process keeps serving the PRE-FLIP config, i.e. a config on disk saying `mode: database` and a coordinator still in file mode, reported to the operator as a successful step. `policy.mode` is read once at startup, so that state is invisible until the next real restart. FIX (this ticket, policy_migration.rs): new `Supervisor {Systemd | Kamaji{pid} | Unknown}` on Observation, `on_box_commands(dir, &supervisor)`, and `restart_commands()` which emits `sudo kill <pid>` for the kamaji case (kamaji's native supervisor re-execs the child per RestartPolicy::Always — see oss/kamaji/crates/kamaji/src/native.rs, ALWAYS_RESTART_DELAY = 1s — so ending the process IS the restart) plus a sleep and a pgrep confirmation, and for Unknown emits ONLY comments so no rail can execute a guess. Two new tests pin both.")
92//! @yah:handoff("PHASE B — THE EXECUTOR IS WIRED AND ARMED, AND NOTHING HAS BEEN FIRED. New subcommand `yah mesh migrate-policy [machine] [--path .] [--execute]` (app/yah/cli/src/mesh.rs: enum arm in MeshCommands, dispatch arm in handle_mesh_command, handler `handle_migrate_policy` + helpers `ssh_bash`, `sudo_prefix`, `observe_coordinator`). READ-ONLY BY DEFAULT, which is deliberately INVERTED relative to its `--dry-run` siblings (promote/bootstrap): this command's live half rewrites the coordinator's config, restarts it and deletes the only on-disk copy of the policy, so the safe mode has to be the one you get by forgetting a flag. `Execution::Live` is now constructed in exactly ONE production place, app/yah/cli/src/mesh.rs inside the `execute` branch of the PushCarriedPolicy arm — `rg \"Execution::Live\"` over oss/ app/ crates/ finds that one call plus the enum's own arm, the module doc, and one test. One step per invocation, re-observed from scratch each time, so it resumes correctly after an interruption. Machine resolution: [connect].ssh via crate::rollout::fleet::FleetTopology::load, defaulting to the vault's mesh-coordinator-machine. THE FAIL-OPEN GATE IS ENFORCED IN CODE, not just documented: new `policy_migration::is_permissive()` compares the carried policy semantically against cloud::mesh::DEFAULT_ACL_POLICY, and the handler BAILS before doing anything if a flip is pending against a non-permissive acls.yaml. Also refuses to flip when the supervisor is Unknown. ALSO ADDED, both used by the rail and by tests so the operator's rehearsal cannot drift from the tested model: `Step::label()` (one line instead of a Debug that embeds a whole config.yaml) and `pub fn simulate_step()` — the step-effect model promoted out of the test module, so `rehearse()` can run against a REAL observation.")
93//! @yah:verify("REHEARSED AGAINST THE REAL COORDINATOR, READ-ONLY — `./target/debug/yah mesh migrate-policy us-west-001` (no --execute) run twice. It resolved debian@15.204.89.240, read the live config.yaml and acls.yaml over SSH and the served policy over the API, and reported: policy.mode = File{path: /var/lib/yah-cloud/headscale/acls.yaml}; supervisor = Kamaji{pid: 515991}; acls.yaml = 77 bytes, the permissive allow-all default; served policy = 77 bytes; and the rehearsed plan from that observed state = 1. FlipConfigToDatabaseMode 2. PushCarriedPolicy (77 bytes) 3. RemoveAclsFile 4. Done, with widens_before_push = true and deletes_the_file_early = FALSE. Flip-then-push, never push-then-flip; the file is never removed before the push. The rewritten config it would write was inspected: `policy:` / `  mode: database` with the `path:` line dropped, every other byte of the 1060-byte config preserved (server_url, tls_letsencrypt_*, noise.private_key_path, database.sqlite.path, unix_socket, dns, prefixes, derp all unchanged). The on-box command list it emits is `sudo kill 515991` + sleep + pgrep, NOT systemctl. Run ended with \"DRY RUN — nothing was changed.\"")
94//! @yah:verify("BASELINES, ALL MET. `cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib` = 1081 passed / 0 failed / 4 ignored (baseline 1078/0/4; +3 are this ticket's new tests: a_kamaji_supervised_coordinator_is_never_told_to_systemctl_restart, an_unidentified_supervisor_yields_no_runnable_restart, the_live_acls_file_reads_as_permissive_and_a_real_rule_does_not). `cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib` = 649 passed / 0 failed (baseline 639/0 — the +10 are a peer's, untouched by this ticket, all green). `cargo check -p yah --lib` = exit 0, 19 warnings all pre-existing and on files this ticket did not touch; `cargo build -p yah --bin yah` also clean. rustfmt --check on exactly the two edited files (app/yah/cli/src/mesh.rs, policy_migration.rs) = clean, hand-applied 4 hunks; write-mode `cargo fmt` was NOT run, per the standing rule about cloud/src/lib.rs following mod decls into peer files. ONE TRANSIENT PEER BREAKAGE seen and not touched: an unterminated block comment at oss/yah-base/crates/keys/src/spec.rs:1225 failed the `fob` crate mid-session (E0758); it was gone on the retry and is not this ticket's.")
95//! @yah:next("MIGRATION DONE — nothing left to execute on the existing fleet. The `acls.yaml` replication gap that motivated R858-T2 is now moot on us-west-001: the policy lives in headscale.db, which litestream already replicates. Tell R858 it can drop acls.yaml from its failover-state enumeration.")
96//! @yah:next("ROLLBACK PATH, if a policy problem surfaces later: /var/lib/yah-cloud/headscale/config.yaml.pre-R861-T3 is the pre-flip config on the box (mode=file + path). Restoring it and killing the kamaji-supervised pid reverts — but acls.yaml is gone, so re-create it from the DB policy (GET /api/v1/policy) first.")
97//! @yah:handoff("EXECUTED AGAINST THE LIVE FLEET 2026-09-04 18:53-18:58 PDT by @Ashguard:rune (picked up after session:0c65aeae errored on session limits mid-verification). ONE TARGET, us-west-001, driven with the rehearsed ./target/debug/yah (built 16:55, the same binary T3's dry-run rehearsal used; NOT installed to ~/.local/bin, tree still carries peers' in-flight work). PRE-FLIGHT re-observe matched the rehearsal byte for byte: mode=File{/var/lib/yah-cloud/headscale/acls.yaml}, supervisor=Kamaji{pid 515991}, acls.yaml 77 bytes permissive, served 77 bytes — fail-open gate satisfied. STEP 1 FlipConfigToDatabaseMode: config rewritten (old kept at config.yaml.pre-R861-T3), `sudo kill 515991`, kamaji respawned as pid 517125 serving the same argv. STEP 2 PushCarriedPolicy: re-observe showed mode=Database and `served policy: none (no policy row)` — independent proof the restart really re-read the config, and the fail-open window opened and was closed within one invocation; PUT succeeded with read-back verified. STEP 3 RemoveAclsFile: `sudo rm acls.yaml`, taken only after a read-only run confirmed served policy = 77 bytes and https://cloud.mesh.yah.dev/key?v=138 = 200. FINAL: `next step: Done`, acls.yaml absent, served policy 77 bytes from the DB. `yah mesh status` = api reachable HTTP 200, 10 nodes / 9 online — identical to the pre-migration count (us-west-002 is the known-offline WSL box). No node dropped, no re-auth, zero downtime observed.")
98//! @yah:verify("LIVE VERIFICATION, POST-MIGRATION (2026-09-04 18:58 PDT): `yah mesh migrate-policy us-west-001` (read-only) reports policy.mode = Database, supervisor = Kamaji{pid 517125}, acls.yaml = absent, served policy = 77 bytes, next step = Done. `curl https://cloud.mesh.yah.dev/key?v=138` = 200. `yah mesh status` = api reachable (HTTP 200), 10 nodes / 9 online, unchanged from pre-migration. The one target was us-west-001; T3's Phase C enumeration (no other box carries /var/lib/yah-cloud/headscale) was re-confirmed by the migrator's own machine resolution.")
99
100use anyhow::{Context, Result};
101
102use super::policies_equivalent;
103use crate::mesh::{HeadscaleClient, POLICY_MODE};
104
105/// Where this module lives, for error messages that want to point a reader at
106/// the migration without hardcoding a path in three places.
107pub const MODULE_PATH: &str = "cloud::reconciler::headscale::policy_migration";
108
109/// Default on-box location of a coordinator's headscale state.
110/// Matches us-west-001 (measured 2026-09-04) and `yubaba::DEFAULT_HEADSCALE_DIR`.
111pub const DEFAULT_HEADSCALE_DIR: &str = "/var/lib/yah-cloud/headscale";
112
113/// The `policy` block of a coordinator's `config.yaml`, as headscale reads it.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum PolicyMode {
116    /// `mode: file` — or no `policy:` block at all, since headscale's viper
117    /// default for `policy.mode` is `"file"`.
118    File { path: Option<String> },
119    /// `mode: database`. The migrated state.
120    Database,
121    /// Something headscale would `log.Fatal` on at startup. Surfaced rather
122    /// than coerced: a coordinator in this state is already broken and the
123    /// migration must not paper over it.
124    Unrecognised(String),
125}
126
127/// What is keeping `headscale serve` alive on the box.
128///
129/// This is not decoration: `policy.mode` is read once at startup, so the flip
130/// is only real after a restart, and the two supervisors restart by completely
131/// different means. Guessing produces the worst outcome available — a
132/// coordinator still serving the old mode while the config on disk says
133/// otherwise and the operator has been told the step succeeded.
134///
135/// Measured on us-west-001 2026-09-04: `headscale.service` is `inactive` and
136/// `disabled`, and the live `headscale serve` is a child of
137/// `/usr/local/bin/kamaji`. So [`Supervisor::Kamaji`] is the shape the one live
138/// coordinator actually has, and the systemd arm is the fallback path
139/// `leader.rs::start_headscale` takes when no kamaji backend is attached.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Supervisor {
142    /// `headscale.service` is the active unit. `systemctl restart` is correct.
143    Systemd,
144    /// kamaji supervises the process as a native workload. Its spec carries
145    /// `RestartPolicy::Always` (`yubaba::headscale_appliance::appliance_spec`),
146    /// so kamaji's own restart loop respawns the child on exit with the same
147    /// argv — which is what re-reads `config.yaml`.
148    ///
149    /// **`systemctl restart` is actively wrong here.** The unit is disabled, so
150    /// it would fork a *second* headscale against the same `0.0.0.0:443`; that
151    /// one fails to bind while the kamaji-supervised process keeps serving the
152    /// pre-flip config.
153    Kamaji { pid: u32 },
154    /// The probe could not tell. Emitted as a refusal to act rather than a
155    /// guess, because both wrong answers above are silent.
156    Unknown,
157}
158
159/// Everything the migration needs to know about one coordinator, gathered
160/// read-only.
161#[derive(Debug, Clone)]
162pub struct Observation {
163    /// Verbatim contents of the coordinator's `config.yaml`.
164    pub config_yaml: String,
165    /// Verbatim contents of `acls.yaml`, or `None` if the file is gone.
166    pub acls_file: Option<String>,
167    /// What `GET /api/v1/policy` returned, or `None` when the coordinator has
168    /// no policy to serve. In database mode with no row that GET is an error
169    /// (`loading ACL from database: policy not found`), not an empty string —
170    /// callers map that error to `None`, which is what it means.
171    pub live_policy: Option<String>,
172    /// Directory holding `config.yaml` / `acls.yaml` on the box.
173    pub headscale_dir: String,
174    /// What restarts `headscale serve` here — see [`Supervisor`].
175    pub supervisor: Supervisor,
176}
177
178impl Observation {
179    /// Read the observed `policy.mode`.
180    pub fn policy_mode(&self) -> PolicyMode {
181        read_policy_mode(&self.config_yaml)
182    }
183}
184
185/// The one action to take next. Re-derive it after every step; never queue a
186/// batch, because each step changes what the correct next one is.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum Step {
189    /// Write `config_yaml` back and restart headscale. Carries the rewritten
190    /// config so the caller never has to re-derive it.
191    FlipConfigToDatabaseMode { config_yaml: String },
192    /// `PUT /api/v1/policy` with this document. Only reachable once the
193    /// coordinator is in database mode.
194    PushCarriedPolicy { hujson: String },
195    /// The coordinator serves the carried policy from its database. The file
196    /// is now genuinely redundant and can go.
197    RemoveAclsFile,
198    /// Migrated. Re-running from here changes nothing.
199    Done,
200    /// The coordinator is in a state this migration refuses to guess at.
201    Blocked { reason: String },
202}
203
204impl Step {
205    /// A one-line name for the step, for plans and progress output.
206    ///
207    /// [`Step`]'s `Debug` embeds a whole `config.yaml` or policy document, which
208    /// is right for a failure message and unreadable in a list of steps.
209    pub fn label(&self) -> String {
210        match self {
211            Step::FlipConfigToDatabaseMode { .. } => {
212                "FlipConfigToDatabaseMode (rewrite config.yaml + restart)".to_string()
213            }
214            Step::PushCarriedPolicy { hujson } => {
215                format!(
216                    "PushCarriedPolicy (PUT /api/v1/policy, {} bytes)",
217                    hujson.len()
218                )
219            }
220            Step::RemoveAclsFile => "RemoveAclsFile (rm acls.yaml)".to_string(),
221            Step::Done => "Done".to_string(),
222            Step::Blocked { reason } => format!("Blocked: {reason}"),
223        }
224    }
225
226    /// Shell the operator (or a gated rail) runs on the box for the steps that
227    /// are not API calls. Empty for [`Step::PushCarriedPolicy`], which is
228    /// driven through [`apply_push`], and for the terminal states.
229    ///
230    /// The restart line depends on `supervisor` and there is no safe default —
231    /// see [`Supervisor`] for why emitting `systemctl restart` against a
232    /// kamaji-supervised coordinator is worse than emitting nothing.
233    pub fn on_box_commands(&self, headscale_dir: &str, supervisor: &Supervisor) -> Vec<String> {
234        match self {
235            Step::FlipConfigToDatabaseMode { .. } => {
236                let mut cmds = vec![format!(
237                    "# write the rewritten config to {headscale_dir}/config.yaml"
238                )];
239                // Restart, not reload: `policy.mode` is read once, in
240                // loadACLPolicy, during startup.
241                cmds.extend(restart_commands(supervisor));
242                cmds.push(format!("grep -A2 '^policy:' {headscale_dir}/config.yaml"));
243                cmds
244            }
245            Step::RemoveAclsFile => vec![format!("sudo rm {headscale_dir}/acls.yaml")],
246            _ => Vec::new(),
247        }
248    }
249}
250
251/// How to make `headscale serve` re-read `config.yaml` under each supervisor.
252///
253/// [`Supervisor::Unknown`] returns a refusal line rather than a command: a
254/// restart aimed at the wrong supervisor either does nothing visible or stands
255/// up a second coordinator, and both failure modes look like success.
256pub fn restart_commands(supervisor: &Supervisor) -> Vec<String> {
257    match supervisor {
258        Supervisor::Systemd => vec!["sudo systemctl restart headscale".to_string()],
259        // kamaji's native supervisor owns the child and re-execs it per
260        // `RestartPolicy::Always`, so ending the process IS the restart. SIGTERM
261        // rather than SIGKILL so headscale closes its SQLite handles.
262        Supervisor::Kamaji { pid } => vec![
263            format!(
264                "sudo kill {pid}  # kamaji (RestartPolicy::Always) respawns it with the same argv"
265            ),
266            // kamaji's ALWAYS_RESTART_DELAY is 1s (kamaji::native), so the new
267            // child does not exist for at least that long; 5s leaves headscale
268            // time to bind :443 too.
269            "sleep 5".to_string(),
270            "pgrep -af 'headscale serve' || true  # confirm a NEW pid is serving".to_string(),
271        ],
272        Supervisor::Unknown => vec![
273            "# REFUSING to emit a restart: the supervisor could not be identified.".to_string(),
274            "# Determine it first — `systemctl is-active headscale` and the ppid of".to_string(),
275            "# `pgrep -af 'headscale serve'` — then re-run. A restart aimed at the".to_string(),
276            "# wrong supervisor fails silently or binds a second coordinator to :443.".to_string(),
277        ],
278    }
279}
280
281/// Whether a policy document is the permissive allow-all default
282/// ([`crate::mesh::DEFAULT_ACL_POLICY`]), compared semantically rather than
283/// byte-wise.
284///
285/// This is the fail-open gate. The migration necessarily opens a window in
286/// which the coordinator has no policy row and therefore compiles
287/// `FilterAllowAll`; that is a no-op only when the policy being carried across
288/// the window is itself allow-all. On any other document the same window is a
289/// silent widening, which a caller must refuse rather than narrate.
290pub fn is_permissive(hujson: &str) -> Result<bool> {
291    policies_equivalent(hujson, crate::mesh::DEFAULT_ACL_POLICY)
292        .context("comparing a policy against the permissive allow-all default")
293}
294
295/// Decide the single next action from a read-only observation.
296///
297/// The ordering is forced by headscale's own behaviour, not chosen:
298///
299/// 1. **Flip the config first** — `SetPolicy` is refused outright in file mode.
300/// 2. **Push immediately after** — the interval in between is allow-all.
301/// 3. **Delete `acls.yaml` last** — it is the only copy of the policy until the
302///    coordinator serves an equivalent one from its database, so it is what
303///    makes an interrupted run resumable.
304pub fn next_step(obs: &Observation) -> Result<Step> {
305    match obs.policy_mode() {
306        PolicyMode::Unrecognised(mode) => {
307            return Ok(Step::Blocked {
308                reason: format!(
309                    "{}/config.yaml declares `policy.mode: {mode}`, which headscale v0.23.0 \
310                     rejects at startup (it accepts only \"file\" or \"database\"). Fix that \
311                     before migrating anything.",
312                    obs.headscale_dir,
313                ),
314            });
315        }
316        PolicyMode::File { .. } => {
317            let config_yaml = rewrite_to_database_mode(&obs.config_yaml)?;
318            return Ok(Step::FlipConfigToDatabaseMode { config_yaml });
319        }
320        PolicyMode::Database => {}
321    }
322
323    // Database mode from here on.
324    let Some(carried) = obs.acls_file.as_deref() else {
325        // No file left. Whatever the database holds is the whole truth, and
326        // there is nothing left to carry or delete.
327        return Ok(Step::Done);
328    };
329
330    let in_sync = match obs.live_policy.as_deref() {
331        // No policy row yet: the coordinator is serving allow-all and the file
332        // is the only copy. Always a push, never a delete.
333        None => false,
334        Some(live) => policies_equivalent(live, carried).with_context(|| {
335            format!(
336                "comparing the policy {} serves against the carried {}/acls.yaml",
337                "the coordinator", obs.headscale_dir,
338            )
339        })?,
340    };
341
342    if in_sync {
343        Ok(Step::RemoveAclsFile)
344    } else {
345        Ok(Step::PushCarriedPolicy {
346            hujson: carried.to_string(),
347        })
348    }
349}
350
351/// Whether writes are permitted. An explicit type rather than a `bool`, so a
352/// caller cannot arm a live push by getting an argument order wrong.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum Execution {
355    /// Describe the write; perform nothing.
356    DryRun,
357    /// Actually `PUT /api/v1/policy`.
358    Live,
359}
360
361/// Perform the one migration step that is an API call.
362///
363/// Returns the human-readable account of what happened (or, under
364/// [`Execution::DryRun`], what would have). Any other [`Step`] is a no-op here
365/// — the on-box steps come back from [`Step::on_box_commands`] instead.
366pub async fn apply_push(
367    client: &HeadscaleClient,
368    step: &Step,
369    execution: Execution,
370) -> Result<String> {
371    let Step::PushCarriedPolicy { hujson } = step else {
372        return Ok(format!("no API step to run for {step:?}"));
373    };
374    match execution {
375        Execution::DryRun => Ok(format!(
376            "DRY RUN: would PUT /api/v1/policy with {} bytes of HuJSON",
377            hujson.len()
378        )),
379        Execution::Live => {
380            client
381                .set_policy(hujson)
382                .await
383                .context("pushing the carried acls.yaml into the coordinator's database")?;
384            // Read back through the same comparison the reconciler uses, so a
385            // coordinator that accepted the write but stored something else
386            // cannot read as success.
387            let live = client
388                .get_policy()
389                .await
390                .context("reading the policy back after pushing it")?;
391            if !policies_equivalent(&live, hujson)? {
392                anyhow::bail!(
393                    "the coordinator accepted the policy push but serves a different document \
394                     back — refusing to treat this as migrated, and acls.yaml must NOT be deleted"
395                );
396            }
397            Ok("pushed the carried policy and verified the read-back".to_string())
398        }
399    }
400}
401
402/// A rehearsal: drive [`next_step`] to a terminal state over a simulated
403/// coordinator, so the whole sequence can be inspected without a live box.
404///
405/// `advance` applies one step to the observation exactly as the real world
406/// would, and returns the next observation.
407pub fn rehearse<F>(mut obs: Observation, mut advance: F) -> Result<MigrationPlan>
408where
409    F: FnMut(&Step, &Observation) -> Observation,
410{
411    let mut steps = Vec::new();
412    // Five is generous: the longest real path is flip -> push -> remove ->
413    // done. Anything longer means the state machine is cycling, which is a bug
414    // worth failing on rather than looping forever.
415    for _ in 0..5 {
416        let step = next_step(&obs)?;
417        steps.push(step.clone());
418        if matches!(step, Step::Done | Step::Blocked { .. }) {
419            return Ok(MigrationPlan { steps });
420        }
421        obs = advance(&step, &obs);
422    }
423    anyhow::bail!("migration did not converge in 5 steps: {steps:?}")
424}
425
426/// Apply one step to an observation the way the real world would, for
427/// [`rehearse`].
428///
429/// This is the honest model of each step's effect, and it is public so an
430/// operator rail can rehearse a plan against a *real* observation rather than
431/// only against a hand-built one: read the coordinator, then
432/// `rehearse(obs, simulate_step)` shows the whole sequence before anything is
433/// armed.
434pub fn simulate_step(step: &Step, obs: &Observation) -> Observation {
435    let mut next = obs.clone();
436    match step {
437        Step::FlipConfigToDatabaseMode { config_yaml } => {
438            next.config_yaml = config_yaml.clone();
439            // Restarting in database mode with no row: headscale serves a nil
440            // policy, and GET /api/v1/policy errors -> None.
441            next.live_policy = None;
442        }
443        Step::PushCarriedPolicy { hujson } => next.live_policy = Some(hujson.clone()),
444        Step::RemoveAclsFile => next.acls_file = None,
445        Step::Done | Step::Blocked { .. } => {}
446    }
447    next
448}
449
450/// The ordered steps a [`rehearse`] produced.
451#[derive(Debug, Clone)]
452pub struct MigrationPlan {
453    pub steps: Vec<Step>,
454}
455
456impl MigrationPlan {
457    /// Whether this migration passes through a window where the coordinator
458    /// serves allow-all because the config was flipped before the policy was
459    /// pushed.
460    ///
461    /// True whenever a flip is followed by a push. Harmless when the carried
462    /// policy is itself permissive (us-west-001's is); a real, if brief,
463    /// widening otherwise — and the operator should know which one they have
464    /// before running it, not after.
465    pub fn widens_before_push(&self) -> bool {
466        self.steps.windows(2).any(|w| {
467            matches!(w[0], Step::FlipConfigToDatabaseMode { .. })
468                && matches!(w[1], Step::PushCarriedPolicy { .. })
469        })
470    }
471
472    /// Whether the plan ever removes `acls.yaml` before the coordinator is
473    /// serving the policy from its database. Must always be false — this is
474    /// the invariant that makes an interrupted run recoverable.
475    pub fn deletes_the_file_early(&self) -> bool {
476        let remove_at = self.steps.iter().position(|s| *s == Step::RemoveAclsFile);
477        let push_at = self
478            .steps
479            .iter()
480            .position(|s| matches!(s, Step::PushCarriedPolicy { .. }));
481        match (remove_at, push_at) {
482            (Some(r), Some(p)) => r < p,
483            _ => false,
484        }
485    }
486}
487
488// ─── config.yaml surgery ─────────────────────────────────────────────────────
489
490/// Read `policy.mode` (and `policy.path`) out of a headscale `config.yaml`.
491///
492/// Hand-parsed rather than round-tripped through a YAML crate, for two reasons:
493/// `serde_yaml` is a dev-dependency of this crate, and a reserialize would
494/// reorder and reformat a config a human may have edited on the box. The
495/// subset handled is exactly what headscale's own config is: a top-level
496/// `policy:` key whose block-mapping children are plain `key: value` scalars.
497/// Anything else reads as [`PolicyMode::Unrecognised`] rather than being
498/// guessed at.
499///
500/// A missing `policy:` block is [`PolicyMode::File`] with no path, because
501/// that is what headscale does — `viper.SetDefault("policy.mode", "file")`.
502pub fn read_policy_mode(config_yaml: &str) -> PolicyMode {
503    let Some(block) = policy_block(config_yaml) else {
504        return PolicyMode::File { path: None };
505    };
506    let mut mode = None;
507    let mut path = None;
508    for line in &config_yaml.lines().collect::<Vec<_>>()[block.first_child..block.end] {
509        let Some((key, value)) = line.split_once(':') else {
510            continue;
511        };
512        let value = value.trim().trim_matches('"').to_string();
513        match key.trim() {
514            "mode" => mode = Some(value),
515            "path" => path = Some(value),
516            _ => {}
517        }
518    }
519    match mode.as_deref() {
520        None | Some("") | Some("file") => PolicyMode::File { path },
521        Some("database") => PolicyMode::Database,
522        Some(other) => PolicyMode::Unrecognised(other.to_string()),
523    }
524}
525
526/// Rewrite a coordinator's `config.yaml` so its policy block reads
527/// `mode: database` and carries no `path:`.
528///
529/// Every other byte is preserved: only the `policy:` block's own lines are
530/// replaced, at whatever indentation that block already uses. Idempotent —
531/// running it on an already-migrated config returns the same bytes.
532///
533/// `policy.path` is dropped rather than left in place because headscale reads
534/// it only in file mode; a leftover path documents a file that is no longer
535/// the source of truth, which is exactly the confusion this ticket exists to
536/// remove.
537pub fn rewrite_to_database_mode(config_yaml: &str) -> Result<String> {
538    let lines: Vec<&str> = config_yaml.lines().collect();
539    let trailing_newline = config_yaml.ends_with('\n');
540
541    let Some(block) = policy_block(config_yaml) else {
542        // No `policy:` block at all — headscale defaults to file mode, so this
543        // config still needs migrating. Append one.
544        let mut out = config_yaml.to_string();
545        if !out.is_empty() && !trailing_newline {
546            out.push('\n');
547        }
548        out.push_str(&format!("policy:\n  mode: {POLICY_MODE}\n"));
549        return Ok(out);
550    };
551
552    let indent = block.child_indent.clone();
553    let mut out: Vec<String> = lines[..block.first_child]
554        .iter()
555        .map(|l| l.to_string())
556        .collect();
557    out.push(format!("{indent}mode: {POLICY_MODE}"));
558    // Anything in the block that is neither `mode:` nor `path:` is somebody
559    // else's key and is kept — dropping a key this module does not understand
560    // would be exactly the silent damage it is supposed to avoid.
561    for line in &lines[block.first_child..block.end] {
562        let key = line.split_once(':').map(|(k, _)| k.trim()).unwrap_or("");
563        if key == "mode" || key == "path" || line.trim().is_empty() {
564            continue;
565        }
566        out.push((*line).to_string());
567    }
568    out.extend(lines[block.end..].iter().map(|l| l.to_string()));
569
570    let mut rendered = out.join("\n");
571    if trailing_newline {
572        rendered.push('\n');
573    }
574    Ok(rendered)
575}
576
577/// The `policy:` mapping's extent within a config, in line indices.
578struct PolicyBlock {
579    /// First line of the block's children (one past the `policy:` line).
580    first_child: usize,
581    /// One past the block's last child.
582    end: usize,
583    /// The indentation the children use, reused verbatim on rewrite.
584    child_indent: String,
585}
586
587fn policy_block(config_yaml: &str) -> Option<PolicyBlock> {
588    let lines: Vec<&str> = config_yaml.lines().collect();
589    // A top-level key: no leading whitespace, and nothing after the colon.
590    let header = lines
591        .iter()
592        .position(|l| l.trim_end() == "policy:" && !l.starts_with(char::is_whitespace))?;
593
594    let first_child = header + 1;
595    let child_indent = lines
596        .get(first_child)
597        .map(|l| l[..l.len() - l.trim_start().len()].to_string())
598        .filter(|i| !i.is_empty())
599        // An empty `policy:` block (next line is another top-level key) still
600        // needs children written; two spaces is what every renderer emits.
601        .unwrap_or_else(|| "  ".to_string());
602
603    let end = lines[first_child..]
604        .iter()
605        .position(|l| !l.trim().is_empty() && !l.starts_with(char::is_whitespace))
606        .map(|offset| first_child + offset)
607        .unwrap_or(lines.len());
608
609    Some(PolicyBlock {
610        first_child,
611        end,
612        child_indent,
613    })
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    /// The exact policy block us-west-001 was measured to carry on 2026-09-04,
621    /// embedded in a config shaped like the one `generate_remote_headscale_config`
622    /// produced when that box was provisioned.
623    fn live_file_mode_config() -> String {
624        "---\n\
625         server_url: https://cloud.mesh.yah.dev\n\
626         listen_addr: 0.0.0.0:443\n\
627         database:\n\
628         \x20\x20type: sqlite\n\
629         \x20\x20sqlite:\n\
630         \x20\x20\x20\x20path: /var/lib/yah-cloud/headscale/headscale.db\n\
631         policy:\n\
632         \x20\x20mode: file\n\
633         \x20\x20path: /var/lib/yah-cloud/headscale/acls.yaml\n\
634         derp:\n\
635         \x20\x20server:\n\
636         \x20\x20\x20\x20enabled: false\n"
637            .to_string()
638    }
639
640    /// us-west-001's live acls.yaml, 77 bytes, measured 2026-09-04.
641    const LIVE_ACLS: &str = r#"{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}"#;
642
643    fn observation(config: &str, acls: Option<&str>, live: Option<&str>) -> Observation {
644        Observation {
645            config_yaml: config.to_string(),
646            acls_file: acls.map(str::to_string),
647            live_policy: live.map(str::to_string),
648            headscale_dir: DEFAULT_HEADSCALE_DIR.to_string(),
649            // The shape us-west-001 was measured to have on 2026-09-04.
650            supervisor: Supervisor::Kamaji { pid: 515991 },
651        }
652    }
653
654    #[test]
655    fn the_live_config_reads_as_file_mode() {
656        assert_eq!(
657            read_policy_mode(&live_file_mode_config()),
658            PolicyMode::File {
659                path: Some("/var/lib/yah-cloud/headscale/acls.yaml".to_string()),
660            }
661        );
662    }
663
664    #[test]
665    fn a_config_with_no_policy_block_is_file_mode() {
666        // headscale's own default: viper.SetDefault("policy.mode", "file").
667        assert_eq!(
668            read_policy_mode("---\nserver_url: https://x\n"),
669            PolicyMode::File { path: None }
670        );
671    }
672
673    #[test]
674    fn an_unknown_mode_is_surfaced_not_coerced() {
675        let cfg = "policy:\n  mode: postgres\n";
676        assert_eq!(
677            read_policy_mode(cfg),
678            PolicyMode::Unrecognised("postgres".to_string())
679        );
680    }
681
682    #[test]
683    fn all_three_renderers_now_emit_database_mode() {
684        let dir = std::path::PathBuf::from("/var/lib/yah-cloud/headscale");
685        let cloud = crate::mesh::generate_headscale_config("https://mesh.example.com", &dir);
686        assert_eq!(read_policy_mode(&cloud), PolicyMode::Database, "{cloud}");
687        // yubaba's two renderers live in a crate this one does not depend on,
688        // so their outputs are pinned by tests over there. What is asserted
689        // here is that the mode string both crates hardcode agrees.
690        assert_eq!(POLICY_MODE, "database");
691    }
692
693    #[test]
694    fn rewriting_the_live_config_flips_the_mode_and_drops_the_path() {
695        let out = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
696        assert_eq!(read_policy_mode(&out), PolicyMode::Database, "{out}");
697        assert!(!out.contains("acls.yaml"), "{out}");
698        // Everything else survives, including the sibling `path:` under
699        // `database.sqlite` that a careless line-based edit would eat.
700        assert!(
701            out.contains("server_url: https://cloud.mesh.yah.dev"),
702            "{out}"
703        );
704        assert!(
705            out.contains("/var/lib/yah-cloud/headscale/headscale.db"),
706            "{out}"
707        );
708        assert!(out.contains("listen_addr: 0.0.0.0:443"), "{out}");
709        assert!(out.contains("enabled: false"), "{out}");
710        assert!(out.ends_with('\n'), "{out}");
711    }
712
713    #[test]
714    fn rewriting_is_idempotent() {
715        let once = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
716        let twice = rewrite_to_database_mode(&once).unwrap();
717        assert_eq!(once, twice);
718    }
719
720    #[test]
721    fn rewriting_a_config_without_a_policy_block_appends_one() {
722        let out = rewrite_to_database_mode("---\nserver_url: https://x\n").unwrap();
723        assert_eq!(read_policy_mode(&out), PolicyMode::Database, "{out}");
724        assert!(out.contains("server_url: https://x"), "{out}");
725    }
726
727    #[test]
728    fn rewriting_keeps_a_policy_key_this_module_does_not_understand() {
729        let cfg = "policy:\n  mode: file\n  path: /x/acls.yaml\n  something_new: 7\n";
730        let out = rewrite_to_database_mode(cfg).unwrap();
731        assert!(out.contains("something_new: 7"), "{out}");
732        assert!(!out.contains("acls.yaml"), "{out}");
733    }
734
735    #[test]
736    fn a_file_mode_coordinator_is_told_to_flip_the_config_first() {
737        // Not to push: SetPolicy is refused outright in file mode.
738        let obs = observation(&live_file_mode_config(), Some(LIVE_ACLS), Some(LIVE_ACLS));
739        match next_step(&obs).unwrap() {
740            Step::FlipConfigToDatabaseMode { config_yaml } => {
741                assert_eq!(read_policy_mode(&config_yaml), PolicyMode::Database);
742            }
743            other => panic!("expected a config flip, got {other:?}"),
744        }
745    }
746
747    #[test]
748    fn a_database_mode_coordinator_with_no_policy_row_is_pushed_to() {
749        let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
750        let obs = observation(&flipped, Some(LIVE_ACLS), None);
751        assert_eq!(
752            next_step(&obs).unwrap(),
753            Step::PushCarriedPolicy {
754                hujson: LIVE_ACLS.to_string()
755            }
756        );
757    }
758
759    #[test]
760    fn the_file_is_removed_only_once_the_database_serves_it() {
761        let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
762        // Same policy, reformatted by headscale's own round trip — semantic
763        // comparison, not bytes, so this is in sync.
764        let served = "{\n  \"acls\": [\n    { \"action\": \"accept\", \"src\": [\"*\"], \"dst\": [\"*:*\"] }\n  ]\n}";
765        let obs = observation(&flipped, Some(LIVE_ACLS), Some(served));
766        assert_eq!(next_step(&obs).unwrap(), Step::RemoveAclsFile);
767    }
768
769    #[test]
770    fn drift_between_the_file_and_the_database_is_a_push_not_a_delete() {
771        let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
772        let served = r#"{"acls":[{"action":"accept","src":["tag:ci"],"dst":["*:22"]}]}"#;
773        let obs = observation(&flipped, Some(LIVE_ACLS), Some(served));
774        assert!(matches!(
775            next_step(&obs).unwrap(),
776            Step::PushCarriedPolicy { .. }
777        ));
778    }
779
780    #[test]
781    fn a_migrated_coordinator_is_done_and_stays_done() {
782        let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
783        let obs = observation(&flipped, None, Some(LIVE_ACLS));
784        assert_eq!(next_step(&obs).unwrap(), Step::Done);
785        // A freshly-bootstrapped box: database mode, no file, no policy row.
786        let fresh = observation(&flipped, None, None);
787        assert_eq!(next_step(&fresh).unwrap(), Step::Done);
788    }
789
790    #[test]
791    fn an_unrecognised_mode_blocks_instead_of_guessing() {
792        let obs = observation("policy:\n  mode: postgres\n", Some(LIVE_ACLS), None);
793        match next_step(&obs).unwrap() {
794            Step::Blocked { reason } => assert!(reason.contains("postgres"), "{reason}"),
795            other => panic!("expected Blocked, got {other:?}"),
796        }
797    }
798
799    /// Apply a step the way the real world would, so [`rehearse`] walks the
800    /// same path a live run would.
801    /// The production model, exercised here rather than a test-local twin — a
802    /// second copy would let the rehearsal an operator sees drift from the one
803    /// these tests prove.
804    fn simulate(step: &Step, obs: &Observation) -> Observation {
805        simulate_step(step, obs)
806    }
807
808    #[test]
809    fn the_whole_migration_converges_from_the_live_starting_state() {
810        let start = observation(&live_file_mode_config(), Some(LIVE_ACLS), Some(LIVE_ACLS));
811        let plan = rehearse(start, simulate).unwrap();
812        assert!(
813            matches!(
814                plan.steps.as_slice(),
815                [
816                    Step::FlipConfigToDatabaseMode { .. },
817                    Step::PushCarriedPolicy { .. },
818                    Step::RemoveAclsFile,
819                    Step::Done,
820                ]
821            ),
822            "{:?}",
823            plan.steps
824        );
825        // The recoverability invariant: never delete the only copy first.
826        assert!(!plan.deletes_the_file_early());
827        // And the window this ordering opens is reported, not hidden.
828        assert!(plan.widens_before_push());
829    }
830
831    #[test]
832    fn resuming_from_any_midpoint_reaches_the_same_end_state() {
833        // Every interruption point is re-derivable from what is on the box,
834        // which is the property that makes a half-completed run safe to rerun.
835        let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
836        let midpoints = [
837            // Interrupted right after the flip, before the push.
838            observation(&flipped, Some(LIVE_ACLS), None),
839            // Interrupted after the push, before the delete.
840            observation(&flipped, Some(LIVE_ACLS), Some(LIVE_ACLS)),
841            // Interrupted after the delete: already done.
842            observation(&flipped, None, Some(LIVE_ACLS)),
843        ];
844        for obs in midpoints {
845            let plan = rehearse(obs, simulate).unwrap();
846            assert_eq!(*plan.steps.last().unwrap(), Step::Done, "{:?}", plan.steps);
847            assert!(!plan.deletes_the_file_early(), "{:?}", plan.steps);
848        }
849    }
850
851    #[test]
852    fn the_on_box_steps_restart_rather_than_reload() {
853        // policy.mode is read once, in loadACLPolicy, at startup — a SIGHUP
854        // would leave the coordinator on the old mode with a config that says
855        // otherwise, which is the worst of both.
856        let step = Step::FlipConfigToDatabaseMode {
857            config_yaml: String::new(),
858        };
859        let cmds = step.on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Systemd);
860        assert!(cmds
861            .iter()
862            .any(|c| c.contains("systemctl restart headscale")));
863        assert!(!cmds.iter().any(|c| c.contains("reload")));
864        assert_eq!(
865            Step::RemoveAclsFile.on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Systemd),
866            vec!["sudo rm /var/lib/yah-cloud/headscale/acls.yaml".to_string()]
867        );
868        // The push is not an on-box command; it goes through apply_push.
869        assert!(Step::PushCarriedPolicy {
870            hujson: LIVE_ACLS.to_string()
871        }
872        .on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Systemd)
873        .is_empty());
874    }
875
876    #[test]
877    fn a_kamaji_supervised_coordinator_is_never_told_to_systemctl_restart() {
878        // us-west-001's live shape (measured 2026-09-04): headscale.service is
879        // inactive+disabled and the running `headscale serve` is a child of
880        // /usr/local/bin/kamaji. `systemctl restart` there forks a SECOND
881        // headscale onto the same :443 — it fails to bind while the real one
882        // keeps serving the pre-flip config, i.e. a silent no-op dressed as a
883        // successful step.
884        let cmds = Step::FlipConfigToDatabaseMode {
885            config_yaml: String::new(),
886        }
887        .on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Kamaji { pid: 515991 });
888        assert!(!cmds.iter().any(|c| c.contains("systemctl")), "{cmds:?}");
889        assert!(
890            cmds.iter().any(|c| c.starts_with("sudo kill 515991")),
891            "{cmds:?}"
892        );
893    }
894
895    #[test]
896    fn an_unidentified_supervisor_yields_no_runnable_restart() {
897        // Every emitted line is a comment: there is nothing here a rail could
898        // execute, which is the intended refusal.
899        let cmds = restart_commands(&Supervisor::Unknown);
900        assert!(!cmds.is_empty());
901        assert!(cmds.iter().all(|c| c.starts_with('#')), "{cmds:?}");
902    }
903
904    #[test]
905    fn the_live_acls_file_reads_as_permissive_and_a_real_rule_does_not() {
906        // The whole fail-open safety argument rests on this comparison.
907        assert!(is_permissive(LIVE_ACLS).unwrap());
908        assert!(is_permissive(crate::mesh::DEFAULT_ACL_POLICY).unwrap());
909        let restrictive = r#"{"acls":[{"action":"accept","src":["tag:dev"],"dst":["tag:dev:*"]}]}"#;
910        assert!(!is_permissive(restrictive).unwrap());
911    }
912
913    #[tokio::test]
914    async fn a_dry_run_push_touches_no_coordinator() {
915        // The URL is unroutable on purpose: if DryRun ever performed the PUT,
916        // this test would fail with a connection error instead of passing.
917        let client = HeadscaleClient::new("http://127.0.0.1:1", "unused".to_string()).unwrap();
918        let step = Step::PushCarriedPolicy {
919            hujson: LIVE_ACLS.to_string(),
920        };
921        let out = apply_push(&client, &step, Execution::DryRun).await.unwrap();
922        assert!(out.starts_with("DRY RUN"), "{out}");
923        assert!(out.contains(&LIVE_ACLS.len().to_string()), "{out}");
924        // And a non-push step is a no-op even when execution is Live.
925        let out = apply_push(&client, &Step::RemoveAclsFile, Execution::Live)
926            .await
927            .unwrap();
928        assert!(out.contains("no API step"), "{out}");
929    }
930}