@homeflare/alchemy 0.8.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 +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- 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 +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.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.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- 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.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- 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-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.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.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-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- 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/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- 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/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- 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/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -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 +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- 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.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- 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 +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- 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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this apply RESUMES a create or replace the engine already started for this resource AND
|
|
3
|
+
* the live object it will find is proven that generation's own — the one case in which a stateless
|
|
4
|
+
* `reconcile` may write over a live object without `--adopt`.
|
|
5
|
+
*
|
|
6
|
+
* ★ HOW THE ENGINE TELLS US. Alchemy calls `diff` whenever a state row exists, and hands it `output`
|
|
7
|
+
* = that row's attributes (alchemy beta.79 Plan.ts). A row with NO attributes is exactly an
|
|
8
|
+
* unfinished generation: `creating` (a create interrupted after its write, before its commit),
|
|
9
|
+
* `replacing` (the same for a replace), or `deleting` re-driven as a create. A fresh create is
|
|
10
|
+
* never diffed at all. So the diff of such a row decides, and the apply that follows (the same
|
|
11
|
+
* instance id: Apply.ts reuses the row's) finds the note.
|
|
12
|
+
* ⛔ A ROW PROVES NOTHING BY ITSELF (red team, 2026-09-21). Apply commits it BEFORE reconcile runs, so
|
|
13
|
+
* a deploy killed before the claim — an upstream that failed, a Ctrl-C — leaves a row whose create
|
|
14
|
+
* never asked whose object sat at its identity. 🔴 MEASURED (openbao/adopt-holes.test.ts): with the
|
|
15
|
+
* name an Output, the next deploy "resumed" that create and wrote over another owner's role, in
|
|
16
|
+
* every family that had a note on the row alone. So the note is left only when the family's own
|
|
17
|
+
* `read`, asked as the recovery read (probe.ts ownedRead: a recorded instance, a row carrying the
|
|
18
|
+
* whole declaration, and a live object that matches it), answers "ours". For a `creating` row
|
|
19
|
+
* that is the question Plan.ts already asked; for a `replacing` row it is the only time it is asked.
|
|
20
|
+
* ⚠️ WHY NOT THE STATE STORE ALONE. At reconcile both look the same: Apply commits the fresh create's
|
|
21
|
+
* `creating` row before calling reconcile, so a row with this instance id is always there.
|
|
22
|
+
* ★ THE CHANNEL IS ALCHEMY'S OWN: `Artifacts`, the per-FQN bag the engine shares between one plan
|
|
23
|
+
* and its apply (Artifacts.ts: "diff computes … create / update reads the same artifact").
|
|
24
|
+
* Deploy.ts and the Alchemist session both hold one store across the pair.
|
|
25
|
+
* ⚠️ IT FAILS CLOSED. No bag, a bag the plan never wrote, or an object the read cannot prove ours
|
|
26
|
+
* reads as a fresh create, and adopt.ts then asks for `--adopt` — the safe direction.
|
|
27
|
+
* ★ AN UNPROVEN RESUME IS STILL MARKED UNFINISHED (`noteUnfinished`), so `--adopt` can take it over
|
|
28
|
+
* the way Alchemy's own "Cannot resume creating … Re-run with --adopt" offers for a create — while
|
|
29
|
+
* a FRESH replace's new generation, which no diff ever saw without attributes, stays unadoptable.
|
|
30
|
+
*/
|
|
31
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
32
|
+
import { Artifacts } from 'alchemy/Artifacts';
|
|
33
|
+
import * as Effect from 'effect/Effect';
|
|
34
|
+
import * as Option from 'effect/Option';
|
|
35
|
+
|
|
36
|
+
const PROVEN = 'homeflare/ownership/resumes';
|
|
37
|
+
const UNFINISHED = 'homeflare/ownership/unfinished';
|
|
38
|
+
|
|
39
|
+
const note = (key: string, instanceId: string): Effect.Effect<void> =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const bag = yield* Effect.serviceOption(Artifacts);
|
|
42
|
+
if (Option.isSome(bag)) yield* bag.value.set(key, instanceId);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const noted = (key: string, instanceId: string): Effect.Effect<boolean> =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const bag = yield* Effect.serviceOption(Artifacts);
|
|
48
|
+
if (Option.isNone(bag)) return false;
|
|
49
|
+
return (yield* bag.value.get<string>(key)) === instanceId;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `diff`'s note for a row with no attributes: `instanceId` is an unfinished generation of this
|
|
54
|
+
* resource's own, which `--adopt` may resume when nothing proves it (adopt.ts adoptable). Returns
|
|
55
|
+
* `undefined`, a diff's "defer to the engine".
|
|
56
|
+
*/
|
|
57
|
+
export const noteUnfinished = (instanceId: string): Effect.Effect<undefined> =>
|
|
58
|
+
Effect.as(note(UNFINISHED, instanceId), undefined);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Note `instanceId` unfinished, and a proven resume when `recovered` — the family's `read` of the
|
|
62
|
+
* row's props, asked with no attributes — answers a plain object: ours. `Unowned`, nothing, or a
|
|
63
|
+
* read that fails or dies proves nothing. ⚠️ Never fails the plan: an unproven resume is refused
|
|
64
|
+
* at apply instead, unless `--adopt`.
|
|
65
|
+
*/
|
|
66
|
+
export const noteResume = <R>(
|
|
67
|
+
instanceId: string,
|
|
68
|
+
recovered: Effect.Effect<unknown, unknown, R>,
|
|
69
|
+
): Effect.Effect<void, never, R> =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
yield* noteUnfinished(instanceId);
|
|
72
|
+
const unproven = () => Effect.succeed(undefined);
|
|
73
|
+
const found = yield* recovered.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
|
|
74
|
+
if (found === undefined || found === null || Unowned.is(found)) return;
|
|
75
|
+
yield* note(PROVEN, instanceId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Whether the plan for this apply noted `instanceId` as a proven resumed create or replace. */
|
|
79
|
+
export const resumes = (instanceId: string): Effect.Effect<boolean> => noted(PROVEN, instanceId);
|
|
80
|
+
|
|
81
|
+
/** Whether the plan for this apply saw `instanceId` as an unfinished generation (proven or not). */
|
|
82
|
+
export const unfinished = (instanceId: string): Effect.Effect<boolean> =>
|
|
83
|
+
noted(UNFINISHED, instanceId);
|
|
84
|
+
|
|
85
|
+
/** The fields of a `read` or `diff` call provingResumes uses; a family's own input carries more. */
|
|
86
|
+
type Ask = {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly fqn: string;
|
|
89
|
+
readonly instanceId: string;
|
|
90
|
+
readonly olds: unknown;
|
|
91
|
+
readonly output: unknown;
|
|
92
|
+
};
|
|
93
|
+
type Hook = Effect.Effect<unknown, unknown, unknown>;
|
|
94
|
+
|
|
95
|
+
/** The two hooks provingResumes wraps, as Alchemy's ProviderService declares them. */
|
|
96
|
+
interface Resumable {
|
|
97
|
+
read?(input: Ask): Hook;
|
|
98
|
+
diff?(input: Ask): Hook;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A provider whose `diff`, for a row with no attributes, first asks the provider's own `read` whether
|
|
103
|
+
* the live object is that unfinished generation's (noteResume), then runs as written. Every Bao.*
|
|
104
|
+
* family is built through it, so their diffs answer such a row with a plain `undefined`.
|
|
105
|
+
*/
|
|
106
|
+
export const provingResumes = <S extends Resumable>(service: S): S => {
|
|
107
|
+
const { diff, read } = service;
|
|
108
|
+
if (diff === undefined || read === undefined) return service;
|
|
109
|
+
const proving: Resumable['diff'] = (input) =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
if (input.output === undefined) {
|
|
112
|
+
yield* noteResume(input.instanceId, read({ ...input, output: undefined }));
|
|
113
|
+
}
|
|
114
|
+
return yield* diff(input);
|
|
115
|
+
});
|
|
116
|
+
// ⚠️ The one cast: `diff` keeps the family's own signature — it is the family's diff, run after.
|
|
117
|
+
return { ...service, diff: proving } as S;
|
|
118
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questions ownership/ asks of Alchemy's state store: does a row record this instance, and with
|
|
3
|
+
* its whole declaration (probe.ts); is this apply a create rather than a replace's new generation
|
|
4
|
+
* (adopt.ts); and — when reconcile refuses a create — forget the row Apply committed for it
|
|
5
|
+
* (adopt.ts). Alchemy's own AWS.EC2.SecurityGroup provider reads the store the same way: the Stack
|
|
6
|
+
* service names the stack and stage, and `State` yields the store.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ WITHOUT A STACK OR STATE IN CONTEXT, or with a store that errors, a row reads as "not
|
|
9
|
+
* recorded" — the object is then `Unowned`, the safe answer — and nothing is forgotten, which
|
|
10
|
+
* leaves the one gap forgetRefusedCreate exists to close. `alchemy deploy` and the Alchemist
|
|
11
|
+
* session provide both.
|
|
12
|
+
*/
|
|
13
|
+
import { Stack } from 'alchemy/Stack';
|
|
14
|
+
import { State, type StateService, isActionState } from 'alchemy/State/State';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import * as Option from 'effect/Option';
|
|
17
|
+
import { wholeDeclaration } from './whole.ts';
|
|
18
|
+
|
|
19
|
+
type Where = { readonly stack: string; readonly stage: string };
|
|
20
|
+
|
|
21
|
+
/** The store, where this stack lives, and what it declares — or undefined outside a stack. */
|
|
22
|
+
const storeOf = Effect.gen(function* () {
|
|
23
|
+
const stack = yield* Effect.serviceOption(Stack);
|
|
24
|
+
const state = yield* Effect.serviceOption(State);
|
|
25
|
+
if (Option.isNone(stack) || Option.isNone(state)) return undefined;
|
|
26
|
+
const store: StateService = yield* state.value;
|
|
27
|
+
const where: Where = { stack: stack.value.name, stage: stack.value.stage };
|
|
28
|
+
return { resources: stack.value.resources, store, where };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const rowAt = (store: StateService, where: Where, fqn: string) =>
|
|
32
|
+
store.get({ ...where, fqn }).pipe(Effect.orElseSucceed(() => undefined));
|
|
33
|
+
|
|
34
|
+
type Generation = {
|
|
35
|
+
readonly instanceId?: unknown;
|
|
36
|
+
readonly status?: unknown;
|
|
37
|
+
readonly props?: unknown;
|
|
38
|
+
readonly attr?: unknown;
|
|
39
|
+
readonly old?: unknown;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The generation of a persisted row that carries `instanceId`: the row itself, or one in `old`. */
|
|
43
|
+
const generationOf = (row: unknown, instanceId: string): Generation | undefined => {
|
|
44
|
+
let at = row;
|
|
45
|
+
while (typeof at === 'object' && at !== null && !isActionState(at as never)) {
|
|
46
|
+
const generation = at as Generation;
|
|
47
|
+
if (generation.instanceId === instanceId) return generation;
|
|
48
|
+
at = generation.old;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The generation `instanceId` names — at the FQN, or at a former FQN `renamedFrom` names. */
|
|
54
|
+
const recordedGeneration = (fqn: string, instanceId: string) =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const found = yield* storeOf;
|
|
57
|
+
if (found === undefined) return undefined;
|
|
58
|
+
for (const at of [fqn, ...(found.resources[fqn]?.FormerFqns ?? [])]) {
|
|
59
|
+
const generation = generationOf(yield* rowAt(found.store, found.where, at), instanceId);
|
|
60
|
+
if (generation !== undefined) return generation;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the state store says of `instanceId` for this resource (at its FQN, or at a former FQN its
|
|
67
|
+
* `renamedFrom` names — the planner migrates that row before it recovers it):
|
|
68
|
+
* · `absent` — no row holds it. ★ This is what separates the probe from the recovery read: the
|
|
69
|
+
* probe's instance id is minted for the probe (Plan.ts `generateInstanceId()`), 128 random bits
|
|
70
|
+
* no row can hold.
|
|
71
|
+
* · `partial` — a row holds it, but its props lack part of the declaration: a prop stripped at
|
|
72
|
+
* commit because it was still an Output (whole.ts). Those props cannot prove an object ours.
|
|
73
|
+
* · `whole` — a row holds it with every prop the declaration names.
|
|
74
|
+
*/
|
|
75
|
+
export type Recorded = 'absent' | 'partial' | 'whole';
|
|
76
|
+
|
|
77
|
+
export const recordedInstance = (fqn: string, instanceId: string): Effect.Effect<Recorded> =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const generation = yield* recordedGeneration(fqn, instanceId);
|
|
80
|
+
if (generation === undefined) return 'absent';
|
|
81
|
+
return (yield* wholeDeclaration(fqn, generation.props)) ? 'whole' : 'partial';
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether this apply's `instanceId` is a CREATE: the row Apply committed for it is `creating`.
|
|
86
|
+
* ⛔ A REPLACE'S NEW GENERATION IS NOT ONE. Apply commits it as `replacing`, and the planner never
|
|
87
|
+
* offers it adoption — the probe runs only for a resource with no state — so `--adopt` must not
|
|
88
|
+
* let it write over whatever sits at its new identity (adopt.ts adoptsAtApply).
|
|
89
|
+
*/
|
|
90
|
+
export const isCreate = (fqn: string, instanceId: string): Effect.Effect<boolean> =>
|
|
91
|
+
Effect.map(
|
|
92
|
+
recordedGeneration(fqn, instanceId),
|
|
93
|
+
(generation) => generation?.status === 'creating',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* ⛔ A REFUSED CREATE MUST NOT LEAVE A ROW THAT CLAIMS THE OBJECT IT REFUSED. Apply commits a
|
|
98
|
+
* `creating` row for this instance before it calls reconcile, and a failed reconcile leaves it
|
|
99
|
+
* there. The next plan's recovery read would then find our instance id and an object that can
|
|
100
|
+
* match its props — a prop that was an Output is stripped from the row, and an omitted prop is
|
|
101
|
+
* "not managed" — and adopt exactly what this apply refused. 🔴 MEASURED 2026-09-21: Bao.Mount
|
|
102
|
+
* and Bao.AuthMethod did (openbao/adopt-core.test.ts). So the row goes, and state reads as if
|
|
103
|
+
* the create never started: the next plan probes, and the probe answers `Unowned`.
|
|
104
|
+
* ⚠️ ONLY a `creating` row of THIS instance with no attributes — the one Apply just wrote. A
|
|
105
|
+
* `replacing` row carries the previous generation in `old` and is never touched.
|
|
106
|
+
*/
|
|
107
|
+
export const forgetRefusedCreate = (fqn: string, instanceId: string): Effect.Effect<void> =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const found = yield* storeOf;
|
|
110
|
+
if (found === undefined) return;
|
|
111
|
+
const row = yield* rowAt(found.store, found.where, fqn);
|
|
112
|
+
const mine = row as { status?: unknown; instanceId?: unknown; attr?: unknown } | undefined;
|
|
113
|
+
if (mine?.status !== 'creating' || mine.instanceId !== instanceId || mine.attr !== undefined) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
yield* found.store.delete({ ...found.where, fqn }).pipe(Effect.ignore);
|
|
117
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whole.ts `carries`: a state row's props against the declaration — every declared value, Outputs
|
|
3
|
+
* included, needs a value in the row; an optional prop left out of both is no hole.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { carries } from './whole.ts';
|
|
7
|
+
|
|
8
|
+
/** Stands in for an Output: Alchemy's are function-typed proxies (Diff.ts stripUnresolved). */
|
|
9
|
+
const output = Object.assign(() => undefined, { kind: 'Output' });
|
|
10
|
+
|
|
11
|
+
describe('carries', () => {
|
|
12
|
+
test('a row with every declared value is whole; an omission from both is no hole', () => {
|
|
13
|
+
expect(carries({ name: 'a', ttl: output }, { name: 'a', ttl: '15m' })).toBe(true);
|
|
14
|
+
expect(carries({ name: 'a', ttl: undefined }, { name: 'a' })).toBe(true);
|
|
15
|
+
expect(carries(undefined, { name: 'a' })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('a declared value — an Output or a literal — missing from the row is a hole', () => {
|
|
19
|
+
expect(carries({ name: output, ttl: '15m' }, { ttl: '15m' })).toBe(false);
|
|
20
|
+
expect(carries({ name: 'a', ttl: '15m' }, { name: 'a' })).toBe(false);
|
|
21
|
+
expect(carries({ name: 'a' }, undefined)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('holes inside arrays and nested objects count, as JSON stores keep them (null)', () => {
|
|
25
|
+
expect(carries({ policies: ['a', output] }, { policies: ['a', null] })).toBe(false);
|
|
26
|
+
expect(carries({ policies: ['a', output] }, { policies: ['a'] })).toBe(false);
|
|
27
|
+
expect(carries({ policies: [{ groups: [output] }] }, { policies: [{ groups: [] }] })).toBe(
|
|
28
|
+
false,
|
|
29
|
+
);
|
|
30
|
+
expect(carries({ policies: [{ groups: ['g'] }] }, { policies: [{ groups: ['g'] }] })).toBe(
|
|
31
|
+
true,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a shape the row does not share is not the declaration', () => {
|
|
36
|
+
expect(carries({ policies: ['a'] }, { policies: 'a' })).toBe(false);
|
|
37
|
+
expect(carries({ tune: { ttl: '1h' } }, { tune: '1h' })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a state row's props say everything the declaration says — the test that separates a row
|
|
3
|
+
* whose props can prove an object ours from one that cannot (probe.ts, rows.ts).
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY A ROW CAN BE MISSING PART OF ITS DECLARATION. Apply commits the `creating` (or `replacing`)
|
|
6
|
+
* row BEFORE reconcile runs, and strips every prop still an Output at that moment (alchemy
|
|
7
|
+
* beta.79 Apply.ts `commit` → Diff.ts `stripUnresolved`); a JSON store then drops the key. So a
|
|
8
|
+
* row written while an upstream was still being created carries a HOLE where that prop was.
|
|
9
|
+
* ⛔ A HOLE IS "UNKNOWN", NOT "UNMANAGED". Most families read an absent prop as "not managed", so a
|
|
10
|
+
* recovery read comparing the live object with holed props proves nothing: 🔴 MEASURED 2026-09-21
|
|
11
|
+
* (openbao/adopt-holes.test.ts), a Bao.Mount whose `defaultLeaseTtl` was an Output, killed before
|
|
12
|
+
* its reconcile ran, adopted — and tuned — another owner's mount on the next deploy; with the
|
|
13
|
+
* NAME an Output, every role family read an empty name, found nothing, and the resumed create
|
|
14
|
+
* wrote over another owner's role. Neither deploy had ever asked whose object it was.
|
|
15
|
+
* ★ THE DECLARATION IS WHAT TELLS A HOLE FROM AN OMISSION. It still holds the Output (or the literal)
|
|
16
|
+
* the row lacks, while an optional prop left out is absent from both.
|
|
17
|
+
* ⚠️ CONSERVATIVE BY DESIGN: a prop added to the declaration after the crash also reads as a hole.
|
|
18
|
+
* That costs one `--adopt`, never a takeover.
|
|
19
|
+
*/
|
|
20
|
+
import { Stack } from 'alchemy/Stack';
|
|
21
|
+
import * as Effect from 'effect/Effect';
|
|
22
|
+
import * as Option from 'effect/Option';
|
|
23
|
+
|
|
24
|
+
const absent = (value: unknown): boolean => value === undefined || value === null;
|
|
25
|
+
|
|
26
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
27
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
28
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
29
|
+
return proto === Object.prototype || proto === null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether `recorded` carries a value everywhere `declared` does. Plain objects and arrays are
|
|
34
|
+
* walked; any other declared value — a literal, an Output, an Effect — needs a value in the row.
|
|
35
|
+
*/
|
|
36
|
+
export const carries = (declared: unknown, recorded: unknown): boolean => {
|
|
37
|
+
if (absent(declared)) return true;
|
|
38
|
+
if (absent(recorded)) return false;
|
|
39
|
+
if (Array.isArray(declared)) {
|
|
40
|
+
if (!Array.isArray(recorded)) return false;
|
|
41
|
+
return declared.every((each, index) => carries(each, recorded[index]));
|
|
42
|
+
}
|
|
43
|
+
if (isPlainObject(declared)) {
|
|
44
|
+
if (!isPlainObject(recorded)) return false;
|
|
45
|
+
return Object.entries(declared).every(([key, each]) => carries(each, recorded[key]));
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `props` — one generation's, from the state store — carry the whole declaration of the
|
|
52
|
+
* resource at `fqn`. ⛔ FALSE WITH NOTHING TO COMPARE: no Stack in context, or a resource no longer
|
|
53
|
+
* declared (an orphan's delete). That object then reads as `Unowned` and Apply leaves it in place
|
|
54
|
+
* with a note, rather than deleting what it cannot prove it made.
|
|
55
|
+
*/
|
|
56
|
+
export const wholeDeclaration = (fqn: string, props: unknown): Effect.Effect<boolean> =>
|
|
57
|
+
Effect.map(Effect.serviceOption(Stack), (stack) => {
|
|
58
|
+
const declared = Option.isSome(stack) ? stack.value.resources[fqn] : undefined;
|
|
59
|
+
return declared !== undefined && carries(declared.Props, props);
|
|
60
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopting an object that already matches must be read-only — measured per reconcile SHAPE, through
|
|
3
|
+
* Alchemy's own Plan and Apply over a fake cluster (fake-pve.ts, ../verify/fake-engine.ts).
|
|
4
|
+
*
|
|
5
|
+
* ★ ONE ROW PER WAY A FAMILY REACHES ITS WRITE, not one per family. Every family built on
|
|
6
|
+
* `pveHandlers` shares resource.ts's reconcile, so `Proxmox.Pool` stands for all of them; `Acl`
|
|
7
|
+
* wraps that reconcile; `PbsDatastore` and `SdnApply` write their own. CephPool, the one that got
|
|
8
|
+
* it wrong, has its own file. docs/adopt-verify.md carries the full per-family table.
|
|
9
|
+
* ⚠️ A FIXTURE THAT DOES NOT MATCH TURNS A ROW INTO A DRIFT TEST, and the assertion on the
|
|
10
|
+
* verifier's `noop` is what catches that: a wrong fixture fails loudly instead of passing for
|
|
11
|
+
* the wrong reason. The Pool drift row proves the harness can see a write at all.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
16
|
+
import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
17
|
+
import type { PbsTarget } from './credentials.ts';
|
|
18
|
+
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
20
|
+
import { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
|
|
21
|
+
import { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
|
|
22
|
+
|
|
23
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
24
|
+
|
|
25
|
+
type Case = {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly provider: () => Layer.Layer<never, never, never>;
|
|
28
|
+
readonly declare: () => ReturnType<typeof ProxmoxPool>;
|
|
29
|
+
/** GET path → the `data` PVE/PBS answers; anything unlisted answers `null`. */
|
|
30
|
+
readonly live: Readonly<Record<string, unknown>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const cases: readonly Case[] = [
|
|
34
|
+
{
|
|
35
|
+
declare: () => ProxmoxPool('house', { comment: 'x', poolid: 'house', target: FAKE_TARGET }),
|
|
36
|
+
live: { 'pools/house': { comment: 'x', members: [] } },
|
|
37
|
+
name: 'Proxmox.Pool (pveHandlers: every factory family)',
|
|
38
|
+
provider: ProxmoxPoolProvider as never,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
declare: () =>
|
|
42
|
+
ProxmoxAcl('auditor', {
|
|
43
|
+
path: '/',
|
|
44
|
+
roleid: 'PVEAuditor',
|
|
45
|
+
target: FAKE_TARGET,
|
|
46
|
+
type: 'user',
|
|
47
|
+
ugid: 'u@pve',
|
|
48
|
+
}) as never,
|
|
49
|
+
live: {
|
|
50
|
+
'access/acl': [
|
|
51
|
+
{ path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
name: 'Proxmox.Acl (wraps the factory reconcile)',
|
|
55
|
+
provider: ProxmoxAclProvider as never,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
declare: () =>
|
|
59
|
+
PbsDatastore('store1', {
|
|
60
|
+
comment: 'c',
|
|
61
|
+
'gc-schedule': 'daily',
|
|
62
|
+
name: 'store1',
|
|
63
|
+
path: '/mnt/store1',
|
|
64
|
+
target: PBS,
|
|
65
|
+
}) as never,
|
|
66
|
+
live: {
|
|
67
|
+
'config/datastore/store1': {
|
|
68
|
+
comment: 'c',
|
|
69
|
+
'gc-schedule': 'daily',
|
|
70
|
+
name: 'store1',
|
|
71
|
+
path: '/mnt/store1',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
name: 'Pbs.Datastore (its own reconcile)',
|
|
75
|
+
provider: PbsDatastoreProvider as never,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
declare: () => ProxmoxSdnApply('sdn', { target: FAKE_TARGET }) as never,
|
|
79
|
+
live: {},
|
|
80
|
+
name: 'Proxmox.SdnApply (its own reconcile; nothing staged)',
|
|
81
|
+
provider: ProxmoxSdnApplyProvider as never,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const clusterWith = (live: Readonly<Record<string, unknown>>) =>
|
|
86
|
+
fakePve((call) => (call.method === 'GET' ? live[call.path] : 'UPID:fake'));
|
|
87
|
+
|
|
88
|
+
describe('adopting a matching object only reads', () => {
|
|
89
|
+
test.each(cases.map((c) => [c.name, c] as const))('%s', async (_, c) => {
|
|
90
|
+
const fake = clusterWith(c.live);
|
|
91
|
+
await withoutBao(async () => {
|
|
92
|
+
const engine = engineOver(c.provider().pipe(Layer.provideMerge(fake.layer)));
|
|
93
|
+
const report = await engine.verify(c.declare());
|
|
94
|
+
expect(report.rows).toEqual([expect.objectContaining({ diff: 'noop', ok: true })]);
|
|
95
|
+
expect(Object.values(await engine.deploy(c.declare()))).toEqual(['adopted']);
|
|
96
|
+
});
|
|
97
|
+
expect(fake.writes()).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('and the harness does see a write: a drifted Pool is PUT once', async () => {
|
|
101
|
+
const fake = clusterWith({ 'pools/house': { comment: 'old', members: [] } });
|
|
102
|
+
await withoutBao(async () => {
|
|
103
|
+
const engine = engineOver(ProxmoxPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
104
|
+
const declared = () =>
|
|
105
|
+
ProxmoxPool('house', { comment: 'x', poolid: 'house', target: FAKE_TARGET });
|
|
106
|
+
const report = await engine.verify(declared());
|
|
107
|
+
expect(report.rows[0]).toMatchObject({ changed: ['comment'], diff: 'update', ok: false });
|
|
108
|
+
expect(await engine.deploy(declared())).toEqual({ house: 'adopted' });
|
|
109
|
+
});
|
|
110
|
+
expect(fake.writes()).toEqual(['PUT pools/house']);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephPool` adopted through Alchemy's own Plan and Apply, over a fake cluster.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 THE BUG THIS PINS (found 2026-09-21, update-guard.ts). The family's hand-written reconcile
|
|
5
|
+
* PUT the declared set whenever the pool existed. Plan.ts forces reconcile after the adoption
|
|
6
|
+
* probe even when the diff says `noop`, so adopting a pool that already matched sent `setpool`
|
|
7
|
+
* and forked a worker on the live cluster. The first test fails with that PUT on the old code.
|
|
8
|
+
* ★ THE VERIFIER RUNS OVER THE SAME ENGINE, so the same test also shows what it reports before
|
|
9
|
+
* the deploy: `adopted` from the engine, `noop` from the provider.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
14
|
+
import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
|
|
15
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
16
|
+
|
|
17
|
+
const STATUS = 'nodes/n2/ceph/pool/cephtb4/status?verbose=1';
|
|
18
|
+
const OBJECT = 'nodes/n2/ceph/pool/cephtb4';
|
|
19
|
+
|
|
20
|
+
/** TB4's rbd pool as `getpool` answers it — the fields ceph-pool.ts reads. */
|
|
21
|
+
const livePool = (size: number): Record<string, unknown> => ({
|
|
22
|
+
application_list: ['rbd'],
|
|
23
|
+
crush_rule: 'replicated_rule',
|
|
24
|
+
id: 2,
|
|
25
|
+
min_size: 2,
|
|
26
|
+
name: 'cephtb4',
|
|
27
|
+
pg_autoscale_mode: 'on',
|
|
28
|
+
pg_num: 128,
|
|
29
|
+
size,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** A cluster holding one pool; a PUT to it lands, as `setpool` would. */
|
|
33
|
+
const cluster = (size: number) => {
|
|
34
|
+
const pool = livePool(size);
|
|
35
|
+
return fakePve((call: PveCall) => {
|
|
36
|
+
if (call.method === 'GET' && call.path === STATUS) return pool;
|
|
37
|
+
if (call.method === 'PUT' && call.path === OBJECT) {
|
|
38
|
+
for (const [key, value] of Object.entries(call.form)) {
|
|
39
|
+
pool[key] = /^\d+$/.test(value) ? Number(value) : value;
|
|
40
|
+
}
|
|
41
|
+
return 'UPID:n2:fake:setpool';
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const declared = () =>
|
|
48
|
+
ProxmoxCephPool('cephtb4', {
|
|
49
|
+
crush_rule: 'replicated_rule',
|
|
50
|
+
min_size: 2,
|
|
51
|
+
name: 'cephtb4',
|
|
52
|
+
node: 'n2',
|
|
53
|
+
pg_autoscale_mode: 'on',
|
|
54
|
+
size: 3,
|
|
55
|
+
target: FAKE_TARGET,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
59
|
+
engineOver(ProxmoxCephPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
60
|
+
|
|
61
|
+
describe('adopting a Ceph pool', () => {
|
|
62
|
+
test('that already matches: the verifier says noop, and the deploy only reads', async () => {
|
|
63
|
+
const fake = cluster(3);
|
|
64
|
+
await withoutBao(async () => {
|
|
65
|
+
const engine = engineFor(fake);
|
|
66
|
+
const report = await engine.verify(declared());
|
|
67
|
+
expect(report.rows).toEqual([
|
|
68
|
+
expect.objectContaining({ diff: 'noop', ok: true, planned: 'adopted', read: 'found' }),
|
|
69
|
+
]);
|
|
70
|
+
expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
|
|
71
|
+
});
|
|
72
|
+
expect(fake.writes()).toEqual([]);
|
|
73
|
+
expect(fake.calls.every((call) => call.path === STATUS)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('that drifted: the verifier names the field, and the deploy PUTs once', async () => {
|
|
77
|
+
const fake = cluster(2);
|
|
78
|
+
await withoutBao(async () => {
|
|
79
|
+
const engine = engineFor(fake);
|
|
80
|
+
const report = await engine.verify(declared());
|
|
81
|
+
expect(report.rows[0]).toMatchObject({ changed: ['size'], diff: 'update', ok: false });
|
|
82
|
+
expect(fake.writes()).toEqual([]);
|
|
83
|
+
expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
|
|
84
|
+
});
|
|
85
|
+
expect(fake.writes()).toEqual([`PUT ${OBJECT}`]);
|
|
86
|
+
expect(fake.calls.find((call) => call.method === 'PUT')?.form['size']).toBe('3');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -192,9 +192,9 @@ const numeric = (value: number | undefined) => (value === undefined ? undefined
|
|
|
192
192
|
* the way storage.ts's `mutable` serves both of its forms. `name` is the path's last segment on an
|
|
193
193
|
* update and is not repeated here.
|
|
194
194
|
*
|
|
195
|
-
* ⚠️ SAFE TO RE-APPLY TO A POOL THAT ALREADY MATCHES
|
|
196
|
-
* PVE::Ceph::Tools::set_pool (Tools.pm:293-300): it reads `osd pool get all` first
|
|
197
|
-
*
|
|
195
|
+
* ⚠️ SAFE TO RE-APPLY TO A POOL THAT ALREADY MATCHES (reconcile leaned on it until update-guard.ts).
|
|
196
|
+
* MEASURED in PVE::Ceph::Tools::set_pool (Tools.pm:293-300): it reads `osd pool get all` first
|
|
197
|
+
* and SKIPS every unchanged setting, so the undrifted fields that ride along cost one mon read.
|
|
198
198
|
* ⛔ BUT A SETTING THAT DID CHANGE AND CANNOT BE APPLIED KILLS THE WHOLE PUT. Tools.pm:310 dies
|
|
199
199
|
* with "Could not set: <fields>" if any parameter is left unapplied, so a pool carrying Ceph's
|
|
200
200
|
* `nosizechange` fails an entire update over one field. That is why the `no*` flags are reported
|
package/src/proxmox/ceph-pool.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
import { confirmAbsent, settle } from './ceph-pool-settle.ts';
|
|
54
54
|
import { pve } from './client.ts';
|
|
55
55
|
import { type PveRequirements, type PveSpec, pveOperations } from './resource.ts';
|
|
56
|
+
import { formToSend } from './update-guard.ts';
|
|
56
57
|
import { bool, int, num, text } from './values.ts';
|
|
57
58
|
|
|
58
59
|
export type { CephPoolAttributes, CephPoolProps };
|
|
@@ -160,9 +161,15 @@ export const ProxmoxCephPoolProvider = () =>
|
|
|
160
161
|
createBody(news),
|
|
161
162
|
);
|
|
162
163
|
} else {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
/**
|
|
165
|
+
* ⛔ ONLY WHEN `matches` IS FALSE. This PUT used to fire whenever the pool existed, so
|
|
166
|
+
* adopting a pool that already matched — Plan.ts forces reconcile after the probe —
|
|
167
|
+
* sent `setpool` and forked a worker on the live cluster. PVE skipping the unchanged
|
|
168
|
+
* settings (ceph-pool-form.ts) made it harmless to Ceph, not free: it is still a
|
|
169
|
+
* provision-lease write and a task in the cluster's list. update-guard.ts has the rest.
|
|
170
|
+
*/
|
|
171
|
+
const form = formToSend(spec.matches, live, news, updateBody(news));
|
|
172
|
+
if (form !== undefined) {
|
|
166
173
|
upid = yield* pve<string>(news.target, 'provision', 'PUT', object(news), form);
|
|
167
174
|
}
|
|
168
175
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's OpenBao variables, replaced for the life of one test run and put back afterwards.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ WHY A TEST CANNOT LEAVE THEM ALONE. `leased` mints through credentials.ts `mint`, which reads
|
|
5
|
+
* `BAO_AGENT_ADDR` before `BAO_ADDR` (and the `VAULT_*` spellings), and sends `BAO_TOKEN` as a
|
|
6
|
+
* header. A developer shell with any of them set would send its real token to a fake, or a mint
|
|
7
|
+
* to a real agent. credentials.test.ts avoids it by passing an explicit environment; a test that
|
|
8
|
+
* drives a provider cannot, because the lease cache reads `process.env`.
|
|
9
|
+
* ★ ONE HELPER FOR EVERY FAKE. lxc-harness.ts and fake-pve.ts each carried their own copy; the
|
|
10
|
+
* first covered three names and the second every `BAO_*` / `VAULT_*`, so they now share the
|
|
11
|
+
* wider one. ⛔ TEST-ONLY. No provider imports this file.
|
|
12
|
+
*/
|
|
13
|
+
const BAO_VARIABLES = /^(BAO|VAULT)_/;
|
|
14
|
+
|
|
15
|
+
/** Run `body` with every BAO_* / VAULT_* unset except `BAO_ADDR=address`, then restore them. */
|
|
16
|
+
export const withFakeBao = async <A>(address: string, body: () => Promise<A>): Promise<A> => {
|
|
17
|
+
const saved = Object.entries(process.env).filter(([name]) => BAO_VARIABLES.test(name));
|
|
18
|
+
for (const [name] of saved) delete process.env[name];
|
|
19
|
+
process.env['BAO_ADDR'] = address;
|
|
20
|
+
try {
|
|
21
|
+
return await body();
|
|
22
|
+
} finally {
|
|
23
|
+
for (const name of Object.keys(process.env)) {
|
|
24
|
+
if (BAO_VARIABLES.test(name)) delete process.env[name];
|
|
25
|
+
}
|
|
26
|
+
for (const [name, value] of saved) process.env[name] = value;
|
|
27
|
+
}
|
|
28
|
+
};
|