@intentius/chant 0.38.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +48 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apply.test.ts +169 -0
- package/src/apply.ts +249 -0
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/cli/handlers/lifecycle.ts +10 -0
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The config namespace a lexicon owns (#1344).
|
|
3
|
+
*
|
|
4
|
+
* Three lexicons read a top-level `chant.config.ts` key named after themselves —
|
|
5
|
+
* `k8s.profiles.<env>.context`, `temporal.profiles`, `forgejo.runnerLabels` and
|
|
6
|
+
* `forgejo.actionsRoot` — all documented for users, none declared anywhere.
|
|
7
|
+
* `ChantConfigSchema` is `.passthrough()`, so at runtime any key is accepted and
|
|
8
|
+
* a typo is silently ignored: write `runnerLabel` and the Forgejo dialect just
|
|
9
|
+
* uses its defaults, with nothing said. The `ChantConfig` interface is closed,
|
|
10
|
+
* so the documented forgejo example did not even compile:
|
|
11
|
+
*
|
|
12
|
+
* error TS2353: Object literal may only specify known properties,
|
|
13
|
+
* and 'forgejo' does not exist in type 'ChantConfig'.
|
|
14
|
+
*
|
|
15
|
+
* Three lexicons had arrived at three different workarounds: temporal exported
|
|
16
|
+
* its own widened `TemporalChantConfig`, k8s's docs dropped `satisfies`, and
|
|
17
|
+
* forgejo's kept it and were wrong.
|
|
18
|
+
*
|
|
19
|
+
* A lexicon now declares the shape of its namespace. The declaration is both
|
|
20
|
+
* halves of the fix at once: core validates against it at load, and the lexicon
|
|
21
|
+
* derives the type it augments `ChantConfig` with from the same schema, so the
|
|
22
|
+
* runtime rule and the compile-time one cannot disagree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ZodObject, ZodRawShape } from "zod";
|
|
26
|
+
import type { ChantConfig } from "./config";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A lexicon's config schema. A `ZodObject` specifically, so core can apply
|
|
30
|
+
* `.strict()` itself rather than trusting each lexicon to remember — an unknown
|
|
31
|
+
* key at the top of a declared namespace is a typo, and silently ignoring it is
|
|
32
|
+
* the behavior this replaces.
|
|
33
|
+
*
|
|
34
|
+
* Nested objects are the lexicon's own responsibility: author them with
|
|
35
|
+
* `z.strictObject` so a typo in `k8s.profiles.prod.contxt` fails too. `.strict()`
|
|
36
|
+
* applies to one level.
|
|
37
|
+
*/
|
|
38
|
+
export type LexiconConfigSchema = ZodObject<ZodRawShape>;
|
|
39
|
+
|
|
40
|
+
/** What a lexicon needs to expose for its namespace to be validated. */
|
|
41
|
+
export interface ConfigOwningLexicon {
|
|
42
|
+
name: string;
|
|
43
|
+
configSchema?: LexiconConfigSchema;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LexiconConfigProblem {
|
|
47
|
+
/** The lexicon whose namespace failed. */
|
|
48
|
+
lexicon: string;
|
|
49
|
+
/** Dotted path to the offending value, e.g. `forgejo.runnerLabel`. */
|
|
50
|
+
path: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate each lexicon's own namespace against the schema it declares.
|
|
56
|
+
*
|
|
57
|
+
* A lexicon that declares nothing keeps today's passthrough — silence for an
|
|
58
|
+
* unknown key — because tightening a namespace nobody described would fail
|
|
59
|
+
* configs that are working. Declaring is the opt-in.
|
|
60
|
+
*
|
|
61
|
+
* An absent namespace is not a problem: every one of them is optional.
|
|
62
|
+
*/
|
|
63
|
+
export function validateLexiconConfig(
|
|
64
|
+
lexicons: readonly ConfigOwningLexicon[],
|
|
65
|
+
config: ChantConfig | undefined,
|
|
66
|
+
): LexiconConfigProblem[] {
|
|
67
|
+
if (!config) return [];
|
|
68
|
+
const problems: LexiconConfigProblem[] = [];
|
|
69
|
+
const raw = config as unknown as Record<string, unknown>;
|
|
70
|
+
|
|
71
|
+
for (const lexicon of lexicons) {
|
|
72
|
+
const schema = lexicon.configSchema;
|
|
73
|
+
if (!schema) continue;
|
|
74
|
+
const value = raw[lexicon.name];
|
|
75
|
+
if (value === undefined) continue;
|
|
76
|
+
|
|
77
|
+
const result = schema.strict().safeParse(value);
|
|
78
|
+
if (result.success) continue;
|
|
79
|
+
|
|
80
|
+
for (const issue of result.error.issues) {
|
|
81
|
+
const path = [lexicon.name, ...issue.path.map(String)].join(".");
|
|
82
|
+
problems.push({ lexicon: lexicon.name, path, message: issue.message });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return problems;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One line per problem, for a CLI error. */
|
|
90
|
+
export function formatLexiconConfigProblems(problems: readonly LexiconConfigProblem[]): string {
|
|
91
|
+
return problems.map((p) => ` ${p.path}: ${p.message}`).join("\n");
|
|
92
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
|
|
|
10
10
|
import type { DriverComponent } from "./components/driver";
|
|
11
11
|
import type { EmulatorDeclaration } from "./op/emulator-lifecycle";
|
|
12
12
|
import type { OwnershipChannel } from "./ownership";
|
|
13
|
+
import type { LexiconConfigSchema } from "./lexicon-config";
|
|
13
14
|
import type { RuleMeta } from "./audit/catalog";
|
|
14
15
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
15
16
|
import type { IREdge } from "./graph-ir";
|
|
@@ -440,6 +441,25 @@ export interface LexiconPlugin {
|
|
|
440
441
|
/** Package lexicon into distributable tarball */
|
|
441
442
|
package(options?: { verbose?: boolean; force?: boolean }): Promise<void>;
|
|
442
443
|
|
|
444
|
+
/**
|
|
445
|
+
* The shape of this lexicon's own `chant.config.ts` namespace — the top-level
|
|
446
|
+
* key named after the lexicon (#1344).
|
|
447
|
+
*
|
|
448
|
+
* k8s reads `k8s.profiles.<env>.context`, temporal `temporal.profiles`,
|
|
449
|
+
* forgejo `forgejo.runnerLabels` and `forgejo.actionsRoot`. All were
|
|
450
|
+
* documented for users and declared nowhere: the config schema is
|
|
451
|
+
* `.passthrough()`, so a typo was accepted and silently ignored, and the
|
|
452
|
+
* `ChantConfig` interface is closed, so the documented examples did not
|
|
453
|
+
* compile.
|
|
454
|
+
*
|
|
455
|
+
* Declaring the schema makes an unknown key inside the namespace an error
|
|
456
|
+
* rather than a default, and gives the lexicon a single source to derive the
|
|
457
|
+
* type it augments `ChantConfig` with — so the runtime rule and the
|
|
458
|
+
* compile-time one cannot disagree. Omit it and the namespace keeps today's
|
|
459
|
+
* passthrough.
|
|
460
|
+
*/
|
|
461
|
+
readonly configSchema?: LexiconConfigSchema;
|
|
462
|
+
|
|
443
463
|
/**
|
|
444
464
|
* Local emulator(s) (#920), if this lexicon has any: Floci for aws, floci-az
|
|
445
465
|
* for azure, floci-gcp for gcp, mudflaps and spritzer for fly. Drives
|
|
@@ -4,7 +4,7 @@ import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation"
|
|
|
4
4
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
5
5
|
|
|
6
6
|
const live = (
|
|
7
|
-
resources: Record<string, { type: string; properties: Record<string, unknown> }>,
|
|
7
|
+
resources: Record<string, { type: string; properties: Record<string, unknown>; fieldOwners?: Record<string, string> }>,
|
|
8
8
|
unobserved: NormalizedDeepObservation["unobserved"] = {},
|
|
9
9
|
): NormalizedDeepObservation => ({ resources, unobserved });
|
|
10
10
|
|
|
@@ -155,3 +155,50 @@ describe("diffDeep with an accepted baseline", () => {
|
|
|
155
155
|
expect(result.drifted[0].changes.map((c) => c.path)).toEqual(["Extra"]);
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
// #1189 — `kind` says a path is undeclared or changed; `owner` says who did it.
|
|
160
|
+
// The two are independent: `hpa-controller` owning `spec.replicas` and somebody
|
|
161
|
+
// running `kubectl edit` are the same kind and opposite situations.
|
|
162
|
+
describe("diffDeep — owning field manager (#1189)", () => {
|
|
163
|
+
const declared = { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } };
|
|
164
|
+
|
|
165
|
+
test("names the manager on a drifted path", () => {
|
|
166
|
+
const result = diffDeep({
|
|
167
|
+
declared,
|
|
168
|
+
live: live({
|
|
169
|
+
web: {
|
|
170
|
+
type: "K8s::Apps::Deployment",
|
|
171
|
+
properties: { spec: { replicas: 5 } },
|
|
172
|
+
fieldOwners: { "spec.replicas": "hpa-controller" },
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
expect(result.drifted[0].changes[0]).toMatchObject({
|
|
177
|
+
path: "spec.replicas",
|
|
178
|
+
kind: "changed",
|
|
179
|
+
owner: "hpa-controller",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("is absent when the substrate records no per-field ownership", () => {
|
|
184
|
+
// Every substrate but k8s. The field must not appear at all rather than
|
|
185
|
+
// appear empty — a consumer branches on its presence.
|
|
186
|
+
const result = diffDeep({
|
|
187
|
+
declared,
|
|
188
|
+
live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
|
|
189
|
+
});
|
|
190
|
+
expect(result.drifted[0].changes[0]).not.toHaveProperty("owner");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("is absent for a path with no live value — nobody owns a field that is not there", () => {
|
|
194
|
+
const result = diffDeep({
|
|
195
|
+
declared,
|
|
196
|
+
live: live({
|
|
197
|
+
web: { type: "K8s::Apps::Deployment", properties: {}, fieldOwners: { "spec.replicas": "someone" } },
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
const change = result.drifted[0].changes.find((c) => c.path === "spec.replicas")!;
|
|
201
|
+
expect(change.kind).toBe("absent");
|
|
202
|
+
expect(change).not.toHaveProperty("owner");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -59,6 +59,17 @@ export interface PropertyDrift {
|
|
|
59
59
|
* accepted value there would lose the most useful column in the report.
|
|
60
60
|
*/
|
|
61
61
|
baseline?: unknown;
|
|
62
|
+
/**
|
|
63
|
+
* The field manager that owns this path live, where the substrate records one
|
|
64
|
+
* (#1189) — Kubernetes' `managedFields`, and nowhere else today.
|
|
65
|
+
*
|
|
66
|
+
* `kind` says a path is `undeclared` or `changed`; this says who did it.
|
|
67
|
+
* "Owned by `kubectl-client-side-apply`" and "owned by `hpa-controller`" are
|
|
68
|
+
* the same `kind` and mean opposite things: one is somebody bypassing the
|
|
69
|
+
* pipeline, the other is a controller doing its job. Absent on a substrate
|
|
70
|
+
* with no per-field ownership, which is every substrate but k8s.
|
|
71
|
+
*/
|
|
72
|
+
owner?: string;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/** Property-level drift for one declared entity. */
|
|
@@ -173,11 +184,16 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
|
173
184
|
if (hasDeclared && hasLive && deepValueEqual(declaredValue, liveValue)) continue;
|
|
174
185
|
|
|
175
186
|
const kind: PropertyDriftKind = !hasDeclared ? "undeclared" : !hasLive ? "absent" : "changed";
|
|
187
|
+
// Who owns the path live, where the substrate records it (#1189). Only
|
|
188
|
+
// meaningful for a path that exists live — an `absent` drift has no live
|
|
189
|
+
// field for anyone to own.
|
|
190
|
+
const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
|
|
176
191
|
const drift: PropertyDrift = {
|
|
177
192
|
path,
|
|
178
193
|
kind,
|
|
179
194
|
...(hasDeclared ? { declared: declaredValue } : {}),
|
|
180
195
|
...(hasLive ? { live: liveValue } : {}),
|
|
196
|
+
...(owner ? { owner } : {}),
|
|
181
197
|
};
|
|
182
198
|
|
|
183
199
|
const acceptedEntry = acceptedDeviation(baseline, name, path);
|
|
@@ -115,3 +115,84 @@ describe("diffDigests", () => {
|
|
|
115
115
|
expect(result.removed.sort()).toEqual(["c"]);
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* chant #1442 — a digest records what interpreted the declarations, not only
|
|
121
|
+
* what was declared.
|
|
122
|
+
*/
|
|
123
|
+
function withVersions(versions: Record<string, string> | undefined): BuildResult {
|
|
124
|
+
const result = makeBuildResult({ k8s: [{ name: "app", type: "K8s::Apps::Deployment", props: { replicas: 2 } }] });
|
|
125
|
+
return { ...result, lexiconVersions: versions } as unknown as BuildResult;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("computeBuildDigest — lexicon versions (#1442)", () => {
|
|
129
|
+
test("records the version of each lexicon that served the build", () => {
|
|
130
|
+
expect(computeBuildDigest(withVersions({ k8s: "0.38.0" })).lexiconVersions).toEqual({ k8s: "0.38.0" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("records once per lexicon, not once per resource", () => {
|
|
134
|
+
const many = makeBuildResult({
|
|
135
|
+
k8s: [
|
|
136
|
+
{ name: "a", type: "K8s::Apps::Deployment", props: {} },
|
|
137
|
+
{ name: "b", type: "K8s::Core::Service", props: {} },
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
const digest = computeBuildDigest({ ...many, lexiconVersions: { k8s: "0.38.0" } } as unknown as BuildResult);
|
|
141
|
+
expect(Object.keys(digest.resources)).toHaveLength(2);
|
|
142
|
+
expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a build with no plugins records an empty map, not absence", () => {
|
|
146
|
+
// Absent and empty mean different things when read back: absent is
|
|
147
|
+
// "recorded before #1442", empty is "recorded, nothing loaded".
|
|
148
|
+
expect(computeBuildDigest(withVersions(undefined)).lexiconVersions).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the recorded map is a copy, so later mutation cannot rewrite history", () => {
|
|
152
|
+
const versions = { k8s: "0.38.0" };
|
|
153
|
+
const digest = computeBuildDigest(withVersions(versions));
|
|
154
|
+
versions.k8s = "0.39.0";
|
|
155
|
+
expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("diffDigests — lexicon version changes (#1442)", () => {
|
|
160
|
+
const resources = { app: { type: "K8s::Apps::Deployment", lexicon: "k8s", propsHash: "same" } };
|
|
161
|
+
const digest = (lexiconVersions?: Record<string, string>): BuildDigest =>
|
|
162
|
+
({ resources, dependencies: {}, outputs: {}, deployOrder: ["k8s"], lexiconVersions }) as BuildDigest;
|
|
163
|
+
|
|
164
|
+
test("reports a bump even when every resource is unchanged", () => {
|
|
165
|
+
const diff = diffDigests(digest({ k8s: "0.39.0" }), digest({ k8s: "0.38.0" }));
|
|
166
|
+
expect(diff.changed).toEqual([]);
|
|
167
|
+
expect(diff.unchanged).toEqual(["app"]);
|
|
168
|
+
expect(diff.lexiconVersionChanges).toEqual([{ lexicon: "k8s", previous: "0.38.0", current: "0.39.0" }]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("reports nothing when versions match", () => {
|
|
172
|
+
expect(diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("reports a lexicon added to or dropped from the build", () => {
|
|
176
|
+
const added = diffDigests(digest({ k8s: "0.38.0", aws: "0.38.0" }), digest({ k8s: "0.38.0" }));
|
|
177
|
+
expect(added.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: undefined, current: "0.38.0" }]);
|
|
178
|
+
|
|
179
|
+
const dropped = diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0", aws: "0.38.0" }));
|
|
180
|
+
expect(dropped.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: "0.38.0", current: undefined }]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a pre-#1442 snapshot reports no change rather than inventing one", () => {
|
|
184
|
+
// The older digest never recorded versions. Every lexicon would otherwise
|
|
185
|
+
// look newly-added on the first comparison after upgrading chant.
|
|
186
|
+
expect(diffDigests(digest({ k8s: "0.38.0" }), digest(undefined)).lexiconVersionChanges).toEqual([]);
|
|
187
|
+
expect(diffDigests(digest(undefined), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("with no previous digest at all, there is no version change", () => {
|
|
191
|
+
expect(diffDigests(digest({ k8s: "0.38.0" }), undefined).lexiconVersionChanges).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("changes are ordered by lexicon name, so output is stable", () => {
|
|
195
|
+
const diff = diffDigests(digest({ k8s: "2", aws: "2", gcp: "2" }), digest({ k8s: "1", aws: "1", gcp: "1" }));
|
|
196
|
+
expect(diff.lexiconVersionChanges.map((c) => c.lexicon)).toEqual(["aws", "gcp", "k8s"]);
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/lifecycle/digest.ts
CHANGED
|
@@ -41,6 +41,10 @@ export function computeBuildDigest(buildResult: BuildResult): BuildDigest {
|
|
|
41
41
|
|
|
42
42
|
return {
|
|
43
43
|
resources,
|
|
44
|
+
// chant #1442 — what interpreted the declarations, alongside what was
|
|
45
|
+
// declared. Always present on a freshly computed digest, so "absent"
|
|
46
|
+
// unambiguously means "recorded before #1442".
|
|
47
|
+
lexiconVersions: { ...buildResult.lexiconVersions },
|
|
44
48
|
dependencies,
|
|
45
49
|
outputs: buildResult.manifest.outputs,
|
|
46
50
|
deployOrder: buildResult.manifest.deployOrder,
|
|
@@ -60,9 +64,10 @@ export function diffDigests(
|
|
|
60
64
|
const unchanged: string[] = [];
|
|
61
65
|
|
|
62
66
|
if (!previous) {
|
|
63
|
-
// No previous digest — everything is added
|
|
67
|
+
// No previous digest — everything is added, and there is no version to
|
|
68
|
+
// have moved away from.
|
|
64
69
|
added.push(...Object.keys(current.resources));
|
|
65
|
-
return { added, removed, changed, unchanged };
|
|
70
|
+
return { added, removed, changed, unchanged, lexiconVersionChanges: [] };
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
// Check current resources against previous
|
|
@@ -84,5 +89,31 @@ export function diffDigests(
|
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
return { added, removed, changed, unchanged };
|
|
92
|
+
return { added, removed, changed, unchanged, lexiconVersionChanges: diffLexiconVersions(current, previous) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Lexicons whose version moved between two digests (chant #1442).
|
|
97
|
+
*
|
|
98
|
+
* A digest recorded before this existed has no `lexiconVersions` at all. That
|
|
99
|
+
* is reported as no change rather than as every lexicon appearing — comparing
|
|
100
|
+
* against an older snapshot must not manufacture a difference that the older
|
|
101
|
+
* build simply never recorded.
|
|
102
|
+
*/
|
|
103
|
+
function diffLexiconVersions(
|
|
104
|
+
current: BuildDigest,
|
|
105
|
+
previous: BuildDigest,
|
|
106
|
+
): Array<{ lexicon: string; previous?: string; current?: string }> {
|
|
107
|
+
if (!current.lexiconVersions || !previous.lexiconVersions) return [];
|
|
108
|
+
|
|
109
|
+
const changes: Array<{ lexicon: string; previous?: string; current?: string }> = [];
|
|
110
|
+
for (const lexicon of new Set([
|
|
111
|
+
...Object.keys(current.lexiconVersions),
|
|
112
|
+
...Object.keys(previous.lexiconVersions),
|
|
113
|
+
])) {
|
|
114
|
+
const now = current.lexiconVersions[lexicon];
|
|
115
|
+
const before = previous.lexiconVersions[lexicon];
|
|
116
|
+
if (now !== before) changes.push({ lexicon, previous: before, current: now });
|
|
117
|
+
}
|
|
118
|
+
return changes.sort((a, b) => a.lexicon.localeCompare(b.lexicon));
|
|
88
119
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { regionOf, unqualifiedKey } from "./identity";
|
|
3
|
+
|
|
4
|
+
describe("regionOf", () => {
|
|
5
|
+
it("reads the region a lexicon stamped", () => {
|
|
6
|
+
expect(regionOf({ attributes: { region: "us-west-1" } })).toBe("us-west-1");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("treats a missing, empty or non-string region as none", () => {
|
|
10
|
+
expect(regionOf({})).toBeUndefined();
|
|
11
|
+
expect(regionOf({ attributes: {} })).toBeUndefined();
|
|
12
|
+
expect(regionOf({ attributes: { region: "" } })).toBeUndefined();
|
|
13
|
+
expect(regionOf({ attributes: { region: 1 } })).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("unqualifiedKey", () => {
|
|
18
|
+
it("carries the region when there is one", () => {
|
|
19
|
+
expect(unqualifiedKey("sg-1", { attributes: { region: "us-east-1" } })).toBe("us-east-1::sg-1");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("leaves an id with no region bare", () => {
|
|
23
|
+
expect(unqualifiedKey("arn:aws:iam::1:policy/p", {})).toBe("arn:aws:iam::1:policy/p");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("is idempotent, so a second pass does not double-qualify", () => {
|
|
27
|
+
const meta = { attributes: { region: "us-east-1" } };
|
|
28
|
+
expect(unqualifiedKey(unqualifiedKey("sg-1", meta), meta)).toBe("us-east-1::sg-1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("qualifies an id that already carries a DIFFERENT region", () => {
|
|
32
|
+
// Not a case the readers produce, but the guard is a prefix test and it
|
|
33
|
+
// should not silently accept `us-west-1::sg-1` as already being in
|
|
34
|
+
// us-east-1.
|
|
35
|
+
expect(unqualifiedKey("us-west-1::sg-1", { attributes: { region: "us-east-1" } })).toBe(
|
|
36
|
+
"us-east-1::us-west-1::sg-1",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes an unqualified resource one resource (#1416).
|
|
3
|
+
*
|
|
4
|
+
* Managed resources are stack-qualified (`${stack}::${id}`, #1162) and cannot
|
|
5
|
+
* collide. Everything else — dependencies (#1273) and ambient resources (#1278)
|
|
6
|
+
* — is keyed by physical id and merged across stacks, because the account's
|
|
7
|
+
* default VPC route table is one resource however many stacks route through it.
|
|
8
|
+
*
|
|
9
|
+
* That is right for a resource that really is account-level, and wrong for a
|
|
10
|
+
* regional one. A VPC, subnet or security group in `us-west-1` is a different
|
|
11
|
+
* resource from one in `us-east-1`, and merging them by id drops all but the
|
|
12
|
+
* first sighting.
|
|
13
|
+
*
|
|
14
|
+
* On AWS the difference is invisible: VPC, subnet and security-group ids are
|
|
15
|
+
* globally unique, so keying by id and keying by (region, id) give the same
|
|
16
|
+
* answer. It shows up against an emulator that reuses ids across regions
|
|
17
|
+
* (lex00/floci#21), where a three-region estate's nine default subnets merged
|
|
18
|
+
* to three and "how many subnets are empty" came back 2 instead of 8.
|
|
19
|
+
*
|
|
20
|
+
* The rule is deliberately not a table of regional kinds. A lexicon stamps a
|
|
21
|
+
* region on a resource when region is part of that resource's identity — the
|
|
22
|
+
* AWS lexicon does it in `stampRegion` for ambient resources and its own
|
|
23
|
+
* property reads — and this reads that stamp back. A genuinely account-level
|
|
24
|
+
* resource carries no region and keeps merging as it always did, which is why
|
|
25
|
+
* dependencies (never stamped) are untouched by this.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The region a resource records itself as being in, if it records one.
|
|
32
|
+
*
|
|
33
|
+
* Read from `attributes.region`, which is where a lexicon stamps it. Absent
|
|
34
|
+
* means the resource is account-level, or the lexicon has no region to give —
|
|
35
|
+
* both of which are read the same way, as "region is not part of this id".
|
|
36
|
+
*/
|
|
37
|
+
export function regionOf(meta: Pick<ResourceMetadata, "attributes">): string | undefined {
|
|
38
|
+
const region = meta.attributes?.region;
|
|
39
|
+
return typeof region === "string" && region.length > 0 ? region : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The key an unqualified resource merges under.
|
|
44
|
+
*
|
|
45
|
+
* `${region}::${id}` for a resource that carries a region, matching the shape
|
|
46
|
+
* stack qualification already uses, and the bare id for one that does not.
|
|
47
|
+
*
|
|
48
|
+
* Applied only where results from more than one region are actually merged —
|
|
49
|
+
* `collectAmbient` across stacks, `replaySnapshots` across recorded stacks.
|
|
50
|
+
* Recording one region's observation qualifies nothing, so a stored snapshot
|
|
51
|
+
* keeps the keys it has always had and the first snapshot taken after this
|
|
52
|
+
* lands does not diff as "every ambient resource replaced".
|
|
53
|
+
*
|
|
54
|
+
* Idempotent, so an id that already carries this region survives a second pass
|
|
55
|
+
* unchanged rather than becoming `us-east-1::us-east-1::sg-1`.
|
|
56
|
+
*/
|
|
57
|
+
export function unqualifiedKey(id: string, meta: Pick<ResourceMetadata, "attributes">): string {
|
|
58
|
+
const region = regionOf(meta);
|
|
59
|
+
if (!region) return id;
|
|
60
|
+
return id.startsWith(`${region}::`) ? id : `${region}::${id}`;
|
|
61
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { observeResources } from "./observe";
|
|
2
|
+
import { observeResources, collectAmbient } from "./observe";
|
|
3
3
|
import { observation } from "../observation";
|
|
4
4
|
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
5
5
|
import type { BuildResult } from "../build";
|
|
@@ -246,3 +246,77 @@ describe("observeResources", () => {
|
|
|
246
246
|
expect(calls[0]).not.toHaveProperty("stack");
|
|
247
247
|
});
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("collectAmbient — one resource per region, not per account (#1416)", () => {
|
|
251
|
+
const subnets = (region: string): Record<string, ResourceMetadata> =>
|
|
252
|
+
Object.fromEntries(
|
|
253
|
+
["subnet-default-a", "subnet-default-b"].map((id) => [
|
|
254
|
+
id,
|
|
255
|
+
{
|
|
256
|
+
type: "AWS::EC2::Subnet",
|
|
257
|
+
status: "OBSERVED",
|
|
258
|
+
physicalId: id,
|
|
259
|
+
ambient: true,
|
|
260
|
+
attributes: { region },
|
|
261
|
+
} as ResourceMetadata,
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
function ambientPlugin(): ObservationLexicon {
|
|
266
|
+
return {
|
|
267
|
+
name: "aws",
|
|
268
|
+
describeResources: async () => ({}),
|
|
269
|
+
observeAmbient: async (opts: { region?: string }) => subnets(opts.region ?? "us-east-1"),
|
|
270
|
+
} as unknown as ObservationLexicon;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
it("keeps every region's copy when the ids collide across regions", async () => {
|
|
274
|
+
const found = await collectAmbient(ambientPlugin(), {
|
|
275
|
+
environment: "prod",
|
|
276
|
+
kinds: ["AWS::EC2::Subnet"],
|
|
277
|
+
observed: {},
|
|
278
|
+
stacks: [
|
|
279
|
+
{ name: "east", region: "us-east-1" },
|
|
280
|
+
{ name: "west1", region: "us-west-1" },
|
|
281
|
+
{ name: "west2", region: "us-west-2" },
|
|
282
|
+
],
|
|
283
|
+
warnings: [],
|
|
284
|
+
});
|
|
285
|
+
expect(Object.keys(found).sort()).toEqual([
|
|
286
|
+
"us-east-1::subnet-default-a",
|
|
287
|
+
"us-east-1::subnet-default-b",
|
|
288
|
+
"us-west-1::subnet-default-a",
|
|
289
|
+
"us-west-1::subnet-default-b",
|
|
290
|
+
"us-west-2::subnet-default-a",
|
|
291
|
+
"us-west-2::subnet-default-b",
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("still merges two stacks in the same region to one resource each", async () => {
|
|
296
|
+
const found = await collectAmbient(ambientPlugin(), {
|
|
297
|
+
environment: "prod",
|
|
298
|
+
kinds: ["AWS::EC2::Subnet"],
|
|
299
|
+
observed: {},
|
|
300
|
+
stacks: [
|
|
301
|
+
{ name: "web", region: "us-east-1" },
|
|
302
|
+
{ name: "api", region: "us-east-1" },
|
|
303
|
+
],
|
|
304
|
+
warnings: [],
|
|
305
|
+
});
|
|
306
|
+
expect(Object.keys(found).sort()).toEqual([
|
|
307
|
+
"us-east-1::subnet-default-a",
|
|
308
|
+
"us-east-1::subnet-default-b",
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("leaves a single stack's ids bare — one region is not a merge", async () => {
|
|
313
|
+
const found = await collectAmbient(ambientPlugin(), {
|
|
314
|
+
environment: "prod",
|
|
315
|
+
kinds: ["AWS::EC2::Subnet"],
|
|
316
|
+
observed: {},
|
|
317
|
+
stacks: [{ name: "main", region: "us-east-1" }],
|
|
318
|
+
warnings: [],
|
|
319
|
+
});
|
|
320
|
+
expect(Object.keys(found).sort()).toEqual(["subnet-default-a", "subnet-default-b"]);
|
|
321
|
+
});
|
|
322
|
+
});
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type NormalizedObservation,
|
|
25
25
|
} from "../observation";
|
|
26
26
|
import { zeroResourcesWarning } from "../live-endpoint";
|
|
27
|
+
import { unqualifiedKey } from "./identity";
|
|
27
28
|
|
|
28
29
|
export interface ObserveResult {
|
|
29
30
|
observations: LiveObservation[];
|
|
@@ -208,7 +209,21 @@ export async function observeResources(
|
|
|
208
209
|
warnings,
|
|
209
210
|
})
|
|
210
211
|
: {};
|
|
211
|
-
|
|
212
|
+
// A resource can be reached as a dependency AND enumerated as ambient —
|
|
213
|
+
// the default VPC an instance sits in is both. It is one resource, and
|
|
214
|
+
// the dependency entry is the one carrying `referencedBy`, so it wins.
|
|
215
|
+
// Matched on physical id rather than on key, because ambient keys carry a
|
|
216
|
+
// region (#1416) and dependency keys do not, so the keys no longer
|
|
217
|
+
// collide even when the resource is the same one.
|
|
218
|
+
const dependedOn = new Set(
|
|
219
|
+
Object.values(dependencies.resources)
|
|
220
|
+
.map((m) => m.physicalId)
|
|
221
|
+
.filter((id): id is string => typeof id === "string"),
|
|
222
|
+
);
|
|
223
|
+
for (const [id, meta] of Object.entries(ambient)) {
|
|
224
|
+
if (meta.physicalId && dependedOn.has(meta.physicalId)) continue;
|
|
225
|
+
dependencies.resources[id] ??= meta;
|
|
226
|
+
}
|
|
212
227
|
pushObservation(
|
|
213
228
|
observations,
|
|
214
229
|
warnings,
|
|
@@ -268,6 +283,11 @@ function scopeToStack(
|
|
|
268
283
|
* Ask a lexicon what exists of the kinds it manages, beyond what is declared
|
|
269
284
|
* (#1278). Once per stack for the region, merged by physical id — the same
|
|
270
285
|
* ambient resource seen from two stacks is one resource.
|
|
286
|
+
*
|
|
287
|
+
* "The same" is per region (#1416). Two stacks in one region reporting the
|
|
288
|
+
* account's default security group is one group; two regions' default security
|
|
289
|
+
* groups are two, whatever their ids look like, so the merge key carries the
|
|
290
|
+
* region a resource records itself in.
|
|
271
291
|
*/
|
|
272
292
|
export async function collectAmbient(
|
|
273
293
|
plugin: ObservationLexicon,
|
|
@@ -282,6 +302,10 @@ export async function collectAmbient(
|
|
|
282
302
|
if (!plugin.observeAmbient || opts.kinds.length === 0) return {};
|
|
283
303
|
const found: Record<string, ResourceMetadata> = {};
|
|
284
304
|
const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
|
|
305
|
+
// Only a merge needs region-qualified keys, and one ref is not a merge. This
|
|
306
|
+
// is what keeps a single-region project's ids exactly what they were, and a
|
|
307
|
+
// recorded snapshot — always one stack, so always one ref — bare.
|
|
308
|
+
const merging = refs.length > 1;
|
|
285
309
|
for (const ref of refs) {
|
|
286
310
|
try {
|
|
287
311
|
const part = await plugin.observeAmbient({
|
|
@@ -291,7 +315,9 @@ export async function collectAmbient(
|
|
|
291
315
|
...(ref.name ? { stack: ref.name } : {}),
|
|
292
316
|
...(ref.region ? { region: ref.region } : {}),
|
|
293
317
|
});
|
|
294
|
-
for (const [id, meta] of Object.entries(part))
|
|
318
|
+
for (const [id, meta] of Object.entries(part)) {
|
|
319
|
+
found[merging ? unqualifiedKey(id, meta) : id] ??= meta;
|
|
320
|
+
}
|
|
295
321
|
} catch (err) {
|
|
296
322
|
opts.warnings.push(
|
|
297
323
|
`${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`,
|