@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
package/src/openbao/README.md
CHANGED
|
@@ -14,6 +14,14 @@ plus `FetchHttpClient.layer`.
|
|
|
14
14
|
secret, a CA key, a plugin `env` or an OIDC client secret.
|
|
15
15
|
- Every family defaults to `retain` on destroy. For what a rename does, read
|
|
16
16
|
[REPLACE.md](./REPLACE.md) before you change any resource's path or name.
|
|
17
|
+
- ⛔ A rename or move onto a name or path that already exists fails the plan, before anything is
|
|
18
|
+
written (every family but the mounts, whose moves are below). Move through a free name, in two
|
|
19
|
+
deploys, or remove the target first.
|
|
20
|
+
- ⛔ **Nothing is adopted without `--adopt`** (0.9.0), not even an object identical to the
|
|
21
|
+
declaration: a live name with no state fails the plan, or the apply when a prop was an Output.
|
|
22
|
+
Our own interrupted create still resumes when its row can prove it — not one killed while a
|
|
23
|
+
prop was still an Output. [docs/ownership.md](../../docs/ownership.md). Change
|
|
24
|
+
a logical id with Alchemy's `renamedFrom` (REPLACE.md), never by adopting.
|
|
17
25
|
|
|
18
26
|
## assertBaoIdentity: call it first
|
|
19
27
|
|
|
@@ -70,9 +78,10 @@ export const hostRoles = Effect.gen(function* () {
|
|
|
70
78
|
- A role is named `<class>--<host>`. Class and host names may contain only lowercase letters,
|
|
71
79
|
digits and single hyphens.
|
|
72
80
|
- It refuses `secretIdTtl` 0 (a secret_id that never expires), empty policies, `root`, unknown
|
|
73
|
-
classes and
|
|
81
|
+
classes and a host listed twice in one class. A host in several classes gets one role per class.
|
|
74
82
|
- ⚠️ Renaming a host or class makes a new role. Under `retain` the old one stays live and keeps
|
|
75
|
-
admitting its secret_ids (REPLACE.md).
|
|
83
|
+
admitting its secret_ids (REPLACE.md). Under `destroy` its secret_ids go, but tokens it already
|
|
84
|
+
issued run to their TTL: deleting a role revokes no token.
|
|
76
85
|
|
|
77
86
|
## BaoJwtRole / BaoKubernetesRole / BaoJwtAuthConfig
|
|
78
87
|
|
package/src/openbao/REPLACE.md
CHANGED
|
@@ -4,9 +4,10 @@ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, i
|
|
|
4
4
|
Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
|
|
5
5
|
|
|
6
6
|
★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
|
|
7
|
-
in-memory state store against the fake engines in `fake-engines.ts
|
|
8
|
-
`role-rename.test.ts
|
|
9
|
-
|
|
7
|
+
in-memory state store against the fake engines in `fake-engines.ts` and `fake-engines-roles.ts`.
|
|
8
|
+
`policy-rename.test.ts`, `role-rename.test.ts`, `rename-occupied.test.ts`,
|
|
9
|
+
`rename-families.test.ts`, `rename-identity.test.ts` and `rename-adoption.test.ts` deploy, rename,
|
|
10
|
+
and deploy again, then check the plan and every call that reached the fake.
|
|
10
11
|
|
|
11
12
|
## What Alchemy does with each answer
|
|
12
13
|
|
|
@@ -44,46 +45,60 @@ stayed live. `destroy` could not help, because nothing recorded the old one.
|
|
|
44
45
|
|
|
45
46
|
## Answers, by resource
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
| `Bao.
|
|
55
|
-
| `Bao.
|
|
56
|
-
| `Bao.
|
|
57
|
-
| `Bao.
|
|
58
|
-
| `Bao.
|
|
59
|
-
| `Bao.
|
|
60
|
-
| `Bao.
|
|
48
|
+
⛔ Every family below except `Bao.Mount` and `Bao.AuthMethod` judges its identity **before**
|
|
49
|
+
`isResolved(news)`, and **fails the plan** on a move onto an object that already exists
|
|
50
|
+
(`rename-identity.ts`, below). Names compare as the server keys them: exact, except where a row
|
|
51
|
+
says folded.
|
|
52
|
+
|
|
53
|
+
| Resource | Identity change | Answer now | Other changes |
|
|
54
|
+
| ------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
|
|
56
|
+
| `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
|
|
57
|
+
| `Bao.AuthRole` | `name` (folded) | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. Its delete clears the secret_ids; issued tokens run to their TTL (below). | `update` |
|
|
58
|
+
| `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
|
|
59
|
+
| `Bao.JwtRole` | `mount`, `name` (folded) | `replace`. The name is folded: the server reads it lowercased (TypeLowerCaseString). | `update` (merge write; every managed field is sent) |
|
|
60
|
+
| `Bao.KubernetesRole` | `mount`, `name` (folded) | `replace`. The name is folded, as the server stores it. | `update` |
|
|
61
|
+
| `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. A move onto a mount with a config fails the plan too: it would rewrite that mount's logins. | `update` (full-replace write) |
|
|
62
|
+
| `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret. Reconcile refuses it too, when the name was an Output. | `update` (the upsert keeps the id) |
|
|
63
|
+
| `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
|
|
64
|
+
| `Bao.SshRole` | `mount`, `name` | `replace` | `update` (full-replace write) |
|
|
65
|
+
| `Bao.Plugin` | `type`, `name`, `version` | `replace`: a version bump is a new catalog entry, and `retain` keeps the old version registered. | `update` (full-replace register) |
|
|
66
|
+
| `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. | `update` (fragments digest) |
|
|
67
|
+
| `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. | `update` |
|
|
68
|
+
| `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** if the rename also changes `mintUser`, unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
|
|
61
69
|
|
|
62
70
|
`hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
|
|
63
71
|
renames the role. With the logical id taken from the role name, as the README does, the old id
|
|
64
72
|
leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
|
|
65
|
-
`Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
|
|
73
|
+
`Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live. A
|
|
74
|
+
host may sit in several classes: it gets one role per class, and only the same host twice in one
|
|
75
|
+
class is refused.
|
|
66
76
|
|
|
67
77
|
## When the new identity is not known at plan
|
|
68
78
|
|
|
69
79
|
- **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
|
|
70
|
-
changing).
|
|
71
|
-
|
|
80
|
+
changing). Every family judges the identity props before `isResolved(news)`
|
|
81
|
+
(`rename-identity.ts`), so the rename still plans `replace`. Measured for all twelve. Before
|
|
82
|
+
2026-09-21 the nine batch-2 families planned `update` here and orphaned the old object.
|
|
72
83
|
- **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
|
|
73
84
|
unknown identity, because under `destroy` a replace onto the same path deletes what it just
|
|
74
85
|
wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
|
|
75
86
|
object, **refuses before any write**. The row is left `updating`, with the new props and the
|
|
76
|
-
old attributes. The next deploy sees both names and plans `replace`. Measured
|
|
87
|
+
old attributes. The next deploy sees both names and plans `replace`. Measured for all twelve.
|
|
88
|
+
`Bao.MfaTotpMethod` refuses there with its rename refusal, and its next plan fails the same way.
|
|
77
89
|
- `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
|
|
78
90
|
rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
|
|
79
91
|
|
|
80
92
|
## A move onto an object that already exists fails the plan
|
|
81
93
|
|
|
82
|
-
⛔
|
|
83
|
-
is there. 🔴 Measured before the guard: two policies that swapped names under
|
|
94
|
+
⛔ Every family in the table reads the new path at plan (`judgeMove`, `rename.ts`) and refuses the
|
|
95
|
+
move if anything is there. 🔴 Measured before the guard: two policies that swapped names under
|
|
84
96
|
`RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
|
|
85
97
|
policy, and each old generation's delete then removed the name the other had just written. The
|
|
86
|
-
deploy was green, and both policies were gone, every grant revoked.
|
|
98
|
+
deploy was green, and both policies were gone, every grant revoked. The same swap deleted both
|
|
99
|
+
objects for `Bao.AuthRole`, `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.SshRole` and
|
|
100
|
+
`Bao.Plugin`. `Bao.JwtAuthConfig` (no delete) and `Bao.MfaLoginEnforcement` (delete refused) lost
|
|
101
|
+
nothing, but each new generation silently rewrote the other's live object. Measured the same way:
|
|
87
102
|
|
|
88
103
|
- a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
|
|
89
104
|
stack deleted the name it moved onto;
|
|
@@ -94,10 +109,12 @@ deploy was green, and both policies were gone, every grant revoked. Measured the
|
|
|
94
109
|
|
|
95
110
|
⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
|
|
96
111
|
have been harmless. So would a move back onto a retained old generation. Move in two deploys
|
|
97
|
-
through a name nothing holds, or remove the target by hand first.
|
|
98
|
-
|
|
112
|
+
through a name nothing holds, or remove the target by hand first. For `Bao.Plugin` that includes a
|
|
113
|
+
version bump onto a version already registered by hand. A change of case the server folds (policy,
|
|
114
|
+
AppRole, JWT and Kubernetes role names), or a trailing `/` on a mount, is the same object, so it is
|
|
115
|
+
not a move and is never refused (`rename-identity.test.ts`).
|
|
99
116
|
|
|
100
|
-
## What `retain` means for
|
|
117
|
+
## What `retain` means for batch 3A and AppRole
|
|
101
118
|
|
|
102
119
|
Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
|
|
103
120
|
retained."). It is live and unmanaged from then on:
|
|
@@ -112,13 +129,29 @@ retained."). It is live and unmanaged from then on:
|
|
|
112
129
|
- **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
|
|
113
130
|
plugin's delete leaves outstanding leases revocable (its own
|
|
114
131
|
`TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
|
|
132
|
+
- **`Bao.AuthRole`**: the old role keeps admitting its secret_ids. Under `destroy` the delete
|
|
133
|
+
removes every secret_id and the role_id, so no new login succeeds, but it revokes no token:
|
|
134
|
+
tokens already issued live to their TTL and only fail to renew (openbao v2.6.2 approle
|
|
135
|
+
`pathRoleDelete`, path_role.go:1858-1902; `pathLoginRenew`, path_login.go:414-438). Revoke by
|
|
136
|
+
accessor to contain one.
|
|
115
137
|
|
|
116
138
|
★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
|
|
117
139
|
`declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
|
|
118
140
|
orphan delete, which `retain` also keeps live.
|
|
119
141
|
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
## A new declaration of a live name: refused unless adopted
|
|
143
|
+
|
|
144
|
+
⛔ A NEW logical id whose name already exists now FAILS THE PLAN (0.9.0): with no state, `read`
|
|
145
|
+
answers `Unowned` for any live object, identical or not (`src/ownership/probe.ts`,
|
|
146
|
+
[docs/ownership.md](../../docs/ownership.md)). Nothing moved, so `judgeMove` never runs; the
|
|
147
|
+
probe is what catches it. 🔴 Measured before (`rename-adoption.test.ts`): the families' `read`
|
|
148
|
+
answered plain attributes, so Alchemy silently adopted the object, and under `destroy` the old
|
|
149
|
+
owner's delete then ran after the adoption wrote and removed the object the new id claimed, in a
|
|
150
|
+
green deploy — for each of the nine batch-2 families whose delete runs (a TOTP method and its
|
|
151
|
+
enrolments included), and when a new `Bao.AuthRole` took a name another one moved off in the same
|
|
152
|
+
deploy. ⚠️ `--adopt` is exactly that takeover again, on purpose. Change a logical id with Alchemy's
|
|
153
|
+
`renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a freed name
|
|
154
|
+
in the deploy after the move.
|
|
122
155
|
|
|
123
156
|
## Why the moves fail instead of replacing
|
|
124
157
|
|
|
@@ -152,12 +185,9 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
|
|
|
152
185
|
3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
|
|
153
186
|
it fails on purpose instead (above).
|
|
154
187
|
|
|
155
|
-
##
|
|
188
|
+
## Closed
|
|
156
189
|
|
|
157
|
-
-
|
|
158
|
-
|
|
159
|
-
`Bao.
|
|
160
|
-
|
|
161
|
-
guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
|
|
162
|
-
helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
|
|
163
|
-
reconcile re-plans the move from the stated path.)
|
|
190
|
+
- ✅ **Nine families checked the identity after `isResolved(news)`** and never read the target
|
|
191
|
+
(closed 2026-09-21 by `rename-identity.ts`, measured in `rename-families.test.ts`).
|
|
192
|
+
`Bao.Mount` and `Bao.AuthMethod` never needed it: their reconcile re-plans the move from the
|
|
193
|
+
stated path.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ownership rule for the five families fake-core.ts carries — Bao.Policy, Bao.CloudflareRole,
|
|
3
|
+
* Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — through Alchemy's own plan and apply: the same
|
|
4
|
+
* claims adopt-families.test.ts and adopt-recovery.test.ts make for the other nine.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { describe, it } from 'node:test';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { BaoAuthRole } from './auth-role.ts';
|
|
12
|
+
import { crashAfterWrite } from './fake-bao.ts';
|
|
13
|
+
import { CORE, type CoreRow, withCore } from './fake-core.ts';
|
|
14
|
+
import { writesOf } from './fake-stack.ts';
|
|
15
|
+
import { BaoMount } from './mount.ts';
|
|
16
|
+
|
|
17
|
+
const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
|
|
18
|
+
const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
|
|
19
|
+
|
|
20
|
+
/** `Z`, with its knob an Output until apply: an upstream AppRole's `tokenTtl`, created with it. */
|
|
21
|
+
const pending = (row: CoreRow) =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
24
|
+
name: 'upstream',
|
|
25
|
+
secretIdTtl: '24h',
|
|
26
|
+
tokenMaxTtl: '1h',
|
|
27
|
+
tokenPolicies: ['default'],
|
|
28
|
+
tokenTtl: '15m',
|
|
29
|
+
});
|
|
30
|
+
yield* row.declare('Z', up.tokenTtl);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const row of CORE) {
|
|
34
|
+
describe(`${row.family}: ownership`, () => {
|
|
35
|
+
it('a live object another stack owns, identical, fails the plan; nothing is written', async () => {
|
|
36
|
+
await withCore(async (stack, estate, seen, owner) => {
|
|
37
|
+
await owner.deploy(row.declare('X', '15m'));
|
|
38
|
+
seen.length = 0;
|
|
39
|
+
await assert.rejects(stack.deploy(row.declare('Z', '15m')), NOT_OWNED);
|
|
40
|
+
assert.deepEqual(writesOf(seen), []);
|
|
41
|
+
assert.ok(row.has(estate));
|
|
42
|
+
assert.deepEqual(await stack.deploy(row.declare('Z', '15m'), { adopt: true }), {
|
|
43
|
+
Z: 'adopted',
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(writesOf(seen), []);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('with a prop still an Output, the apply refuses it; the next plan refuses too', async () => {
|
|
50
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
51
|
+
await owner.deploy(row.declare('X', '15m'));
|
|
52
|
+
seen.length = 0;
|
|
53
|
+
await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
|
|
54
|
+
// ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
|
|
55
|
+
await assert.rejects(stack.deploy(pending(row)), NOT_OWNED);
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
writesOf(seen).filter((line) => !line.endsWith('/upstream')),
|
|
58
|
+
[],
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('with a prop still an Output, --adopt takes it over at apply', async () => {
|
|
64
|
+
await withCore(async (stack, _estate, _seen, owner) => {
|
|
65
|
+
await owner.deploy(row.declare('X', '15m'));
|
|
66
|
+
assert.deepEqual(await stack.deploy(pending(row), { adopt: true }), {
|
|
67
|
+
Upstream: 'create',
|
|
68
|
+
Z: 'create',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('an interrupted create is resumed without --adopt, and nothing is rewritten', async () => {
|
|
74
|
+
const fault = crashAfterWrite();
|
|
75
|
+
await withCore(async (stack, estate, seen) => {
|
|
76
|
+
// ★ Bao.Policy reads nothing back after its write, so the crash is the write's own reply.
|
|
77
|
+
fault.crashNext(row.family === 'Bao.Policy' ? 'write' : 'read-back');
|
|
78
|
+
await assert.rejects(stack.deploy(row.declare('X', '15m')), /injected/);
|
|
79
|
+
assert.ok(row.has(estate));
|
|
80
|
+
seen.length = 0;
|
|
81
|
+
assert.deepEqual(await stack.deploy(row.declare('X', '15m')), { X: 'create' });
|
|
82
|
+
assert.deepEqual(writesOf(seen), []);
|
|
83
|
+
}, fault.wrap);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('Bao.Mount: remountFrom on a create', () => {
|
|
89
|
+
const moved = BaoMount('Kv', { path: 'kv-moved', remountFrom: 'kv', type: 'kv' });
|
|
90
|
+
const SOURCE = /Bao\.Mount kv \(the remountFrom source\): already exists/;
|
|
91
|
+
|
|
92
|
+
it('moving a live mount this stack holds no state for is refused before the move', async () => {
|
|
93
|
+
await withCore(async (stack, estate, seen, owner) => {
|
|
94
|
+
await owner.deploy(BaoMount('Src', { path: 'kv', type: 'kv' }));
|
|
95
|
+
seen.length = 0;
|
|
96
|
+
// ★ The probe reads only the target, which is free: the plan says create.
|
|
97
|
+
await assert.rejects(stack.deploy(moved), SOURCE);
|
|
98
|
+
assert.deepEqual(writesOf(seen), []);
|
|
99
|
+
assert.deepEqual([...estate.mounts.table.keys()], ['kv/']);
|
|
100
|
+
assert.deepEqual(await stack.deploy(moved, { adopt: true }), { Kv: 'create' });
|
|
101
|
+
assert.deepEqual([...estate.mounts.table.keys()], ['kv-moved/']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Bao.Mount: a create interrupted between its enable and its tune', () => {
|
|
107
|
+
it('is not proven ours — the mount is not what was declared — so it resumes with --adopt', async () => {
|
|
108
|
+
const fault = crashAfterWrite();
|
|
109
|
+
const kv = BaoMount('Kv', { defaultLeaseTtl: '15m', path: 'kv', type: 'kv' });
|
|
110
|
+
await withCore(async (stack, estate) => {
|
|
111
|
+
fault.crashNext('write');
|
|
112
|
+
await assert.rejects(stack.deploy(kv), /injected/);
|
|
113
|
+
assert.ok(estate.mounts.table.has('kv/'));
|
|
114
|
+
await assert.rejects(stack.deploy(kv), /Cannot resume creating resource 'Kv'.*--adopt/s);
|
|
115
|
+
assert.deepEqual(await stack.deploy(kv, { adopt: true }), { Kv: 'create' });
|
|
116
|
+
assert.deepEqual(await stack.deploy(kv), { Kv: 'noop' });
|
|
117
|
+
}, fault.wrap);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTHING IS ADOPTED WITHOUT `--adopt` (decided 2026-09-21), for the nine batch-2 families, through
|
|
3
|
+
* Alchemy's own plan and apply (fake-stack.ts). The object is put there by ANOTHER stack over the
|
|
4
|
+
* same fake (`owner`) — the case the rule exists for: identical to this declaration, and still
|
|
5
|
+
* not ours.
|
|
6
|
+
* · plan time: the adoption probe answers `Unowned` (ownership/probe.ts);
|
|
7
|
+
* · apply time: where the probe never ran — a prop still an Output — reconcile refuses before any
|
|
8
|
+
* write (ownership/adopt.ts).
|
|
9
|
+
* adopt-recovery.test.ts pins the other side: our own interrupted create is still resumed.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
12
|
+
*/
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import type { Seen } from './fake-bao.ts';
|
|
18
|
+
import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
|
|
19
|
+
import { writesOf } from './fake-stack.ts';
|
|
20
|
+
|
|
21
|
+
const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
|
|
22
|
+
const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
|
|
23
|
+
const STILL_REFUSED = /Cannot adopt resource 'Z'.*--adopt/s;
|
|
24
|
+
|
|
25
|
+
/** `Z` names `a`, with its knob an Output until apply: the upstream AppRole is created with it. */
|
|
26
|
+
const pending = (row: Family, scoped?: boolean) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const up = yield* upstream('15m');
|
|
29
|
+
const declared = row.declare('Z', 'a', up.tokenTtl);
|
|
30
|
+
yield* scoped === undefined ? declared : declared.pipe(adopt(scoped));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Writes other than the upstream AppRole's own create. */
|
|
34
|
+
const writesToA = (seen: readonly Seen[]) =>
|
|
35
|
+
writesOf(seen).filter((line) => !line.endsWith('/upstream'));
|
|
36
|
+
|
|
37
|
+
for (const row of FAMILIES) {
|
|
38
|
+
describe(`${row.family}: a live object another stack owns`, () => {
|
|
39
|
+
it('identical to the declaration, still fails the plan; nothing is written', async () => {
|
|
40
|
+
await withEstate(async (stack, estate, seen, owner) => {
|
|
41
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
42
|
+
seen.length = 0;
|
|
43
|
+
await assert.rejects(stack.deploy(row.declare('Z', 'a', '15m')), NOT_OWNED);
|
|
44
|
+
assert.deepEqual(writesOf(seen), []);
|
|
45
|
+
assert.ok(row.has(estate, 'a'));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is adopted with --adopt, and with adopt(true) — identical, so nothing is written', async () => {
|
|
50
|
+
for (const deploy of ['flag', 'scope'] as const) {
|
|
51
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
52
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
53
|
+
seen.length = 0;
|
|
54
|
+
const body =
|
|
55
|
+
deploy === 'flag'
|
|
56
|
+
? stack.deploy(row.declare('Z', 'a', '15m'), { adopt: true })
|
|
57
|
+
: stack.deploy(row.declare('Z', 'a', '15m').pipe(adopt(true)));
|
|
58
|
+
assert.deepEqual(await body, { Z: 'adopted' });
|
|
59
|
+
assert.deepEqual(writesOf(seen), []);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('with a prop still an Output, the plan cannot ask, so the apply refuses', async () => {
|
|
65
|
+
await withEstate(async (stack, estate, seen, owner) => {
|
|
66
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
67
|
+
seen.length = 0;
|
|
68
|
+
await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
|
|
69
|
+
assert.deepEqual(writesToA(seen), []);
|
|
70
|
+
assert.ok(row.has(estate, 'a'));
|
|
71
|
+
// ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
|
|
72
|
+
await assert.rejects(stack.deploy(pending(row)), STILL_REFUSED);
|
|
73
|
+
assert.deepEqual(writesToA(seen), []);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('with a prop still an Output, --adopt and adopt(true) take it over at apply', async () => {
|
|
78
|
+
for (const deploy of ['flag', 'scope'] as const) {
|
|
79
|
+
await withEstate(async (stack, _estate, _seen, owner) => {
|
|
80
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
81
|
+
const planned =
|
|
82
|
+
deploy === 'flag'
|
|
83
|
+
? await stack.deploy(pending(row), { adopt: true })
|
|
84
|
+
: await stack.deploy(pending(row, true));
|
|
85
|
+
assert.deepEqual(planned, { Upstream: 'create', Z: 'create' });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('adopt(false) on the resource wins over --adopt, at apply as at plan', async () => {
|
|
91
|
+
await withEstate(async (stack, _estate, _seen, owner) => {
|
|
92
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
93
|
+
await assert.rejects(stack.deploy(pending(row, false), { adopt: true }), TAKEOVER);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deploy killed BEFORE a create's claim ran leaves a `creating` (or `replacing`) row that proves
|
|
3
|
+
* nothing: its create never asked whose object sat at its identity. Found by the red team on
|
|
4
|
+
* 2026-09-21, through Alchemy's own plan and apply, before ownership/whole.ts and the proof in
|
|
5
|
+
* ownership/resume.ts:
|
|
6
|
+
* · an Output IDENTITY — every role family read an empty name, found nothing, "resumed" the create
|
|
7
|
+
* and wrote over another owner's object;
|
|
8
|
+
* · an Output KNOB — Bao.Mount and Bao.AuthMethod read the hole as "not managed", adopted another
|
|
9
|
+
* owner's mount in the recovery read and tuned it;
|
|
10
|
+
* · a REPLACE killed before its write — the next deploy wrote over whatever another owner had put
|
|
11
|
+
* at the new identity since.
|
|
12
|
+
* Each is now refused, writing nothing; `--adopt` resumes it, as Alchemy offers for a create.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
15
|
+
*/
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { BaoAuthRole } from './auth-role.ts';
|
|
21
|
+
import { type Seen, refuseWriteOnce } from './fake-bao.ts';
|
|
22
|
+
import { CORE, type CoreRow, withCore } from './fake-core.ts';
|
|
23
|
+
import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
|
|
24
|
+
import { writesOf } from './fake-stack.ts';
|
|
25
|
+
import { BaoMount } from './mount.ts';
|
|
26
|
+
import { BaoPkiRole } from './pki-role.ts';
|
|
27
|
+
|
|
28
|
+
const REFUSED =
|
|
29
|
+
/Cannot resume creating resource 'Z'|: already exists, and this stack holds no state/s;
|
|
30
|
+
const RESUME_REFUSED = /already exists, and this stack holds no state.*Deploy with --adopt/s;
|
|
31
|
+
|
|
32
|
+
/** Writes other than the upstream's. */
|
|
33
|
+
const writesBut = (seen: readonly Seen[], upstreamPath: string) =>
|
|
34
|
+
writesOf(seen).filter((line) => !line.endsWith(upstreamPath));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `Z`'s NAME is an Output: an upstream named `a` hands it over. An AppRole, or — for Bao.AuthRole,
|
|
38
|
+
* whose own `a` that would be — a PKI role.
|
|
39
|
+
*/
|
|
40
|
+
const namedByUpstream = (row: Family) => {
|
|
41
|
+
const approle = row.family !== 'Bao.AuthRole';
|
|
42
|
+
const path = approle ? '/auth/approle/role/a' : '/pki/roles/a';
|
|
43
|
+
const body = Effect.gen(function* () {
|
|
44
|
+
const up = approle
|
|
45
|
+
? yield* BaoAuthRole('Upstream', {
|
|
46
|
+
name: 'a',
|
|
47
|
+
secretIdTtl: '24h',
|
|
48
|
+
tokenMaxTtl: '1h',
|
|
49
|
+
tokenPolicies: ['default'],
|
|
50
|
+
tokenTtl: '15m',
|
|
51
|
+
})
|
|
52
|
+
: yield* BaoPkiRole('Upstream', {
|
|
53
|
+
allowedDomains: ['x.test'],
|
|
54
|
+
maxTtl: '1h',
|
|
55
|
+
name: 'a',
|
|
56
|
+
ttl: '30m',
|
|
57
|
+
});
|
|
58
|
+
yield* row.declare('Z', up.name, '15m');
|
|
59
|
+
});
|
|
60
|
+
return { body, path };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (const row of FAMILIES) {
|
|
64
|
+
describe(`${row.family}: a create killed before its claim, its name an Output`, () => {
|
|
65
|
+
it('never writes over the object another stack owns; --adopt takes it over', async () => {
|
|
66
|
+
const { body, path } = namedByUpstream(row);
|
|
67
|
+
const fault = refuseWriteOnce(path);
|
|
68
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
69
|
+
await owner.deploy(row.declare('X', 'a', '30m'));
|
|
70
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
71
|
+
seen.length = 0;
|
|
72
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
73
|
+
assert.deepEqual(writesBut(seen, path), []);
|
|
74
|
+
await stack.deploy(body, { adopt: true });
|
|
75
|
+
}, fault);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
|
|
80
|
+
it('never adopts the object another stack owns', async () => {
|
|
81
|
+
const fault = refuseWriteOnce('/upstream');
|
|
82
|
+
const body = Effect.gen(function* () {
|
|
83
|
+
yield* row.declare('Z', 'a', (yield* upstream('15m')).tokenTtl);
|
|
84
|
+
});
|
|
85
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
86
|
+
await owner.deploy(row.declare('X', 'a', '30m'));
|
|
87
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
88
|
+
seen.length = 0;
|
|
89
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
90
|
+
assert.deepEqual(writesBut(seen, '/upstream'), []);
|
|
91
|
+
}, fault);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `Z` at the row's fixed identity, its knob an upstream AppRole's `tokenTtl`. */
|
|
97
|
+
const pendingCore = (row: CoreRow) =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
100
|
+
name: 'upstream',
|
|
101
|
+
secretIdTtl: '24h',
|
|
102
|
+
tokenMaxTtl: '1h',
|
|
103
|
+
tokenPolicies: ['default'],
|
|
104
|
+
tokenTtl: '15m',
|
|
105
|
+
});
|
|
106
|
+
yield* row.declare('Z', up.tokenTtl);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const row of CORE) {
|
|
110
|
+
describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
|
|
111
|
+
it('never adopts — or tunes — the object another stack owns; --adopt takes it over', async () => {
|
|
112
|
+
const fault = refuseWriteOnce('/upstream');
|
|
113
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
114
|
+
await owner.deploy(row.declare('X', '30m'));
|
|
115
|
+
await assert.rejects(stack.deploy(pendingCore(row)), /injected/);
|
|
116
|
+
seen.length = 0;
|
|
117
|
+
await assert.rejects(stack.deploy(pendingCore(row)), REFUSED);
|
|
118
|
+
assert.deepEqual(writesBut(seen, '/upstream'), []);
|
|
119
|
+
await stack.deploy(pendingCore(row), { adopt: true });
|
|
120
|
+
}, fault);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('Bao.Mount: a create killed before its claim, its path an Output', () => {
|
|
126
|
+
it('never tunes the mount another stack owns; --adopt takes it over', async () => {
|
|
127
|
+
const fault = refuseWriteOnce('/auth/approle/role/kv');
|
|
128
|
+
const body = Effect.gen(function* () {
|
|
129
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
130
|
+
name: 'kv',
|
|
131
|
+
secretIdTtl: '24h',
|
|
132
|
+
tokenMaxTtl: '1h',
|
|
133
|
+
tokenPolicies: ['default'],
|
|
134
|
+
tokenTtl: '15m',
|
|
135
|
+
});
|
|
136
|
+
yield* BaoMount('Z', { defaultLeaseTtl: '15m', path: up.name, type: 'kv' });
|
|
137
|
+
});
|
|
138
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
139
|
+
await owner.deploy(BaoMount('X', { defaultLeaseTtl: '30m', path: 'kv', type: 'kv' }));
|
|
140
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
141
|
+
seen.length = 0;
|
|
142
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
143
|
+
assert.deepEqual(writesBut(seen, '/auth/approle/role/kv'), []);
|
|
144
|
+
await stack.deploy(body, { adopt: true });
|
|
145
|
+
}, fault);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
|
|
150
|
+
for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
|
|
151
|
+
describe(`${row.family}: a replace killed before its write`, () => {
|
|
152
|
+
it('never writes over what another stack put at the new identity since; --adopt does', async () => {
|
|
153
|
+
const suffix = row.family === 'Bao.JwtAuthConfig' ? '/b/config' : '/b';
|
|
154
|
+
const fault = refuseWriteOnce(suffix);
|
|
155
|
+
const { retain } = RemovalPolicy;
|
|
156
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
157
|
+
await stack.deploy(row.declare('X', 'a', '15m', retain));
|
|
158
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
|
|
159
|
+
await owner.deploy(row.declare('O', 'b', '30m'));
|
|
160
|
+
seen.length = 0;
|
|
161
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), RESUME_REFUSED);
|
|
162
|
+
assert.deepEqual(writesOf(seen), []);
|
|
163
|
+
await stack.deploy(row.declare('X', 'b', '15m', retain), { adopt: true });
|
|
164
|
+
assert.notDeepEqual(writesOf(seen), []);
|
|
165
|
+
}, fault);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|