@intentius/chant 0.44.14 → 0.45.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/audit/discover.d.ts +26 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +10 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/build-params-cli.d.ts +16 -7
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +20 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +9 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts +28 -0
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +27 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/codegen/docs-pages.d.ts +39 -0
- package/dist/codegen/docs-pages.d.ts.map +1 -0
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sidebar.d.ts +13 -2
- package/dist/codegen/docs-sidebar.d.ts.map +1 -1
- package/dist/codegen/docs-types.d.ts +30 -10
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +14 -2
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/surface-snapshot.d.ts +27 -3
- package/dist/codegen/surface-snapshot.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +11 -3
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts +13 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/driver.d.ts +38 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/config.d.ts +57 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +100 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +12 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ops.d.ts +17 -0
- package/dist/graph-ops.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +23 -2
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +8 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +7 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +33 -21
- package/dist/managed-fields.d.ts.map +1 -1
- package/dist/observation.d.ts +23 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/yaml.d.ts +0 -8
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +20 -0
- package/src/audit/discover.test.ts +55 -1
- package/src/audit/discover.ts +70 -2
- package/src/audit/edge-init-safety.test.ts +149 -18
- package/src/audit/report-model.ts +12 -1
- package/src/audit/rules-doc.ts +1 -0
- package/src/build-params.test.ts +52 -0
- package/src/cli/build-params-cli.test.ts +25 -8
- package/src/cli/build-params-cli.ts +37 -23
- package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
- package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
- package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
- package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
- package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
- package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
- package/src/cli/commands/audit.test.ts +83 -1
- package/src/cli/commands/audit.ts +121 -28
- package/src/cli/commands/build.test.ts +144 -5
- package/src/cli/commands/build.ts +66 -14
- package/src/cli/commands/check-lexicon-docs.ts +30 -0
- package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
- package/src/cli/commands/check-lexicon-examples.ts +66 -17
- package/src/cli/commands/check-lexicon.test.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +19 -4
- package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
- package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
- package/src/cli/commands/init-lexicon.test.ts +42 -1
- package/src/cli/commands/init-lexicon.ts +3 -1
- package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
- package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
- package/src/cli/commands/lexicon-surface-diff.ts +1 -1
- package/src/cli/commands/onboard.test.ts +319 -225
- package/src/cli/commands/onboard.ts +144 -58
- package/src/cli/handlers/build.test.ts +3 -3
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.ts +2 -2
- package/src/cli/handlers/graph.test.ts +44 -0
- package/src/cli/handlers/graph.ts +22 -2
- package/src/cli/handlers/lifecycle.test.ts +117 -0
- package/src/cli/handlers/lifecycle.ts +16 -0
- package/src/cli/handlers/run.test.ts +5 -5
- package/src/cli/handlers/run.ts +1 -0
- package/src/cli/handlers/search-live.test.ts +217 -0
- package/src/cli/handlers/search.test.ts +41 -1
- package/src/cli/handlers/search.ts +55 -6
- package/src/cli/main.ts +5 -3
- package/src/cli/security-boundary.test.ts +2 -1
- package/src/codegen/docs-pages.test.ts +194 -0
- package/src/codegen/docs-pages.ts +138 -0
- package/src/codegen/docs-sections.ts +0 -8
- package/src/codegen/docs-sidebar.ts +50 -37
- package/src/codegen/docs-types.ts +32 -4
- package/src/codegen/docs.ts +78 -40
- package/src/codegen/lexicon-regen.ts +3 -1
- package/src/codegen/rolling-upgrade.test.ts +1 -1
- package/src/codegen/rolling-upgrade.ts +1 -1
- package/src/codegen/surface-snapshot.test.ts +139 -0
- package/src/codegen/surface-snapshot.ts +297 -122
- package/src/codegen/validate.test.ts +16 -3
- package/src/codegen/validate.ts +17 -7
- package/src/components/auto-release.ts +1 -1
- package/src/components/capability-plugin.ts +23 -2
- package/src/components/driver.test.ts +45 -0
- package/src/components/driver.ts +64 -27
- package/src/components/index.ts +2 -0
- package/src/components/starter-plugin.ts +5 -2
- package/src/config.ts +101 -7
- package/src/discovery/fold-import.test.ts +427 -2
- package/src/discovery/fold-import.ts +163 -7
- package/src/discovery/sandbox/driver.test.ts +11 -0
- package/src/fold/fold.test.ts +41 -0
- package/src/fold/fold.ts +372 -2
- package/src/graph-ir-live.test.ts +42 -0
- package/src/graph-ir.ts +42 -2
- package/src/graph-ops.test.ts +69 -0
- package/src/graph-ops.ts +42 -0
- package/src/lifecycle/change-set.test.ts +41 -0
- package/src/lifecycle/change-set.ts +30 -2
- package/src/lifecycle/deep-observe.ts +4 -0
- package/src/lifecycle/observe.ts +29 -2
- package/src/lifecycle/replay.ts +3 -0
- package/src/lifecycle/snapshot.ts +3 -0
- package/src/lifecycle/types.ts +7 -0
- package/src/lint/policy.ts +14 -3
- package/src/managed-fields.test.ts +15 -42
- package/src/managed-fields.ts +43 -26
- package/src/observation.test.ts +24 -8
- package/src/observation.ts +48 -4
- package/src/ownership.test.ts +63 -1
- package/src/yaml.test.ts +15 -0
- package/src/yaml.ts +19 -2
package/src/graph-ops.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import type { Declarable } from "./declarable";
|
|
3
|
+
import { discoverOps } from "./op/discover";
|
|
4
|
+
import { Op } from "./op/builders";
|
|
5
|
+
import { setProvenance } from "./provenance";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #1675 — the graph IR is built from `discover(sourceDir)`, but the Op
|
|
9
|
+
* convention keeps `*.op.ts` files OUTSIDE `sourceDir` (`ops/` or the project
|
|
10
|
+
* root beside `sourceDir: "src"`), and `chant op` / `chant graph` find them via
|
|
11
|
+
* {@link discoverOps}, which scans from the git root. Join the two scopes: every
|
|
12
|
+
* Op `discoverOps` finds that discovery did not already load becomes a
|
|
13
|
+
* `Temporal::Op` entity in the map, keyed the way discovery keys a default
|
|
14
|
+
* export (the file's basename sans `.op.ts`), so the IR carries the declared
|
|
15
|
+
* DAG whatever the layout. Ops discovery already loaded (same file) are left
|
|
16
|
+
* alone. Returns the op-discovery errors for the caller to surface.
|
|
17
|
+
*/
|
|
18
|
+
export async function mergeProjectOps(
|
|
19
|
+
entities: Map<string, Declarable>,
|
|
20
|
+
sourceFiles: readonly string[],
|
|
21
|
+
projectPath: string,
|
|
22
|
+
): Promise<{ added: string[]; errors: string[] }> {
|
|
23
|
+
const added: string[] = [];
|
|
24
|
+
let found: Awaited<ReturnType<typeof discoverOps>>;
|
|
25
|
+
try {
|
|
26
|
+
found = await discoverOps({ cwd: projectPath });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// Not a git checkout — nothing beyond sourceDir to join.
|
|
29
|
+
return { added, errors: [err instanceof Error ? err.message : String(err)] };
|
|
30
|
+
}
|
|
31
|
+
const loaded = new Set(sourceFiles.map((f) => resolve(f)));
|
|
32
|
+
for (const { config, filePath } of found.ops.values()) {
|
|
33
|
+
if (loaded.has(resolve(filePath))) continue;
|
|
34
|
+
const key = basename(filePath).replace(/\.ts$/, "").replace(/\.op$/, "");
|
|
35
|
+
if (entities.has(key)) continue;
|
|
36
|
+
const entity = Op(config) as unknown as Declarable;
|
|
37
|
+
setProvenance(entity, { sourceFile: resolve(filePath) });
|
|
38
|
+
entities.set(key, entity);
|
|
39
|
+
added.push(key);
|
|
40
|
+
}
|
|
41
|
+
return { added, errors: found.errors };
|
|
42
|
+
}
|
|
@@ -346,6 +346,47 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
|
346
346
|
expect(e.queried).toBe("from-entry");
|
|
347
347
|
});
|
|
348
348
|
|
|
349
|
+
// #1674 — lexicon attribution and the provider's physical id per entry.
|
|
350
|
+
test("stamps every entry with the lexicon it was built for, when given one", () => {
|
|
351
|
+
const cs = buildChangeSet("prod", {
|
|
352
|
+
declared: new Set(["web", "api"]),
|
|
353
|
+
observedNow: { web: meta(), "sg-0abc123": meta({ ownership: "foreign" }) },
|
|
354
|
+
observedThen: undefined,
|
|
355
|
+
}, { lexicon: "aws" });
|
|
356
|
+
expect(cs.entries.map((e) => e.lexicon)).toEqual(["aws", "aws", "aws"]);
|
|
357
|
+
const without = buildChangeSet("prod", { declared: new Set(["web"]), observedNow: {}, observedThen: undefined });
|
|
358
|
+
expect(without.entries[0]).not.toHaveProperty("lexicon");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("physicalId comes from the live observation — name stays the lexicon's key", () => {
|
|
362
|
+
const cs = buildChangeSet("prod", {
|
|
363
|
+
declared: new Set(["web"]),
|
|
364
|
+
observedNow: {
|
|
365
|
+
web: meta({ physicalId: "arn:aws:elasticloadbalancing:us-east-1:123:loadbalancer/app/web/abc" }),
|
|
366
|
+
"sg-0abc123": meta({ physicalId: "sg-0abc123", ownership: "foreign" }),
|
|
367
|
+
"pod-xyz": meta({ physicalId: "default/pod-xyz", ownerChain: { root: "declared", entity: "web" } }),
|
|
368
|
+
owned: meta({ physicalId: "i-0deadbeef", ownership: "owned" }),
|
|
369
|
+
},
|
|
370
|
+
observedThen: undefined,
|
|
371
|
+
});
|
|
372
|
+
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e]));
|
|
373
|
+
expect(byName.web.physicalId).toBe("arn:aws:elasticloadbalancing:us-east-1:123:loadbalancer/app/web/abc");
|
|
374
|
+
expect(byName["sg-0abc123"]).toMatchObject({ action: "adopt", physicalId: "sg-0abc123", name: "sg-0abc123" });
|
|
375
|
+
expect(byName["pod-xyz"]).toMatchObject({ action: "runtime", physicalId: "default/pod-xyz" });
|
|
376
|
+
expect(byName.owned).toMatchObject({ action: "delete", physicalId: "i-0deadbeef" });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("physicalId is absent when no side reported one, and falls back to the snapshot's when the resource is gone", () => {
|
|
380
|
+
const cs = buildChangeSet("prod", {
|
|
381
|
+
declared: new Set(["web"]),
|
|
382
|
+
observedNow: {},
|
|
383
|
+
observedThen: { gone: meta({ physicalId: "i-gone" }) },
|
|
384
|
+
});
|
|
385
|
+
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e]));
|
|
386
|
+
expect(byName.web).not.toHaveProperty("physicalId");
|
|
387
|
+
expect(byName.gone).toMatchObject({ action: "noop", physicalId: "i-gone" });
|
|
388
|
+
});
|
|
389
|
+
|
|
349
390
|
test("a returned resource wins over an unobserved claim for the same name", () => {
|
|
350
391
|
const cs = buildChangeSet("prod", {
|
|
351
392
|
declared: new Set(["queue"]),
|
|
@@ -45,10 +45,27 @@ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime"
|
|
|
45
45
|
export type Ownership = "owned" | "foreign" | "unknown";
|
|
46
46
|
|
|
47
47
|
export interface ChangeSetEntry {
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* chant entity name for a declared entity. For an undeclared live resource
|
|
50
|
+
* (`adopt`, `delete`, `runtime`) this is the lexicon's live key — not an
|
|
51
|
+
* IR-joinable entity name; read `physicalId` for the provider id (#1674).
|
|
52
|
+
*/
|
|
49
53
|
name: string;
|
|
50
54
|
/** Resource type, when known from either side. */
|
|
51
55
|
type?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The lexicon whose observation produced this entry (#1674). Set when the
|
|
58
|
+
* change set is built for one lexicon; `lifecycle plan` merges every
|
|
59
|
+
* lexicon's change set into one `entries[]`, and this is what keeps the
|
|
60
|
+
* attribution through the merge.
|
|
61
|
+
*/
|
|
62
|
+
lexicon?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Provider-assigned physical id (ARN, resource id, pod name) from the live
|
|
65
|
+
* observation's `ResourceMetadata.physicalId`, falling back to the snapshot's
|
|
66
|
+
* when the resource is gone (#1674). Absent when neither side reported one.
|
|
67
|
+
*/
|
|
68
|
+
physicalId?: string;
|
|
52
69
|
action: ChangeAction;
|
|
53
70
|
/** The three-way evidence the classification was derived from. */
|
|
54
71
|
evidence: {
|
|
@@ -101,7 +118,12 @@ export interface ChangeSet {
|
|
|
101
118
|
* classifies as `unobserved` and nothing else: no `create` is ever synthesized
|
|
102
119
|
* from a read that did not happen.
|
|
103
120
|
*/
|
|
104
|
-
export
|
|
121
|
+
export interface ChangeSetOptions {
|
|
122
|
+
/** Stamp every entry with the lexicon it was observed by (#1674). */
|
|
123
|
+
lexicon?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildChangeSet(env: string, input: DiffLiveInput, options?: ChangeSetOptions): ChangeSet {
|
|
105
127
|
const diff = diffLive(input);
|
|
106
128
|
const { declared, observedNow } = input;
|
|
107
129
|
const observedThen = input.observedThen ?? {};
|
|
@@ -184,9 +206,15 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
184
206
|
// classification above never reads it.
|
|
185
207
|
const queried = unobservedEntry?.queried ?? input.queried?.[name];
|
|
186
208
|
|
|
209
|
+
// The provider's id for the row (#1674). Live first; the snapshot's only
|
|
210
|
+
// when the resource is no longer live (a snapshot-only noop).
|
|
211
|
+
const physicalId = observedNow[name]?.physicalId ?? observedThen[name]?.physicalId;
|
|
212
|
+
|
|
187
213
|
entries.push({
|
|
188
214
|
name,
|
|
189
215
|
type,
|
|
216
|
+
...(options?.lexicon ? { lexicon: options.lexicon } : {}),
|
|
217
|
+
...(physicalId ? { physicalId } : {}),
|
|
190
218
|
action,
|
|
191
219
|
evidence,
|
|
192
220
|
deltas,
|
|
@@ -143,6 +143,10 @@ export function diffDeepObservation(
|
|
|
143
143
|
normalizedLive[name] = {
|
|
144
144
|
type: liveEntity.type || entity.entityType,
|
|
145
145
|
...(liveEntity.physicalId ? { physicalId: liveEntity.physicalId } : {}),
|
|
146
|
+
// Per-path owners (#1189) ride through unchanged; the diff looks them
|
|
147
|
+
// up by the flattened path, so a keyed list element (`[#name]`) has
|
|
148
|
+
// no owner today — its raw index path is what the reader recorded.
|
|
149
|
+
...(liveEntity.fieldOwners ? { fieldOwners: liveEntity.fieldOwners } : {}),
|
|
146
150
|
properties: normalizeDeepProperties(liveRaw, {
|
|
147
151
|
entityType: liveEntity.type || entity.entityType,
|
|
148
152
|
side: "live",
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface ObserveResult {
|
|
|
31
31
|
observations: LiveObservation[];
|
|
32
32
|
warnings: string[];
|
|
33
33
|
errors: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Run-level notices from the lexicons (#1265), each said once however many
|
|
36
|
+
* stacks or lexicons reported it — "ownership filter unavailable on this
|
|
37
|
+
* read path" is the canonical one. Kept apart from `warnings`, which are
|
|
38
|
+
* per-entity, so a caller can print them where a note belongs: after the
|
|
39
|
+
* answer, not ahead of it.
|
|
40
|
+
*/
|
|
41
|
+
notes: string[];
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/**
|
|
@@ -43,7 +51,14 @@ export interface ObserveResult {
|
|
|
43
51
|
function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
|
|
44
52
|
const q = <T>(m: Record<string, T>): Record<string, T> =>
|
|
45
53
|
Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
|
|
46
|
-
return {
|
|
54
|
+
return {
|
|
55
|
+
resources: q(obs.resources),
|
|
56
|
+
unobserved: q(obs.unobserved),
|
|
57
|
+
queried: q(obs.queried),
|
|
58
|
+
notes: obs.notes,
|
|
59
|
+
// Exports are already keyed by stack (#1279); nothing to qualify.
|
|
60
|
+
...(obs.stackExports ? { stackExports: obs.stackExports } : {}),
|
|
61
|
+
};
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
/**
|
|
@@ -110,6 +125,7 @@ export async function observeResources(
|
|
|
110
125
|
const observations: LiveObservation[] = [];
|
|
111
126
|
const warnings: string[] = [];
|
|
112
127
|
const errors: string[] = [];
|
|
128
|
+
const notes: string[] = [];
|
|
113
129
|
|
|
114
130
|
for (const plugin of plugins) {
|
|
115
131
|
if (!plugin.describeResources) continue;
|
|
@@ -200,6 +216,13 @@ export async function observeResources(
|
|
|
200
216
|
}),
|
|
201
217
|
);
|
|
202
218
|
}
|
|
219
|
+
// Run-level notices (#1265): one line per distinct note per run,
|
|
220
|
+
// whatever the stack count. `mergeObservations` already folded the
|
|
221
|
+
// per-stack copies; this folds across lexicons.
|
|
222
|
+
for (const note of observed.notes) {
|
|
223
|
+
const line = `[${plugin.name}] ${note}`;
|
|
224
|
+
if (!notes.includes(line)) notes.push(line);
|
|
225
|
+
}
|
|
203
226
|
// What the estate depends on but does not declare (#1273). Read after the
|
|
204
227
|
// managed resources, because the declared observation is the closure's
|
|
205
228
|
// roots — there is nothing to reference out from until it exists.
|
|
@@ -260,6 +283,7 @@ export async function observeResources(
|
|
|
260
283
|
resources: {},
|
|
261
284
|
unobserved: unobservedAll(entityNames, "read-failed", message, entities),
|
|
262
285
|
queried: {},
|
|
286
|
+
notes: [],
|
|
263
287
|
},
|
|
264
288
|
environment,
|
|
265
289
|
entityNames.length,
|
|
@@ -267,7 +291,7 @@ export async function observeResources(
|
|
|
267
291
|
}
|
|
268
292
|
}
|
|
269
293
|
|
|
270
|
-
return { observations, warnings, errors };
|
|
294
|
+
return { observations, warnings, errors, notes };
|
|
271
295
|
}
|
|
272
296
|
|
|
273
297
|
/**
|
|
@@ -451,5 +475,8 @@ function pushObservation(
|
|
|
451
475
|
: observed.resources,
|
|
452
476
|
...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
|
|
453
477
|
...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
|
|
478
|
+
...(observed.stackExports && Object.keys(observed.stackExports).length > 0
|
|
479
|
+
? { stackExports: observed.stackExports }
|
|
480
|
+
: {}),
|
|
454
481
|
});
|
|
455
482
|
}
|
package/src/lifecycle/replay.ts
CHANGED
|
@@ -177,6 +177,9 @@ export async function replaySnapshots(
|
|
|
177
177
|
lexicon,
|
|
178
178
|
resources,
|
|
179
179
|
...(edges.length > 0 ? { edges } : {}),
|
|
180
|
+
...(snapshot.stackExports && Object.keys(snapshot.stackExports).length > 0
|
|
181
|
+
? { stackExports: snapshot.stackExports }
|
|
182
|
+
: {}),
|
|
180
183
|
});
|
|
181
184
|
commit ||= snapshot.commit ?? "";
|
|
182
185
|
// Report the OLDEST timestamp across stacks: a caller asking how stale this
|
|
@@ -140,6 +140,7 @@ export async function takeSnapshot(
|
|
|
140
140
|
let resources: Record<string, ResourceMetadata> = {};
|
|
141
141
|
let artifacts: Record<string, ArtifactMetadata> = {};
|
|
142
142
|
let unobserved: Record<string, UnobservedEntity> = {};
|
|
143
|
+
let stackExports: Record<string, Record<string, unknown>> | undefined;
|
|
143
144
|
|
|
144
145
|
try {
|
|
145
146
|
if (plugin.describeResources) {
|
|
@@ -163,6 +164,7 @@ export async function takeSnapshot(
|
|
|
163
164
|
// entity nobody could read must not be recorded as "was not there",
|
|
164
165
|
// because the next diff would then read it back as absent.
|
|
165
166
|
unobserved = observed.unobserved;
|
|
167
|
+
stackExports = observed.stackExports;
|
|
166
168
|
for (const [name, entry] of Object.entries(unobserved)) {
|
|
167
169
|
warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
|
|
168
170
|
}
|
|
@@ -258,6 +260,7 @@ export async function takeSnapshot(
|
|
|
258
260
|
timestamp,
|
|
259
261
|
resources: withDependencies,
|
|
260
262
|
...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
|
|
263
|
+
...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
|
|
261
264
|
...(Object.keys(unobserved).length > 0 && { unobserved }),
|
|
262
265
|
...(Object.keys(artifacts).length > 0 && { artifacts }),
|
|
263
266
|
// Only written when deep. An absent field means identity, which is what
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface LifecycleSnapshot {
|
|
|
75
75
|
* relationships recorded" rather than "no relationships existed".
|
|
76
76
|
*/
|
|
77
77
|
edges?: IREdge[];
|
|
78
|
+
/**
|
|
79
|
+
* What the deployed stack publishes (#1279), keyed by stack name — a
|
|
80
|
+
* CloudFormation stack's outputs. Recorded once here rather than on every
|
|
81
|
+
* resource's `attributes`, which is where they used to be copied. Absent on
|
|
82
|
+
* every snapshot written before this, and on a lexicon with no such notion.
|
|
83
|
+
*/
|
|
84
|
+
stackExports?: Record<string, Record<string, unknown>>;
|
|
78
85
|
/** Build digest at snapshot time — what was declared when this snapshot was taken */
|
|
79
86
|
digest?: BuildDigest;
|
|
80
87
|
}
|
package/src/lint/policy.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `policyGate` Op step runs this to gate an apply on the same checks.
|
|
5
5
|
*/
|
|
6
6
|
import { resolve, dirname } from "node:path";
|
|
7
|
-
import { loadChantConfigUpward } from "../config";
|
|
7
|
+
import { loadChantConfigUpward, resolveOwnershipEnv, resolveOwnershipMarker } from "../config";
|
|
8
|
+
import { resolveBuildParams } from "../build-params";
|
|
8
9
|
import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
|
|
9
10
|
import { build } from "../build";
|
|
10
11
|
import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
|
|
@@ -86,9 +87,19 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
86
87
|
const loaded = await loadChantConfigUpward(buildPath);
|
|
87
88
|
const config = loaded.config;
|
|
88
89
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : buildPath;
|
|
89
|
-
|
|
90
|
+
// #1396 — `ownership.env` may reference a build parameter, so the declared
|
|
91
|
+
// parameters are resolved first (from their env mappings and defaults, the
|
|
92
|
+
// same inputs an Op step has) and the build sees them too.
|
|
93
|
+
const params = resolveBuildParams(config.buildParams, { env: process.env });
|
|
94
|
+
if (params.errors.length > 0) {
|
|
95
|
+
throw new Error(`Build parameters did not resolve — cannot evaluate policy:\n ${params.errors.join("\n ")}`);
|
|
96
|
+
}
|
|
97
|
+
const env = opts.env ?? resolveOwnershipEnv(config, params.provenance);
|
|
90
98
|
|
|
91
|
-
const result = await build(buildPath, serializers
|
|
99
|
+
const result = await build(buildPath, serializers, undefined, {
|
|
100
|
+
ownership: resolveOwnershipMarker(config, params.provenance),
|
|
101
|
+
buildParams: params.provenance,
|
|
102
|
+
});
|
|
92
103
|
if (result.errors.length > 0) {
|
|
93
104
|
throw new Error("Build failed — cannot evaluate policy on a broken build");
|
|
94
105
|
}
|
|
@@ -2,28 +2,16 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS,
|
|
4
4
|
k8sListMapOrderKey,
|
|
5
|
+
K8S_SYSTEM_METADATA_PRUNE_PATTERNS,
|
|
5
6
|
buildOwnershipSets,
|
|
6
|
-
pruneByOwnership,
|
|
7
|
-
type OwnershipSets,
|
|
8
7
|
} from "./managed-fields";
|
|
9
|
-
import { normalizeDeepProperties
|
|
8
|
+
import { normalizeDeepProperties } from "./deep-observation";
|
|
10
9
|
|
|
11
10
|
/** The naming scheme both the k8s lexicon (via `@intentius/chant-k8s-client`'s `isChantFieldManager`) and gcp restate use in their own tests. */
|
|
12
11
|
function isChantManager(manager: string | undefined): boolean {
|
|
13
12
|
return !!manager && (manager === "chant" || manager.startsWith("chant:"));
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
function node(partial: Partial<DeepNode> & Pick<DeepNode, "path" | "pattern">): DeepNode {
|
|
17
|
-
return {
|
|
18
|
-
entityType: "Test::Entity",
|
|
19
|
-
key: partial.pattern,
|
|
20
|
-
value: undefined,
|
|
21
|
-
side: "live",
|
|
22
|
-
counterpart: "unknown",
|
|
23
|
-
...partial,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
15
|
describe("K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS — the generic Kubernetes object envelope", () => {
|
|
28
16
|
test("covers status and the server-minted metadata fields", () => {
|
|
29
17
|
for (const p of [
|
|
@@ -150,36 +138,21 @@ describe("buildOwnershipSets — resolving managedFields against live and declar
|
|
|
150
138
|
});
|
|
151
139
|
});
|
|
152
140
|
|
|
153
|
-
describe("
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
test("never prunes the declared side", () => {
|
|
166
|
-
expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas", side: "declared" }), sets)).toBe(false);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("never prunes a chant-owned path", () => {
|
|
170
|
-
expect(pruneByOwnership(node({ path: "metadata.labels.tier", pattern: "metadata.labels.tier" }), sets)).toBe(false);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("prunes a foreign-owned, uncontested (undeclared) path", () => {
|
|
174
|
-
expect(pruneByOwnership(node({ path: "metadata.annotations.noise", pattern: "metadata.annotations.noise" }), sets)).toBe(true);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("keeps a foreign-owned, contested (declared) path", () => {
|
|
178
|
-
expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas" }), sets)).toBe(false);
|
|
141
|
+
describe("K8S_SYSTEM_METADATA_PRUNE_PATTERNS — what the controllers stamp, not what a human labels (#1191)", () => {
|
|
142
|
+
test("covers client-side apply bookkeeping, the Deployment controller's rollout counters and the controllers' selector labels", () => {
|
|
143
|
+
for (const p of [
|
|
144
|
+
"metadata.annotations.kubectl.kubernetes.io/last-applied-configuration",
|
|
145
|
+
"metadata.annotations.deployment.kubernetes.io/revision",
|
|
146
|
+
"metadata.labels.pod-template-hash",
|
|
147
|
+
"metadata.labels.controller-revision-hash",
|
|
148
|
+
]) {
|
|
149
|
+
expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has(p)).toBe(true);
|
|
150
|
+
}
|
|
179
151
|
});
|
|
180
152
|
|
|
181
|
-
test("
|
|
182
|
-
expect(
|
|
153
|
+
test("an out-of-band user label is not on the list — it has to reach the diff as undeclared", () => {
|
|
154
|
+
expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has("metadata.labels.team")).toBe(false);
|
|
155
|
+
expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has("metadata.annotations.kubernetes.io/change-cause")).toBe(false);
|
|
183
156
|
});
|
|
184
157
|
});
|
|
185
158
|
|
package/src/managed-fields.ts
CHANGED
|
@@ -142,17 +142,18 @@ export interface OwnershipSets {
|
|
|
142
142
|
chantOwned: ReadonlySet<string>;
|
|
143
143
|
/** Paths owned by a manager that is not chant. */
|
|
144
144
|
foreignOwned: ReadonlySet<string>;
|
|
145
|
-
/** The subset of `foreignOwned` where the declared manifest also sets the path —
|
|
145
|
+
/** The subset of `foreignOwned` where the declared manifest also sets the path — a contested field. */
|
|
146
146
|
foreignContested: ReadonlySet<string>;
|
|
147
147
|
/**
|
|
148
148
|
* Path → the name of the manager that owns it (#1189).
|
|
149
149
|
*
|
|
150
|
-
* The three sets above answer *which category* owns a path
|
|
151
|
-
*
|
|
152
|
-
* `
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
150
|
+
* The three sets above answer *which category* owns a path. A reader needs
|
|
151
|
+
* the other question — "owned by `kubectl-client-side-apply`" and "owned by
|
|
152
|
+
* `hpa-controller`" are the same category and mean very different things to
|
|
153
|
+
* an operator. Since chant #1191 no category is pruned from the diff, so
|
|
154
|
+
* this map is what tells the two apart in the report. Last writer wins
|
|
155
|
+
* where several managers touch one path, matching what the API server
|
|
156
|
+
* itself reports.
|
|
156
157
|
*/
|
|
157
158
|
owners: ReadonlyMap<string, string>;
|
|
158
159
|
}
|
|
@@ -323,25 +324,41 @@ export function buildOwnershipSets(
|
|
|
323
324
|
}
|
|
324
325
|
|
|
325
326
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
327
|
+
* Well-known metadata the control plane writes on an object regardless of
|
|
328
|
+
* who applied it — matched on the exact index-erased pattern, like
|
|
329
|
+
* {@link K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS}, but gated by the caller on
|
|
330
|
+
* `side === "live" && counterpart === "absent"` so a manifest that *does*
|
|
331
|
+
* declare one of these (a hand-pinned `change-cause`, say) is still compared.
|
|
330
332
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
333
|
+
* This list is the noise valve that replaced the managed-fields prune
|
|
334
|
+
* (chant #1191). Until then a foreign-owned, undeclared path was dropped
|
|
335
|
+
* before the diff ever saw it, which silenced `kubectl label deploy web
|
|
336
|
+
* team=platform` — the exact console-edit class the `undeclared` drift kind
|
|
337
|
+
* exists for. Now a foreign-owned path nobody declared is reported as
|
|
338
|
+
* `undeclared` (with its owner named, #1189) unless the accepted baseline
|
|
339
|
+
* already carries it — and what remains to subtract statically is only the
|
|
340
|
+
* handful of annotations and labels Kubernetes' own controllers stamp on
|
|
341
|
+
* every object of a kind. Widening this set is additive; each entry must be
|
|
342
|
+
* something *no* human writes out of band.
|
|
337
343
|
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
344
|
+
* - `kubectl.kubernetes.io/last-applied-configuration` — client-side apply's
|
|
345
|
+
* bookkeeping, a JSON copy of whatever was last applied.
|
|
346
|
+
* - `deployment.kubernetes.io/revision` (+ `revision-history`,
|
|
347
|
+
* `desired-replicas`, `max-replicas`) — the Deployment controller's
|
|
348
|
+
* rollout counters, written to Deployments and their ReplicaSets.
|
|
349
|
+
* - `pod-template-hash` / `controller-revision-hash` /
|
|
350
|
+
* `statefulset.kubernetes.io/pod-name` — the selector labels the
|
|
351
|
+
* ReplicaSet, DaemonSet and StatefulSet controllers add to what they own.
|
|
340
352
|
*/
|
|
341
|
-
export
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
353
|
+
export const K8S_SYSTEM_METADATA_PRUNE_PATTERNS: ReadonlySet<string> = new Set([
|
|
354
|
+
"metadata.annotations.kubectl.kubernetes.io/last-applied-configuration",
|
|
355
|
+
"metadata.annotations.deployment.kubernetes.io/revision",
|
|
356
|
+
"metadata.annotations.deployment.kubernetes.io/revision-history",
|
|
357
|
+
"metadata.annotations.deployment.kubernetes.io/desired-replicas",
|
|
358
|
+
"metadata.annotations.deployment.kubernetes.io/max-replicas",
|
|
359
|
+
"metadata.labels.pod-template-hash",
|
|
360
|
+
"metadata.labels.controller-revision-hash",
|
|
361
|
+
"metadata.labels.statefulset.kubernetes.io/pod-name",
|
|
362
|
+
"spec.template.metadata.labels.pod-template-hash",
|
|
363
|
+
"spec.template.metadata.labels.controller-revision-hash",
|
|
364
|
+
]);
|
package/src/observation.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
|
29
29
|
|
|
30
30
|
describe("normalizeObservation", () => {
|
|
31
31
|
test("a bare map means 'I looked at everything'", () => {
|
|
32
|
-
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {} });
|
|
32
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test("the envelope carries both halves", () => {
|
|
@@ -38,11 +38,19 @@ describe("normalizeObservation", () => {
|
|
|
38
38
|
resources: { a: meta() },
|
|
39
39
|
unobserved: { b: { reason: "read-failed" } },
|
|
40
40
|
queried: {},
|
|
41
|
+
notes: [],
|
|
41
42
|
});
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
test("run-level notes ride the envelope, distinct (#1265)", () => {
|
|
46
|
+
const value = observation({ a: meta() }, undefined, undefined, ["ownership unknown", "ownership unknown"]);
|
|
47
|
+
expect(value.notes).toEqual(["ownership unknown", "ownership unknown"]);
|
|
48
|
+
expect(normalizeObservation(value).notes).toEqual(["ownership unknown"]);
|
|
49
|
+
expect(observation({}).notes).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
44
52
|
test("undefined normalizes to empty maps", () => {
|
|
45
|
-
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {} });
|
|
53
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, notes: [] });
|
|
46
54
|
});
|
|
47
55
|
|
|
48
56
|
test("the envelope carries the queried addresses through normalization (#1620)", () => {
|
|
@@ -84,25 +92,33 @@ describe("unobservedAll", () => {
|
|
|
84
92
|
describe("mergeObservations (multi-stack)", () => {
|
|
85
93
|
test("present beats not-observed beats absent", () => {
|
|
86
94
|
const merged = mergeObservations([
|
|
87
|
-
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {} },
|
|
88
|
-
{ resources: { a: meta() }, unobserved: {}, queried: {} },
|
|
95
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, notes: [] },
|
|
96
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
89
97
|
]);
|
|
90
98
|
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
91
99
|
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
92
100
|
});
|
|
93
101
|
|
|
102
|
+
test("the same note from four stacks is one note (#1265)", () => {
|
|
103
|
+
const note = "ownership filter unavailable";
|
|
104
|
+
const merged = mergeObservations(
|
|
105
|
+
["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, notes: [note] })),
|
|
106
|
+
);
|
|
107
|
+
expect(merged.notes).toEqual([note]);
|
|
108
|
+
});
|
|
109
|
+
|
|
94
110
|
test("an entity nobody looked for in any stack stays absent", () => {
|
|
95
111
|
const merged = mergeObservations([
|
|
96
|
-
{ resources: { a: meta() }, unobserved: {}, queried: {} },
|
|
97
|
-
{ resources: { b: meta() }, unobserved: {}, queried: {} },
|
|
112
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
113
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
98
114
|
]);
|
|
99
115
|
expect(merged.unobserved).toEqual({});
|
|
100
116
|
});
|
|
101
117
|
|
|
102
118
|
test("queried addresses union across stacks (#1620)", () => {
|
|
103
119
|
const merged = mergeObservations([
|
|
104
|
-
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" } },
|
|
105
|
-
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" } },
|
|
120
|
+
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, notes: [] },
|
|
121
|
+
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, notes: [] },
|
|
106
122
|
]);
|
|
107
123
|
expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
|
|
108
124
|
});
|