@homeflare/alchemy 0.10.0 → 0.11.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/dist/caddy/index.js.map +2 -2
- package/dist/launchd/index.js.map +2 -2
- package/dist/openbao/index.js +10 -8
- package/dist/openbao/index.js.map +4 -4
- package/dist/ownership/adopting.d.ts +34 -0
- package/dist/ownership/adopting.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +15 -5
- package/dist/ownership/probe.d.ts.map +1 -1
- package/dist/ownership/rows.d.ts +12 -0
- package/dist/ownership/rows.d.ts.map +1 -1
- package/dist/ownership/store-fixture.d.ts +10 -0
- package/dist/ownership/store-fixture.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/dist/proxmox/index.js +147 -113
- package/dist/proxmox/index.js.map +8 -6
- package/dist/proxmox/lxc-adoption.d.ts +32 -0
- package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +12 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -1
- package/dist/proxmox/lxc.d.ts +5 -5
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/verify/cli.js +79 -29
- package/dist/verify/cli.js.map +8 -6
- package/dist/verify/fake-engine.d.ts +9 -1
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/dist/verify/index.d.ts +1 -1
- package/dist/verify/index.d.ts.map +1 -1
- package/dist/verify/index.js +67 -17
- package/dist/verify/index.js.map +8 -6
- 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/rows.d.ts +13 -3
- package/dist/verify/rows.d.ts.map +1 -1
- package/dist/verify/spy.d.ts +12 -0
- package/dist/verify/spy.d.ts.map +1 -1
- package/dist/verify/verify.d.ts.map +1 -1
- package/docs/adopt-verify.md +13 -2
- package/docs/adopted-deploys.md +4 -2
- package/docs/ownership.md +1 -1
- package/docs/proxmox-lxc-adopt.md +83 -0
- package/docs/proxmox-lxc.md +7 -45
- package/docs/proxmox.md +2 -0
- package/package.json +1 -1
- package/src/ownership/adopting.test.ts +63 -0
- package/src/ownership/adopting.ts +46 -0
- package/src/ownership/ownership.test.ts +1 -34
- package/src/ownership/probe.ts +27 -14
- package/src/ownership/rows.ts +8 -2
- package/src/ownership/store-fixture.ts +50 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +32 -0
- package/src/proxmox/fake-pve-lxc.ts +13 -1
- package/src/proxmox/lxc-adopt.test.ts +13 -20
- package/src/proxmox/lxc-adoption.ts +50 -0
- package/src/proxmox/lxc-guards.test.ts +6 -13
- package/src/proxmox/lxc-harness.ts +25 -2
- package/src/proxmox/lxc-ownership.test.ts +19 -14
- package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
- package/src/proxmox/lxc.ts +16 -12
- package/src/verify/fake-engine.ts +24 -0
- package/src/verify/index.ts +1 -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/rows.ts +46 -5
- package/src/verify/spy.ts +14 -3
- package/src/verify/verify.test.ts +22 -0
- package/src/verify/verify.ts +38 -25
package/src/verify/rows.ts
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
* this file only decides.
|
|
8
8
|
*/
|
|
9
9
|
import { deepEqual, isResolved } from 'alchemy/Diff';
|
|
10
|
-
import type { DiffAnswer, Observations, ReadAnswer } from './spy.ts';
|
|
10
|
+
import type { DiffAnswer, Observations, ReadAnswer, RecheckAnswer } from './spy.ts';
|
|
11
11
|
|
|
12
12
|
export interface AdoptRow {
|
|
13
13
|
readonly fqn: string;
|
|
14
14
|
readonly type: string;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Whether the row plans from a row the state store holds for it (verify.ts `withState`). An
|
|
17
|
+
* adoption is a row without one.
|
|
18
|
+
*/
|
|
16
19
|
readonly stateRow: boolean;
|
|
17
20
|
/** The engine's action — what `alchemy plan` prints (`adopted`, `create`, `noop`, …). */
|
|
18
21
|
readonly planned: string;
|
|
@@ -23,9 +26,16 @@ export interface AdoptRow {
|
|
|
23
26
|
/**
|
|
24
27
|
* Declared fields whose live value differs, by NAME only — values can be secrets. Compared on
|
|
25
28
|
* same-named keys of the declaration and the read's attributes, so a family whose attributes
|
|
26
|
-
* rename a field cannot show it here
|
|
29
|
+
* rename a field cannot show it here. The diff answer is the verdict; a name here beside an
|
|
30
|
+
* adopted `noop` only makes recheck.ts ask that diff again.
|
|
27
31
|
*/
|
|
28
32
|
readonly changed: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* What the provider's `diff` answered when asked again with the live values of `changed` as its
|
|
35
|
+
* recorded props (recheck.ts). Absent when it was not asked: only an adopted `noop` with
|
|
36
|
+
* something in `changed` is.
|
|
37
|
+
*/
|
|
38
|
+
readonly recheck?: RecheckAnswer;
|
|
29
39
|
/** Bindings the engine will create, update or delete — reconcile applies them even on `noop`. */
|
|
30
40
|
readonly bindings: number;
|
|
31
41
|
readonly ok: boolean;
|
|
@@ -77,9 +87,38 @@ const isNoop = (planned: string, diff: DiffAnswer, stateRow: boolean) =>
|
|
|
77
87
|
STILL_LIVE.has(planned) &&
|
|
78
88
|
(diff === 'noop' || (diff === 'none' && stateRow && planned === 'noop'));
|
|
79
89
|
|
|
90
|
+
/**
|
|
91
|
+
* ⛔ AND A `noop` MUST SURVIVE BEING ASKED WITH THE LIVE VALUES. A diff that compares recorded props
|
|
92
|
+
* answers an adopted row `noop` whatever the cloud holds; recheck.ts asks it again, and only a
|
|
93
|
+
* second `noop` (or no need to ask) proves anything.
|
|
94
|
+
*/
|
|
95
|
+
const proven = (recheck: RecheckAnswer | undefined) => recheck === undefined || recheck === 'noop';
|
|
96
|
+
|
|
97
|
+
/** Notes on a row that passes: nothing to say is the common case. */
|
|
98
|
+
const notes = (row: Omit<AdoptRow, 'ok' | 'why'>): string =>
|
|
99
|
+
[
|
|
100
|
+
row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '',
|
|
101
|
+
row.recheck === 'noop'
|
|
102
|
+
? `${row.changed.join(', ')} differ, but the diff says noop even given the live values: ` +
|
|
103
|
+
'the family does not manage them, and the deploy leaves them alone'
|
|
104
|
+
: '',
|
|
105
|
+
]
|
|
106
|
+
.filter((note) => note !== '')
|
|
107
|
+
.join('; ');
|
|
108
|
+
|
|
80
109
|
const reason = (row: Omit<AdoptRow, 'ok' | 'why'>, ok: boolean): string => {
|
|
81
|
-
if (ok) return row
|
|
110
|
+
if (ok) return notes(row);
|
|
82
111
|
if (isNoop(row.planned, row.diff, row.stateRow)) {
|
|
112
|
+
const live = `the live ${row.changed.join(', ')}`;
|
|
113
|
+
if (row.recheck === 'failed') {
|
|
114
|
+
return `asked again with ${live} as recorded props, the provider's diff failed: not proven`;
|
|
115
|
+
}
|
|
116
|
+
if (!proven(row.recheck)) {
|
|
117
|
+
return (
|
|
118
|
+
`the provider's diff compares recorded props, not the live object: given ${live} it ` +
|
|
119
|
+
`says ${String(row.recheck)}, and the deploy's forced reconcile writes`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
83
122
|
return `${String(row.bindings)} binding(s) change: the deploy reconciles them though the diff is noop`;
|
|
84
123
|
}
|
|
85
124
|
if (row.planned === 'delete') return 'no longer declared: the deploy deletes it';
|
|
@@ -107,6 +146,7 @@ export const rowFor = (
|
|
|
107
146
|
seen: Observations,
|
|
108
147
|
): AdoptRow => {
|
|
109
148
|
const observed = seen.get(fqn);
|
|
149
|
+
const recheck = observed?.recheck;
|
|
110
150
|
const base = {
|
|
111
151
|
bindings: (node.bindings ?? []).filter((binding) => binding.action !== 'noop').length,
|
|
112
152
|
changed: changedFields(observed?.diff?.news, observed?.read?.attributes),
|
|
@@ -114,6 +154,7 @@ export const rowFor = (
|
|
|
114
154
|
fqn,
|
|
115
155
|
planned: node.action,
|
|
116
156
|
read: observed?.read?.answer ?? 'not-read',
|
|
157
|
+
...(recheck === undefined ? {} : { recheck }),
|
|
117
158
|
stateRow,
|
|
118
159
|
type: node.resource.Type,
|
|
119
160
|
} satisfies Omit<AdoptRow, 'ok' | 'why'>;
|
|
@@ -122,7 +163,7 @@ export const rowFor = (
|
|
|
122
163
|
* non-noop binding to `reconcile`, which an adopted row reaches anyway — a Worker adopted with
|
|
123
164
|
* a new binding writes although its own diff said nothing changed.
|
|
124
165
|
*/
|
|
125
|
-
const ok = isNoop(base.planned, base.diff, stateRow) && base.bindings === 0;
|
|
166
|
+
const ok = isNoop(base.planned, base.diff, stateRow) && proven(recheck) && base.bindings === 0;
|
|
126
167
|
return { ...base, ok, why: reason(base, ok) };
|
|
127
168
|
};
|
|
128
169
|
|
package/src/verify/spy.ts
CHANGED
|
@@ -31,11 +31,22 @@ import * as Effect from 'effect/Effect';
|
|
|
31
31
|
|
|
32
32
|
export type ReadAnswer = 'found' | 'unowned' | 'absent' | 'failed' | 'not-read';
|
|
33
33
|
export type DiffAnswer = 'noop' | 'update' | 'replace' | 'none' | 'not-run';
|
|
34
|
+
/** What the diff answered when asked again with the live values as its recorded props (recheck.ts). */
|
|
35
|
+
export type RecheckAnswer = DiffAnswer | 'failed';
|
|
36
|
+
|
|
37
|
+
/** Exactly what the planner handed a provider's `diff`. */
|
|
38
|
+
export type DiffInput = Parameters<NonNullable<ProviderService['diff']>>[0];
|
|
34
39
|
|
|
35
40
|
/** What one FQN's provider answered during the plan — the LAST answer of each kind. */
|
|
36
41
|
export interface Seen {
|
|
37
42
|
read?: { readonly answer: ReadAnswer; readonly attributes: unknown };
|
|
38
|
-
|
|
43
|
+
/**
|
|
44
|
+
* ★ `input` IS THE WHOLE CALL, NOT ONLY `news`, so recheck.ts can ask the same question again
|
|
45
|
+
* with one field changed. In memory only; the report carries field names, never values. An
|
|
46
|
+
* observation built by hand without it (rows.ts's tests) is simply never rechecked.
|
|
47
|
+
*/
|
|
48
|
+
diff?: { readonly answer: DiffAnswer; readonly news: unknown; readonly input?: DiffInput };
|
|
49
|
+
recheck?: RecheckAnswer;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/** Keyed by FQN. Filled while the plan runs; read once it is done. */
|
|
@@ -63,7 +74,7 @@ const refuse = (type: string, operation: string) => () =>
|
|
|
63
74
|
const readAnswer = (attributes: unknown): ReadAnswer =>
|
|
64
75
|
attributes === undefined ? 'absent' : Unowned.is(attributes) ? 'unowned' : 'found';
|
|
65
76
|
|
|
66
|
-
const diffAnswer = (diff: unknown): DiffAnswer => {
|
|
77
|
+
export const diffAnswer = (diff: unknown): DiffAnswer => {
|
|
67
78
|
const action = (diff as { action?: unknown } | undefined)?.action;
|
|
68
79
|
return action === 'noop' || action === 'update' || action === 'replace' ? action : 'none';
|
|
69
80
|
};
|
|
@@ -82,7 +93,7 @@ export const spyService = (type: string, service: ProviderService, seen: Observa
|
|
|
82
93
|
(diff === undefined ? Effect.succeed(undefined) : diff(input)).pipe(
|
|
83
94
|
Effect.tap((answer) =>
|
|
84
95
|
Effect.sync(() => {
|
|
85
|
-
seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), news: input.news };
|
|
96
|
+
seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), input, news: input.news };
|
|
86
97
|
}),
|
|
87
98
|
),
|
|
88
99
|
),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* this tool can be re-thought rather than silently duplicated.
|
|
9
9
|
*/
|
|
10
10
|
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { renamedFrom } from 'alchemy';
|
|
11
12
|
import * as Effect from 'effect/Effect';
|
|
12
13
|
import {
|
|
13
14
|
Blind,
|
|
@@ -112,6 +113,27 @@ describe('verifySession', () => {
|
|
|
112
113
|
expect(writesOf(cloud)).toEqual([]);
|
|
113
114
|
});
|
|
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
|
+
|
|
115
137
|
test('with all, a row no longer declared is a pending delete', async () => {
|
|
116
138
|
const cloud = cloudOf();
|
|
117
139
|
const engine = fakeEngine(cloud);
|
package/src/verify/verify.ts
CHANGED
|
@@ -11,25 +11,20 @@
|
|
|
11
11
|
*
|
|
12
12
|
* ⛔ READ-ONLY BY CONSTRUCTION. The planner calls `read` and `diff`; spy.ts replaces `reconcile`,
|
|
13
13
|
* `delete` and `precreate` with refusals; nothing here calls `apply`. `--all` adds one `read` per
|
|
14
|
-
* row with state
|
|
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.
|
|
15
16
|
* ⚠️ READS ARE NOT FREE FOR EVERY FAMILY. A kit PVE/PBS read mints a lease through OpenBao, which
|
|
16
17
|
* creates a short-lived API token on the cluster, exactly as `alchemy plan` does.
|
|
17
18
|
*/
|
|
18
19
|
import * as Alchemist from 'alchemy/Alchemist';
|
|
19
|
-
import {
|
|
20
|
-
ArtifactStore,
|
|
21
|
-
Artifacts,
|
|
22
|
-
createArtifactStore,
|
|
23
|
-
makeScopedArtifacts,
|
|
24
|
-
} from 'alchemy/Artifacts';
|
|
25
|
-
import { InstanceId } from 'alchemy/InstanceId';
|
|
26
20
|
import * as Plan from 'alchemy/Plan';
|
|
27
21
|
import type { ProviderService } from 'alchemy/Provider';
|
|
28
22
|
import type { CompiledStack } from 'alchemy/Stack';
|
|
29
23
|
import { State, isActionState } from 'alchemy/State';
|
|
30
24
|
import type * as Context from 'effect/Context';
|
|
31
25
|
import * as Effect from 'effect/Effect';
|
|
32
|
-
import
|
|
26
|
+
import { inPlanScope } from './plan-scope.ts';
|
|
27
|
+
import { recheck } from './recheck.ts';
|
|
33
28
|
import { type AdoptRow, type PlanView, rowsOf } from './rows.ts';
|
|
34
29
|
import { type Observations, spyContext } from './spy.ts';
|
|
35
30
|
|
|
@@ -52,8 +47,9 @@ export interface VerifySession {
|
|
|
52
47
|
readonly context: Context.Context<unknown>;
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
/** The plan-node fields `--all` needs to read a row that has state. */
|
|
50
|
+
/** The plan-node fields `--all` needs to read a row that has state, and the recheck to replay a diff. */
|
|
56
51
|
type StatefulNode = {
|
|
52
|
+
readonly action: string;
|
|
57
53
|
readonly provider?: ProviderService;
|
|
58
54
|
readonly resource: { readonly LogicalId: string };
|
|
59
55
|
readonly state?: {
|
|
@@ -63,37 +59,49 @@ type StatefulNode = {
|
|
|
63
59
|
};
|
|
64
60
|
};
|
|
65
61
|
|
|
66
|
-
/**
|
|
67
|
-
* ⚠️ THE SAME SCOPE Plan.ts GIVES ITS OWN READS (`providePlanScope`, not exported): a scoped
|
|
68
|
-
* `Artifacts` bag and the row's `InstanceId`. A kit family that consults either — the ownership
|
|
69
|
-
* helpers do — sees what it would see under `alchemy plan`.
|
|
70
|
-
*/
|
|
62
|
+
/** ⚠️ In the scope Plan.ts gives its own reads (plan-scope.ts). */
|
|
71
63
|
const readWithState = (fqn: string, node: StatefulNode) =>
|
|
72
64
|
Effect.gen(function* () {
|
|
73
65
|
const read = node.provider?.read;
|
|
74
66
|
if (read === undefined || node.state === undefined) return;
|
|
75
|
-
const store = Option.getOrElse(yield* Effect.serviceOption(ArtifactStore), createArtifactStore);
|
|
76
67
|
yield* read({
|
|
77
68
|
fqn,
|
|
78
69
|
id: node.resource.LogicalId,
|
|
79
70
|
instanceId: node.state.instanceId,
|
|
80
71
|
olds: node.state.props,
|
|
81
72
|
output: node.state.attr,
|
|
82
|
-
}).pipe(
|
|
83
|
-
Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
|
|
84
|
-
Effect.provideService(InstanceId, node.state.instanceId),
|
|
85
|
-
);
|
|
73
|
+
}).pipe(inPlanScope(fqn, node.state.instanceId));
|
|
86
74
|
});
|
|
87
75
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
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
90
|
Effect.gen(function* () {
|
|
91
91
|
const store = yield* yield* State;
|
|
92
92
|
const found = yield* Effect.all(
|
|
93
|
-
|
|
93
|
+
Object.entries(nodes).map(([fqn, node]) =>
|
|
94
94
|
store
|
|
95
95
|
.get({ fqn, stack: stack.name, stage: stack.stage })
|
|
96
|
-
.pipe(
|
|
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
|
+
),
|
|
97
105
|
),
|
|
98
106
|
{ concurrency: 8 },
|
|
99
107
|
);
|
|
@@ -118,7 +126,12 @@ export const verifySession = (
|
|
|
118
126
|
>;
|
|
119
127
|
return Effect.gen(function* () {
|
|
120
128
|
const planned = yield* plan;
|
|
121
|
-
const stateRows = yield* withState(session.stack,
|
|
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
|
+
);
|
|
122
135
|
if (all) {
|
|
123
136
|
const stateful = Object.entries(planned.resources).filter(([fqn]) => stateRows.has(fqn));
|
|
124
137
|
yield* Effect.all(
|