@intentius/chant 0.1.14 → 0.1.15
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/build.ts +18 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/import-live.test.ts +126 -0
- package/src/cli/commands/import.ts +152 -2
- package/src/cli/commands/migrate.ts +2 -2
- package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
- package/src/cli/handlers/misc.ts +31 -2
- package/src/cli/handlers/run.test.ts +98 -0
- package/src/cli/handlers/run.ts +123 -0
- package/src/cli/main.test.ts +14 -0
- package/src/cli/main.ts +38 -12
- package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
- package/src/cli/mcp/op-tools.ts +2 -2
- package/src/cli/mcp/resource-handlers.ts +1 -1
- package/src/cli/mcp/server.test.ts +2 -2
- package/src/cli/mcp/server.ts +1 -1
- package/src/cli/registry.ts +21 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +31 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/index.ts +2 -2
- package/src/lexicon-export.test.ts +92 -0
- package/src/lexicon.ts +88 -9
- package/src/lifecycle/change-set.test.ts +151 -0
- package/src/lifecycle/change-set.ts +172 -0
- package/src/{state → lifecycle}/git.test.ts +15 -15
- package/src/{state → lifecycle}/git.ts +14 -14
- package/src/{state → lifecycle}/index.ts +2 -0
- package/src/{state → lifecycle}/snapshot.test.ts +5 -5
- package/src/{state → lifecycle}/snapshot.ts +9 -9
- package/src/{state → lifecycle}/types.ts +1 -1
- package/src/op/activity-registry.test.ts +59 -0
- package/src/op/activity-registry.ts +91 -0
- package/src/op/builders.ts +3 -3
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +247 -0
- package/src/op/local-executor.ts +300 -0
- package/src/op/local-output.test.ts +54 -0
- package/src/op/local-output.ts +63 -0
- package/src/op/op.test.ts +4 -4
- package/src/op/types.ts +1 -1
- package/src/ownership.test.ts +109 -0
- package/src/ownership.ts +142 -0
- package/src/serializer.ts +19 -1
- package/src/toml-parse.ts +3 -3
- /package/src/{state → lifecycle}/digest.test.ts +0 -0
- /package/src/{state → lifecycle}/digest.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.ts +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { extractFromTar } from "./fetch";
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { extractFromTar, fetchWithRetry } from "./fetch";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Build a minimal valid tar buffer with a single file entry.
|
|
@@ -117,3 +117,104 @@ describe("extractFromTar", () => {
|
|
|
117
117
|
expect(files.get("multi.txt")!.toString()).toBe(content);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("fetchWithRetry", () => {
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.restoreAllMocks();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ok = () => new Response("payload", { status: 200 });
|
|
127
|
+
const status = (code: number) => new Response("", { status: code });
|
|
128
|
+
|
|
129
|
+
test("returns immediately on a successful response", async () => {
|
|
130
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
131
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
132
|
+
|
|
133
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1);
|
|
134
|
+
expect(resp.ok).toBe(true);
|
|
135
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("retries a transient status then succeeds", async () => {
|
|
139
|
+
const fetchMock = vi
|
|
140
|
+
.fn()
|
|
141
|
+
.mockResolvedValueOnce(status(504))
|
|
142
|
+
.mockResolvedValueOnce(status(503))
|
|
143
|
+
.mockResolvedValueOnce(ok());
|
|
144
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
145
|
+
|
|
146
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1);
|
|
147
|
+
expect(resp.ok).toBe(true);
|
|
148
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("retries a network error then succeeds", async () => {
|
|
152
|
+
const fetchMock = vi
|
|
153
|
+
.fn()
|
|
154
|
+
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
|
155
|
+
.mockResolvedValueOnce(ok());
|
|
156
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
157
|
+
|
|
158
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1);
|
|
159
|
+
expect(resp.ok).toBe(true);
|
|
160
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("does not retry a permanent status", async () => {
|
|
164
|
+
const fetchMock = vi.fn().mockResolvedValue(status(404));
|
|
165
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
166
|
+
|
|
167
|
+
await expect(fetchWithRetry("https://example.test/x", 4, 1)).rejects.toThrow("returned 404");
|
|
168
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("throws after exhausting retries on a transient status", async () => {
|
|
172
|
+
const fetchMock = vi.fn().mockResolvedValue(status(504));
|
|
173
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
174
|
+
|
|
175
|
+
await expect(fetchWithRetry("https://example.test/x", 2, 1)).rejects.toThrow("returned 504");
|
|
176
|
+
// initial attempt + 2 retries
|
|
177
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("calls fetch with no init argument when none is given", async () => {
|
|
181
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
182
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
183
|
+
|
|
184
|
+
await fetchWithRetry("https://example.test/x", 4, 1);
|
|
185
|
+
expect(fetchMock).toHaveBeenCalledWith("https://example.test/x");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("passes request init through to fetch", async () => {
|
|
189
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
190
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
191
|
+
|
|
192
|
+
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
193
|
+
await fetchWithRetry("https://example.test/x", 4, 1, init);
|
|
194
|
+
expect(fetchMock).toHaveBeenCalledWith("https://example.test/x", init);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("preserves init across retries on a transient status", async () => {
|
|
198
|
+
const fetchMock = vi
|
|
199
|
+
.fn()
|
|
200
|
+
.mockResolvedValueOnce(status(503))
|
|
201
|
+
.mockResolvedValueOnce(ok());
|
|
202
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
203
|
+
|
|
204
|
+
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
205
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1, init);
|
|
206
|
+
expect(resp.ok).toBe(true);
|
|
207
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect(fetchMock).toHaveBeenNthCalledWith(1, "https://example.test/x", init);
|
|
209
|
+
expect(fetchMock).toHaveBeenNthCalledWith(2, "https://example.test/x", init);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("does not retry a permanent status when init is given", async () => {
|
|
213
|
+
const fetchMock = vi.fn().mockResolvedValue(status(403));
|
|
214
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
215
|
+
|
|
216
|
+
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
217
|
+
await expect(fetchWithRetry("https://example.test/x", 4, 1, init)).rejects.toThrow("returned 403");
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
219
|
+
});
|
|
220
|
+
});
|
package/src/codegen/fetch.ts
CHANGED
|
@@ -20,6 +20,66 @@ export interface FetchConfig {
|
|
|
20
20
|
cacheTtlMs?: number;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// ── Transient-aware fetch ──────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* HTTP statuses worth retrying. These are transient: a gateway hiccup,
|
|
27
|
+
* a rate limit, or an overloaded upstream — not a permanent 404/403.
|
|
28
|
+
*/
|
|
29
|
+
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
30
|
+
|
|
31
|
+
const DEFAULT_RETRIES = 4;
|
|
32
|
+
const DEFAULT_BACKOFF_MS = 1000;
|
|
33
|
+
|
|
34
|
+
function sleep(ms: number): Promise<void> {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch a URL, retrying on transient failures (network errors and
|
|
40
|
+
* retryable HTTP statuses) with exponential backoff.
|
|
41
|
+
*
|
|
42
|
+
* Permanent failures (e.g. 404, 403) are not retried — they throw on the
|
|
43
|
+
* first response. The returned response is guaranteed `ok`.
|
|
44
|
+
*
|
|
45
|
+
* `init` is passed through to `fetch` on every attempt, so callers that
|
|
46
|
+
* need request headers (e.g. the GitHub API `Accept` header) or an abort
|
|
47
|
+
* signal get retries without duplicating the loop.
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchWithRetry(
|
|
50
|
+
url: string,
|
|
51
|
+
retries = DEFAULT_RETRIES,
|
|
52
|
+
backoffMs = DEFAULT_BACKOFF_MS,
|
|
53
|
+
init?: RequestInit,
|
|
54
|
+
): Promise<Response> {
|
|
55
|
+
let lastError: Error | undefined;
|
|
56
|
+
|
|
57
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
58
|
+
if (attempt > 0) {
|
|
59
|
+
const delay = backoffMs * 2 ** (attempt - 1);
|
|
60
|
+
debug(`retrying ${url} (attempt ${attempt}/${retries}) after ${delay}ms: ${lastError?.message}`);
|
|
61
|
+
await sleep(delay);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let response: Response;
|
|
65
|
+
try {
|
|
66
|
+
response = init === undefined ? await fetch(url) : await fetch(url, init);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// Network-level failure (DNS, connection reset, timeout). Transient.
|
|
69
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (response.ok) return response;
|
|
74
|
+
|
|
75
|
+
const err = new Error(`Download from ${url} returned ${response.status}`);
|
|
76
|
+
if (!RETRYABLE_STATUSES.has(response.status)) throw err;
|
|
77
|
+
lastError = err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw lastError ?? new Error(`Download from ${url} failed after ${retries} retries`);
|
|
81
|
+
}
|
|
82
|
+
|
|
23
83
|
// ── Fetch with cache ───────────────────────────────────────────────
|
|
24
84
|
|
|
25
85
|
const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
@@ -46,11 +106,7 @@ export async function fetchWithCache(config: FetchConfig, force = false): Promis
|
|
|
46
106
|
}
|
|
47
107
|
}
|
|
48
108
|
|
|
49
|
-
const response = await
|
|
50
|
-
if (!response.ok) {
|
|
51
|
-
throw new Error(`Download from ${config.url} returned ${response.status}`);
|
|
52
|
-
}
|
|
53
|
-
|
|
109
|
+
const response = await fetchWithRetry(config.url);
|
|
54
110
|
const arrayBuffer = await response.arrayBuffer();
|
|
55
111
|
const data = Buffer.from(arrayBuffer);
|
|
56
112
|
|
|
@@ -205,11 +261,7 @@ export async function fetchAndExtractTar(
|
|
|
205
261
|
}
|
|
206
262
|
}
|
|
207
263
|
|
|
208
|
-
const resp = await
|
|
209
|
-
if (!resp.ok) {
|
|
210
|
-
throw new Error(`Tarball download from ${config.url} returned ${resp.status}`);
|
|
211
|
-
}
|
|
212
|
-
|
|
264
|
+
const resp = await fetchWithRetry(config.url);
|
|
213
265
|
const compressed = new Uint8Array(await resp.arrayBuffer());
|
|
214
266
|
const { gunzipSync } = await import("fflate");
|
|
215
267
|
const tarData = gunzipSync(compressed);
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "fs";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import type { LintConfig } from "./lint/config";
|
|
5
|
+
import type { OwnershipMarker } from "./ownership";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Zod schema for ChantConfig validation.
|
|
@@ -10,6 +11,11 @@ export const ChantConfigSchema = z.object({
|
|
|
10
11
|
lexicons: z.array(z.string().min(1)).optional(),
|
|
11
12
|
environments: z.array(z.string().min(1)).optional(),
|
|
12
13
|
lint: z.record(z.string(), z.unknown()).optional(),
|
|
14
|
+
ownership: z.object({
|
|
15
|
+
stack: z.string().min(1).optional(),
|
|
16
|
+
env: z.string().min(1).optional(),
|
|
17
|
+
enabled: z.boolean().optional(),
|
|
18
|
+
}).optional(),
|
|
13
19
|
}).passthrough();
|
|
14
20
|
|
|
15
21
|
/**
|
|
@@ -26,6 +32,21 @@ export interface ChantConfig {
|
|
|
26
32
|
|
|
27
33
|
/** Lint configuration (rules, extends, overrides, plugins) */
|
|
28
34
|
lint?: LintConfig;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Opt-in cloud-side ownership marking. When `stack` is set (and `enabled`
|
|
38
|
+
* is not false), the serializer stamps a chant ownership marker carrying
|
|
39
|
+
* this stack/env identity onto every supported resource. See {@link
|
|
40
|
+
* resolveOwnershipMarker}.
|
|
41
|
+
*/
|
|
42
|
+
ownership?: {
|
|
43
|
+
/** Stack identity stamped onto resources (required to enable stamping). */
|
|
44
|
+
stack?: string;
|
|
45
|
+
/** Optional environment identity. */
|
|
46
|
+
env?: string;
|
|
47
|
+
/** Set false to disable stamping even when `stack` is present. */
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
};
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
/**
|
|
@@ -71,6 +92,16 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
|
|
|
71
92
|
return { config: DEFAULT_CHANT_CONFIG };
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the ownership marker to stamp from project config, or undefined when
|
|
97
|
+
* ownership marking is off (no `stack`, or `enabled: false`).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveOwnershipMarker(config: ChantConfig): OwnershipMarker | undefined {
|
|
100
|
+
const o = config.ownership;
|
|
101
|
+
if (!o || !o.stack || o.enabled === false) return undefined;
|
|
102
|
+
return { stack: o.stack, env: o.env };
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
/**
|
|
75
106
|
* Validate and normalize a raw config object into ChantConfig shape.
|
|
76
107
|
*/
|
|
@@ -238,12 +238,12 @@ describe("detectLexicons", () => {
|
|
|
238
238
|
const file = join(testDir, "infra.ts");
|
|
239
239
|
await writeFile(
|
|
240
240
|
file,
|
|
241
|
-
`import {\n Namespace,\n Service,\n} from "@intentius/chant-lexicon-k8s";\nimport {\n
|
|
241
|
+
`import {\n Namespace,\n Service,\n} from "@intentius/chant-lexicon-k8s";\nimport {\n HelmRelease,\n HelmChart,\n} from "@intentius/chant-lexicon-helm";\n\nexport const ns = new Namespace({});`
|
|
242
242
|
);
|
|
243
243
|
|
|
244
244
|
const result = await detectLexicons([file]);
|
|
245
245
|
expect(result).toContain("k8s");
|
|
246
|
-
expect(result).toContain("
|
|
246
|
+
expect(result).toContain("helm");
|
|
247
247
|
expect(result).toHaveLength(2);
|
|
248
248
|
});
|
|
249
249
|
|
package/src/index.ts
CHANGED
|
@@ -59,8 +59,8 @@ export * from "./child-project";
|
|
|
59
59
|
export * from "./lsp/types";
|
|
60
60
|
export * from "./lsp/lexicon-providers";
|
|
61
61
|
export * from "./mcp/types";
|
|
62
|
-
export * from "./
|
|
62
|
+
export * from "./lifecycle/index";
|
|
63
63
|
// Op builders — use explicit exports to avoid collision with the core `build` function
|
|
64
64
|
export { Op, phase, activity, gate, kubectlApply, helmInstall, waitForStack,
|
|
65
|
-
gitlabPipeline,
|
|
65
|
+
gitlabPipeline, lifecycleSnapshot, shell, teardown, OpResource } from "./op/index";
|
|
66
66
|
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./op/index";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
LexiconPlugin,
|
|
4
|
+
ObservationLexicon,
|
|
5
|
+
ExportedTemplate,
|
|
6
|
+
ResourceSelector,
|
|
7
|
+
} from "./lexicon";
|
|
8
|
+
import type { TypeScriptGenerator } from "./import/generator";
|
|
9
|
+
import type { TemplateIR } from "./import/parser";
|
|
10
|
+
|
|
11
|
+
// A throwaway generator standing in for any lexicon's real templateGenerator().
|
|
12
|
+
const generator: TypeScriptGenerator = {
|
|
13
|
+
generate(ir: TemplateIR) {
|
|
14
|
+
return ir.resources.map((r) => ({
|
|
15
|
+
path: `${r.logicalId}.ts`,
|
|
16
|
+
content: `export const ${r.logicalId} = ${JSON.stringify(r.properties)};`,
|
|
17
|
+
}));
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A minimal lexicon that implements live export — the acceptance shape.
|
|
22
|
+
const exportingLexicon: Pick<LexiconPlugin, "name" | "exportResources"> = {
|
|
23
|
+
name: "fake",
|
|
24
|
+
async exportResources(opts: {
|
|
25
|
+
environment: string;
|
|
26
|
+
selector?: ResourceSelector;
|
|
27
|
+
owned?: boolean;
|
|
28
|
+
}): Promise<ExportedTemplate> {
|
|
29
|
+
const selector = opts.selector;
|
|
30
|
+
const all: ExportedTemplate = {
|
|
31
|
+
resources: [
|
|
32
|
+
{ logicalId: "Bucket", type: "Fake::Bucket", properties: { versioning: true } },
|
|
33
|
+
{ logicalId: "Queue", type: "Fake::Queue", properties: { fifo: false } },
|
|
34
|
+
],
|
|
35
|
+
parameters: [],
|
|
36
|
+
metadata: { environment: opts.environment, owned: opts.owned ?? false },
|
|
37
|
+
};
|
|
38
|
+
if (!selector) return all;
|
|
39
|
+
return {
|
|
40
|
+
...all,
|
|
41
|
+
resources: all.resources.filter(
|
|
42
|
+
(r) =>
|
|
43
|
+
(selector.type === undefined || r.type === selector.type) &&
|
|
44
|
+
(selector.name === undefined || r.logicalId === selector.name),
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("exportResources contract (#113)", () => {
|
|
51
|
+
test("an ExportedTemplate feeds templateGenerator() unchanged", async () => {
|
|
52
|
+
const ir = await exportingLexicon.exportResources!({ environment: "prod" });
|
|
53
|
+
// ExportedTemplate is structurally a TemplateIR — no adapter needed.
|
|
54
|
+
const files = generator.generate(ir);
|
|
55
|
+
expect(files.map((f) => f.path)).toEqual(["Bucket.ts", "Queue.ts"]);
|
|
56
|
+
expect(files[0].content).toContain("versioning");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("selector narrows the exported set", async () => {
|
|
60
|
+
const ir = await exportingLexicon.exportResources!({
|
|
61
|
+
environment: "prod",
|
|
62
|
+
selector: { name: "Queue" },
|
|
63
|
+
});
|
|
64
|
+
expect(ir.resources).toHaveLength(1);
|
|
65
|
+
expect(ir.resources[0].logicalId).toBe("Queue");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("owned is accepted but inert (carried into metadata, no filtering yet)", async () => {
|
|
69
|
+
const ir = await exportingLexicon.exportResources!({ environment: "prod", owned: true });
|
|
70
|
+
expect(ir.metadata?.owned).toBe(true);
|
|
71
|
+
expect(ir.resources).toHaveLength(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("type separation: observation view cannot reach exportResources", () => {
|
|
75
|
+
// A full plugin is assignable to the narrowed observation view…
|
|
76
|
+
const full = exportingLexicon as unknown as LexiconPlugin;
|
|
77
|
+
const observed: ObservationLexicon = full;
|
|
78
|
+
// …but exportResources is not visible on it. Compile-time guarantee:
|
|
79
|
+
// @ts-expect-error exportResources is omitted from ObservationLexicon
|
|
80
|
+
void observed.exportResources;
|
|
81
|
+
expect(typeof observed.name).toBe("string");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("type separation: scrubbed metadata is not an ExportedTemplate", () => {
|
|
85
|
+
// A ResourceMetadata-shaped object lacks resources/parameters, so it can
|
|
86
|
+
// never be passed where an ExportedTemplate (full config) is expected.
|
|
87
|
+
const scrubbed = { type: "Fake::Bucket", status: "OK" };
|
|
88
|
+
// @ts-expect-error observation metadata is not a full-fidelity export
|
|
89
|
+
const asExport: ExportedTemplate = scrubbed;
|
|
90
|
+
expect(asExport).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/lexicon.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Serializer } from "./serializer";
|
|
|
2
2
|
import type { LintRule } from "./lint/rule";
|
|
3
3
|
import type { RuleSpec } from "./lint/declarative";
|
|
4
4
|
import type { PostSynthCheck } from "./lint/post-synth";
|
|
5
|
-
import type { TemplateParser } from "./import/parser";
|
|
5
|
+
import type { TemplateParser, TemplateIR } from "./import/parser";
|
|
6
6
|
import type { TypeScriptGenerator } from "./import/generator";
|
|
7
7
|
import type { ArtifactIntegrity } from "./lexicon-integrity";
|
|
8
8
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
|
|
@@ -267,6 +267,13 @@ export interface LexiconPlugin {
|
|
|
267
267
|
buildOutput: string;
|
|
268
268
|
entityNames: string[];
|
|
269
269
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
270
|
+
/**
|
|
271
|
+
* Restrict the result to chant-owned resources (those carrying the
|
|
272
|
+
* ownership marker, #119). Where a lexicon has no durable marker channel,
|
|
273
|
+
* it must log that ownership is unavailable rather than silently returning
|
|
274
|
+
* everything.
|
|
275
|
+
*/
|
|
276
|
+
owned?: boolean;
|
|
270
277
|
}): Promise<Record<string, ResourceMetadata>>;
|
|
271
278
|
|
|
272
279
|
/**
|
|
@@ -274,22 +281,87 @@ export interface LexiconPlugin {
|
|
|
274
281
|
*
|
|
275
282
|
* Use this for lexicons whose chant entities describe *authoring*
|
|
276
283
|
* primitives rather than 1:1 cloud resources — e.g. Helm (charts vs
|
|
277
|
-
* releases), Docker (Compose vs running containers)
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* between snapshots, not vs. declared.
|
|
284
|
+
* releases), Docker (Compose vs running containers). The contract is
|
|
285
|
+
* context-keyed: given an environment, list all artifacts visible there.
|
|
286
|
+
* There is no `declared` comparison axis — `state diff --live` reports
|
|
287
|
+
* added/removed/changed between snapshots, not vs. declared.
|
|
282
288
|
*
|
|
283
289
|
* `entities` is passed for cases where the lexicon needs to know what
|
|
284
|
-
* was declared in order to
|
|
285
|
-
*
|
|
290
|
+
* was declared in order to scope its enumeration (e.g. a per-tenant
|
|
291
|
+
* runtime where the declared entities name which tenants to query).
|
|
286
292
|
*/
|
|
287
293
|
listArtifacts?(options: {
|
|
288
294
|
environment: string;
|
|
289
295
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
290
296
|
}): Promise<Record<string, ArtifactMetadata>>;
|
|
297
|
+
|
|
298
|
+
// Live export (cloud → code)
|
|
299
|
+
/**
|
|
300
|
+
* Export full-fidelity import IR from a live API — the cloud→code primitive
|
|
301
|
+
* that lets chant regenerate TypeScript from running cloud/cluster state.
|
|
302
|
+
*
|
|
303
|
+
* Deliberately separate from {@link describeResources}: that returns scrubbed
|
|
304
|
+
* *output* metadata for diffing, this returns full *input* config — enough to
|
|
305
|
+
* regenerate a resource, and therefore possibly containing secrets. The
|
|
306
|
+
* scrubbing boundary stays single-purpose; never overload one method for both.
|
|
307
|
+
*
|
|
308
|
+
* The return type {@link ExportedTemplate} is branded distinct from the
|
|
309
|
+
* observation types so a full-fidelity export can never flow into the
|
|
310
|
+
* observation/`state` code paths by accident.
|
|
311
|
+
*
|
|
312
|
+
* `owned` is accepted now but inert until ownership marking exists (#119/#120).
|
|
313
|
+
*
|
|
314
|
+
* `verbatim` controls fidelity: by default an implementation strips
|
|
315
|
+
* server-defaulted and server-managed fields to reach the declared shape
|
|
316
|
+
* (the form a user would have authored). `verbatim: true` keeps them. Targets
|
|
317
|
+
* whose live config is already the declared shape (e.g. a CloudFormation
|
|
318
|
+
* original template) may ignore it.
|
|
319
|
+
*/
|
|
320
|
+
exportResources?(options: {
|
|
321
|
+
environment: string;
|
|
322
|
+
selector?: ResourceSelector;
|
|
323
|
+
owned?: boolean;
|
|
324
|
+
verbatim?: boolean;
|
|
325
|
+
}): Promise<ExportedTemplate>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The observation view of a lexicon — every capability except live export.
|
|
330
|
+
*
|
|
331
|
+
* State/observation code (snapshots, `state diff --live`) consumes lexicons
|
|
332
|
+
* through this type so that `exportResources` is unreachable from those paths:
|
|
333
|
+
* a full-fidelity {@link ExportedTemplate} (which may carry secrets) must never
|
|
334
|
+
* be read where scrubbed {@link ResourceMetadata} is expected. A full
|
|
335
|
+
* {@link LexiconPlugin} is assignable to this type; accessing `exportResources`
|
|
336
|
+
* on it is a compile error.
|
|
337
|
+
*/
|
|
338
|
+
export type ObservationLexicon = Omit<LexiconPlugin, "exportResources">;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Narrows which live resources {@link LexiconPlugin.exportResources} and the
|
|
342
|
+
* `owned` filter operate on. Both fields are optional; omit to export all.
|
|
343
|
+
*/
|
|
344
|
+
export interface ResourceSelector {
|
|
345
|
+
/** Restrict to a single chant resource type (e.g. "AWS::S3::Bucket"). */
|
|
346
|
+
readonly type?: string;
|
|
347
|
+
/** Restrict to a single resource name. */
|
|
348
|
+
readonly name?: string;
|
|
291
349
|
}
|
|
292
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Full-fidelity import IR read from a live API, branded distinct from the
|
|
353
|
+
* scrubbed observation metadata. It IS a {@link TemplateIR} — it feeds the
|
|
354
|
+
* existing `templateGenerator()` unchanged — but the phantom brand keeps it
|
|
355
|
+
* from being passed where observation metadata is expected, and keeps
|
|
356
|
+
* observation metadata from being passed where an export is expected.
|
|
357
|
+
*
|
|
358
|
+
* The brand is type-only; nothing is added at runtime.
|
|
359
|
+
*/
|
|
360
|
+
export type ExportedTemplate = TemplateIR & {
|
|
361
|
+
/** Phantom marker — never present at runtime. */
|
|
362
|
+
readonly __fidelity?: "full-config";
|
|
363
|
+
};
|
|
364
|
+
|
|
293
365
|
/**
|
|
294
366
|
* Metadata about a deployed resource, returned by describeResources.
|
|
295
367
|
*/
|
|
@@ -304,6 +376,13 @@ export interface ResourceMetadata {
|
|
|
304
376
|
lastUpdated?: string;
|
|
305
377
|
/** Cloud-assigned output properties */
|
|
306
378
|
attributes?: Record<string, unknown>;
|
|
379
|
+
/**
|
|
380
|
+
* Live ownership verdict from the resource's marker (#119/#120), when the
|
|
381
|
+
* lexicon could determine it. `owned` = carries chant's marker; `foreign` =
|
|
382
|
+
* no marker. Absent = the lexicon has no marker channel here. The change set
|
|
383
|
+
* reads this — never the snapshot — to decide whether an orphan is a delete.
|
|
384
|
+
*/
|
|
385
|
+
ownership?: "owned" | "foreign";
|
|
307
386
|
}
|
|
308
387
|
|
|
309
388
|
/**
|
|
@@ -311,7 +390,7 @@ export interface ResourceMetadata {
|
|
|
311
390
|
* as ResourceMetadata; the conceptual distinction is whether the lexicon's
|
|
312
391
|
* chant entities have 1:1 runtime equivalents (resources) or whether the
|
|
313
392
|
* runtime artifacts are created by tooling outside chant's entity model
|
|
314
|
-
* (artifacts — e.g. Helm releases, Docker containers
|
|
393
|
+
* (artifacts — e.g. Helm releases, Docker containers).
|
|
315
394
|
*/
|
|
316
395
|
export interface ArtifactMetadata {
|
|
317
396
|
/** Artifact type (e.g. Helm::Release, Docker::Container) */
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { buildChangeSet, renderChangeSet, summarize } from "./change-set";
|
|
3
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
4
|
+
|
|
5
|
+
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
6
|
+
type: "Fake::Resource",
|
|
7
|
+
status: "OK",
|
|
8
|
+
...over,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("buildChangeSet (#118)", () => {
|
|
12
|
+
test("declared but not live → create", () => {
|
|
13
|
+
const cs = buildChangeSet("prod", {
|
|
14
|
+
declared: new Set(["bucket"]),
|
|
15
|
+
observedNow: {},
|
|
16
|
+
observedThen: undefined,
|
|
17
|
+
});
|
|
18
|
+
const e = cs.entries.find((x) => x.name === "bucket")!;
|
|
19
|
+
expect(e.action).toBe("create");
|
|
20
|
+
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false });
|
|
21
|
+
expect(e.ownership).toBe("unknown");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("declared and live with drift since snapshot → update with deltas", () => {
|
|
25
|
+
const cs = buildChangeSet("prod", {
|
|
26
|
+
declared: new Set(["queue"]),
|
|
27
|
+
observedNow: { queue: meta({ status: "ACTIVE" }) },
|
|
28
|
+
observedThen: { queue: meta({ status: "CREATING" }) },
|
|
29
|
+
});
|
|
30
|
+
const e = cs.entries.find((x) => x.name === "queue")!;
|
|
31
|
+
expect(e.action).toBe("update");
|
|
32
|
+
expect(e.deltas).toEqual([{ path: "status", oldValue: "CREATING", newValue: "ACTIVE" }]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("declared and live, unchanged → noop", () => {
|
|
36
|
+
const cs = buildChangeSet("prod", {
|
|
37
|
+
declared: new Set(["queue"]),
|
|
38
|
+
observedNow: { queue: meta({ status: "ACTIVE" }) },
|
|
39
|
+
observedThen: { queue: meta({ status: "ACTIVE" }) },
|
|
40
|
+
});
|
|
41
|
+
expect(cs.entries.find((x) => x.name === "queue")!.action).toBe("noop");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("live but undeclared, no ownership data → adopt, never delete", () => {
|
|
45
|
+
const cs = buildChangeSet("prod", {
|
|
46
|
+
declared: new Set(),
|
|
47
|
+
observedNow: { orphan: meta() },
|
|
48
|
+
observedThen: undefined,
|
|
49
|
+
});
|
|
50
|
+
const e = cs.entries.find((x) => x.name === "orphan")!;
|
|
51
|
+
expect(e.action).toBe("adopt");
|
|
52
|
+
expect(e.ownership).toBe("unknown");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("owned orphan → delete (#121)", () => {
|
|
56
|
+
const cs = buildChangeSet("prod", {
|
|
57
|
+
declared: new Set(),
|
|
58
|
+
observedNow: { orphan: meta({ ownership: "owned" }) },
|
|
59
|
+
observedThen: undefined,
|
|
60
|
+
});
|
|
61
|
+
const e = cs.entries.find((x) => x.name === "orphan")!;
|
|
62
|
+
expect(e.action).toBe("delete");
|
|
63
|
+
expect(e.ownership).toBe("owned");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("foreign orphan → adopt, never delete (#121)", () => {
|
|
67
|
+
const cs = buildChangeSet("prod", {
|
|
68
|
+
declared: new Set(),
|
|
69
|
+
observedNow: { orphan: meta({ ownership: "foreign" }) },
|
|
70
|
+
observedThen: undefined,
|
|
71
|
+
});
|
|
72
|
+
const e = cs.entries.find((x) => x.name === "orphan")!;
|
|
73
|
+
expect(e.action).toBe("adopt");
|
|
74
|
+
expect(e.ownership).toBe("foreign");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("snapshot is never load-bearing: ownership/delete ignores observedThen", () => {
|
|
78
|
+
// The same live orphan, once with a rich snapshot and once with none.
|
|
79
|
+
// The delete decision must depend only on the LIVE ownership marker, so the
|
|
80
|
+
// result must be identical regardless of what the snapshot says.
|
|
81
|
+
const withSnapshot = buildChangeSet("prod", {
|
|
82
|
+
declared: new Set(),
|
|
83
|
+
observedNow: { orphan: meta({ ownership: "owned" }) },
|
|
84
|
+
observedThen: { orphan: meta({ ownership: "foreign", status: "STALE" }) },
|
|
85
|
+
});
|
|
86
|
+
const withoutSnapshot = buildChangeSet("prod", {
|
|
87
|
+
declared: new Set(),
|
|
88
|
+
observedNow: { orphan: meta({ ownership: "owned" }) },
|
|
89
|
+
observedThen: undefined,
|
|
90
|
+
});
|
|
91
|
+
const a = withSnapshot.entries.find((x) => x.name === "orphan")!;
|
|
92
|
+
const b = withoutSnapshot.entries.find((x) => x.name === "orphan")!;
|
|
93
|
+
expect(a.action).toBe("delete");
|
|
94
|
+
expect(a.ownership).toBe("owned");
|
|
95
|
+
expect(a.action).toBe(b.action);
|
|
96
|
+
expect(a.ownership).toBe(b.ownership);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("never proposes a delete without ownership data", () => {
|
|
100
|
+
const cs = buildChangeSet("prod", {
|
|
101
|
+
declared: new Set(["a"]),
|
|
102
|
+
observedNow: { b: meta(), c: meta() }, // two orphans
|
|
103
|
+
observedThen: { b: meta(), c: meta() },
|
|
104
|
+
});
|
|
105
|
+
expect(cs.entries.some((e) => e.action === "delete")).toBe(false);
|
|
106
|
+
expect(cs.entries.filter((e) => e.action === "adopt").map((e) => e.name)).toEqual(["b", "c"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("only in snapshot (gone now, undeclared) → noop", () => {
|
|
110
|
+
const cs = buildChangeSet("prod", {
|
|
111
|
+
declared: new Set(),
|
|
112
|
+
observedNow: {},
|
|
113
|
+
observedThen: { ghost: meta() },
|
|
114
|
+
});
|
|
115
|
+
expect(cs.entries.find((x) => x.name === "ghost")!.action).toBe("noop");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("entries are sorted by name", () => {
|
|
119
|
+
const cs = buildChangeSet("prod", {
|
|
120
|
+
declared: new Set(["z", "a", "m"]),
|
|
121
|
+
observedNow: {},
|
|
122
|
+
observedThen: undefined,
|
|
123
|
+
});
|
|
124
|
+
expect(cs.entries.map((e) => e.name)).toEqual(["a", "m", "z"]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("summarize / renderChangeSet", () => {
|
|
129
|
+
const cs = buildChangeSet("prod", {
|
|
130
|
+
declared: new Set(["create-me", "keep-me"]),
|
|
131
|
+
observedNow: { "keep-me": meta(), orphan: meta() },
|
|
132
|
+
observedThen: { "keep-me": meta() },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("summarize counts each action", () => {
|
|
136
|
+
const counts = summarize(cs);
|
|
137
|
+
expect(counts.create).toBe(1);
|
|
138
|
+
expect(counts.noop).toBe(1);
|
|
139
|
+
expect(counts.adopt).toBe(1);
|
|
140
|
+
expect(counts.delete).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("render shows the env and grouped sections", () => {
|
|
144
|
+
const out = renderChangeSet(cs);
|
|
145
|
+
expect(out).toContain("Plan for prod");
|
|
146
|
+
expect(out).toContain("CREATE:");
|
|
147
|
+
expect(out).toContain("create-me");
|
|
148
|
+
expect(out).toContain("ADOPT:");
|
|
149
|
+
expect(out).toContain("orphan");
|
|
150
|
+
});
|
|
151
|
+
});
|