@homeflare/alchemy 0.10.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/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/package.json +1 -1
- package/src/proxmox/ceph-pool-adopt.test.ts +32 -0
- 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
|
@@ -33,6 +33,14 @@ export const Thing = Alchemy.Resource<Thing>('Test.Thing');
|
|
|
33
33
|
export interface Blind extends Alchemy.Resource<'Test.Blind', ThingProps, ThingAttributes> {}
|
|
34
34
|
export const Blind = Alchemy.Resource<Blind>('Test.Blind');
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The same object with a `diff` that compares the RECORDED props with the declaration and never
|
|
38
|
+
* the live object — the shape of Alchemy's own `Cloudflare.R2Bucket` diff (beta.79
|
|
39
|
+
* Cloudflare/R2/Bucket.ts: `olds.domains` against `news.domains`). recheck.ts exists for it.
|
|
40
|
+
*/
|
|
41
|
+
export interface Recorded extends Alchemy.Resource<'Test.Recorded', ThingProps, ThingAttributes> {}
|
|
42
|
+
export const Recorded = Alchemy.Resource<Recorded>('Test.Recorded');
|
|
43
|
+
|
|
36
44
|
/** The fake cloud: what exists, what is someone else's, and every call made to it, in order. */
|
|
37
45
|
export interface Cloud {
|
|
38
46
|
readonly live: Map<string, string>;
|
|
@@ -93,6 +101,22 @@ export const thingProviders = (cloud: Cloud) =>
|
|
|
93
101
|
),
|
|
94
102
|
),
|
|
95
103
|
Provider.effect(Blind, Effect.succeed(Blind.Provider.of(lifecycle(cloud)))),
|
|
104
|
+
Provider.effect(
|
|
105
|
+
Recorded,
|
|
106
|
+
Effect.succeed(
|
|
107
|
+
Recorded.Provider.of({
|
|
108
|
+
...lifecycle(cloud),
|
|
109
|
+
diff: ({ news, olds }) =>
|
|
110
|
+
Effect.sync(() => {
|
|
111
|
+
if (!isResolved(news)) return undefined;
|
|
112
|
+
cloud.calls.push(`diff ${news.name}`);
|
|
113
|
+
return (olds.comment ?? '') === (news.comment ?? '')
|
|
114
|
+
? ({ action: 'noop' } as const)
|
|
115
|
+
: ({ action: 'update' } as const);
|
|
116
|
+
}),
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
96
120
|
);
|
|
97
121
|
|
|
98
122
|
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
package/src/verify/index.ts
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scope Plan.ts gives every provider call it makes, for the calls the verifier makes itself
|
|
3
|
+
* after the plan: `--all`'s reads (verify.ts) and the recheck's diffs (recheck.ts).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ RE-CREATED, BECAUSE Plan.ts DOES NOT EXPORT IT (`providePlanScope`, alchemy beta.79): a scoped
|
|
6
|
+
* `Artifacts` bag over the run's `ArtifactStore` (a fresh one when none is provided) and the row's
|
|
7
|
+
* `InstanceId`. A kit family that consults either — the ownership helpers do — sees what it
|
|
8
|
+
* would see under `alchemy plan`. Moved here from verify.ts once a second caller needed it.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
ArtifactStore,
|
|
12
|
+
Artifacts,
|
|
13
|
+
createArtifactStore,
|
|
14
|
+
makeScopedArtifacts,
|
|
15
|
+
} from 'alchemy/Artifacts';
|
|
16
|
+
import { InstanceId } from 'alchemy/InstanceId';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as Option from 'effect/Option';
|
|
19
|
+
|
|
20
|
+
export const inPlanScope =
|
|
21
|
+
(fqn: string, instanceId: string) =>
|
|
22
|
+
<A, E, R>(
|
|
23
|
+
effect: Effect.Effect<A, E, R>,
|
|
24
|
+
): Effect.Effect<A, E, Exclude<R, Artifacts | InstanceId>> =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const store = Option.getOrElse(
|
|
27
|
+
yield* Effect.serviceOption(ArtifactStore),
|
|
28
|
+
createArtifactStore,
|
|
29
|
+
);
|
|
30
|
+
return yield* effect.pipe(
|
|
31
|
+
Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
|
|
32
|
+
Effect.provideService(InstanceId, instanceId),
|
|
33
|
+
);
|
|
34
|
+
}) as Effect.Effect<A, E, Exclude<R, Artifacts | InstanceId>>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An adopted row whose provider's `diff` never looks at the live object (recheck.ts).
|
|
3
|
+
*
|
|
4
|
+
* 🔴 THE FALSE PASS THIS PINS (red-teamed 2026-09-21). Plan.ts hands an adopted row's diff
|
|
5
|
+
* `olds: news`, so a diff that compares recorded props with the declaration answers `noop`
|
|
6
|
+
* whatever the cloud holds. The verifier printed `ok` and exited 0 over a drift it had itself
|
|
7
|
+
* listed under `changed`, and the deploy then wrote it. The first test fails that way on the
|
|
8
|
+
* verifier without recheck.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import { Recorded, Thing, cloudOf, fakeEngine, writesOf } from './fake-engine.ts';
|
|
13
|
+
import { exitCodeOf } from './report.ts';
|
|
14
|
+
|
|
15
|
+
const recorded = (name: string, comment: string) => Recorded(name, { comment, name });
|
|
16
|
+
|
|
17
|
+
describe('an adopted row whose diff compares recorded props', () => {
|
|
18
|
+
test('that drifted: asked again with the live value, it says update, and the row fails', async () => {
|
|
19
|
+
const cloud = cloudOf({ drifted: 'old' });
|
|
20
|
+
const engine = fakeEngine(cloud);
|
|
21
|
+
const report = await engine.verify(recorded('drifted', 'new'));
|
|
22
|
+
expect(report.rows).toEqual([
|
|
23
|
+
expect.objectContaining({
|
|
24
|
+
changed: ['comment'],
|
|
25
|
+
diff: 'noop',
|
|
26
|
+
ok: false,
|
|
27
|
+
planned: 'adopted',
|
|
28
|
+
recheck: 'update',
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
expect(report.rows[0]?.why).toContain('recorded props');
|
|
32
|
+
expect(exitCodeOf(report)).toBe(1);
|
|
33
|
+
expect(writesOf(cloud)).toEqual([]);
|
|
34
|
+
|
|
35
|
+
// ★ And the verdict is the deploy's: the forced reconcile writes the drift the diff hid.
|
|
36
|
+
expect(await engine.deploy(recorded('drifted', 'new'))).toEqual({ drifted: 'adopted' });
|
|
37
|
+
expect(cloud.live.get('drifted')).toBe('new');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('that matches: nothing differs, so it is not asked again and passes', async () => {
|
|
41
|
+
const cloud = cloudOf({ same: 'x' });
|
|
42
|
+
const report = await fakeEngine(cloud).verify(recorded('same', 'x'));
|
|
43
|
+
expect(report.rows[0]).toMatchObject({ changed: [], diff: 'noop', ok: true });
|
|
44
|
+
expect(report.rows[0]?.recheck).toBeUndefined();
|
|
45
|
+
expect(cloud.calls.filter((call) => call.startsWith('diff'))).toEqual(['diff same']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a diff that reads the live object is not asked again when it already said update', async () => {
|
|
49
|
+
const cloud = cloudOf({ drifted: 'old' });
|
|
50
|
+
const report = await fakeEngine(cloud).verify(
|
|
51
|
+
Effect.all([Thing('drifted', { comment: 'new', name: 'drifted' })]),
|
|
52
|
+
);
|
|
53
|
+
expect(report.rows[0]).toMatchObject({ diff: 'update', ok: false });
|
|
54
|
+
expect(report.rows[0]?.recheck).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask an adopted row's `diff` a second time, with the live values as its recorded props, when its
|
|
3
|
+
* first `noop` sits beside a read that differs from the declaration.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY (red-teamed 2026-09-21). Plan.ts hands an adopted row's diff `olds: news`, because the
|
|
6
|
+
* adopted state carries `props: news`. A diff that compares the RECORDED props with the
|
|
7
|
+
* declaration therefore answers `noop` whatever the cloud holds, and the forced reconcile then
|
|
8
|
+
* writes. Alchemy's own `Cloudflare.R2Bucket` diff is that shape (beta.79
|
|
9
|
+
* Cloudflare/R2/Bucket.ts, `olds.domains` against `news.domains`). The verifier said `ok`,
|
|
10
|
+
* exited 0, and listed the drift under `changed` on the same line. rows.ts already refused to
|
|
11
|
+
* trust a MISSING diff for this reason; a diff that never looks at the live object is the same
|
|
12
|
+
* blind spot one level down.
|
|
13
|
+
*
|
|
14
|
+
* ★ THE QUESTION IS "WOULD YOU STILL SAY noop IF STATE HAD RECORDED WHAT THE READ FOUND?" It is the
|
|
15
|
+
* planner's own input, replayed with one change: `olds` carries the read's value for each field
|
|
16
|
+
* in `changed`. A diff that reads the live object ignores `olds` and answers as before. Every kit
|
|
17
|
+
* PVE/PBS family re-reads (`pveOperations.diff`), so a field its `matches` leaves out, such as
|
|
18
|
+
* CephPool's autoscaled `pg_num`, still passes. A diff that compares `olds` now sees the drift.
|
|
19
|
+
*
|
|
20
|
+
* ⛔ ONLY `diff`, a read by Alchemy's contract, and through the WATCHED provider, so every write
|
|
21
|
+
* path still answers with spy.ts's refusal. The first answer is put back afterwards: the report's
|
|
22
|
+
* `diff` column stays what the planner heard.
|
|
23
|
+
* ⚠️ IT CAN ONLY TURN A PASS INTO A FAIL. It runs on a row that would otherwise pass (adopted,
|
|
24
|
+
* `noop`, something in `changed`), and anything but a second `noop`, a failure included, fails it.
|
|
25
|
+
* ⚠️ SAME-NAMED FIELDS ONLY, like `changed`: a drift in a field whose attribute has another name is
|
|
26
|
+
* invisible to both. Whether a `reconcile` PUTs identical values anyway is still up to the
|
|
27
|
+
* provider (docs/adopt-verify.md, Limits).
|
|
28
|
+
*/
|
|
29
|
+
import type { ProviderService } from 'alchemy/Provider';
|
|
30
|
+
import * as Effect from 'effect/Effect';
|
|
31
|
+
import { inPlanScope } from './plan-scope.ts';
|
|
32
|
+
import { changedFields } from './rows.ts';
|
|
33
|
+
import { type Observations, type RecheckAnswer, diffAnswer } from './spy.ts';
|
|
34
|
+
|
|
35
|
+
/** The plan-node fields a recheck needs. */
|
|
36
|
+
export interface RecheckNode {
|
|
37
|
+
readonly action: string;
|
|
38
|
+
readonly provider?: ProviderService;
|
|
39
|
+
readonly state?: { readonly instanceId: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Recheck one planned row if it needs it; the answer lands in `seen` as `recheck`. */
|
|
43
|
+
export const recheck = (fqn: string, node: RecheckNode, seen: Observations): Effect.Effect<void> =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const observed = seen.get(fqn);
|
|
46
|
+
const first = observed?.diff;
|
|
47
|
+
const input = first?.input;
|
|
48
|
+
const diff = node.provider?.diff;
|
|
49
|
+
if (node.action !== 'adopted' || first?.answer !== 'noop' || input === undefined) return;
|
|
50
|
+
const live = observed?.read?.attributes as Record<string, unknown> | undefined;
|
|
51
|
+
const changed = changedFields(input.news, live);
|
|
52
|
+
if (live === undefined || changed.length === 0) return;
|
|
53
|
+
if (diff === undefined || node.state === undefined) return;
|
|
54
|
+
const olds = {
|
|
55
|
+
...(input.olds as object),
|
|
56
|
+
...Object.fromEntries(changed.map((key) => [key, live[key]])),
|
|
57
|
+
};
|
|
58
|
+
// ⚠️ THE CAST IS THE ENGINE'S BOUNDARY, as in verify.ts: the diff's requirements are the
|
|
59
|
+
// session's, which verifySession provides around this call.
|
|
60
|
+
const asked = diff({ ...input, olds } as typeof input) as Effect.Effect<unknown>;
|
|
61
|
+
const answer: RecheckAnswer = yield* asked.pipe(
|
|
62
|
+
inPlanScope(fqn, node.state.instanceId),
|
|
63
|
+
Effect.map(diffAnswer),
|
|
64
|
+
Effect.catchCause(() => Effect.succeed('failed' as const)),
|
|
65
|
+
);
|
|
66
|
+
// The watched diff recorded the replay over the planner's answer; put the planner's back.
|
|
67
|
+
if (observed !== undefined) {
|
|
68
|
+
observed.diff = first;
|
|
69
|
+
observed.recheck = answer;
|
|
70
|
+
}
|
|
71
|
+
});
|
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(
|