@intentius/chant 0.1.7 → 0.1.9
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/package.json +1 -1
- package/src/cli/commands/build.test.ts +58 -5
- package/src/cli/commands/build.ts +7 -3
- package/src/cli/handlers/graph.test.ts +91 -0
- package/src/cli/handlers/run.test.ts +448 -0
- package/src/cli/handlers/state.test.ts +409 -0
- package/src/cli/handlers/state.ts +232 -10
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/mcp/tools/search.ts +6 -1
- package/src/cli/registry.ts +1 -0
- package/src/lexicon-plugin-helpers.ts +13 -5
- package/src/lexicon.ts +57 -1
- package/src/lint/config.test.ts +21 -0
- package/src/lint/config.ts +19 -3
- package/src/lint/rule-loader.test.ts +28 -0
- package/src/lint/rule-loader.ts +41 -8
- package/src/op/types.ts +13 -0
- package/src/state/digest.test.ts +117 -0
- package/src/state/git.test.ts +191 -0
- package/src/state/git.ts +63 -11
- package/src/state/live-diff.test.ts +184 -0
- package/src/state/live-diff.ts +215 -0
- package/src/state/snapshot.test.ts +171 -0
- package/src/state/snapshot.ts +39 -19
- package/src/state/types.ts +4 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
writeSnapshot,
|
|
8
|
+
readSnapshot,
|
|
9
|
+
readEnvironmentSnapshots,
|
|
10
|
+
listSnapshots,
|
|
11
|
+
getHeadCommit,
|
|
12
|
+
pushState,
|
|
13
|
+
StaleStateBranchError,
|
|
14
|
+
} from "./git";
|
|
15
|
+
|
|
16
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
17
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
18
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function initRepo(dir: string): Promise<void> {
|
|
22
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
23
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
24
|
+
git(["config", "user.name", "Test"], dir);
|
|
25
|
+
// Need at least one commit so HEAD exists.
|
|
26
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
27
|
+
git(["add", "README.md"], dir);
|
|
28
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("state/git", () => {
|
|
32
|
+
test("writeSnapshot creates the orphan branch and writes JSON addressable by readSnapshot", async () => {
|
|
33
|
+
await withTestDir(async (dir) => {
|
|
34
|
+
await initRepo(dir);
|
|
35
|
+
const json = JSON.stringify({ resources: { bucket: { type: "T", status: "OK" } } });
|
|
36
|
+
const sha = await writeSnapshot("prod", "aws", json, { cwd: dir });
|
|
37
|
+
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
|
38
|
+
|
|
39
|
+
const out = await readSnapshot("prod", "aws", { cwd: dir });
|
|
40
|
+
expect(out).not.toBeNull();
|
|
41
|
+
expect(JSON.parse(out!)).toEqual(JSON.parse(json));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("readSnapshot returns null for missing env/lexicon", async () => {
|
|
46
|
+
await withTestDir(async (dir) => {
|
|
47
|
+
await initRepo(dir);
|
|
48
|
+
const out = await readSnapshot("prod", "aws", { cwd: dir });
|
|
49
|
+
expect(out).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("subsequent writes preserve other env+lexicon entries", async () => {
|
|
54
|
+
await withTestDir(async (dir) => {
|
|
55
|
+
await initRepo(dir);
|
|
56
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
|
|
57
|
+
await writeSnapshot("prod", "gcp", JSON.stringify({ b: 2 }), { cwd: dir });
|
|
58
|
+
await writeSnapshot("staging", "aws", JSON.stringify({ c: 3 }), { cwd: dir });
|
|
59
|
+
|
|
60
|
+
expect(await readSnapshot("prod", "aws", { cwd: dir })).toBeTruthy();
|
|
61
|
+
expect(await readSnapshot("prod", "gcp", { cwd: dir })).toBeTruthy();
|
|
62
|
+
expect(await readSnapshot("staging", "aws", { cwd: dir })).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("re-writing the same env+lexicon updates the entry rather than duplicating", async () => {
|
|
67
|
+
await withTestDir(async (dir) => {
|
|
68
|
+
await initRepo(dir);
|
|
69
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 1 }), { cwd: dir });
|
|
70
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 2 }), { cwd: dir });
|
|
71
|
+
const out = await readSnapshot("prod", "aws", { cwd: dir });
|
|
72
|
+
expect(JSON.parse(out!)).toEqual({ v: 2 });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("readEnvironmentSnapshots returns all lexicons for an env", async () => {
|
|
77
|
+
await withTestDir(async (dir) => {
|
|
78
|
+
await initRepo(dir);
|
|
79
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: dir });
|
|
80
|
+
await writeSnapshot("prod", "gcp", JSON.stringify({ b: 2 }), { cwd: dir });
|
|
81
|
+
const all = await readEnvironmentSnapshots("prod", { cwd: dir });
|
|
82
|
+
expect([...all.keys()].sort()).toEqual(["aws", "gcp"]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("listSnapshots returns commit history of the orphan branch", async () => {
|
|
87
|
+
await withTestDir(async (dir) => {
|
|
88
|
+
await initRepo(dir);
|
|
89
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 1 }), { cwd: dir });
|
|
90
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ v: 2 }), { cwd: dir });
|
|
91
|
+
const log = await listSnapshots({ cwd: dir });
|
|
92
|
+
expect(log.length).toBe(2);
|
|
93
|
+
expect(log[0].commit).toMatch(/^[0-9a-f]{40}$/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("getHeadCommit returns the working-branch HEAD sha", async () => {
|
|
98
|
+
await withTestDir(async (dir) => {
|
|
99
|
+
await initRepo(dir);
|
|
100
|
+
const head = await getHeadCommit({ cwd: dir });
|
|
101
|
+
expect(head).toMatch(/^[0-9a-f]{40}$/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── Concurrent push rejection (#30) ─────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a "remote ↔ clone" pair where `clone` has `remote` configured as
|
|
109
|
+
* `origin`. Returns the clone path; the caller writes snapshots there.
|
|
110
|
+
*/
|
|
111
|
+
async function setupClonePair(): Promise<{ clonePath: string; remotePath: string; cleanup: () => Promise<void> }> {
|
|
112
|
+
const remotePath = join(import.meta.dirname ?? "/tmp", `chant-state-remote-${Date.now()}-${Math.random()}`);
|
|
113
|
+
const clonePath = join(import.meta.dirname ?? "/tmp", `chant-state-clone-${Date.now()}-${Math.random()}`);
|
|
114
|
+
const { mkdir, rm } = await import("node:fs/promises");
|
|
115
|
+
await mkdir(remotePath, { recursive: true });
|
|
116
|
+
git(["init", "-q", "--bare", "-b", "main"], remotePath);
|
|
117
|
+
git(["clone", "-q", remotePath, clonePath], import.meta.dirname ?? "/tmp");
|
|
118
|
+
git(["config", "user.email", "test@chant.dev"], clonePath);
|
|
119
|
+
git(["config", "user.name", "Test"], clonePath);
|
|
120
|
+
writeFileSync(join(clonePath, "README.md"), "fixture\n");
|
|
121
|
+
git(["add", "README.md"], clonePath);
|
|
122
|
+
git(["commit", "-q", "-m", "init"], clonePath);
|
|
123
|
+
git(["push", "-q", "origin", "main"], clonePath);
|
|
124
|
+
return {
|
|
125
|
+
clonePath,
|
|
126
|
+
remotePath,
|
|
127
|
+
cleanup: async () => {
|
|
128
|
+
await rm(remotePath, { recursive: true, force: true });
|
|
129
|
+
await rm(clonePath, { recursive: true, force: true });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("first push to remote succeeds (no remote ref yet)", async () => {
|
|
135
|
+
const { clonePath, cleanup } = await setupClonePair();
|
|
136
|
+
try {
|
|
137
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
138
|
+
const ok = await pushState({ cwd: clonePath });
|
|
139
|
+
expect(ok).toBe(true);
|
|
140
|
+
} finally {
|
|
141
|
+
await cleanup();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("subsequent push from same clone (after fetch) succeeds via lease", async () => {
|
|
146
|
+
const { clonePath, cleanup } = await setupClonePair();
|
|
147
|
+
try {
|
|
148
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
149
|
+
expect(await pushState({ cwd: clonePath })).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Pull the remote ref into local remote-tracking, then commit + push again
|
|
152
|
+
git(["fetch", "-q", "origin", "+refs/heads/chant/state:refs/remotes/origin/chant/state"], clonePath);
|
|
153
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: clonePath });
|
|
154
|
+
expect(await pushState({ cwd: clonePath })).toBe(true);
|
|
155
|
+
} finally {
|
|
156
|
+
await cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("concurrent write rejected: second push throws StaleStateBranchError", async () => {
|
|
161
|
+
// Simulate two concurrent operators by setting up two clones of the same remote.
|
|
162
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
163
|
+
const cloneB = join(import.meta.dirname ?? "/tmp", `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
164
|
+
try {
|
|
165
|
+
git(["clone", "-q", remotePath, cloneB], import.meta.dirname ?? "/tmp");
|
|
166
|
+
git(["config", "user.email", "test@chant.dev"], cloneB);
|
|
167
|
+
git(["config", "user.name", "Test"], cloneB);
|
|
168
|
+
|
|
169
|
+
// Operator A writes + pushes first.
|
|
170
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
171
|
+
expect(await pushState({ cwd: cloneA })).toBe(true);
|
|
172
|
+
|
|
173
|
+
// Operator B writes from the same baseline (chant/state doesn't exist
|
|
174
|
+
// on cloneB's remote-tracking yet) and tries to push — should fail
|
|
175
|
+
// with StaleStateBranchError because A's push moved the remote ref.
|
|
176
|
+
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
177
|
+
await expect(pushState({ cwd: cloneB })).rejects.toBeInstanceOf(StaleStateBranchError);
|
|
178
|
+
} finally {
|
|
179
|
+
await cleanup();
|
|
180
|
+
const { rm } = await import("node:fs/promises");
|
|
181
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("StaleStateBranchError carries the expected SHA used as the lease", async () => {
|
|
186
|
+
const err = new StaleStateBranchError(null, "stale info: ...");
|
|
187
|
+
expect(err.name).toBe("StaleStateBranchError");
|
|
188
|
+
expect(err.expected).toBeNull();
|
|
189
|
+
expect(err.message).toContain("moved");
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/state/git.ts
CHANGED
|
@@ -22,13 +22,8 @@ export async function writeSnapshot(
|
|
|
22
22
|
const rt = getRuntime();
|
|
23
23
|
const cwd = opts?.cwd;
|
|
24
24
|
|
|
25
|
-
// 1. Write blob
|
|
26
|
-
|
|
27
|
-
["git", "hash-object", "-w", "--stdin"],
|
|
28
|
-
{ cwd },
|
|
29
|
-
);
|
|
30
|
-
// hash-object reads from stdin, but spawn doesn't support piping directly.
|
|
31
|
-
// Use a shell pipeline instead.
|
|
25
|
+
// 1. Write blob — hash-object reads from stdin, but spawn() doesn't expose
|
|
26
|
+
// a stdin handle, so we run via a shell pipeline (`echo … | git hash-object`).
|
|
32
27
|
const blobResult = await rt.spawn(
|
|
33
28
|
["sh", "-c", `echo '${json.replace(/'/g, "'\\''")}' | git hash-object -w --stdin`],
|
|
34
29
|
{ cwd },
|
|
@@ -179,20 +174,77 @@ export async function listSnapshots(
|
|
|
179
174
|
}
|
|
180
175
|
|
|
181
176
|
/**
|
|
182
|
-
*
|
|
177
|
+
* Thrown by pushState when the remote chant/state branch has moved since
|
|
178
|
+
* the local snapshot was prepared — i.e. another snapshot for this or a
|
|
179
|
+
* different env was pushed concurrently. The caller should fetch and retry.
|
|
180
|
+
*/
|
|
181
|
+
export class StaleStateBranchError extends Error {
|
|
182
|
+
readonly expected: string | null;
|
|
183
|
+
constructor(expected: string | null, stderr: string) {
|
|
184
|
+
super(
|
|
185
|
+
"chant/state remote branch has moved since this run started — " +
|
|
186
|
+
"another snapshot was pushed concurrently. " +
|
|
187
|
+
`git stderr: ${stderr.trim()}`,
|
|
188
|
+
);
|
|
189
|
+
this.name = "StaleStateBranchError";
|
|
190
|
+
this.expected = expected;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Look up the remote-tracking SHA for chant/state, if any. Returns null when
|
|
196
|
+
* the remote ref doesn't exist locally yet (e.g. first-ever snapshot).
|
|
197
|
+
*/
|
|
198
|
+
export async function getRemoteStateBranchSha(
|
|
199
|
+
remote: string,
|
|
200
|
+
opts?: { cwd?: string },
|
|
201
|
+
): Promise<string | null> {
|
|
202
|
+
const rt = getRuntime();
|
|
203
|
+
const ref = `refs/remotes/${remote}/${STATE_BRANCH}`;
|
|
204
|
+
const result = await rt.spawn(["git", "rev-parse", "--verify", ref], { cwd: opts?.cwd });
|
|
205
|
+
if (result.exitCode !== 0) return null;
|
|
206
|
+
return result.stdout.trim() || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Push the state branch to remote with --force-with-lease.
|
|
211
|
+
*
|
|
212
|
+
* If the remote chant/state ref has advanced past the local remote-tracking
|
|
213
|
+
* SHA captured at the start of this push, the push is rejected and we throw
|
|
214
|
+
* StaleStateBranchError so the caller can surface a recovery hint.
|
|
215
|
+
*
|
|
216
|
+
* Returns false (without throwing) only when no remote is configured at all.
|
|
183
217
|
*/
|
|
184
218
|
export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
|
|
185
219
|
const rt = getRuntime();
|
|
186
|
-
// Check if remote exists
|
|
187
220
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
188
221
|
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
|
|
189
222
|
|
|
190
223
|
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
224
|
+
|
|
225
|
+
// Capture the lease SHA — if null, the remote ref doesn't exist yet
|
|
226
|
+
// (first-time push) and we send `--force-with-lease=ref:` (empty SHA),
|
|
227
|
+
// which git interprets as "ref does not exist on remote".
|
|
228
|
+
const expected = await getRemoteStateBranchSha(remote, opts);
|
|
229
|
+
const lease = `refs/heads/${STATE_BRANCH}:${expected ?? ""}`;
|
|
230
|
+
|
|
191
231
|
const pushResult = await rt.spawn(
|
|
192
|
-
["git", "push", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
232
|
+
["git", "push", `--force-with-lease=${lease}`, remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
193
233
|
{ cwd: opts?.cwd },
|
|
194
234
|
);
|
|
195
|
-
|
|
235
|
+
|
|
236
|
+
if (pushResult.exitCode !== 0) {
|
|
237
|
+
const stderr = pushResult.stderr ?? "";
|
|
238
|
+
if (
|
|
239
|
+
stderr.includes("stale info") ||
|
|
240
|
+
stderr.includes("rejected") ||
|
|
241
|
+
stderr.includes("non-fast-forward")
|
|
242
|
+
) {
|
|
243
|
+
throw new StaleStateBranchError(expected, stderr);
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
196
248
|
}
|
|
197
249
|
|
|
198
250
|
/**
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { diffLive, diffLiveArtifacts } from "./live-diff";
|
|
3
|
+
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
4
|
+
|
|
5
|
+
const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
6
|
+
type: "AWS::S3::Bucket",
|
|
7
|
+
status: "CREATE_COMPLETE",
|
|
8
|
+
physicalId: "bucket-1",
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("diffLive", () => {
|
|
13
|
+
test("empty inputs produce empty result", () => {
|
|
14
|
+
const result = diffLive({
|
|
15
|
+
declared: new Set(),
|
|
16
|
+
observedNow: {},
|
|
17
|
+
observedThen: undefined,
|
|
18
|
+
});
|
|
19
|
+
expect(result).toEqual({
|
|
20
|
+
missing: [],
|
|
21
|
+
orphan: [],
|
|
22
|
+
disappeared: [],
|
|
23
|
+
newlyObserved: [],
|
|
24
|
+
driftedSinceSnapshot: [],
|
|
25
|
+
unchanged: [],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("declared but not observed → missing", () => {
|
|
30
|
+
const result = diffLive({
|
|
31
|
+
declared: new Set(["bucket"]),
|
|
32
|
+
observedNow: {},
|
|
33
|
+
observedThen: undefined,
|
|
34
|
+
});
|
|
35
|
+
expect(result.missing).toEqual(["bucket"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("observed but not declared → orphan", () => {
|
|
39
|
+
const result = diffLive({
|
|
40
|
+
declared: new Set(),
|
|
41
|
+
observedNow: { abandoned: meta() },
|
|
42
|
+
observedThen: undefined,
|
|
43
|
+
});
|
|
44
|
+
expect(result.orphan).toEqual(["abandoned"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("in previous snapshot but not observed now → disappeared", () => {
|
|
48
|
+
const result = diffLive({
|
|
49
|
+
declared: new Set(["bucket"]),
|
|
50
|
+
observedNow: {},
|
|
51
|
+
observedThen: { bucket: meta() },
|
|
52
|
+
});
|
|
53
|
+
expect(result.missing).toEqual(["bucket"]);
|
|
54
|
+
expect(result.disappeared).toEqual(["bucket"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("observed for the first time (declared, no previous snapshot) → newlyObserved", () => {
|
|
58
|
+
const result = diffLive({
|
|
59
|
+
declared: new Set(["bucket"]),
|
|
60
|
+
observedNow: { bucket: meta() },
|
|
61
|
+
observedThen: {},
|
|
62
|
+
});
|
|
63
|
+
expect(result.newlyObserved).toEqual(["bucket"]);
|
|
64
|
+
expect(result.unchanged).toEqual([]);
|
|
65
|
+
expect(result.driftedSinceSnapshot).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("attribute changed between snapshots → driftedSinceSnapshot with attribute path", () => {
|
|
69
|
+
const result = diffLive({
|
|
70
|
+
declared: new Set(["bucket"]),
|
|
71
|
+
observedNow: { bucket: meta({ status: "UPDATE_COMPLETE", attributes: { tags: { env: "prod" } } }) },
|
|
72
|
+
observedThen: { bucket: meta({ status: "CREATE_COMPLETE", attributes: { tags: { env: "stage" } } }) },
|
|
73
|
+
});
|
|
74
|
+
expect(result.driftedSinceSnapshot).toHaveLength(1);
|
|
75
|
+
const drift = result.driftedSinceSnapshot[0];
|
|
76
|
+
expect(drift.name).toBe("bucket");
|
|
77
|
+
expect(drift.type).toBe("AWS::S3::Bucket");
|
|
78
|
+
const paths = drift.changes.map((c) => c.path).sort();
|
|
79
|
+
expect(paths).toEqual(["attributes.tags", "status"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("identical metadata between snapshots → unchanged", () => {
|
|
83
|
+
const sameMeta = meta({ attributes: { tags: { env: "prod" } } });
|
|
84
|
+
const result = diffLive({
|
|
85
|
+
declared: new Set(["bucket"]),
|
|
86
|
+
observedNow: { bucket: sameMeta },
|
|
87
|
+
observedThen: { bucket: sameMeta },
|
|
88
|
+
});
|
|
89
|
+
expect(result.unchanged).toEqual(["bucket"]);
|
|
90
|
+
expect(result.driftedSinceSnapshot).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("mixed: counts add up across all six categories", () => {
|
|
94
|
+
const result = diffLive({
|
|
95
|
+
declared: new Set(["a", "b", "c", "d"]),
|
|
96
|
+
observedNow: {
|
|
97
|
+
b: meta(), // unchanged
|
|
98
|
+
c: meta({ status: "UPDATE_COMPLETE" }), // drift
|
|
99
|
+
d: meta(), // newlyObserved
|
|
100
|
+
e: meta(), // orphan
|
|
101
|
+
},
|
|
102
|
+
observedThen: {
|
|
103
|
+
a: meta(), // disappeared (and missing, since declared)
|
|
104
|
+
b: meta(), // unchanged
|
|
105
|
+
c: meta(), // drift
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(result.missing).toEqual(["a"]);
|
|
109
|
+
expect(result.orphan).toEqual(["e"]);
|
|
110
|
+
expect(result.disappeared).toEqual(["a"]);
|
|
111
|
+
expect(result.newlyObserved).toEqual(["d"]);
|
|
112
|
+
expect(result.driftedSinceSnapshot.map((d) => d.name)).toEqual(["c"]);
|
|
113
|
+
expect(result.unchanged).toEqual(["b"]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("diffLiveArtifacts", () => {
|
|
118
|
+
const a = (overrides: Partial<ArtifactMetadata> = {}): ArtifactMetadata => ({
|
|
119
|
+
type: "Helm::Release",
|
|
120
|
+
physicalId: "default/foo",
|
|
121
|
+
status: "deployed",
|
|
122
|
+
...overrides,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("empty inputs produce empty result", () => {
|
|
126
|
+
expect(diffLiveArtifacts({ observedNow: {}, observedThen: undefined })).toEqual({
|
|
127
|
+
added: [], removed: [], changed: [], unchanged: [],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("observed now, no previous snapshot → added", () => {
|
|
132
|
+
const result = diffLiveArtifacts({
|
|
133
|
+
observedNow: { "release/default/foo": a() },
|
|
134
|
+
observedThen: undefined,
|
|
135
|
+
});
|
|
136
|
+
expect(result.added).toEqual(["release/default/foo"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("in previous snapshot, gone now → removed", () => {
|
|
140
|
+
const result = diffLiveArtifacts({
|
|
141
|
+
observedNow: {},
|
|
142
|
+
observedThen: { "release/default/foo": a() },
|
|
143
|
+
});
|
|
144
|
+
expect(result.removed).toEqual(["release/default/foo"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("metadata differs between snapshots → changed", () => {
|
|
148
|
+
const result = diffLiveArtifacts({
|
|
149
|
+
observedNow: { "release/default/foo": a({ status: "failed" }) },
|
|
150
|
+
observedThen: { "release/default/foo": a({ status: "deployed" }) },
|
|
151
|
+
});
|
|
152
|
+
expect(result.changed).toHaveLength(1);
|
|
153
|
+
expect(result.changed[0].name).toBe("release/default/foo");
|
|
154
|
+
expect(result.changed[0].changes.map((c) => c.path)).toContain("status");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("identical metadata → unchanged", () => {
|
|
158
|
+
const same = a();
|
|
159
|
+
const result = diffLiveArtifacts({
|
|
160
|
+
observedNow: { "release/default/foo": same },
|
|
161
|
+
observedThen: { "release/default/foo": same },
|
|
162
|
+
});
|
|
163
|
+
expect(result.unchanged).toEqual(["release/default/foo"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("mixed: counts add up across all four categories", () => {
|
|
167
|
+
const result = diffLiveArtifacts({
|
|
168
|
+
observedNow: {
|
|
169
|
+
"release/default/b": a(), // unchanged
|
|
170
|
+
"release/default/c": a({ status: "failed" }), // changed
|
|
171
|
+
"release/default/d": a(), // added
|
|
172
|
+
},
|
|
173
|
+
observedThen: {
|
|
174
|
+
"release/default/a": a(), // removed
|
|
175
|
+
"release/default/b": a(), // unchanged
|
|
176
|
+
"release/default/c": a(), // changed
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(result.added).toEqual(["release/default/d"]);
|
|
180
|
+
expect(result.removed).toEqual(["release/default/a"]);
|
|
181
|
+
expect(result.changed.map((c) => c.name)).toEqual(["release/default/c"]);
|
|
182
|
+
expect(result.unchanged).toEqual(["release/default/b"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-state diff: compares declared vs observed-now vs observed-then.
|
|
3
|
+
*
|
|
4
|
+
* Produces structured drift signal — *what is in the cloud right now* against
|
|
5
|
+
* both *what was declared in source* and *what was observed at the last
|
|
6
|
+
* snapshot*. Pure function; all I/O happens in the caller.
|
|
7
|
+
*
|
|
8
|
+
* Two diff flavors:
|
|
9
|
+
* - diffLive — entity-keyed (declared ↔ observedNow ↔ observedThen)
|
|
10
|
+
* - diffLiveArtifacts — context-keyed (observedNow ↔ observedThen only;
|
|
11
|
+
* no `declared` axis since artifacts aren't declared
|
|
12
|
+
* as chant entities — they're created by tooling
|
|
13
|
+
* outside chant's entity model)
|
|
14
|
+
*/
|
|
15
|
+
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
16
|
+
|
|
17
|
+
export interface AttributeChange {
|
|
18
|
+
/** Attribute path (e.g. "status", "physicalId", "attributes.tags.env"). */
|
|
19
|
+
path: string;
|
|
20
|
+
oldValue: unknown;
|
|
21
|
+
newValue: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResourceDrift {
|
|
25
|
+
name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
changes: AttributeChange[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LiveDiffResult {
|
|
31
|
+
/** Declared in current build, but not observed in cloud right now. */
|
|
32
|
+
missing: string[];
|
|
33
|
+
/** Observed in cloud right now, but not declared. */
|
|
34
|
+
orphan: string[];
|
|
35
|
+
/** Was in last snapshot but isn't observed now. */
|
|
36
|
+
disappeared: string[];
|
|
37
|
+
/** Observed now and declared, but not in the previous snapshot. */
|
|
38
|
+
newlyObserved: string[];
|
|
39
|
+
/** Observed both then and now; metadata changed. */
|
|
40
|
+
driftedSinceSnapshot: ResourceDrift[];
|
|
41
|
+
/** Observed both then and now; metadata identical. */
|
|
42
|
+
unchanged: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DiffLiveInput {
|
|
46
|
+
/** Entity names from the current build. */
|
|
47
|
+
declared: Set<string>;
|
|
48
|
+
/** Resources returned by `plugin.describeResources()` right now. */
|
|
49
|
+
observedNow: Record<string, ResourceMetadata>;
|
|
50
|
+
/** Resources captured by the previous snapshot, if any. */
|
|
51
|
+
observedThen: Record<string, ResourceMetadata> | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TRACKED_FIELDS: Array<keyof ResourceMetadata> = [
|
|
55
|
+
"status",
|
|
56
|
+
"physicalId",
|
|
57
|
+
"lastUpdated",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function compareMetadata(
|
|
61
|
+
oldMeta: ResourceMetadata,
|
|
62
|
+
newMeta: ResourceMetadata,
|
|
63
|
+
): AttributeChange[] {
|
|
64
|
+
const changes: AttributeChange[] = [];
|
|
65
|
+
|
|
66
|
+
for (const field of TRACKED_FIELDS) {
|
|
67
|
+
if (oldMeta[field] !== newMeta[field]) {
|
|
68
|
+
changes.push({ path: field, oldValue: oldMeta[field], newValue: newMeta[field] });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const oldAttrs = oldMeta.attributes ?? {};
|
|
73
|
+
const newAttrs = newMeta.attributes ?? {};
|
|
74
|
+
const allAttrKeys = new Set([...Object.keys(oldAttrs), ...Object.keys(newAttrs)]);
|
|
75
|
+
for (const key of allAttrKeys) {
|
|
76
|
+
const oldValue = oldAttrs[key];
|
|
77
|
+
const newValue = newAttrs[key];
|
|
78
|
+
if (!shallowEqual(oldValue, newValue)) {
|
|
79
|
+
changes.push({ path: `attributes.${key}`, oldValue, newValue });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return changes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
87
|
+
if (a === b) return true;
|
|
88
|
+
if (a == null || b == null) return false;
|
|
89
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
90
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
94
|
+
const { declared, observedNow, observedThen } = input;
|
|
95
|
+
const observedThenMap = observedThen ?? {};
|
|
96
|
+
const observedNowNames = new Set(Object.keys(observedNow));
|
|
97
|
+
const observedThenNames = new Set(Object.keys(observedThenMap));
|
|
98
|
+
|
|
99
|
+
const missing: string[] = [];
|
|
100
|
+
const orphan: string[] = [];
|
|
101
|
+
const disappeared: string[] = [];
|
|
102
|
+
const newlyObserved: string[] = [];
|
|
103
|
+
const driftedSinceSnapshot: ResourceDrift[] = [];
|
|
104
|
+
const unchanged: string[] = [];
|
|
105
|
+
|
|
106
|
+
// Declared but not observed in cloud right now → missing
|
|
107
|
+
for (const name of declared) {
|
|
108
|
+
if (!observedNowNames.has(name)) {
|
|
109
|
+
missing.push(name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// In cloud right now but not declared → orphan
|
|
114
|
+
for (const name of observedNowNames) {
|
|
115
|
+
if (!declared.has(name)) {
|
|
116
|
+
orphan.push(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// In previous snapshot but not observed now → disappeared
|
|
121
|
+
for (const name of observedThenNames) {
|
|
122
|
+
if (!observedNowNames.has(name)) {
|
|
123
|
+
disappeared.push(name);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Observed now: classify drift relative to previous snapshot
|
|
128
|
+
for (const name of observedNowNames) {
|
|
129
|
+
const now = observedNow[name];
|
|
130
|
+
const then = observedThenMap[name];
|
|
131
|
+
if (!then) {
|
|
132
|
+
if (declared.has(name)) {
|
|
133
|
+
newlyObserved.push(name);
|
|
134
|
+
}
|
|
135
|
+
// else: orphan, already classified above
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const changes = compareMetadata(then, now);
|
|
139
|
+
if (changes.length === 0) {
|
|
140
|
+
unchanged.push(name);
|
|
141
|
+
} else {
|
|
142
|
+
driftedSinceSnapshot.push({
|
|
143
|
+
name,
|
|
144
|
+
type: now.type,
|
|
145
|
+
changes,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
missing: missing.sort(),
|
|
152
|
+
orphan: orphan.sort(),
|
|
153
|
+
disappeared: disappeared.sort(),
|
|
154
|
+
newlyObserved: newlyObserved.sort(),
|
|
155
|
+
driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
|
|
156
|
+
unchanged: unchanged.sort(),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Artifact diff (no `declared` axis) ──────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export interface LiveArtifactDiffResult {
|
|
163
|
+
/** Observed now, not in previous snapshot. */
|
|
164
|
+
added: string[];
|
|
165
|
+
/** In previous snapshot, not observed now. */
|
|
166
|
+
removed: string[];
|
|
167
|
+
/** In both; metadata changed. */
|
|
168
|
+
changed: ResourceDrift[];
|
|
169
|
+
/** In both; metadata identical. */
|
|
170
|
+
unchanged: string[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface DiffLiveArtifactsInput {
|
|
174
|
+
/** Artifacts returned by `plugin.listArtifacts()` right now. */
|
|
175
|
+
observedNow: Record<string, ArtifactMetadata>;
|
|
176
|
+
/** Artifacts captured by the previous snapshot, if any. */
|
|
177
|
+
observedThen: Record<string, ArtifactMetadata> | undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function diffLiveArtifacts(input: DiffLiveArtifactsInput): LiveArtifactDiffResult {
|
|
181
|
+
const observedThenMap = input.observedThen ?? {};
|
|
182
|
+
const nowNames = new Set(Object.keys(input.observedNow));
|
|
183
|
+
const thenNames = new Set(Object.keys(observedThenMap));
|
|
184
|
+
|
|
185
|
+
const added: string[] = [];
|
|
186
|
+
const removed: string[] = [];
|
|
187
|
+
const changed: ResourceDrift[] = [];
|
|
188
|
+
const unchanged: string[] = [];
|
|
189
|
+
|
|
190
|
+
for (const name of nowNames) {
|
|
191
|
+
if (!thenNames.has(name)) {
|
|
192
|
+
added.push(name);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const now = input.observedNow[name];
|
|
196
|
+
const then = observedThenMap[name];
|
|
197
|
+
const diffs = compareMetadata(then, now);
|
|
198
|
+
if (diffs.length === 0) {
|
|
199
|
+
unchanged.push(name);
|
|
200
|
+
} else {
|
|
201
|
+
changed.push({ name, type: now.type, changes: diffs });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const name of thenNames) {
|
|
206
|
+
if (!nowNames.has(name)) removed.push(name);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
added: added.sort(),
|
|
211
|
+
removed: removed.sort(),
|
|
212
|
+
changed: changed.sort((a, b) => a.name.localeCompare(b.name)),
|
|
213
|
+
unchanged: unchanged.sort(),
|
|
214
|
+
};
|
|
215
|
+
}
|