@homeflare/alchemy 0.6.0 → 0.8.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 +74 -41
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +51 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +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.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 +342 -8
- package/dist/launchd/index.js.map +16 -12
- 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/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 +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- 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 +56 -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-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- 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-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- 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 +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- 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 +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- 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/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- 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 +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -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 +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- 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 +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -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-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rename safety for the families whose identity is a name, a mount and a name, or a catalog key:
|
|
3
|
+
* the primitives. rename-identity.ts builds each family's diff and reconcile step from them, and
|
|
4
|
+
* REPLACE.md has the audit and what each answer does.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE BUG THIS CLOSES, REPRODUCED 2026-09-21 AGAINST alchemy@2.0.0-beta.79's OWN PLAN AND APPLY
|
|
7
|
+
* (fake-stack.ts): a Bao.Policy deployed as `old`, then redeclared as `new` under
|
|
8
|
+
* `RemovalPolicy.destroy()`, planned `update` and left BOTH policies live. diff read the NEW
|
|
9
|
+
* path, found nothing, answered `update`, and reconcile wrote the new object. The old one stayed
|
|
10
|
+
* live under no state record, so no later plan would mention it, and not even `destroy` could
|
|
11
|
+
* remove it.
|
|
12
|
+
* ★ A CHANGED IDENTITY IS A DIFFERENT OBJECT, SO THE ANSWER IS `replace`. Create-first
|
|
13
|
+
* (`deleteFirst` false): the new path never collides with the old, the new generation is written,
|
|
14
|
+
* dependents are updated in the same graph, and only then is the old generation deleted. The
|
|
15
|
+
* engine hands that delete the OLD generation's attributes (Apply.ts deleteOldGenerations and the
|
|
16
|
+
* GC pass both pass `output: old.attr`), and every delete here reads `output`, never `news`.
|
|
17
|
+
* ⛔ A new path that collides with ANOTHER live object is refused at plan instead (`judgeMove`).
|
|
18
|
+
* ⚠️ UNDER `retain` THE OLD OBJECT STAYS LIVE. Apply.ts:2164-2173 skips the old generation's delete
|
|
19
|
+
* and logs "Retaining replaced resource (removal policy: retain)". The plan still says `replace`
|
|
20
|
+
* and the apply says what it kept, so it is no longer silent, but the old object is unmanaged
|
|
21
|
+
* from then on. Remove it by hand, or opt into `RemovalPolicy.destroy()`.
|
|
22
|
+
*/
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import { baoRead } from './bao-http.ts';
|
|
25
|
+
|
|
26
|
+
const exact = (identity: string): string => identity;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A string prop of a diff's `news`, once that one prop is resolved; `undefined` while it is still an
|
|
30
|
+
* Output of a changing upstream (a function-typed proxy) or an Effect.
|
|
31
|
+
*
|
|
32
|
+
* ★ THE IDENTITY IS JUDGED BEFORE `isResolved(news)`. That guard returns `undefined` when ANY prop is
|
|
33
|
+
* pending, and the engine then falls back to `update` (Plan.ts, `havePropsChanged`). A rename that
|
|
34
|
+
* lands in the same deploy as a pending `fragments` or `policies` Output would slip through as
|
|
35
|
+
* `update`, which is the orphan again. launchd/host-effect.ts reads its identity the same way.
|
|
36
|
+
*/
|
|
37
|
+
export const declaredString = (news: unknown, key: string): string | undefined => {
|
|
38
|
+
if (typeof news !== 'object' || news === null) return undefined;
|
|
39
|
+
const value: unknown = (news as Record<string, unknown>)[key];
|
|
40
|
+
return typeof value === 'string' ? value : undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** True while an optional prop is declared but is not yet a value: an Output of a changing upstream. */
|
|
44
|
+
export const isPendingProp = (news: unknown, key: string): boolean => {
|
|
45
|
+
if (typeof news !== 'object' || news === null) return false;
|
|
46
|
+
const value: unknown = (news as Record<string, unknown>)[key];
|
|
47
|
+
return value !== undefined && typeof value !== 'string';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An optional string prop: `fallback` when it is absent, undefined while it is still an Output.
|
|
52
|
+
* ⚠️ Not `declaredString(…) ?? fallback`, which would read a pending mount as the default mount.
|
|
53
|
+
*/
|
|
54
|
+
export const declaredOr = (news: unknown, key: string, fallback: string): string | undefined =>
|
|
55
|
+
isPendingProp(news, key) ? undefined : (declaredString(news, key) ?? fallback);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A role's API path from a diff's `news`, once both `mount` and `name` are resolved. `defaultMount`
|
|
59
|
+
* is what an absent `mount` means; without one, an absent mount is unknown.
|
|
60
|
+
*/
|
|
61
|
+
export const declaredRolePath = (
|
|
62
|
+
news: unknown,
|
|
63
|
+
rolePath: (mount: string, name: string) => string,
|
|
64
|
+
defaultMount?: string,
|
|
65
|
+
): string | undefined => {
|
|
66
|
+
const mount =
|
|
67
|
+
defaultMount === undefined
|
|
68
|
+
? declaredString(news, 'mount')
|
|
69
|
+
: declaredOr(news, 'mount', defaultMount);
|
|
70
|
+
const name = declaredString(news, 'name');
|
|
71
|
+
return mount === undefined || name === undefined ? undefined : rolePath(mount, name);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How OpenBao keys an ACL policy: trimmed and lowercased. openbao v2.6.2
|
|
76
|
+
* vault/policy/policy_store.go:698-700 (`sanitizeName`), applied by SetPolicy (:268), GetPolicy
|
|
77
|
+
* (:378) and DeletePolicy (:562).
|
|
78
|
+
*
|
|
79
|
+
* ⛔ SO `Admin` → `admin` IS THE SAME POLICY, NOT A RENAME. A `replace` there would write it, and then,
|
|
80
|
+
* under `destroy`, delete the old generation, which is that same policy. Every grant it carries
|
|
81
|
+
* would be revoked by a change of case.
|
|
82
|
+
*/
|
|
83
|
+
export const policyKey = (name: string): string => name.trim().toLowerCase();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether the declared identity names a different object than the one in state: `true` → moved (the
|
|
87
|
+
* diff asks `judgeMove`, which also reads the target); `false` → the same object; `undefined` → not
|
|
88
|
+
* knowable yet, because the declared side is still an Output. `key` is how the SERVER keys the object (`policyKey`); the default is exact,
|
|
89
|
+
* which is right for a mount path (vault/routing/router.go folds no case) and for both plugins'
|
|
90
|
+
* roles (each stores `roles/<name>` verbatim).
|
|
91
|
+
*
|
|
92
|
+
* ⛔ NEVER `replace` ON AN UNKNOWN IDENTITY. The Output may resolve to the same path, and a replace
|
|
93
|
+
* onto the same path deletes, under `destroy`, the object it has just written. The diff defers
|
|
94
|
+
* instead. If the engine then plans `update` and the identity turns out to have changed,
|
|
95
|
+
* `refuseMovedUpdate` stops reconcile before it writes anything.
|
|
96
|
+
*/
|
|
97
|
+
export const isMoved = (
|
|
98
|
+
stored: string,
|
|
99
|
+
declared: string | undefined,
|
|
100
|
+
key: (identity: string) => string = exact,
|
|
101
|
+
): boolean | undefined => (declared === undefined ? undefined : key(stored) !== key(declared));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The diff's first question, asked before `isResolved(news)`: the move from the identity this
|
|
105
|
+
* resource last wrote or tried (`tried`) to the declared one, or undefined when both name the same
|
|
106
|
+
* object or either is still unknown. `pathOf` is the API path an identity is read at.
|
|
107
|
+
*
|
|
108
|
+
* ⛔ A MOVE ONTO AN OBJECT THAT ALREADY EXISTS FAILS THE PLAN. MEASURED 2026-09-21 through the engine
|
|
109
|
+
* (rename-occupied.test.ts): two policies that swapped names under `RemovalPolicy.destroy()` each
|
|
110
|
+
* planned `replace`, each new generation wrote over the other's live policy, and each old
|
|
111
|
+
* generation's delete then removed the name the other had just written. The deploy was green and
|
|
112
|
+
* both policies were gone, every grant revoked. A shift (a → b while b → c), and a move onto the
|
|
113
|
+
* name of a resource leaving the stack, were measured ending the same way. So was reverting a
|
|
114
|
+
* move whose new generation failed: the old generation still holds the name, and its delete runs
|
|
115
|
+
* after the revert has rewritten it. That is why `tried` falls back to the props of an unfinished
|
|
116
|
+
* write.
|
|
117
|
+
* ⚠️ THE DIFF CANNOT SEE THE REMOVAL POLICY, SO THIS REFUSES UNDER `retain` TOO, where a swap, or a
|
|
118
|
+
* move back onto a retained old generation, would have been harmless. The cost is a move in two
|
|
119
|
+
* deploys through a free name, or removing the target by hand. The alternative is a green deploy
|
|
120
|
+
* that deletes what it has just written.
|
|
121
|
+
* ★ ONE PLAN-TIME READ, OF A PATH THE NEW GENERATION'S RECONCILE READS ANYWAY. A 404, a missing mount
|
|
122
|
+
* included, is free; a refused read fails the plan, as any other diff read does.
|
|
123
|
+
*/
|
|
124
|
+
export const judgeMove = (
|
|
125
|
+
family: string,
|
|
126
|
+
tried: string | undefined,
|
|
127
|
+
declared: string | undefined,
|
|
128
|
+
pathOf: (identity: string) => string,
|
|
129
|
+
key: (identity: string) => string = exact,
|
|
130
|
+
) =>
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
if (tried === undefined || declared === undefined || key(tried) === key(declared)) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if ((yield* baoRead(pathOf(declared))) === undefined) return { from: tried, to: declared };
|
|
136
|
+
return yield* Effect.die(
|
|
137
|
+
new Error(
|
|
138
|
+
`${family}: ${tried} → ${declared} would land on an object that already exists at ` +
|
|
139
|
+
`${declared}. Under RemovalPolicy.destroy() a resource moving off that name (a swap, a ` +
|
|
140
|
+
'shift, or a reverted move that did not finish) deletes it after this one writes it. ' +
|
|
141
|
+
'Nothing was written. Move through a name nothing holds, in two deploys, or remove ' +
|
|
142
|
+
`${declared} by hand first.`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reconcile's guard: an `update` whose prior attributes name another object.
|
|
149
|
+
*
|
|
150
|
+
* ★ REACHED ONLY WHEN THE DIFF COULD NOT SEE THE NEW IDENTITY at plan time. A fresh create, and the
|
|
151
|
+
* new generation of a `replace`, both reach reconcile with `output` undefined (Apply.ts passes
|
|
152
|
+
* `output: attr`, which is empty for a new generation). An `update` reaches it with the previous
|
|
153
|
+
* generation's attributes.
|
|
154
|
+
* ★ THE FAILED DEPLOY SETTLES ITSELF. Apply has already committed the row as `updating`, with the new
|
|
155
|
+
* props and the OLD attributes. By the next plan the upstream Output has landed, so the diff sees
|
|
156
|
+
* both names and answers `replace` (or refuses, if the new name is already taken: `judgeMove`).
|
|
157
|
+
* The old object is then deleted, or retained, by the removal policy, with no hand repair.
|
|
158
|
+
*/
|
|
159
|
+
export const refuseMovedUpdate = (family: string, from: string, to: string) =>
|
|
160
|
+
Effect.die(
|
|
161
|
+
new Error(
|
|
162
|
+
`${family}: planned as an update, but its identity moved from ${from} to ${to}. Writing ` +
|
|
163
|
+
`${to} now would leave ${from} live with no state record. Nothing was written. Run the ` +
|
|
164
|
+
'deploy again: once the value resolves at plan time it plans as a replace.',
|
|
165
|
+
),
|
|
166
|
+
);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a Bao.CloudflareRole or a Bao.ProxmoxRole to a new `mount` or `name`, through Alchemy's own
|
|
3
|
+
* plan and apply (fake-stack.ts) against fakes of each engine's role store (fake-engines.ts). Before
|
|
4
|
+
* 2026-09-21 every move below planned `update` and left the old role minting.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import * as Output from 'alchemy/Output';
|
|
9
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
13
|
+
import { groupKey } from './cloudflare-group-scope.ts';
|
|
14
|
+
import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
|
|
15
|
+
import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
16
|
+
import type { BaoCloudflareRoleProps } from './cloudflare-role-form.ts';
|
|
17
|
+
import type { Seen } from './fake-bao.ts';
|
|
18
|
+
import { type Store, cloudflareRoles, proxmoxRoles } from './fake-engines.ts';
|
|
19
|
+
import { type StackBody, withFakeStack, writesOf as writes } from './fake-stack.ts';
|
|
20
|
+
import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
|
|
21
|
+
import type { BaoProxmoxRoleProps } from './proxmox-role-form.ts';
|
|
22
|
+
|
|
23
|
+
/** Every group resolves, to an id named after it — this file is about paths, not groups. */
|
|
24
|
+
const groups = Layer.succeed(CloudflarePermissionGroups, {
|
|
25
|
+
resolve: (_mount, refs) =>
|
|
26
|
+
Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
|
|
27
|
+
});
|
|
28
|
+
const providers = Layer.mergeAll(
|
|
29
|
+
BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
|
|
30
|
+
BaoProxmoxRoleProvider(),
|
|
31
|
+
FetchHttpClient.layer,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
type Removal = typeof RemovalPolicy.destroy;
|
|
35
|
+
|
|
36
|
+
const withStack = (
|
|
37
|
+
store: Store,
|
|
38
|
+
body: (deploy: (body: StackBody) => Promise<unknown>, seen: Seen[]) => Promise<void>,
|
|
39
|
+
) => withFakeStack(providers, store, (stack, bao) => body(stack.deploy, bao.seen));
|
|
40
|
+
|
|
41
|
+
const livePaths = (store: Store) => [...store.live.keys()].sort();
|
|
42
|
+
|
|
43
|
+
const CF: BaoCloudflareRoleProps = {
|
|
44
|
+
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
45
|
+
maxTtl: '1h',
|
|
46
|
+
mount: 'cloudflare-acme-dns',
|
|
47
|
+
name: 'example-com-dns-read',
|
|
48
|
+
policies: [
|
|
49
|
+
{
|
|
50
|
+
effect: 'allow',
|
|
51
|
+
groupOrder: 'declared',
|
|
52
|
+
groups: ['DNS Read'],
|
|
53
|
+
resource: 'com.cloudflare.api.account.zone.z1',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
ttl: '5m',
|
|
57
|
+
};
|
|
58
|
+
const cf = (props: Partial<BaoCloudflareRoleProps>, removal: Removal = RemovalPolicy.retain) =>
|
|
59
|
+
Effect.asVoid(BaoCloudflareRole('Role', { ...CF, ...props }).pipe(removal()));
|
|
60
|
+
|
|
61
|
+
describe('Bao.CloudflareRole move, through the engine', () => {
|
|
62
|
+
it('a new name plans replace and deletes the OLD path under destroy', async () => {
|
|
63
|
+
const store = cloudflareRoles();
|
|
64
|
+
await withStack(store, async (deploy, seen) => {
|
|
65
|
+
await deploy(cf({}, RemovalPolicy.destroy));
|
|
66
|
+
seen.length = 0;
|
|
67
|
+
assert.deepEqual(await deploy(cf({ name: 'example-com-dns-edit' }, RemovalPolicy.destroy)), {
|
|
68
|
+
Role: 'replace',
|
|
69
|
+
});
|
|
70
|
+
assert.deepEqual(writes(seen), [
|
|
71
|
+
'PUT /v1/cloudflare-acme-dns/roles/example-com-dns-edit',
|
|
72
|
+
'DELETE /v1/cloudflare-acme-dns/roles/example-com-dns-read',
|
|
73
|
+
]);
|
|
74
|
+
assert.deepEqual(livePaths(store), ['cloudflare-acme-dns/roles/example-com-dns-edit']);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('a new mount plans replace, and the default retain keeps the old role live', async () => {
|
|
79
|
+
const store = cloudflareRoles();
|
|
80
|
+
await withStack(store, async (deploy, seen) => {
|
|
81
|
+
await deploy(cf({}));
|
|
82
|
+
assert.deepEqual(await deploy(cf({ mount: 'cloudflare-other-dns' })), { Role: 'replace' });
|
|
83
|
+
assert.ok(!writes(seen).some((each) => each.startsWith('DELETE')));
|
|
84
|
+
assert.deepEqual(livePaths(store), [
|
|
85
|
+
'cloudflare-acme-dns/roles/example-com-dns-read',
|
|
86
|
+
'cloudflare-other-dns/roles/example-com-dns-read',
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('a trailing slash on the mount is the same path: noop, nothing deleted', async () => {
|
|
92
|
+
const store = cloudflareRoles();
|
|
93
|
+
await withStack(store, async (deploy, seen) => {
|
|
94
|
+
await deploy(cf({}, RemovalPolicy.destroy));
|
|
95
|
+
seen.length = 0;
|
|
96
|
+
const planned = await deploy(cf({ mount: 'cloudflare-acme-dns/' }, RemovalPolicy.destroy));
|
|
97
|
+
assert.deepEqual(planned, { Role: 'noop' });
|
|
98
|
+
assert.deepEqual(writes(seen), []);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const PVE: BaoProxmoxRoleProps = {
|
|
104
|
+
maxTtl: '6h',
|
|
105
|
+
mintUser: 'reader@pve',
|
|
106
|
+
mount: 'proxmox-lab',
|
|
107
|
+
name: 'read',
|
|
108
|
+
ttl: '1h',
|
|
109
|
+
};
|
|
110
|
+
const pve = (props: Partial<BaoProxmoxRoleProps>, removal: Removal = RemovalPolicy.retain) =>
|
|
111
|
+
Effect.asVoid(BaoProxmoxRole('Role', { ...PVE, ...props }).pipe(removal()));
|
|
112
|
+
|
|
113
|
+
describe('Bao.ProxmoxRole move, through the engine', () => {
|
|
114
|
+
it('a new name plans replace and deletes the OLD path under destroy', async () => {
|
|
115
|
+
const store = proxmoxRoles();
|
|
116
|
+
await withStack(store, async (deploy, seen) => {
|
|
117
|
+
await deploy(pve({}, RemovalPolicy.destroy));
|
|
118
|
+
seen.length = 0;
|
|
119
|
+
assert.deepEqual(await deploy(pve({ name: 'reader' }, RemovalPolicy.destroy)), {
|
|
120
|
+
Role: 'replace',
|
|
121
|
+
});
|
|
122
|
+
assert.deepEqual(writes(seen), [
|
|
123
|
+
'PUT /v1/proxmox-lab/roles/reader',
|
|
124
|
+
'DELETE /v1/proxmox-lab/roles/read',
|
|
125
|
+
]);
|
|
126
|
+
assert.deepEqual(livePaths(store), ['proxmox-lab/roles/reader']);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('refuses a rename that also re-scopes mint_user, at plan, before any write', async () => {
|
|
131
|
+
const store = proxmoxRoles();
|
|
132
|
+
await withStack(store, async (deploy, seen) => {
|
|
133
|
+
await deploy(pve({}));
|
|
134
|
+
seen.length = 0;
|
|
135
|
+
const moved = pve({ mintUser: 'provisioner@pve', name: 'provision' });
|
|
136
|
+
await assert.rejects(deploy(moved), /allowMintUserChange: 'reader@pve'/);
|
|
137
|
+
assert.deepEqual(writes(seen), []);
|
|
138
|
+
assert.deepEqual(livePaths(store), ['proxmox-lab/roles/read']);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('lets the re-scoping rename through when it names the mint user it replaces', async () => {
|
|
143
|
+
const store = proxmoxRoles();
|
|
144
|
+
await withStack(store, async (deploy) => {
|
|
145
|
+
await deploy(pve({}));
|
|
146
|
+
const allowed = pve({
|
|
147
|
+
allowMintUserChange: 'reader@pve',
|
|
148
|
+
mintUser: 'provisioner@pve',
|
|
149
|
+
name: 'provision',
|
|
150
|
+
});
|
|
151
|
+
assert.deepEqual(await deploy(allowed), { Role: 'replace' });
|
|
152
|
+
assert.equal(store.live.get('proxmox-lab/roles/provision')?.['mint_user'], 'provisioner@pve');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps a same-path mint_user change an update, refused at apply as before', async () => {
|
|
157
|
+
const store = proxmoxRoles();
|
|
158
|
+
await withStack(store, async (deploy, seen) => {
|
|
159
|
+
await deploy(pve({}));
|
|
160
|
+
seen.length = 0;
|
|
161
|
+
await assert.rejects(deploy(pve({ mintUser: 'provisioner@pve' })), /re-scopes mint_user/);
|
|
162
|
+
assert.deepEqual(writes(seen), []);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* ★ The name is an Output of an upstream role whose `ttl` is changing, so no diff can see it at plan.
|
|
169
|
+
* Reconcile refuses the `update`; the next deploy, with the value settled, plans `replace`.
|
|
170
|
+
*/
|
|
171
|
+
describe('an update across a move the diff could not see', () => {
|
|
172
|
+
it('Bao.CloudflareRole: refused before any write, then replaced', async () => {
|
|
173
|
+
const store = cloudflareRoles();
|
|
174
|
+
const body = (ttl: string): StackBody =>
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
const up = yield* BaoCloudflareRole('Upstream', { ...CF, name: 'upstream', ttl });
|
|
177
|
+
const name = Output.interpolate`role-${up.ttl}`;
|
|
178
|
+
yield* BaoCloudflareRole('Role', { ...CF, name }).pipe(RemovalPolicy.destroy());
|
|
179
|
+
});
|
|
180
|
+
await withStack(store, async (deploy) => {
|
|
181
|
+
await deploy(body('5m'));
|
|
182
|
+
await assert.rejects(deploy(body('10m')), /identity moved from .*role-300 to .*role-600/);
|
|
183
|
+
assert.ok(!store.live.has('cloudflare-acme-dns/roles/role-600'));
|
|
184
|
+
assert.deepEqual(await deploy(body('10m')), { Role: 'replace', Upstream: 'noop' });
|
|
185
|
+
assert.ok(!store.live.has('cloudflare-acme-dns/roles/role-300'));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('Bao.ProxmoxRole: refused before any write, then replaced', async () => {
|
|
190
|
+
const store = proxmoxRoles();
|
|
191
|
+
const body = (ttl: string): StackBody =>
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
const up = yield* BaoProxmoxRole('Upstream', { ...PVE, name: 'upstream', ttl });
|
|
194
|
+
const name = Output.interpolate`r-${up.ttl}`;
|
|
195
|
+
yield* BaoProxmoxRole('Role', { ...PVE, name }).pipe(RemovalPolicy.destroy());
|
|
196
|
+
});
|
|
197
|
+
await withStack(store, async (deploy) => {
|
|
198
|
+
await deploy(body('1h'));
|
|
199
|
+
await assert.rejects(deploy(body('2h')), /identity moved from .*r-1h to .*r-2h/);
|
|
200
|
+
assert.ok(!store.live.has('proxmox-lab/roles/r-2h'));
|
|
201
|
+
assert.deepEqual(await deploy(body('2h')), { Role: 'replace', Upstream: 'noop' });
|
|
202
|
+
assert.deepEqual(livePaths(store), ['proxmox-lab/roles/r-2h', 'proxmox-lab/roles/upstream']);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.SshRole's guard against its own full-replace write: the live fields a write would reset that
|
|
3
|
+
* the declaration does not model. Split from ssh-role.ts, whose reconcile calls it.
|
|
4
|
+
*/
|
|
5
|
+
import { extMap } from './ssh-role-form.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fields this resource deliberately does not model, mapped to the value OpenBao's own write
|
|
9
|
+
* puts there when nobody says otherwise. The templating flags change how a principal is
|
|
10
|
+
* DERIVED at signing time, which is a different kind of decision from which principals
|
|
11
|
+
* exist; the rest are issuance mechanics. MEASURED against all three live roles — every one
|
|
12
|
+
* of them sits on exactly these values, so the guard below fires on nothing that exists.
|
|
13
|
+
*/
|
|
14
|
+
const UNMANAGED_DEFAULTS: Readonly<Record<string, unknown>> = {
|
|
15
|
+
algorithm_signer: 'default',
|
|
16
|
+
allow_commas_in_identity_templates: false,
|
|
17
|
+
allow_user_key_ids: false,
|
|
18
|
+
allowed_domains_template: false,
|
|
19
|
+
allowed_users_template: false,
|
|
20
|
+
default_extensions_template: false,
|
|
21
|
+
default_user_template: false,
|
|
22
|
+
issuer_ref: 'default',
|
|
23
|
+
key_id_format: '',
|
|
24
|
+
not_before_duration: 30,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ ABSENT AND EMPTY BOTH COUNT AS "NEVER SET". A role this resource wrote omits these
|
|
29
|
+
* fields entirely, so a stricter test would refuse on the provider's own output and
|
|
30
|
+
* deadlock the update after next.
|
|
31
|
+
*/
|
|
32
|
+
const isDefault = (live: Record<string, unknown>, field: string) =>
|
|
33
|
+
live[field] === undefined || live[field] === '' || live[field] === UNMANAGED_DEFAULTS[field];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
|
|
37
|
+
* someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
|
|
38
|
+
* `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
|
|
39
|
+
* survive clock skew, a non-default issuer — loses all of it the moment this resource
|
|
40
|
+
* decides to update. A vanished key-length floor is a security regression that no plan
|
|
41
|
+
* output would ever mention, and a vanished backdating window breaks signing on exactly
|
|
42
|
+
* the skewed host it was added for. So reconcile refuses instead of writing.
|
|
43
|
+
*/
|
|
44
|
+
export const wouldErase = (live: Record<string, unknown>): readonly string[] => {
|
|
45
|
+
const lost = Object.keys(UNMANAGED_DEFAULTS).filter((field) => !isDefault(live, field));
|
|
46
|
+
if (Object.keys(extMap(live['allowed_user_key_lengths'])).length > 0) {
|
|
47
|
+
lost.push('allowed_user_key_lengths');
|
|
48
|
+
}
|
|
49
|
+
return lost.sort();
|
|
50
|
+
};
|
package/src/openbao/ssh-role.ts
CHANGED
|
@@ -43,12 +43,13 @@ import {
|
|
|
43
43
|
type BaoSshRoleProps,
|
|
44
44
|
attributesOf,
|
|
45
45
|
badDurations,
|
|
46
|
-
extMap,
|
|
47
46
|
matches,
|
|
48
47
|
readPath,
|
|
49
48
|
resolve,
|
|
50
49
|
writeBody,
|
|
51
50
|
} from './ssh-role-form.ts';
|
|
51
|
+
import { wouldErase } from './ssh-role-erase.ts';
|
|
52
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
52
53
|
|
|
53
54
|
export type { BaoSshRoleAttributes, BaoSshRoleProps };
|
|
54
55
|
|
|
@@ -64,6 +65,9 @@ export const BaoSshRole = Resource<BaoSshRole>('Bao.SshRole', {
|
|
|
64
65
|
defaultRemovalPolicy: 'retain',
|
|
65
66
|
});
|
|
66
67
|
|
|
68
|
+
/** Exact: the engine stores `roles/<name>` verbatim (builtin/logical/ssh/path_roles.go:602). */
|
|
69
|
+
const IDENTITY = roleIdentity<BaoSshRoleAttributes>('Bao.SshRole', readPath, 'ssh');
|
|
70
|
+
|
|
67
71
|
/** Live role, plus the raw body — reconcile needs the raw half to see what it would erase. */
|
|
68
72
|
const readRole = (form: BaoSshRoleForm) =>
|
|
69
73
|
Effect.gen(function* () {
|
|
@@ -72,51 +76,6 @@ const readRole = (form: BaoSshRoleForm) =>
|
|
|
72
76
|
return { attributes: attributesOf(form, live), live };
|
|
73
77
|
});
|
|
74
78
|
|
|
75
|
-
/**
|
|
76
|
-
* Fields this resource deliberately does not model, mapped to the value OpenBao's own write
|
|
77
|
-
* puts there when nobody says otherwise. The templating flags change how a principal is
|
|
78
|
-
* DERIVED at signing time, which is a different kind of decision from which principals
|
|
79
|
-
* exist; the rest are issuance mechanics. MEASURED against all three live roles — every one
|
|
80
|
-
* of them sits on exactly these values, so the guard below fires on nothing that exists.
|
|
81
|
-
*/
|
|
82
|
-
const UNMANAGED_DEFAULTS: Readonly<Record<string, unknown>> = {
|
|
83
|
-
algorithm_signer: 'default',
|
|
84
|
-
allow_commas_in_identity_templates: false,
|
|
85
|
-
allow_user_key_ids: false,
|
|
86
|
-
allowed_domains_template: false,
|
|
87
|
-
allowed_users_template: false,
|
|
88
|
-
default_extensions_template: false,
|
|
89
|
-
default_user_template: false,
|
|
90
|
-
issuer_ref: 'default',
|
|
91
|
-
key_id_format: '',
|
|
92
|
-
not_before_duration: 30,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* ⚠️ ABSENT AND EMPTY BOTH COUNT AS "NEVER SET". A role this resource wrote omits these
|
|
97
|
-
* fields entirely, so a stricter test would refuse on the provider's own output and
|
|
98
|
-
* deadlock the update after next.
|
|
99
|
-
*/
|
|
100
|
-
const isDefault = (live: Record<string, unknown>, field: string) =>
|
|
101
|
-
live[field] === undefined || live[field] === '' || live[field] === UNMANAGED_DEFAULTS[field];
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
|
|
105
|
-
* someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
|
|
106
|
-
* `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
|
|
107
|
-
* survive clock skew, a non-default issuer — loses all of it the moment this resource
|
|
108
|
-
* decides to update. A vanished key-length floor is a security regression that no plan
|
|
109
|
-
* output would ever mention, and a vanished backdating window breaks signing on exactly
|
|
110
|
-
* the skewed host it was added for. So reconcile refuses instead of writing.
|
|
111
|
-
*/
|
|
112
|
-
const wouldErase = (live: Record<string, unknown>): readonly string[] => {
|
|
113
|
-
const lost = Object.keys(UNMANAGED_DEFAULTS).filter((field) => !isDefault(live, field));
|
|
114
|
-
if (Object.keys(extMap(live['allowed_user_key_lengths'])).length > 0) {
|
|
115
|
-
lost.push('allowed_user_key_lengths');
|
|
116
|
-
}
|
|
117
|
-
return lost.sort();
|
|
118
|
-
};
|
|
119
|
-
|
|
120
79
|
const refuse = (message: string) => Effect.die(new Error(message));
|
|
121
80
|
|
|
122
81
|
export const BaoSshRoleProvider = () =>
|
|
@@ -147,18 +106,19 @@ export const BaoSshRoleProvider = () =>
|
|
|
147
106
|
* at risk when something actually writes; if every managed field already matches,
|
|
148
107
|
* the honest answer is `noop` and nothing gets destroyed.
|
|
149
108
|
*/
|
|
150
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
151
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
152
|
-
const form = resolve(news);
|
|
109
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
153
110
|
/**
|
|
154
111
|
* ⛔ A RENAMED ROLE IS A NEW PATH, NOT AN EDIT. `ssh/roles/x` and `ssh-host/roles/x`
|
|
155
112
|
* are different mounts with different CAs. Without this, changing `name` or
|
|
156
113
|
* `mount` would write the new role and leave the old one signing certificates
|
|
157
|
-
* for as long as anyone remembered its name.
|
|
114
|
+
* for as long as anyone remembered its name. Judged before `isResolved(news)`, and
|
|
115
|
+
* ⛔ a move onto a role that exists fails the plan (rename-identity.ts).
|
|
158
116
|
*/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
117
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
118
|
+
if (output === undefined) return undefined;
|
|
119
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
120
|
+
if (!isResolved(news)) return undefined;
|
|
121
|
+
const form = resolve(news);
|
|
162
122
|
const found = yield* readRole(form);
|
|
163
123
|
if (found === undefined) return { action: 'update' } as const;
|
|
164
124
|
return matches(found.attributes, form)
|
|
@@ -166,9 +126,11 @@ export const BaoSshRoleProvider = () =>
|
|
|
166
126
|
: ({ action: 'update' } as const);
|
|
167
127
|
}),
|
|
168
128
|
|
|
169
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
129
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
170
130
|
const form = resolve(news);
|
|
171
131
|
const path = readPath(form.mount, form.name);
|
|
132
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
133
|
+
yield* guardRename(IDENTITY, news, output);
|
|
172
134
|
|
|
173
135
|
const bad = badDurations(form);
|
|
174
136
|
if (bad.length > 0) {
|