@homeflare/alchemy 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -45
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +642 -49
- package/dist/launchd/index.js.map +24 -14
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +14 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +83 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +12 -5
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -10
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Adopt verifier — `adopted` must mean no-op
|
|
2
|
+
|
|
3
|
+
`hf-adopt-verify` plans a stack with Alchemy's own planner, never writes, and reports for each row
|
|
4
|
+
**without a state row** what the provider's `read` and its own `diff` said. It exits `0` only
|
|
5
|
+
when every such row is a no-op. Run it before any gated deploy that adopts.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bunx --bun hf-adopt-verify --config alchemy.run.ts --stage live # rows without state
|
|
9
|
+
bunx --bun hf-adopt-verify --config alchemy.pbs.ts --stage live --all # every row + deletions
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Wrap it in the same credential lane the deploy uses (`bun ./scripts/with-mini-bao.ts --admin …`
|
|
13
|
+
in the Proxmox stacks): a lane that cannot read an object sees it as absent, and the verifier
|
|
14
|
+
then reports `create` for something that exists.
|
|
15
|
+
|
|
16
|
+
## Why a plan line is not enough
|
|
17
|
+
|
|
18
|
+
Measured in alchemy `2.0.0-beta.79`:
|
|
19
|
+
|
|
20
|
+
- `Plan.ts`: when a row has no state, the engine calls `read` (the adoption probe). If the object
|
|
21
|
+
exists it sets `forceUpdateAfterAdoption`, calls `diff`, and turns a `noop` into `update`. The
|
|
22
|
+
node is `adopted` either way. Nothing is logged.
|
|
23
|
+
- `Apply.ts`: `adopted` goes down the `update` branch and calls `reconcile` with `olds: undefined`.
|
|
24
|
+
- So `alchemy plan` prints `(+) adopted` for a matching object **and** for a drifting one, and
|
|
25
|
+
the deploy reconciles both. Whether that writes is up to each family's `reconcile`.
|
|
26
|
+
|
|
27
|
+
Measured on TB4, `pvesh get /cluster/tasks`, 2026-09-21: six `cephsetpool` tasks by
|
|
28
|
+
`hf-provision@pve`, one per pool, on 2026-09-13 and again on 2026-09-20 — the deploys that
|
|
29
|
+
adopted the three Ceph pools. `Proxmox.CephPool` sent `setpool` whenever the pool existed,
|
|
30
|
+
whatever its diff said. It was the one PVE/PBS family that wrote on a no-op adoption, and it is
|
|
31
|
+
fixed. [adopted-deploys.md](./adopted-deploys.md) shows what every family does.
|
|
32
|
+
|
|
33
|
+
## Where the answer is taken from
|
|
34
|
+
|
|
35
|
+
| looked at | what it offers |
|
|
36
|
+
| -------------------------- | --------------------------------------------------------------- |
|
|
37
|
+
| `alchemy plan` (CLI) | the forced action only; `--detailed` is declared YAML, no JSON |
|
|
38
|
+
| `Plan.describePlan` | serializable rows, again only the forced action |
|
|
39
|
+
| `forceUpdateAfterAdoption` | a local variable; no log, no warning, no field on the node |
|
|
40
|
+
| `alchemy drift` | rows **with** state only, and its non-dry run reconciles |
|
|
41
|
+
| `deploy --adopt --dry-run` | the adoption plan (`plan` has no `--adopt`), same forced action |
|
|
42
|
+
| **the provider service** | the raw `read` / `diff` answer, per FQN — used |
|
|
43
|
+
|
|
44
|
+
The engine finds every provider in the Effect context by resource type. The verifier opens the
|
|
45
|
+
stack the way `alchemy plan` does (`Alchemist.open`). It swaps each provider for a watched copy
|
|
46
|
+
(`src/verify/spy.ts`) and runs `Plan.make` itself, so output resolution, state lookup, renames,
|
|
47
|
+
the adoption probe and `adopt(…)` are all still Alchemy's. The copy records what `read` and
|
|
48
|
+
`diff` answered. `reconcile`, `delete` and `precreate` are replaced with a refusal, so a
|
|
49
|
+
verification cannot write even by accident.
|
|
50
|
+
|
|
51
|
+
## The report
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
planned diff read type fqn
|
|
55
|
+
FAIL adopted update found Test.Thing drifted changed: comment
|
|
56
|
+
the provider's diff says update: the deploy writes
|
|
57
|
+
ok adopted noop found Test.Thing same
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| column | meaning |
|
|
61
|
+
| --------- | ---------------------------------------------------------------------------------------- |
|
|
62
|
+
| `planned` | the engine's action — what `alchemy plan` prints |
|
|
63
|
+
| `diff` | the provider's own answer before Plan.ts forced it; `none` = no diff; `not-run` |
|
|
64
|
+
| `read` | `found`, `unowned` (the deploy needs `--adopt`), `absent`, `failed`, `not-read` |
|
|
65
|
+
| `changed` | declared fields whose live value differs, **names only**, same-named keys of props/attrs |
|
|
66
|
+
|
|
67
|
+
A row is a no-op when `planned` is `adopted` or `noop`, the provider said `noop`, **and** no
|
|
68
|
+
binding changes (Plan.ts diffs bindings separately and `reconcile` applies them anyway). An
|
|
69
|
+
adopted row whose provider has no `diff` is not proven: the engine compared the declaration with
|
|
70
|
+
itself. A row with state and no diff is trusted at `noop`, as `alchemy plan` trusts it. A stack
|
|
71
|
+
task (an Alchemy action) that will `run` is always a failing row: no provider sees it.
|
|
72
|
+
|
|
73
|
+
Exit codes: `0` all no-op (or nothing to verify), `1` any row is not, `2` the plan could not be
|
|
74
|
+
computed (a provider died, an `adopt(false)` row is not ours, state unreadable). A gate should stop
|
|
75
|
+
on anything but `0`.
|
|
76
|
+
|
|
77
|
+
## Flags and API
|
|
78
|
+
|
|
79
|
+
`--config` / `-c`, `--stage`, `--profile`, `--env-file` behave as in `alchemy plan`. The
|
|
80
|
+
difference is that `--stage` is never guessed: it comes from the flag or `$ALCHEMY_STAGE`, or the
|
|
81
|
+
run stops. `--all` adds every row with state (read again) and every pending delete. `--json`
|
|
82
|
+
prints the report.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { verifyStack, formatReport, exitCodeOf } from '@homeflare/alchemy/verify';
|
|
86
|
+
|
|
87
|
+
const report = await Effect.runPromise(
|
|
88
|
+
verifyStack({ entrypoint: 'alchemy.run.ts', stage: 'live' }),
|
|
89
|
+
);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`verifySession({ stack, context })` takes an already-opened session. The kit's own tests pass it
|
|
93
|
+
a compiled stack (`src/verify/fake-engine.ts`).
|
|
94
|
+
|
|
95
|
+
## Limits
|
|
96
|
+
|
|
97
|
+
- ⚠️ **`noop` covers what the family compares.** A family's `diff` and its `reconcile` guard share
|
|
98
|
+
one predicate (`matches`). A field that `matches` leaves out is never written on a no-op
|
|
99
|
+
adoption and never reported. `Proxmox.CephPool` `target_size_ratio` is the known case.
|
|
100
|
+
- ⚠️ **`changed` is a hint, not the verdict.** It compares same-named keys. A family whose
|
|
101
|
+
attributes rename or normalise a field shows nothing there, and its `diff` still decides.
|
|
102
|
+
- ⚠️ **Reads are what `alchemy plan` does.** A kit PVE/PBS read mints a lease through OpenBao,
|
|
103
|
+
which creates a short-lived API token on the cluster. `--all` adds one read per row with state.
|
|
104
|
+
- ⚠️ **Outside the kit, `noop` is only the diff's word.** For a kit PVE/PBS family, `noop` means
|
|
105
|
+
the deploy writes nothing ([adopted-deploys.md](./adopted-deploys.md)). For any other provider,
|
|
106
|
+
`noop` means its diff found nothing. Whether its `reconcile` still PUTs is up to that provider.
|
|
107
|
+
- It runs where `alchemy` runs: under bun it needs `@effect/platform-bun`, as the Alchemy CLI
|
|
108
|
+
does. Like `alchemy plan`, it writes a run log under `.alchemy/log/` in the working directory.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# What a deploy does to an adopted PVE/PBS row
|
|
2
|
+
|
|
3
|
+
Traced through the source on 2026-09-21, for every family the Proxmox and PBS stacks use. A row
|
|
4
|
+
**without state** that the adoption probe finds is planned `adopted` and reconciled, even when
|
|
5
|
+
its diff said `noop` (alchemy beta.79 `Plan.ts` `forceUpdateAfterAdoption`, then `Apply.ts`).
|
|
6
|
+
This page answers one question: does that reconcile write when live already equals declared?
|
|
7
|
+
|
|
8
|
+
**Answer: no, for every family below. `Proxmox.CephPool` did until this change.** Its
|
|
9
|
+
hand-written reconcile PUT `setpool` whenever the pool existed. It now skips the PUT when its own
|
|
10
|
+
`matches` holds. `src/proxmox/update-guard.ts` is the one predicate that `resource.ts`,
|
|
11
|
+
`ceph-pool.ts` and `pbs-datastore.ts` share. `hf-adopt-verify` ([adopt-verify.md](./adopt-verify.md))
|
|
12
|
+
reports the diff that decides it, before the deploy.
|
|
13
|
+
|
|
14
|
+
## The shared path — every `pveHandlers` family
|
|
15
|
+
|
|
16
|
+
One `alchemy deploy` plans, then applies, in one process. For a row with no state:
|
|
17
|
+
|
|
18
|
+
| step | calls (path is the family's `path`) |
|
|
19
|
+
| -------------------- | ------------------------------------------------------------------------------------------------ |
|
|
20
|
+
| plan: adoption probe | `GET path` |
|
|
21
|
+
| plan: `diff` | `GET path`; `matches` → `noop` or `update` (printed `adopted`) |
|
|
22
|
+
| apply: `reconcile` | `GET path`; **PUT only when `matches` is false** and the form is non-empty; `GET path` read-back |
|
|
23
|
+
|
|
24
|
+
So a matching adoption costs **four GETs and no write**. A drifting one adds exactly one
|
|
25
|
+
`PUT path` carrying the family's `updateForm`. Each GET goes out on a lease, and each role's lease
|
|
26
|
+
is minted once per run (`lease-cache.ts`). The mint is an OpenBao call that creates a short-lived
|
|
27
|
+
API token on the cluster, just as it does for `alchemy plan`.
|
|
28
|
+
|
|
29
|
+
## Per family
|
|
30
|
+
|
|
31
|
+
| family | read path | lease | reconcile on a no-op adoption |
|
|
32
|
+
| ------------------------------ | --------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `Proxmox.Pool` | `pools/{poolid}` | read | shared path: GET, GET — no write |
|
|
34
|
+
| `Proxmox.Storage` | `storage/{storage}` | provision ¹ | shared path — no write |
|
|
35
|
+
| `Proxmox.Role` | `access/roles/{roleid}` | read | shared path — no write |
|
|
36
|
+
| `Proxmox.Group` | `access/groups/{groupid}` | read | shared path — no write |
|
|
37
|
+
| `Proxmox.User` | `access/users/{userid}` | read | shared path — no write |
|
|
38
|
+
| `Proxmox.BackupJob` | `cluster/backup/{id}` | read | shared path — no write |
|
|
39
|
+
| `Proxmox.MetricServer` | `cluster/metrics/server/{id}` | read | shared path — no write |
|
|
40
|
+
| `Proxmox.NotificationTarget` ² | `cluster/notifications/endpoints/{type}/{name}` | read | shared path — no write |
|
|
41
|
+
| `Proxmox.HaResource` | `cluster/ha/resources/{sid}` | read | shared path — no write |
|
|
42
|
+
| `Proxmox.HaRule` | `cluster/ha/rules/{rule}` | read | shared path — no write |
|
|
43
|
+
| `Proxmox.SdnZone` | `cluster/sdn/zones/{zone}` | provision ¹ | shared path — no write |
|
|
44
|
+
| `Proxmox.SdnVnet` | `cluster/sdn/vnets/{vnet}` | provision ¹ | shared path — no write |
|
|
45
|
+
| `Proxmox.SdnSubnet` | `cluster/sdn/vnets/{vnet}/subnets/{id}` | read | shared path — no write |
|
|
46
|
+
| `Proxmox.Lxc` ³ | `nodes/{node}/lxc/{vmid}/config` | read | own: GET, `judge` finds no drift so no `PUT …/config`, GET — no write |
|
|
47
|
+
| `Pbs.PruneJob` | `config/prune/{id}` | read (PBS) | shared path — no write |
|
|
48
|
+
| `Pbs.SyncJob` | `config/sync/{id}` | read (PBS) | shared path — no write |
|
|
49
|
+
| `Pbs.VerifyJob` | `config/verify/{id}` | read (PBS) | shared path — no write |
|
|
50
|
+
| `Proxmox.Acl` | `access/acl` (whole list, filtered) | provision ¹ | shared path, then its own `bound` check — no write |
|
|
51
|
+
| `Pbs.Datastore` | `config/datastore/{name}` | read (PBS) | own: GET, path/backend guards, PUT skipped on `matches`, settle GET — no write |
|
|
52
|
+
| `Proxmox.SdnApply` | 5 × `cluster/sdn/*?pending=1` + fabrics `?pending` / `?running` | read | own: counts staged objects; zero returns at once — **no `PUT /cluster/sdn`** |
|
|
53
|
+
| `Proxmox.CephPool` ⁴ | `nodes/{node}/ceph/pool/{name}/status?verbose=1` | read | own: GET, **PUT skipped on `matches` (was: always `PUT nodes/{node}/ceph/pool/{name}`)**, settle GET |
|
|
54
|
+
|
|
55
|
+
¹ These object reads are gated on an allocate privilege, so the family reads with the provision
|
|
56
|
+
lease (`readRole`, resource.ts). That is still a read.
|
|
57
|
+
² The PVE notification endpoints: `smtp` (the mail target), `sendmail`, `gotify`, `webhook`. The
|
|
58
|
+
secrets are never props (notification-target.ts), so no write is ever needed to "reset" them.
|
|
59
|
+
³ Rewritten 2026-09-21 (PR 80, [proxmox-lxc.md](./proxmox-lxc.md)): its own reconcile, and its
|
|
60
|
+
probe answers `Unowned`, so the deploy needs `--adopt`. Its `diff` also logs the keys a deploy
|
|
61
|
+
would write, by name. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with no write").
|
|
62
|
+
⁴ `updateBody` sends `target_size_ratio`, but `matches` does not compare it (float equality). A
|
|
63
|
+
declared ratio that differs from the live one is never written on a no-op adoption and never
|
|
64
|
+
reported as a diff.
|
|
65
|
+
|
|
66
|
+
## Before the fix: `Proxmox.CephPool`
|
|
67
|
+
|
|
68
|
+
`reconcile` read the pool and, whenever it existed, PUT the declared `size`, `min_size`,
|
|
69
|
+
`pg_autoscale_mode`, `crush_rule` (and any declared hints) to `nodes/{node}/ceph/pool/{name}`.
|
|
70
|
+
That PUT forks a `cephsetpool` worker under the provision token. PVE's `set_pool` skips each
|
|
71
|
+
setting whose value is unchanged (Tools.pm:293-300), so a matching pool was not re-tuned. It was
|
|
72
|
+
still a provision-lease write and a task on the node every time the pool was adopted. TB4's task
|
|
73
|
+
list shows six such tasks (2026-09-13 and 2026-09-20), one per declared pool per deploy.
|
|
74
|
+
|
|
75
|
+
## How it is pinned
|
|
76
|
+
|
|
77
|
+
- `src/proxmox/ceph-pool-adopt.test.ts` runs the real CephPool provider through Alchemy's Plan
|
|
78
|
+
and Apply over a fake cluster. A matching pool gets GETs only. A drifted one gets exactly one PUT.
|
|
79
|
+
On the old reconcile the first test fails with that PUT.
|
|
80
|
+
- `src/proxmox/adopt-noop.test.ts` does the same for each reconcile shape: the shared path
|
|
81
|
+
(`Proxmox.Pool`), the wrapper (`Proxmox.Acl`), and the two hand-written ones (`Pbs.Datastore`,
|
|
82
|
+
`Proxmox.SdnApply`). A drifted Pool row proves the harness sees a write when there is one.
|
|
83
|
+
- `src/verify/verify.test.ts` pins Alchemy's own behaviour: `adopted` for a match and for a
|
|
84
|
+
drift, and a reconcile for both. If an Alchemy upgrade changes that, the test fails and this
|
|
85
|
+
page needs re-reading.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Caddy's admin endpoint — `@homeflare/alchemy/caddy`
|
|
2
|
+
|
|
3
|
+
How the provider reaches Caddy's admin API, and what it refuses so a Caddyfile cannot cut it off.
|
|
4
|
+
The overview is [caddy.md](./caddy.md).
|
|
5
|
+
|
|
6
|
+
⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
|
|
7
|
+
(`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
|
|
8
|
+
|
|
9
|
+
- **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
|
|
10
|
+
guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
|
|
11
|
+
`enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
|
|
12
|
+
Host check entirely.
|
|
13
|
+
- **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
|
|
14
|
+
ITS port): pass `hostHeader: 'localhost:2019'`.
|
|
15
|
+
- **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
|
|
16
|
+
`localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
|
|
17
|
+
- **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
|
|
18
|
+
if it turns the API off, listens off loopback or somewhere the transport does not reach (another
|
|
19
|
+
port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
|
|
20
|
+
no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
|
|
21
|
+
`enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
|
|
22
|
+
- ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
|
|
23
|
+
load. That is refused unless the transport is at that default: a Caddy reached on a socket or
|
|
24
|
+
another port declares `admin <address>` in its Caddyfile.
|
package/docs/caddy.md
CHANGED
|
@@ -24,10 +24,12 @@ export const edge = Effect.gen(function* () {
|
|
|
24
24
|
yield* LaunchdJob('caddy-daemon', {
|
|
25
25
|
label: 'com.example.caddy',
|
|
26
26
|
domain: 'system',
|
|
27
|
+
// --resume: a restart runs the last config Caddy accepted (see Managed Caddies below).
|
|
27
28
|
// --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
|
|
28
29
|
programArguments: [
|
|
29
30
|
'/usr/local/bin/caddy',
|
|
30
31
|
'run',
|
|
32
|
+
'--resume',
|
|
31
33
|
'--config',
|
|
32
34
|
file.path,
|
|
33
35
|
'--adapter',
|
|
@@ -35,6 +37,8 @@ export const edge = Effect.gen(function* () {
|
|
|
35
37
|
'--envfile',
|
|
36
38
|
'/usr/local/etc/caddy.env',
|
|
37
39
|
],
|
|
40
|
+
// ⛔ Here, not in the envfile: Caddy fixes its autosave path before it reads --envfile.
|
|
41
|
+
environment: { XDG_CONFIG_HOME: '/usr/local/var/caddy-edge' },
|
|
38
42
|
runAtLoad: true,
|
|
39
43
|
keepAlive: true,
|
|
40
44
|
});
|
|
@@ -56,15 +60,15 @@ export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
|
|
|
56
60
|
| `endpoint` | where the admin API was reached |
|
|
57
61
|
| `sourceFile` | as applied |
|
|
58
62
|
|
|
59
|
-
| step | what happens
|
|
60
|
-
| ------- |
|
|
61
|
-
| plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan**
|
|
62
|
-
| apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it
|
|
63
|
-
| refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running
|
|
64
|
-
| read | the digest of `GET /config
|
|
65
|
-
| drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file
|
|
66
|
-
| replace | never — a new Caddyfile is a reload of the same Caddy
|
|
67
|
-
| delete | ⛔ nothing. See [Removing it](#removing-it)
|
|
63
|
+
| step | what happens |
|
|
64
|
+
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
|
|
66
|
+
| apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
|
|
67
|
+
| refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
|
|
68
|
+
| read | the digest of `GET /config/`; with no state, the adoption probe — ⛔ a config that is not the declared one needs `--adopt` ([Adoption](#adoption)) |
|
|
69
|
+
| drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
|
|
70
|
+
| replace | never — a new Caddyfile is a reload of the same Caddy |
|
|
71
|
+
| delete | ⛔ nothing. See [Removing it](#removing-it) |
|
|
68
72
|
|
|
69
73
|
The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
|
|
70
74
|
keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
|
|
@@ -86,31 +90,42 @@ before any admin API exists to load into. What bounds the cost:
|
|
|
86
90
|
`/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
|
|
87
91
|
adoption probe, and the check runs at apply instead.
|
|
88
92
|
- ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
|
|
89
|
-
disk when `/load` fails. Caddy keeps serving the old config
|
|
90
|
-
the refused file
|
|
93
|
+
disk when `/load` fails. Caddy keeps serving the old config, and a managed Caddy's restart runs its
|
|
94
|
+
autosave, not the refused file (below). Without `--resume` a **restart before the fix loads the
|
|
95
|
+
refused file**: fix and redeploy.
|
|
91
96
|
- ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
|
|
92
97
|
- ★ Both resources retain: removing them from a stack leaves the file a restart needs.
|
|
93
98
|
|
|
94
|
-
##
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
- **
|
|
103
|
-
|
|
104
|
-
- **
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
99
|
+
## Managed Caddies: `--resume` and their own config dir
|
|
100
|
+
|
|
101
|
+
★ **Decision, 2026-09-21.** A Caddy this package manages runs `caddy run --resume --config <file>`
|
|
102
|
+
with `XDG_CONFIG_HOME` set to a directory of its own, so a restart runs the last config Caddy
|
|
103
|
+
**accepted**. After every successful load (ours included) Caddy writes the config JSON to
|
|
104
|
+
`$XDG_CONFIG_HOME/caddy/autosave.json`; `--resume` starts from it, and from `--config` only when no
|
|
105
|
+
autosave exists (cmd/commandfuncs.go cmdRun). Read in caddyserver/caddy v2.11.4:
|
|
106
|
+
|
|
107
|
+
- ★ **A refused Caddyfile on disk cannot take sites down.** The file is the first-boot fallback: a
|
|
108
|
+
new host, or an autosave someone deleted.
|
|
109
|
+
- ★ **Its own directory,** so nothing else (a Homebrew service, a person's `caddy run`) writes the
|
|
110
|
+
autosave it resumes from. With neither `HOME` nor `XDG_CONFIG_HOME` a daemon would use `./caddy/`
|
|
111
|
+
under its working directory (storage.go AppConfigDir).
|
|
112
|
+
- ⛔ **Set `XDG_CONFIG_HOME` in the job's `environment`, not the envfile.** The autosave path is
|
|
113
|
+
fixed when the binary starts (storage.go `var ConfigAutosavePath`), before `--envfile` is read.
|
|
114
|
+
- ⛔ **No `persist_config off`** in a managed Caddyfile. It stops the autosave, so `--resume` starts
|
|
115
|
+
from a stale one (the last config before it) or, with none, from the file.
|
|
116
|
+
- ⚠️ **A hand change through the API survives a restart.** The next plan reports it as drift, and the
|
|
117
|
+
deploy puts the declaration back.
|
|
118
|
+
- ⚠️ **SIGUSR1 is not a way to change config.** After a resumed start Caddy records no source file
|
|
119
|
+
(cmdRun calls `SetLastConfig` only after loading `--config`), so SIGUSR1 is ignored with the log
|
|
120
|
+
line `last config unknown`. Our `Caddy-Config-Source-File` header keeps a source Caddy already
|
|
121
|
+
has (caddy.go ClearLastConfigIfDifferent) and never sets one. After a first boot from the file,
|
|
122
|
+
SIGUSR1 still reloads it: the text the deploy wrote. Change config by deploying. Editing the file
|
|
123
|
+
and running `caddy reload` is a hand `/load`, which the next plan reports as drift.
|
|
124
|
+
- ⛔ **No `--watch`.** After a resumed start it has no file to watch, and falls back to a `Caddyfile`
|
|
125
|
+
in the working directory (cmd/main.go watchConfigFile).
|
|
126
|
+
|
|
127
|
+
A Caddy not yet moved over (no `--resume`) restarts from `--config`: see the ⚠️ in
|
|
128
|
+
[Order](#order-file-then-load).
|
|
114
129
|
|
|
115
130
|
## Secrets
|
|
116
131
|
|
|
@@ -133,31 +148,33 @@ bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a sc
|
|
|
133
148
|
|
|
134
149
|
## The admin endpoint
|
|
135
150
|
|
|
136
|
-
⛔ The admin API has no authentication
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
|
|
141
|
-
`enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
|
|
142
|
-
Host check entirely.
|
|
143
|
-
- **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
|
|
144
|
-
ITS port): pass `hostHeader: 'localhost:2019'`.
|
|
145
|
-
- **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
|
|
146
|
-
`localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
|
|
147
|
-
- **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
|
|
148
|
-
if it turns the API off, listens off loopback or somewhere the transport does not reach (another
|
|
149
|
-
port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
|
|
150
|
-
no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
|
|
151
|
-
`enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
|
|
152
|
-
- ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
|
|
153
|
-
load. That is refused unless the transport is at that default: a Caddy reached on a socket or
|
|
154
|
-
another port declares `admin <address>` in its Caddyfile.
|
|
151
|
+
⛔ The admin API has no authentication: `localCaddyAdmin()` accepts only `http://` loopback or
|
|
152
|
+
`unix:///path` (default `http://127.0.0.1:2019`), and a Caddyfile whose `admin` block would move,
|
|
153
|
+
expose or lock out the API is refused before it loads. Host/Origin checks, narrowed `origins`, SSH
|
|
154
|
+
forwards and every refusal: [caddy-admin.md](./caddy-admin.md).
|
|
155
155
|
|
|
156
156
|
## Adoption
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
★ **Nothing is adopted silently** (decision, 2026-09-21) — the rule `HostFile` and `LaunchdJob` keep.
|
|
159
|
+
With no state, `read` is Alchemy's adoption probe, and what the Caddy runs decides:
|
|
160
|
+
|
|
161
|
+
| running | read | plan |
|
|
162
|
+
| ----------------------------------- | --------- | ---------------------------------------------------------- |
|
|
163
|
+
| the declared config (same digest) | ours | adopted; the forced update loads nothing |
|
|
164
|
+
| nothing (`null`, or no apps) | absent | create — nothing to take over, as an empty R2 lock is none |
|
|
165
|
+
| anything else | `Unowned` | refused unless the deploy runs with `--adopt` |
|
|
166
|
+
| a Caddyfile that cannot be compared | `Unowned` | the same, with a warning saying why |
|
|
167
|
+
| no Caddy answering | absent | create, with a warning; the apply checks again |
|
|
168
|
+
|
|
169
|
+
- ⚠️ **Alchemy skips the probe while the props hold an Output** — always on `caddyWithFile()`'s first
|
|
170
|
+
deploy, whose `sourceFile` is the HostFile's path. So the apply of a create checks too, and fails
|
|
171
|
+
before any `/load` unless the deploy runs with `--adopt`. The HostFile is written by then (a file
|
|
172
|
+
already at the path is the HostFile's own `Unowned`); under `--resume` a restart still runs the
|
|
173
|
+
autosave.
|
|
174
|
+
- ★ **That apply-time check resolves adoption as the planner does:** a resource-scoped `adopt(…)`,
|
|
175
|
+
else `--adopt`. So `.pipe(adopt(false))` still refuses under `--adopt`, and `adopt(true)` works.
|
|
176
|
+
- ★ **An uncomparable Caddyfile is `Unowned`, not an error**: the same read recovers an interrupted
|
|
177
|
+
create with that deploy's props, and a throw would fail every later plan, the fixed one included.
|
|
161
178
|
|
|
162
179
|
## Removing it
|
|
163
180
|
|
|
@@ -177,4 +194,6 @@ it by hand at its admin API.
|
|
|
177
194
|
its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
|
|
178
195
|
`retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
|
|
179
196
|
- ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
|
|
180
|
-
Caddy plans an update
|
|
197
|
+
Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
|
|
198
|
+
applied to, so the apply loads there only if it serves nothing, runs the declared config or the
|
|
199
|
+
one last stored — anything else needs `--adopt`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Credentials
|
|
2
|
+
|
|
3
|
+
Moved out of the README (2026-09-21) to make room under its 200-line cap; the text is unchanged.
|
|
4
|
+
|
|
5
|
+
`CLOUDFLARE_API_TOKEN` is read from the environment at call time, never at module scope.
|
|
6
|
+
`MeshNode` instead resolves credentials and the account the way `Cloudflare.providers()` does
|
|
7
|
+
(an Alchemy profile, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in CI).
|
|
8
|
+
|
|
9
|
+
⛔ **Mint a short-lived, scoped token** — do not reuse a long-lived one, and never a Global
|
|
10
|
+
API Key. An empty value fails closed with a message saying so, because an empty render is
|
|
11
|
+
a denied grant rather than a missing file, and `Bearer ` + nothing 401s in a way that reads
|
|
12
|
+
like a bad credential.
|
|
13
|
+
|
|
14
|
+
The Proxmox subpath mints every call's token from an OpenBao mount instead; see
|
|
15
|
+
[proxmox.md](./proxmox.md).
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Deploying a Mac host as yourself — `sudoRunner()`
|
|
2
|
+
|
|
3
|
+
A host stack that declares system daemons needs root for a handful of calls. `sudoRunner()` lets
|
|
4
|
+
the deploy run as you (the operator) and sends **only those calls** through `sudo -n`, in fixed
|
|
5
|
+
argv shapes, logging each one. It is an explicit opt-in: nothing falls back to it, and
|
|
6
|
+
`localRunner()` stays the default and never elevates.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
|
|
10
|
+
|
|
11
|
+
const runner = sudoRunner({
|
|
12
|
+
// ⛔ Required: the directories root may write. Each — every directory above it, and every
|
|
13
|
+
// directory below it on the way to a file — must be one that root owns and only root may
|
|
14
|
+
// write, by mode bits and by ACL; that is checked at every privileged call.
|
|
15
|
+
prefixes: ['/Library/LaunchDaemons', '/opt/example'],
|
|
16
|
+
// log: (line) => …, // default: one line on stderr per privileged call
|
|
17
|
+
});
|
|
18
|
+
// Provide launchdProviders(runner) alongside the stack's other provider layers.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What runs as root: the whole list
|
|
22
|
+
|
|
23
|
+
Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never through a shell.
|
|
24
|
+
|
|
25
|
+
| when | argv |
|
|
26
|
+
| -------------------------------- | ---------------------------------------------------------------------- |
|
|
27
|
+
| a system job is bootstrapped | `/bin/launchctl bootstrap system /Library/LaunchDaemons/<label>.plist` |
|
|
28
|
+
| a system job is booted out | `/bin/launchctl bootout system/<label>` |
|
|
29
|
+
| (your own code, via `exec`) | `/bin/launchctl kickstart [-k] [-p] system/<label>` |
|
|
30
|
+
| a file under a prefix is written | `/usr/bin/install -S -m <0644> [-o <uid>] [-g <gid>] <staged> <path>` |
|
|
31
|
+
| a file under a prefix is removed | `/bin/rm -f -- <path>` |
|
|
32
|
+
|
|
33
|
+
- A file's bytes never reach argv or the log: they are written, as you, to a `0600` file in a
|
|
34
|
+
fresh `0700` temp directory. `install` copies that file (and only that file) into place, and
|
|
35
|
+
the temp directory is removed afterwards, whether or not the install succeeded.
|
|
36
|
+
- `install` writes a temp file beside the target and renames it (install(1) on macOS 27.2), so the
|
|
37
|
+
target is never torn. `-S` adds the fsync.
|
|
38
|
+
- Owner and group are numeric ids, resolved by the provider before the call.
|
|
39
|
+
|
|
40
|
+
## What stays as you
|
|
41
|
+
|
|
42
|
+
- `launchctl print` and `print-disabled`. launchctl(1): "Anyone may read or query the system
|
|
43
|
+
domain"; measured on macOS 27.2, too (see `src/launchd/launchctl.ts`).
|
|
44
|
+
- User and group lookups, `id -G`, every file read and `lstat`.
|
|
45
|
+
- Files outside every prefix, and your own `gui/<uid>` jobs.
|
|
46
|
+
|
|
47
|
+
★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
|
|
48
|
+
|
|
49
|
+
★ **A plan that will write runs the checks below first** (`HostRunner.checkWrite`): a `HostFile`
|
|
50
|
+
or `LaunchdJob` diff that plans an update or a replace asks the runner, as you, whether it would
|
|
51
|
+
refuse that write. A refusal then fails the plan before any resource is applied, instead of the
|
|
52
|
+
apply halfway through. A create has no diff, so its refusal still comes at the write — before
|
|
53
|
+
sudo.
|
|
54
|
+
|
|
55
|
+
## Refused before sudo is asked
|
|
56
|
+
|
|
57
|
+
- Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
|
|
58
|
+
`bootstrap system <directory>` (which loads every plist in it), a plist anywhere but
|
|
59
|
+
`/Library/LaunchDaemons/<label>.plist` (the only place launchd loads daemons from at boot),
|
|
60
|
+
`rm -r`, `install -d`, a label under `org.nixos.`, `com.apple.` or `homebrew.mxcl.`, and any
|
|
61
|
+
argv whose program is `sudo`.
|
|
62
|
+
- A system job's `bootstrap` or `bootout` when `/Library/LaunchDaemons` is not a prefix. ⚠️ Without
|
|
63
|
+
it a delete would boot the job out, then fail to remove its plist, which launchd loads again at
|
|
64
|
+
the next boot.
|
|
65
|
+
- A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
|
|
66
|
+
symlinked prefix makes root write wherever it points, a path the log never names.
|
|
67
|
+
- **A directory between the prefix and the file** that is not owned by root, or that group or
|
|
68
|
+
other may write (checked since 0.9.0). ⛔ Its owner, or anyone who may write it, could swap
|
|
69
|
+
what lies under it for a symlink between the check and the call.
|
|
70
|
+
- **A directory above the prefix**, from `/` down, that is not owned by root or that group or other
|
|
71
|
+
may write (red team, 2026-09-21). ⛔ Whoever may change the prefix's parent may rename the prefix
|
|
72
|
+
away and put a symlink in its place: the same swap, one level up. A root-owned symlink among them
|
|
73
|
+
(`/etc` -> `private/etc`) is root's own and is followed.
|
|
74
|
+
- **An ACL from `/` down to the file that grants a write right** (`add_file`, `add_subdirectory`,
|
|
75
|
+
`delete_child`, `delete`, `write`, `append`, `writesecurity`, `chown`), read with `ls -lden` as
|
|
76
|
+
you. ⛔ On macOS an ACL entry can hand another user exactly the swap the mode bits forbid, and an
|
|
77
|
+
inheritable `allow write` hands them every file root installs there. Deny entries (a home
|
|
78
|
+
folder's `everyone deny delete`) and read-only rights refuse nothing; ACLs that cannot be read
|
|
79
|
+
refuse. Measured 2026-09-21: `/Library/LaunchDaemons`, `/private/etc`, `/opt` and `/usr/local`,
|
|
80
|
+
and everything above them, carry none.
|
|
81
|
+
- **A root-owned file that would be group- or world-writable, setuid or setgid** (`mode & 0o6022`;
|
|
82
|
+
an omitted owner is root). ⛔ Anyone in that class could rewrite a file root installed (a
|
|
83
|
+
daemon's config, a script it runs), and a setuid root file runs as root for whoever executes it.
|
|
84
|
+
A file handed to another user (`owner`) is theirs to change, so its mode is theirs too.
|
|
85
|
+
- A path outside every prefix that needs root: another user as the owner, say.
|
|
86
|
+
- A symlink or missing directory between the prefix and the file, or anything but a regular file
|
|
87
|
+
at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
|
|
88
|
+
the same.
|
|
89
|
+
- A file you could not read back. Both providers read every write back, and read again at every
|
|
90
|
+
plan, as you. A root-only `0600` would be installed, then fail with `EACCES` and stay on disk
|
|
91
|
+
with no state. Membership comes from `id -G`, because macOS caps `getgroups()` at 16 (measured
|
|
92
|
+
2026-09-21: 16 against 18).
|
|
93
|
+
- Another user's `gui/<uid>` or `user/<uid>` domain. Only the system domain is elevated.
|
|
94
|
+
|
|
95
|
+
Every refusal above throws `SudoRefusedError`: nothing ran as root. A privileged command that ran
|
|
96
|
+
and failed throws a plain `Error` with its exit code.
|
|
97
|
+
|
|
98
|
+
## When sudo wants a password
|
|
99
|
+
|
|
100
|
+
`sudo -n` never prompts. It fails at once, and the error says so: `a password is required, and
|
|
101
|
+
this runner never prompts`. The command did not run. You have two options.
|
|
102
|
+
|
|
103
|
+
1. **`sudo -v` just before the deploy.** This caches a ticket. By default it lasts 5 minutes and
|
|
104
|
+
is tied to one terminal (sudoers(5): `timestamp_timeout`, `timestamp_type`).
|
|
105
|
+
⚠️ A deploy that outlives the ticket fails at its next privileged call. Every step is
|
|
106
|
+
idempotent (Alchemy requires `delete` to be), so `sudo -v` and deploying again converges. The
|
|
107
|
+
exception is a `LaunchdJob` label or domain change: it is delete-first, so if it fails between
|
|
108
|
+
the delete and the create, the job is down until you redeploy.
|
|
109
|
+
2. **A `NOPASSWD` sudoers rule for exactly the shapes above.**
|
|
110
|
+
- ⛔ **That rule is root-equivalent.** Anyone who may `install` into `/Library/LaunchDaemons`
|
|
111
|
+
and `launchctl bootstrap system` can run any program as root. The allowlist protects you
|
|
112
|
+
from mistakes, not from the account that holds the rule.
|
|
113
|
+
- ⛔ **Never use wildcards in the arguments.** sudoers(5): in arguments, `*` also matches spaces
|
|
114
|
+
and `/`, so `/bin/rm -f -- /opt/example/*` matches `../../etc/x` and two paths at once. Use
|
|
115
|
+
`^…$` regular expressions (sudo 1.9.10 and later; macOS 27.2 ships 1.9.17p2) and forbid
|
|
116
|
+
`..`. Check the file with `visudo -c -f <file>` before you install it.
|
|
117
|
+
- ⚠️ The kit ships no tested rule. Nothing here has run sudo.
|
|
118
|
+
|
|
119
|
+
⚠️ The error text sudo prints is **reasoned, not measured**. It comes from sudoers(5) and sudo(8)
|
|
120
|
+
for sudo 1.9.17p2. A sudo refusal the runner does not recognise comes back as a failed command,
|
|
121
|
+
with sudo's own stderr.
|
|
122
|
+
|
|
123
|
+
## The log
|
|
124
|
+
|
|
125
|
+
Each privileged call logs one line before it runs:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
homeflare/launchd sudo -n ["/bin/launchctl","bootstrap","system","/Library/LaunchDaemons/com.example.exporter.plist"]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The argv is written as JSON, so a space in a path cannot hide a second argument. The line holds
|
|
132
|
+
the argv only, never file content. ⛔ A custom `log` should keep every line: the log is the only
|
|
133
|
+
record of what ran as root.
|
|
134
|
+
|
|
135
|
+
## Limits
|
|
136
|
+
|
|
137
|
+
- ⚠️ **Moving a job from `system` to another user's `gui/<uid>` passes the plan and fails at
|
|
138
|
+
apply**, after the old job was already removed. The runner reports `privileged: true`, so the
|
|
139
|
+
plan-time check lets it through, and the refusal only comes at the write. Do that move as a
|
|
140
|
+
remove, then an add.
|
|
141
|
+
- ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
|
|
142
|
+
is the directory's group (a new file's group on macOS).
|
|
143
|
+
- ⚠️ **The checks run as you, just before the call.** Every directory from `/` down to the file
|
|
144
|
+
must be root-only, by mode bits and by ACL, so no other user can swap a path in between; root
|
|
145
|
+
itself still could. The target of a root-owned symlink above the prefix is root's choice and is
|
|
146
|
+
not walked.
|
|
147
|
+
- ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.
|
package/docs/launchd.md
CHANGED
|
@@ -87,8 +87,9 @@ The plist path is derived, never declared: `/Library/LaunchDaemons/<label>.plist
|
|
|
87
87
|
it becomes visible — then renamed over the path. Read back after every write. Diffed by SHA-256,
|
|
88
88
|
mode and owner. A new `path` is a create-before-delete replace. Delete removes the file.
|
|
89
89
|
|
|
90
|
-
- ⛔ A different file already at a new `path` is refused, not overwritten: the
|
|
91
|
-
never looks at a replace's new path, so a typo there would otherwise overwrite a
|
|
90
|
+
- ⛔ A different file already at a new `path` is refused, not overwritten, `--adopt` or not: the
|
|
91
|
+
adoption probe never looks at a replace's new path, so a typo there would otherwise overwrite a
|
|
92
|
+
system file.
|
|
92
93
|
|
|
93
94
|
- ⛔ A symlink or directory at the path is refused, never replaced: rename over a symlink replaces
|
|
94
95
|
the link, which takes the path from whatever tool owns it (nix-darwin's `/etc` entries point
|
|
@@ -112,8 +113,11 @@ The `system` domain, and another user's `gui/<uid>`, need root. The provider ref
|
|
|
112
113
|
unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
|
|
113
114
|
`privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
|
|
114
115
|
|
|
115
|
-
- **system daemons
|
|
116
|
-
|
|
116
|
+
- **system daemons, deployed as yourself:** `launchdProviders(sudoRunner({ prefixes }))` sends only
|
|
117
|
+
the privileged calls through `sudo -n`, from a fixed allowlist, logging each. It never prompts.
|
|
118
|
+
See [launchd-sudo.md](./launchd-sudo.md).
|
|
119
|
+
- **system daemons, deployed as root:** start the whole deploy as root; or pass
|
|
120
|
+
`launchdProviders(yourRunner)` where `yourRunner` is a `HostRunner` that already holds root.
|
|
117
121
|
- **your own agents** (`gui/<your uid>`): no root needed.
|
|
118
122
|
|
|
119
123
|
Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
|
|
@@ -123,7 +127,10 @@ you deploy as.
|
|
|
123
127
|
## Adoption: nothing is taken over silently
|
|
124
128
|
|
|
125
129
|
`read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
|
|
126
|
-
`Unowned`, so Alchemy refuses to take it over without `--adopt`.
|
|
130
|
+
`Unowned`, so Alchemy refuses to take it over without `--adopt`. Where the plan never asked (a prop
|
|
131
|
+
still an `Output`), `reconcile` refuses the same takeover, and `--adopt` or the resource's own
|
|
132
|
+
`adopt(true)` lets a create (or an interrupted generation) through there too — never a fresh
|
|
133
|
+
replace's new path or label. The whole rule: [ownership.md](./ownership.md).
|
|
127
134
|
|
|
128
135
|
Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
|
|
129
136
|
rewrites its plists on every activation and unloads any it no longer lists, macOS owns
|
|
@@ -163,5 +170,5 @@ Nix job is booted out first. Per job:
|
|
|
163
170
|
`HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
|
|
164
171
|
`stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
|
|
165
172
|
`privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
|
|
166
|
-
`node:child_process`;
|
|
167
|
-
writes outside a temp directory.
|
|
173
|
+
`node:child_process`; `sudoRunner()` wraps it and elevates the allowlist. Tests use an in-memory
|
|
174
|
+
fake, so nothing in the test suite runs `launchctl` or `sudo`, or writes outside a temp directory.
|