@homeflare/alchemy 0.8.0 → 0.10.1
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 +370 -0
- package/dist/verify/cli.js.map +17 -0
- package/dist/verify/fake-engine.d.ts +65 -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 +347 -0
- package/dist/verify/index.js.map +16 -0
- package/dist/verify/plan-scope.d.ts +14 -0
- package/dist/verify/plan-scope.d.ts.map +1 -0
- package/dist/verify/recheck.d.ts +42 -0
- package/dist/verify/recheck.d.ts.map +1 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +67 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +43 -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 +119 -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 +120 -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 +182 -0
- package/src/verify/index.ts +37 -0
- package/src/verify/plan-scope.ts +34 -0
- package/src/verify/recheck.test.ts +56 -0
- package/src/verify/recheck.ts +71 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +210 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +160 -0
- package/src/verify/verify.test.ts +146 -0
- package/src/verify/verify.ts +185 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verifier against Alchemy's own planner (fake-engine.ts): what it reports for each kind of row,
|
|
3
|
+
* and that it never writes while the engine it watches would.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE FIRST TEST IS THE MEASUREMENT THE TOOL EXISTS FOR. alchemy beta.79 prints `adopted` for a
|
|
6
|
+
* matching object and for a drifting one alike, and the deploy reconciles both. Pinned here so an
|
|
7
|
+
* Alchemy upgrade that stops forcing the update — or starts logging the diff — fails loudly and
|
|
8
|
+
* this tool can be re-thought rather than silently duplicated.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { renamedFrom } from 'alchemy';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import {
|
|
14
|
+
Blind,
|
|
15
|
+
type Body,
|
|
16
|
+
type Cloud,
|
|
17
|
+
Thing,
|
|
18
|
+
cloudOf,
|
|
19
|
+
fakeEngine,
|
|
20
|
+
writesOf,
|
|
21
|
+
} from './fake-engine.ts';
|
|
22
|
+
import { exitCodeOf } from './report.ts';
|
|
23
|
+
|
|
24
|
+
const one = (name: string, comment?: string) =>
|
|
25
|
+
Thing(name, comment === undefined ? { name } : { comment, name });
|
|
26
|
+
|
|
27
|
+
const rowsBy = async (cloud: Cloud, body: Body, all = false) => {
|
|
28
|
+
const report = await fakeEngine(cloud).verify(body, { all });
|
|
29
|
+
return { byFqn: Object.fromEntries(report.rows.map((row) => [row.fqn, row])), report };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('what alchemy beta.79 does with an adoption', () => {
|
|
33
|
+
test('prints adopted for a match and for a drift alike, and reconciles both', async () => {
|
|
34
|
+
const cloud = cloudOf({ same: 'x', drifted: 'old' });
|
|
35
|
+
const planned = await fakeEngine(cloud).deploy(
|
|
36
|
+
Effect.all([one('same', 'x'), one('drifted', 'new')]),
|
|
37
|
+
);
|
|
38
|
+
expect(planned).toEqual({ drifted: 'adopted', same: 'adopted' });
|
|
39
|
+
expect(writesOf(cloud).sort()).toEqual(['reconcile drifted', 'reconcile same']);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('verifySession', () => {
|
|
44
|
+
test('a matching adoption is a no-op, and verifying it writes nothing', async () => {
|
|
45
|
+
const cloud = cloudOf({ same: 'x' });
|
|
46
|
+
const { byFqn, report } = await rowsBy(cloud, one('same', 'x'));
|
|
47
|
+
expect(byFqn['same']).toMatchObject({
|
|
48
|
+
changed: [],
|
|
49
|
+
diff: 'noop',
|
|
50
|
+
ok: true,
|
|
51
|
+
planned: 'adopted',
|
|
52
|
+
read: 'found',
|
|
53
|
+
stateRow: false,
|
|
54
|
+
type: 'Test.Thing',
|
|
55
|
+
});
|
|
56
|
+
expect(exitCodeOf(report)).toBe(0);
|
|
57
|
+
expect(writesOf(cloud)).toEqual([]);
|
|
58
|
+
expect(cloud.calls).toEqual(['read same', 'diff same']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a drifting adoption says update and names the field, though the engine says adopted', async () => {
|
|
62
|
+
const cloud = cloudOf({ drifted: 'old' });
|
|
63
|
+
const { byFqn, report } = await rowsBy(cloud, one('drifted', 'new'));
|
|
64
|
+
expect(byFqn['drifted']).toMatchObject({
|
|
65
|
+
changed: ['comment'],
|
|
66
|
+
diff: 'update',
|
|
67
|
+
ok: false,
|
|
68
|
+
planned: 'adopted',
|
|
69
|
+
});
|
|
70
|
+
expect(byFqn['drifted']?.why).toContain('the deploy writes');
|
|
71
|
+
expect(exitCodeOf(report)).toBe(1);
|
|
72
|
+
expect(writesOf(cloud)).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an object the read cannot find is a create, not an adoption', async () => {
|
|
76
|
+
const cloud = cloudOf();
|
|
77
|
+
const { byFqn } = await rowsBy(cloud, one('fresh'));
|
|
78
|
+
expect(byFqn['fresh']).toMatchObject({ diff: 'not-run', ok: false, planned: 'create' });
|
|
79
|
+
expect(byFqn['fresh']?.read).toBe('absent');
|
|
80
|
+
expect(byFqn['fresh']?.why).toContain('CREATES');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unowned match is a no-op with a note that the deploy needs --adopt', async () => {
|
|
84
|
+
const cloud = cloudOf({ theirs: 'x' });
|
|
85
|
+
cloud.unowned.add('theirs');
|
|
86
|
+
const { byFqn } = await rowsBy(cloud, one('theirs', 'x'));
|
|
87
|
+
expect(byFqn['theirs']).toMatchObject({ diff: 'noop', ok: true, read: 'unowned' });
|
|
88
|
+
expect(byFqn['theirs']?.why).toContain('--adopt');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a provider with no diff cannot prove an adoption', async () => {
|
|
92
|
+
const cloud = cloudOf({ blind: 'x' });
|
|
93
|
+
const { byFqn } = await rowsBy(cloud, Blind('blind', { comment: 'x', name: 'blind' }));
|
|
94
|
+
expect(byFqn['blind']).toMatchObject({ diff: 'none', ok: false, planned: 'adopted' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('rows with state are left out by default and read afresh with all', async () => {
|
|
98
|
+
const cloud = cloudOf();
|
|
99
|
+
const engine = fakeEngine(cloud);
|
|
100
|
+
await engine.deploy(one('kept', 'x'));
|
|
101
|
+
cloud.live.set('new', 'y');
|
|
102
|
+
cloud.calls.length = 0;
|
|
103
|
+
const body = Effect.all([one('kept', 'x'), one('new', 'y')]);
|
|
104
|
+
|
|
105
|
+
const scoped = await engine.verify(body);
|
|
106
|
+
expect(scoped.rows.map((row) => row.fqn)).toEqual(['new']);
|
|
107
|
+
expect(scoped.declared).toBe(2);
|
|
108
|
+
|
|
109
|
+
const all = await engine.verify(body, { all: true });
|
|
110
|
+
const kept = all.rows.find((row) => row.fqn === 'kept');
|
|
111
|
+
expect(kept).toMatchObject({ diff: 'noop', ok: true, planned: 'noop', read: 'found' });
|
|
112
|
+
expect(kept?.stateRow).toBe(true);
|
|
113
|
+
expect(writesOf(cloud)).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ A RENAME MAY HAND ITS OLD ID TO A NEW RESOURCE IN THE SAME DEPLOY (alchemy Rename.ts). The
|
|
118
|
+
* store still holds the renamer's row at that id, so judging state by FQN alone hid the new
|
|
119
|
+
* resource's `create` from the default report. Both rows are without state of their own.
|
|
120
|
+
*/
|
|
121
|
+
test('a rename that hands its old id to a new resource shows both, the create included', async () => {
|
|
122
|
+
const cloud = cloudOf();
|
|
123
|
+
const engine = fakeEngine(cloud);
|
|
124
|
+
await engine.deploy(one('old', 'x'));
|
|
125
|
+
const body = Effect.all([
|
|
126
|
+
Thing('moved', { comment: 'x', name: 'old' }).pipe(renamedFrom('old')),
|
|
127
|
+
Thing('old', { name: 'fresh' }),
|
|
128
|
+
]);
|
|
129
|
+
const report = await engine.verify(body);
|
|
130
|
+
const byFqn = Object.fromEntries(report.rows.map((row) => [row.fqn, row]));
|
|
131
|
+
expect(Object.keys(byFqn).sort()).toEqual(['moved', 'old']);
|
|
132
|
+
expect(byFqn['old']).toMatchObject({ ok: false, planned: 'create', stateRow: false });
|
|
133
|
+
expect(byFqn['moved']).toMatchObject({ ok: false, stateRow: false });
|
|
134
|
+
expect(writesOf(cloud)).toEqual(['reconcile old']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('with all, a row no longer declared is a pending delete', async () => {
|
|
138
|
+
const cloud = cloudOf();
|
|
139
|
+
const engine = fakeEngine(cloud);
|
|
140
|
+
await engine.deploy(one('gone', 'x'));
|
|
141
|
+
const report = await engine.verify(Effect.void, { all: true });
|
|
142
|
+
expect(report.rows).toEqual([expect.objectContaining({ fqn: 'gone', ok: false })]);
|
|
143
|
+
expect(report.rows[0]?.planned).toBe('delete');
|
|
144
|
+
expect(cloud.live.get('gone')).toBe('x');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove a deploy's adoptions are no-ops — with Alchemy's own planner, before anything is written.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE ENGINE PLANS, THIS ONLY WATCHES. `verifySession` runs `Plan.make` — the same call
|
|
5
|
+
* `alchemy plan` makes (Alchemist routes/stack.ts) — over the session's context with every
|
|
6
|
+
* provider swapped for a watched copy (spy.ts). Output resolution, the state lookup, rename
|
|
7
|
+
* migrations, the adoption probe and `adopt(…)` all stay the engine's; nothing here re-derives
|
|
8
|
+
* them. What it adds is the provider's own `diff` answer, which Plan.ts overwrites for every
|
|
9
|
+
* adopted row (`forceUpdateAfterAdoption`), and the state-row check that says which rows ARE
|
|
10
|
+
* adoptions.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ READ-ONLY BY CONSTRUCTION. The planner calls `read` and `diff`; spy.ts replaces `reconcile`,
|
|
13
|
+
* `delete` and `precreate` with refusals; nothing here calls `apply`. `--all` adds one `read` per
|
|
14
|
+
* row with state, and recheck.ts one more `diff` for an adopted `noop` whose read differs from
|
|
15
|
+
* the declaration — both still reads by Alchemy's provider contract.
|
|
16
|
+
* ⚠️ READS ARE NOT FREE FOR EVERY FAMILY. A kit PVE/PBS read mints a lease through OpenBao, which
|
|
17
|
+
* creates a short-lived API token on the cluster, exactly as `alchemy plan` does.
|
|
18
|
+
*/
|
|
19
|
+
import * as Alchemist from 'alchemy/Alchemist';
|
|
20
|
+
import * as Plan from 'alchemy/Plan';
|
|
21
|
+
import type { ProviderService } from 'alchemy/Provider';
|
|
22
|
+
import type { CompiledStack } from 'alchemy/Stack';
|
|
23
|
+
import { State, isActionState } from 'alchemy/State';
|
|
24
|
+
import type * as Context from 'effect/Context';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import { inPlanScope } from './plan-scope.ts';
|
|
27
|
+
import { recheck } from './recheck.ts';
|
|
28
|
+
import { type AdoptRow, type PlanView, rowsOf } from './rows.ts';
|
|
29
|
+
import { type Observations, spyContext } from './spy.ts';
|
|
30
|
+
|
|
31
|
+
export interface VerifyOptions {
|
|
32
|
+
/** Report every declared row and every pending deletion, not only rows without state. */
|
|
33
|
+
readonly all?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AdoptReport {
|
|
37
|
+
readonly stack: string;
|
|
38
|
+
readonly stage: string;
|
|
39
|
+
/** Resources the stack declares. */
|
|
40
|
+
readonly declared: number;
|
|
41
|
+
readonly rows: readonly AdoptRow[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An imported stack and the context it plans under — what `Alchemist.open` returns. */
|
|
45
|
+
export interface VerifySession {
|
|
46
|
+
readonly stack: CompiledStack<unknown, unknown>;
|
|
47
|
+
readonly context: Context.Context<unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The plan-node fields `--all` needs to read a row that has state, and the recheck to replay a diff. */
|
|
51
|
+
type StatefulNode = {
|
|
52
|
+
readonly action: string;
|
|
53
|
+
readonly provider?: ProviderService;
|
|
54
|
+
readonly resource: { readonly LogicalId: string };
|
|
55
|
+
readonly state?: {
|
|
56
|
+
readonly instanceId: string;
|
|
57
|
+
readonly props: object;
|
|
58
|
+
readonly attr: object | undefined;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** ⚠️ In the scope Plan.ts gives its own reads (plan-scope.ts). */
|
|
63
|
+
const readWithState = (fqn: string, node: StatefulNode) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const read = node.provider?.read;
|
|
66
|
+
if (read === undefined || node.state === undefined) return;
|
|
67
|
+
yield* read({
|
|
68
|
+
fqn,
|
|
69
|
+
id: node.resource.LogicalId,
|
|
70
|
+
instanceId: node.state.instanceId,
|
|
71
|
+
olds: node.state.props,
|
|
72
|
+
output: node.state.attr,
|
|
73
|
+
}).pipe(inPlanScope(fqn, node.state.instanceId));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which planned rows plan FROM a resource row the stage's state store holds for them.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ THE ROW MUST BE THE ONE THE NODE PLANS FROM, NOT MERELY A ROW AT THE SAME FQN. Alchemy lets a
|
|
80
|
+
* rename hand its old id to a new resource in the same deploy (Rename.ts: `Bucket("Assets")
|
|
81
|
+
* .pipe(renamedFrom("Bucket"))` beside a fresh `Bucket("Bucket")`). The store still holds the
|
|
82
|
+
* renamer's row at `Bucket`, so a check by FQN alone called the reuser stateful and the default
|
|
83
|
+
* report hid its `create`. Matching the node's `state.instanceId` also sees the renamer, whose
|
|
84
|
+
* row still sits at its FORMER FQN, as a row without state.
|
|
85
|
+
*/
|
|
86
|
+
const withState = (
|
|
87
|
+
stack: CompiledStack<unknown, unknown>,
|
|
88
|
+
nodes: Readonly<Record<string, StatefulNode>>,
|
|
89
|
+
) =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const store = yield* yield* State;
|
|
92
|
+
const found = yield* Effect.all(
|
|
93
|
+
Object.entries(nodes).map(([fqn, node]) =>
|
|
94
|
+
store
|
|
95
|
+
.get({ fqn, stack: stack.name, stage: stack.stage })
|
|
96
|
+
.pipe(
|
|
97
|
+
Effect.map((row) =>
|
|
98
|
+
row === undefined ||
|
|
99
|
+
isActionState(row) ||
|
|
100
|
+
(row as { readonly instanceId?: unknown }).instanceId !== node.state?.instanceId
|
|
101
|
+
? []
|
|
102
|
+
: [fqn],
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
{ concurrency: 8 },
|
|
107
|
+
);
|
|
108
|
+
return new Set(found.flat());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Plan `session.stack` with watched providers and report each row without state (or, with
|
|
113
|
+
* `all`, every row). Never writes.
|
|
114
|
+
*/
|
|
115
|
+
export const verifySession = (
|
|
116
|
+
session: VerifySession,
|
|
117
|
+
options: VerifyOptions = {},
|
|
118
|
+
): Effect.Effect<AdoptReport, unknown> => {
|
|
119
|
+
const seen: Observations = new Map();
|
|
120
|
+
const all = options.all === true;
|
|
121
|
+
// ⚠️ THE CAST IS AT THE ENGINE'S TYPED BOUNDARY. `Plan.make` declares only `State` and hides the
|
|
122
|
+
// rest behind a ts-expect-error; the session context carries all of it, as in routes/stack.ts.
|
|
123
|
+
const plan = Plan.make(session.stack) as unknown as Effect.Effect<
|
|
124
|
+
PlanView & { readonly resources: Readonly<Record<string, StatefulNode>> },
|
|
125
|
+
unknown
|
|
126
|
+
>;
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
const planned = yield* plan;
|
|
129
|
+
const stateRows = yield* withState(session.stack, planned.resources);
|
|
130
|
+
// ★ Before the rows are judged: an adopted `noop` beside a differing read is asked again.
|
|
131
|
+
yield* Effect.all(
|
|
132
|
+
Object.entries(planned.resources).map(([fqn, node]) => recheck(fqn, node, seen)),
|
|
133
|
+
{ concurrency: 8 },
|
|
134
|
+
);
|
|
135
|
+
if (all) {
|
|
136
|
+
const stateful = Object.entries(planned.resources).filter(([fqn]) => stateRows.has(fqn));
|
|
137
|
+
yield* Effect.all(
|
|
138
|
+
stateful.map(([fqn, node]) =>
|
|
139
|
+
readWithState(fqn, node).pipe(
|
|
140
|
+
Effect.catchCause(() =>
|
|
141
|
+
Effect.sync(() => {
|
|
142
|
+
const entry = seen.get(fqn) ?? {};
|
|
143
|
+
entry.read = { answer: 'failed', attributes: undefined };
|
|
144
|
+
seen.set(fqn, entry);
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
{ concurrency: 8 },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
declared: Object.keys(planned.resources).length,
|
|
154
|
+
rows: rowsOf(planned, stateRows, seen, all),
|
|
155
|
+
stack: session.stack.name,
|
|
156
|
+
stage: session.stack.stage,
|
|
157
|
+
} satisfies AdoptReport;
|
|
158
|
+
}).pipe(Effect.provide(spyContext(session.context, seen)), Effect.scoped);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Where the stack lives — the fields `alchemy plan` takes as flags. */
|
|
162
|
+
export interface VerifyTarget {
|
|
163
|
+
/** The stack entrypoint, e.g. `alchemy.run.ts`. */
|
|
164
|
+
readonly entrypoint: string;
|
|
165
|
+
readonly stage: string;
|
|
166
|
+
readonly profile?: string;
|
|
167
|
+
readonly envFile?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Import the stack the way the Alchemy CLI does and verify it.
|
|
172
|
+
*
|
|
173
|
+
* ★ `adopt: true`, ALWAYS, AND IT CANNOT WRITE. Planning is side-effect free (Plan.ts keeps the
|
|
174
|
+
* adopted state in memory, alchemy#793), so the flag only lets an `Unowned` row reach its
|
|
175
|
+
* `diff` instead of failing the plan — the report marks it, because the real deploy then needs
|
|
176
|
+
* `--adopt`. A resource declared `.pipe(adopt(false))` still fails the plan, as it would deploy.
|
|
177
|
+
*/
|
|
178
|
+
export const verifyStack = (
|
|
179
|
+
target: VerifyTarget,
|
|
180
|
+
options: VerifyOptions = {},
|
|
181
|
+
): Effect.Effect<AdoptReport, unknown> =>
|
|
182
|
+
Effect.gen(function* () {
|
|
183
|
+
const session = yield* Alchemist.open(target, { adopt: true });
|
|
184
|
+
return yield* verifySession(session, options);
|
|
185
|
+
}).pipe(Effect.scoped, Effect.provide(Alchemist.layer()));
|