@intentius/chant 0.28.0 → 0.29.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/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +12 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +29 -4
- package/dist/graph-ir.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 +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +31 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +69 -6
- package/src/cli/handlers/graph.ts +61 -27
- package/src/cli/handlers/lifecycle.test.ts +285 -6
- package/src/cli/handlers/lifecycle.ts +297 -185
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.ts +987 -43
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.ts +12 -2
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +68 -12
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +41 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +34 -9
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +55 -0
- package/src/stack-output.ts +41 -20
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
* ## What `ChantConfig` legally holds
|
|
28
28
|
*
|
|
29
29
|
* Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
|
|
30
|
-
* (`lexicons`, `capabilities
|
|
31
|
-
* plain
|
|
30
|
+
* (`lexicons`, `capabilities`), strings (`sourceDir`), an array of strings or
|
|
31
|
+
* plain `{ name, endpoint }` objects (`environments`, #1166), nested plain
|
|
32
|
+
* objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
|
|
32
33
|
* `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
|
|
33
34
|
* plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
|
|
34
35
|
* are a severity string or a `[severity, options]` tuple, and whose `plugins`
|
package/src/env.test.ts
CHANGED
|
@@ -40,6 +40,18 @@ describe("unknownEnvError", () => {
|
|
|
40
40
|
test("rejects an undeclared env with a clear message", () => {
|
|
41
41
|
expect(unknownEnvError("stage", ["dev", "prod"])).toMatch(/Unknown environment "stage".*dev, prod/);
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
// #1166 — `environments` entries may now be `{ name, endpoint }`; validation
|
|
45
|
+
// reduces to names either way, unaffected by an object entry's endpoint.
|
|
46
|
+
test("accepts an object-form declared env, mixed with bare strings", () => {
|
|
47
|
+
expect(unknownEnvError("floci", ["prod", { name: "floci", endpoint: "http://localhost:4566" }])).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects an undeclared env against a mixed string/object list, naming both", () => {
|
|
51
|
+
expect(
|
|
52
|
+
unknownEnvError("stage", ["prod", { name: "floci", endpoint: "http://localhost:4566" }]),
|
|
53
|
+
).toMatch(/Unknown environment "stage".*prod, floci/);
|
|
54
|
+
});
|
|
43
55
|
});
|
|
44
56
|
|
|
45
57
|
describe("env-aware discovery (#505)", () => {
|
package/src/env.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* pinhole renders/diffs them to show environment drift (INTENTIUS/pinhole#3).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { environmentNames, type EnvironmentDeclaration } from "./config";
|
|
22
|
+
|
|
21
23
|
/** The environment variable the CLI sets from `--env`. */
|
|
22
24
|
export const ENV_VAR = "CHANT_ENV";
|
|
23
25
|
|
|
@@ -30,10 +32,16 @@ export function env(fallback?: string): string | undefined {
|
|
|
30
32
|
* Validate a requested environment against the project's declared `environments`
|
|
31
33
|
* (`chant.config`). Returns an error message for an unknown env, or `undefined`
|
|
32
34
|
* when it's valid (or when the project declares no environments, in which case
|
|
33
|
-
* any name is accepted).
|
|
35
|
+
* any name is accepted). `declared` entries may be a bare name or `{ name,
|
|
36
|
+
* endpoint }` (#1166) — {@link environmentNames} reduces either to the names
|
|
37
|
+
* this checks against.
|
|
34
38
|
*/
|
|
35
|
-
export function unknownEnvError(
|
|
39
|
+
export function unknownEnvError(
|
|
40
|
+
requested: string | undefined,
|
|
41
|
+
declared: EnvironmentDeclaration[] | undefined,
|
|
42
|
+
): string | undefined {
|
|
36
43
|
if (!requested || !declared || declared.length === 0) return undefined;
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
const names = environmentNames(declared) ?? [];
|
|
45
|
+
if (names.includes(requested)) return undefined;
|
|
46
|
+
return `Unknown environment "${requested}". Declared environments: ${names.join(", ")}.`;
|
|
39
47
|
}
|
package/src/fold/fold.ts
CHANGED
|
@@ -298,8 +298,18 @@ export function collectConsts(sourceFile: ts.SourceFile): Map<string, ts.Express
|
|
|
298
298
|
return consts;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
301
|
+
/**
|
|
302
|
+
* A property/element key foldable without execution: identifier, string, or
|
|
303
|
+
* numeric literal.
|
|
304
|
+
*
|
|
305
|
+
* Exported for chant #1023's composite-factory interpreter
|
|
306
|
+
* (../discovery/fold-import.ts), which walks object literals itself — a
|
|
307
|
+
* factory body may construct a resource inside one, which {@link fold} has no
|
|
308
|
+
* case for — and must reject a computed key with the identical message
|
|
309
|
+
* {@link fold} would, rather than growing a second, silently divergent copy of
|
|
310
|
+
* this rule.
|
|
311
|
+
*/
|
|
312
|
+
export function propName(node: ts.PropertyName): string {
|
|
303
313
|
if (isLiteralPropertyName(node)) return node.text;
|
|
304
314
|
throw foldError(node, computedPropertyNameMessage(node));
|
|
305
315
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
|
|
2
|
+
import { buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
|
|
3
3
|
|
|
4
4
|
// A fixture "snapshot" — what a lexicon's describeResources() returns for a live
|
|
5
5
|
// environment (managed-only). Two AWS resources; the subnet references the VPC by
|
|
@@ -140,4 +140,31 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
|
|
|
140
140
|
const ir = sourceOverlayGraphs(declared, liveDup);
|
|
141
141
|
expect(ir.edges.filter((e) => e.from === "app-ingress" && e.to === "web-vpc")).toHaveLength(1);
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
// #1089 — "not deployed yet" is a claim the read has to support.
|
|
145
|
+
it("paints a declared node nobody could read `neutral`, not `accent`", () => {
|
|
146
|
+
const ir = sourceOverlayGraphs(declared, live, {
|
|
147
|
+
unobserved: { "planned-db": { reason: "no-binding", detail: "no kubectl context" } },
|
|
148
|
+
});
|
|
149
|
+
const node = ir.nodes.find((x) => x.id === "planned-db")!;
|
|
150
|
+
expect((node.attrs as { _status?: string })._status).toBe("neutral");
|
|
151
|
+
expect((node.attrs as { _unobserved?: string })._unobserved).toBe("no-binding");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("still paints a confirmed-absent declared node `accent`", () => {
|
|
155
|
+
const ir = sourceOverlayGraphs(declared, live, { unobserved: {} });
|
|
156
|
+
expect((ir.nodes.find((x) => x.id === "planned-db")!.attrs as { _status?: string })._status).toBe("accent");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("collectUnobserved (#1089)", () => {
|
|
161
|
+
it("unions every observation's holes", () => {
|
|
162
|
+
expect(
|
|
163
|
+
collectUnobserved([
|
|
164
|
+
{ lexicon: "aws", resources: {}, unobserved: { a: { reason: "read-failed" } } },
|
|
165
|
+
{ lexicon: "k8s", resources: {} },
|
|
166
|
+
{ lexicon: "gcp", resources: {}, unobserved: { b: { reason: "no-binding" } } },
|
|
167
|
+
]),
|
|
168
|
+
).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
|
|
169
|
+
});
|
|
143
170
|
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
|
|
|
6
6
|
import { getProvenance } from "./provenance";
|
|
7
7
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
8
8
|
import type { ResourceMetadata } from "./lexicon";
|
|
9
|
+
import type { UnobservedEntity } from "./observation";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Graph IR — the engine-neutral, lint-gated representation of a project's
|
|
@@ -419,6 +420,12 @@ function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
|
|
|
419
420
|
export interface LiveObservation {
|
|
420
421
|
lexicon: string;
|
|
421
422
|
resources: Record<string, ResourceMetadata>;
|
|
423
|
+
/**
|
|
424
|
+
* Declared entities the lexicon could not observe (#1089), keyed by name.
|
|
425
|
+
* They are not live nodes — but they are not confirmed-absent either, so the
|
|
426
|
+
* overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
|
|
427
|
+
*/
|
|
428
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
422
429
|
}
|
|
423
430
|
|
|
424
431
|
/**
|
|
@@ -446,7 +453,10 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
446
453
|
attrs: meta.attributes ?? {},
|
|
447
454
|
};
|
|
448
455
|
if (meta.physicalId) node.physicalId = meta.physicalId;
|
|
449
|
-
|
|
456
|
+
// `unknown` is a legitimate verdict on the metadata (#1089) but carries no
|
|
457
|
+
// information for a painter, and the IR's `ownership` field means "a
|
|
458
|
+
// verdict was reached" — so only owned/foreign land on the node.
|
|
459
|
+
if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
|
|
450
460
|
nodes.push(node);
|
|
451
461
|
(byLexicon[lexicon] ??= []).push(name);
|
|
452
462
|
// A live lexicon maps to one deployable stack, same as the source IR.
|
|
@@ -465,22 +475,60 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
465
475
|
return { nodes, edges: [], groups };
|
|
466
476
|
}
|
|
467
477
|
|
|
478
|
+
/** How an overlay learns which declared nodes were never looked at (#1089). */
|
|
479
|
+
export interface OverlayOptions {
|
|
480
|
+
/**
|
|
481
|
+
* Declared entities the observation could not read, keyed by name (union of
|
|
482
|
+
* every {@link LiveObservation}'s `unobserved`). They are tagged `neutral`
|
|
483
|
+
* instead of `accent`: "not yet provisioned" is a claim the read never
|
|
484
|
+
* supported.
|
|
485
|
+
*/
|
|
486
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The union of every observation's unobserved entities — the input to the overlays. */
|
|
490
|
+
export function collectUnobserved(observations: LiveObservation[]): Record<string, UnobservedEntity> {
|
|
491
|
+
const out: Record<string, UnobservedEntity> = {};
|
|
492
|
+
for (const o of observations) Object.assign(out, o.unobserved ?? {});
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Paint status a node carries in an overlay. `neutral` = chant could not look. */
|
|
497
|
+
type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral";
|
|
498
|
+
|
|
499
|
+
function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
|
|
500
|
+
return {
|
|
501
|
+
...n,
|
|
502
|
+
attrs: {
|
|
503
|
+
...n.attrs,
|
|
504
|
+
_status: status,
|
|
505
|
+
...(unobserved ? { _unobserved: unobserved.reason } : {}),
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
468
510
|
/**
|
|
469
511
|
* Overlay the declared graph on the provisioned one (#780, `chant graph --live
|
|
470
512
|
* --overlay`) and classify each resource, tagging a `_status` a renderer colours:
|
|
471
513
|
* - **managed** (declared + provisioned) → `good`
|
|
472
514
|
* - **foreign** (provisioned, not declared) → `warn`
|
|
473
|
-
* - **pending** (declared,
|
|
515
|
+
* - **pending** (declared, provider confirmed absent) → `accent`
|
|
516
|
+
* - **unobserved** (declared, chant could not look — #1089) → `neutral`,
|
|
517
|
+
* plus an `_unobserved` attr carrying the reason
|
|
474
518
|
* Live nodes keep their edges/containment; pending nodes are appended (they have
|
|
475
519
|
* no live edges). Sorted; the live groups pass through unchanged.
|
|
476
520
|
*/
|
|
477
|
-
export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
|
|
521
|
+
export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
|
|
478
522
|
const declaredIds = new Set(declared.nodes.map((n) => n.id));
|
|
479
523
|
const liveIds = new Set(live.nodes.map((n) => n.id));
|
|
480
|
-
const
|
|
524
|
+
const unobserved = opts?.unobserved ?? {};
|
|
481
525
|
|
|
482
|
-
const nodes: IRNode[] = live.nodes.map((n) =>
|
|
483
|
-
for (const n of declared.nodes)
|
|
526
|
+
const nodes: IRNode[] = live.nodes.map((n) => tagStatus(n, declaredIds.has(n.id) ? "good" : "warn"));
|
|
527
|
+
for (const n of declared.nodes) {
|
|
528
|
+
if (liveIds.has(n.id)) continue;
|
|
529
|
+
const u = unobserved[n.id];
|
|
530
|
+
nodes.push(u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent"));
|
|
531
|
+
}
|
|
484
532
|
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
485
533
|
|
|
486
534
|
return { ...live, nodes };
|
|
@@ -497,27 +545,35 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
|
|
|
497
545
|
* Each declared node is classified against live observation and tagged `_status`:
|
|
498
546
|
* - **managed** (declared + provisioned) → `good`, carrying the observed
|
|
499
547
|
* `physicalId` / `ownership` onto the declared node
|
|
500
|
-
* - **pending** (declared,
|
|
548
|
+
* - **pending** (declared, provider confirmed absent) → `accent`
|
|
549
|
+
* - **unobserved** (declared, chant could not look — #1089) → `neutral`, with
|
|
550
|
+
* the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
|
|
551
|
+
* to paint the whole estate "pending", which is the diagram equivalent of
|
|
552
|
+
* planning a create for something that already exists.
|
|
501
553
|
* **Foreign** resources (provisioned, not declared) are appended and tagged
|
|
502
554
|
* `warn`, together with any live-reconstructed edges that touch them — a declared
|
|
503
555
|
* edge cannot describe an undeclared resource. Declared groups/exports pass
|
|
504
556
|
* through unchanged; nodes and edges are sorted for deterministic output.
|
|
505
557
|
*/
|
|
506
|
-
export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR): GraphIR {
|
|
558
|
+
export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: OverlayOptions): GraphIR {
|
|
507
559
|
const liveById = new Map(live.nodes.map((n) => [n.id, n]));
|
|
508
560
|
const declaredIds = new Set(declared.nodes.map((n) => n.id));
|
|
509
561
|
const foreignIds = new Set(live.nodes.filter((n) => !declaredIds.has(n.id)).map((n) => n.id));
|
|
510
|
-
const
|
|
562
|
+
const unobserved = opts?.unobserved ?? {};
|
|
511
563
|
|
|
512
564
|
const nodes: IRNode[] = declared.nodes.map((n) => {
|
|
513
565
|
const obs = liveById.get(n.id);
|
|
514
|
-
if (!obs)
|
|
566
|
+
if (!obs) {
|
|
567
|
+
const u = unobserved[n.id];
|
|
568
|
+
// unobserved — declared, and nobody looked; not "pending"
|
|
569
|
+
return u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent");
|
|
570
|
+
}
|
|
515
571
|
const merged: IRNode = { ...n }; // managed — carry the observed identity
|
|
516
572
|
if (obs.physicalId) merged.physicalId = obs.physicalId;
|
|
517
573
|
if (obs.ownership) merged.ownership = obs.ownership;
|
|
518
|
-
return
|
|
574
|
+
return tagStatus(merged, "good");
|
|
519
575
|
});
|
|
520
|
-
for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(
|
|
576
|
+
for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagStatus(n, "warn")); // foreign
|
|
521
577
|
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
522
578
|
|
|
523
579
|
// Declared edges are the canvas (the cross-substrate topology). Add only the
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export * from "./lint/discover";
|
|
|
48
48
|
export * from "./import/parser";
|
|
49
49
|
export * from "./import/generator";
|
|
50
50
|
export * from "./lexicon";
|
|
51
|
+
export * from "./observation";
|
|
51
52
|
export * from "./lexicon-integrity";
|
|
52
53
|
export * from "./lexicon-manifest";
|
|
53
54
|
export * from "./lexicon-schema";
|
package/src/kubectl-context.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { exec } from "node:child_process";
|
|
24
24
|
import { promisify } from "node:util";
|
|
25
|
+
import type { UnobservedReason } from "./observation";
|
|
25
26
|
|
|
26
27
|
const execAsync = promisify(exec);
|
|
27
28
|
|
|
@@ -124,3 +125,83 @@ export async function resolveClusterTarget(
|
|
|
124
125
|
|
|
125
126
|
return { context: bound, source: "bound" };
|
|
126
127
|
}
|
|
128
|
+
|
|
129
|
+
// ── kubectl read outcomes (#1089) ───────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What a failed `kubectl get` actually proved. Shared by the k8s and gcp
|
|
133
|
+
* lexicons, which read through the same kubectl path and used to collapse every
|
|
134
|
+
* non-zero exit into "not there" — so an expired token, a downed API server, or
|
|
135
|
+
* an uninstalled CRD all classified as `create`.
|
|
136
|
+
*/
|
|
137
|
+
export type KubectlReadOutcome =
|
|
138
|
+
/** The API server answered and the object is not there. Safe to plan a create. */
|
|
139
|
+
| { kind: "absent" }
|
|
140
|
+
/** The read proved nothing about the object's existence. */
|
|
141
|
+
| { kind: "unobserved"; reason: UnobservedReason; detail: string };
|
|
142
|
+
|
|
143
|
+
/** Pull whatever the child process actually said out of an exec rejection. */
|
|
144
|
+
function execErrorText(err: unknown): string {
|
|
145
|
+
if (typeof err === "object" && err !== null) {
|
|
146
|
+
const e = err as { stderr?: unknown; message?: unknown };
|
|
147
|
+
const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
|
|
148
|
+
if (stderr) return stderr;
|
|
149
|
+
if (typeof e.message === "string") return e.message.trim();
|
|
150
|
+
}
|
|
151
|
+
return String(err);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Collapse kubectl's noise to one line for a plan/diff entry. */
|
|
155
|
+
function firstLine(text: string, max = 200): string {
|
|
156
|
+
const line = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? text.trim();
|
|
157
|
+
return line.length > max ? `${line.slice(0, max - 3)}...` : line;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify a `kubectl get` failure into the observation tri-state (#1089).
|
|
162
|
+
*
|
|
163
|
+
* Only a genuine `NotFound` from the API server — or a kind the server does not
|
|
164
|
+
* serve at all, where no instance can exist — establishes absence. Auth,
|
|
165
|
+
* connectivity, and unresolvable contexts establish nothing, and must reach the
|
|
166
|
+
* change set as NOT-OBSERVED rather than as an empty result.
|
|
167
|
+
*/
|
|
168
|
+
export function classifyKubectlFailure(err: unknown): KubectlReadOutcome {
|
|
169
|
+
const text = execErrorText(err);
|
|
170
|
+
const lower = text.toLowerCase();
|
|
171
|
+
|
|
172
|
+
// The object was looked for and is not there.
|
|
173
|
+
if (lower.includes("notfound") || /error from server \(notfound\)/.test(lower) || lower.includes("not found")) {
|
|
174
|
+
return { kind: "absent" };
|
|
175
|
+
}
|
|
176
|
+
// The cluster serves no such kind, so no instance of it can exist there. The
|
|
177
|
+
// usual cause is a CRD this same plan has not applied yet — a real absence,
|
|
178
|
+
// and the case a create is for.
|
|
179
|
+
if (
|
|
180
|
+
lower.includes("the server doesn't have a resource type") ||
|
|
181
|
+
lower.includes("the server could not find the requested resource")
|
|
182
|
+
) {
|
|
183
|
+
return { kind: "absent" };
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
lower.includes("unauthorized") ||
|
|
187
|
+
lower.includes("forbidden") ||
|
|
188
|
+
lower.includes("you must be logged in") ||
|
|
189
|
+
lower.includes("invalid bearer token") ||
|
|
190
|
+
lower.includes("credentials")
|
|
191
|
+
) {
|
|
192
|
+
return { kind: "unobserved", reason: "no-credentials", detail: firstLine(text) };
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
lower.includes("unable to connect to the server") ||
|
|
196
|
+
lower.includes("connection refused") ||
|
|
197
|
+
lower.includes("no configuration has been provided") ||
|
|
198
|
+
lower.includes("did you specify the right host or port") ||
|
|
199
|
+
lower.includes("context was not found") ||
|
|
200
|
+
/context ".*" does not exist/.test(lower) ||
|
|
201
|
+
lower.includes("no such host") ||
|
|
202
|
+
lower.includes("i/o timeout")
|
|
203
|
+
) {
|
|
204
|
+
return { kind: "unobserved", reason: "no-binding", detail: firstLine(text) };
|
|
205
|
+
}
|
|
206
|
+
return { kind: "unobserved", reason: "read-failed", detail: firstLine(text) };
|
|
207
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -11,11 +11,23 @@ import type { DriverComponent } from "./components/driver";
|
|
|
11
11
|
import type { EmulatorCapability } from "./op/emulator-lifecycle";
|
|
12
12
|
import type { RuleMeta } from "./audit/catalog";
|
|
13
13
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
|
+
import type { DescribeResourcesResult } from "./observation";
|
|
14
15
|
|
|
15
16
|
// Re-exported so lexicons can author a reference catalog (#778) from the same
|
|
16
17
|
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
17
18
|
export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
|
|
18
19
|
|
|
20
|
+
// The observation contract (#1089), re-exported from the same entry so a
|
|
21
|
+
// lexicon's `describeResources` can report NOT-OBSERVED without a second
|
|
22
|
+
// import path. Runtime helpers live in `@intentius/chant/observation`.
|
|
23
|
+
export type {
|
|
24
|
+
DescribeResourcesResult,
|
|
25
|
+
ObservationResult,
|
|
26
|
+
NormalizedObservation,
|
|
27
|
+
UnobservedEntity,
|
|
28
|
+
UnobservedReason,
|
|
29
|
+
} from "./observation";
|
|
30
|
+
|
|
19
31
|
/**
|
|
20
32
|
* Manifest for a packaged lexicon — metadata embedded in the tarball.
|
|
21
33
|
*
|
|
@@ -507,6 +519,27 @@ export interface LexiconPlugin {
|
|
|
507
519
|
* Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
|
|
508
520
|
* AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
|
|
509
521
|
*
|
|
522
|
+
* **The observation contract (#1089).** Returning nothing for a declared
|
|
523
|
+
* entity is a claim, and there are two different claims to make. Either the
|
|
524
|
+
* provider was asked and reported the resource absent — which is what lets
|
|
525
|
+
* the change set propose `create` — or the lexicon never looked, which must
|
|
526
|
+
* not. An implementation that has a "did not look" case (no reader for the
|
|
527
|
+
* kind, the read errored, no credentials, no cluster binding) must return the
|
|
528
|
+
* {@link ObservationResult} envelope and name those entities in `unobserved`
|
|
529
|
+
* with a total {@link UnobservedReason}. Warning on stderr is not enough: a
|
|
530
|
+
* warning is invisible to `lifecycle plan`, which is where the wrong `create`
|
|
531
|
+
* gets proposed. Returning the bare `name → ResourceMetadata` map is still
|
|
532
|
+
* valid and means "everything I was asked about, I looked at".
|
|
533
|
+
*
|
|
534
|
+
* Throwing is the whole-lexicon failure (see the k8s cluster-binding refusal,
|
|
535
|
+
* #1100): core catches it and marks every declared entity NOT-OBSERVED with
|
|
536
|
+
* `read-failed`, so a failed read is never a list of creates.
|
|
537
|
+
*
|
|
538
|
+
* Ownership verdicts are total (#1089). When `owned` is requested and the
|
|
539
|
+
* lexicon has no marker channel on this path, it must stamp
|
|
540
|
+
* `ownership: "unknown"` on what it returns rather than degrading silently —
|
|
541
|
+
* the change set never escalates `unknown` to a `delete`.
|
|
542
|
+
*
|
|
510
543
|
* `entities` carries the chant-side entity declarations for this lexicon,
|
|
511
544
|
* keyed by chant entity name (e.g. the export name from a `*.ts` file).
|
|
512
545
|
* Implementations that need to map cloud-side names back to chant entity
|
|
@@ -536,7 +569,7 @@ export interface LexiconPlugin {
|
|
|
536
569
|
* everything.
|
|
537
570
|
*/
|
|
538
571
|
owned?: boolean;
|
|
539
|
-
}): Promise<
|
|
572
|
+
}): Promise<DescribeResourcesResult>;
|
|
540
573
|
|
|
541
574
|
/**
|
|
542
575
|
* Report the live status of one deploy unit by its deployed name. Opt-in.
|
|
@@ -683,12 +716,14 @@ export interface ResourceMetadata {
|
|
|
683
716
|
/** Cloud-assigned output properties */
|
|
684
717
|
attributes?: Record<string, unknown>;
|
|
685
718
|
/**
|
|
686
|
-
* Live ownership verdict from the resource's marker (#119/#120)
|
|
687
|
-
*
|
|
688
|
-
* no marker
|
|
689
|
-
*
|
|
719
|
+
* Live ownership verdict from the resource's marker (#119/#120). `owned` =
|
|
720
|
+
* carries chant's marker; `foreign` = no marker; `unknown` = the lexicon has
|
|
721
|
+
* no marker channel on this read path and says so rather than degrading
|
|
722
|
+
* silently (#1089 — verdicts are total). Absent is read as `unknown`. The
|
|
723
|
+
* change set reads this — never the snapshot — to decide whether an orphan is
|
|
724
|
+
* a delete, and never escalates `unknown` to one.
|
|
690
725
|
*/
|
|
691
|
-
ownership?: "owned" | "foreign";
|
|
726
|
+
ownership?: "owned" | "foreign" | "unknown";
|
|
692
727
|
}
|
|
693
728
|
|
|
694
729
|
/**
|
|
@@ -17,7 +17,7 @@ describe("buildChangeSet (#118)", () => {
|
|
|
17
17
|
});
|
|
18
18
|
const e = cs.entries.find((x) => x.name === "bucket")!;
|
|
19
19
|
expect(e.action).toBe("create");
|
|
20
|
-
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false });
|
|
20
|
+
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: true });
|
|
21
21
|
expect(e.ownership).toBe("unknown");
|
|
22
22
|
});
|
|
23
23
|
|
|
@@ -187,3 +187,95 @@ describe("gitlabMrReport (#329)", () => {
|
|
|
187
187
|
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
188
188
|
});
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
// ── The observation tri-state (#1089) ───────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
194
|
+
test("declared and not observed → unobserved, never create", () => {
|
|
195
|
+
const cs = buildChangeSet("prod", {
|
|
196
|
+
declared: new Set(["crd-widget"]),
|
|
197
|
+
observedNow: {},
|
|
198
|
+
observedThen: undefined,
|
|
199
|
+
unobserved: {
|
|
200
|
+
"crd-widget": {
|
|
201
|
+
type: "K8s::Example::Widget",
|
|
202
|
+
reason: "unsupported-kind",
|
|
203
|
+
detail: "no kubectl mapping",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const e = cs.entries.find((x) => x.name === "crd-widget")!;
|
|
208
|
+
expect(e.action).toBe("unobserved");
|
|
209
|
+
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: false });
|
|
210
|
+
expect(e.unobservedReason).toBe("unsupported-kind");
|
|
211
|
+
expect(e.type).toBe("K8s::Example::Widget");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the same entity, confirmed absent, still classifies as create", () => {
|
|
215
|
+
const cs = buildChangeSet("prod", {
|
|
216
|
+
declared: new Set(["crd-widget"]),
|
|
217
|
+
observedNow: {},
|
|
218
|
+
observedThen: undefined,
|
|
219
|
+
});
|
|
220
|
+
expect(cs.entries.find((x) => x.name === "crd-widget")!.action).toBe("create");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a returned resource wins over an unobserved claim for the same name", () => {
|
|
224
|
+
const cs = buildChangeSet("prod", {
|
|
225
|
+
declared: new Set(["queue"]),
|
|
226
|
+
observedNow: { queue: meta() },
|
|
227
|
+
observedThen: undefined,
|
|
228
|
+
unobserved: { queue: { reason: "read-failed" } },
|
|
229
|
+
});
|
|
230
|
+
const e = cs.entries.find((x) => x.name === "queue")!;
|
|
231
|
+
expect(e.action).toBe("noop");
|
|
232
|
+
expect(e.evidence.observed).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("an unobserved entity that is in the snapshot is not read as gone", () => {
|
|
236
|
+
const cs = buildChangeSet("prod", {
|
|
237
|
+
declared: new Set(["queue"]),
|
|
238
|
+
observedNow: {},
|
|
239
|
+
observedThen: { queue: meta() },
|
|
240
|
+
unobserved: { queue: { reason: "no-credentials" } },
|
|
241
|
+
});
|
|
242
|
+
const e = cs.entries.find((x) => x.name === "queue")!;
|
|
243
|
+
expect(e.action).toBe("unobserved");
|
|
244
|
+
expect(e.evidence.inSnapshot).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an unobserved entity is never a delete, even with an owned marker in the snapshot", () => {
|
|
248
|
+
const cs = buildChangeSet("prod", {
|
|
249
|
+
declared: new Set(),
|
|
250
|
+
observedNow: {},
|
|
251
|
+
observedThen: { legacy: meta({ ownership: "owned" }) },
|
|
252
|
+
unobserved: { legacy: { reason: "read-failed" } },
|
|
253
|
+
});
|
|
254
|
+
expect(cs.entries.find((x) => x.name === "legacy")!.action).toBe("unobserved");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("summarize and render surface the hole", () => {
|
|
258
|
+
const cs = buildChangeSet("prod", {
|
|
259
|
+
declared: new Set(["a"]),
|
|
260
|
+
observedNow: {},
|
|
261
|
+
observedThen: undefined,
|
|
262
|
+
unobserved: { a: { reason: "no-binding", detail: "no kubectl context for prod" } },
|
|
263
|
+
});
|
|
264
|
+
expect(summarize(cs).unobserved).toBe(1);
|
|
265
|
+
expect(summarize(cs).create).toBe(0);
|
|
266
|
+
const out = renderChangeSet(cs);
|
|
267
|
+
expect(out).toContain("UNOBSERVED");
|
|
268
|
+
expect(out).toContain("no binding for this environment");
|
|
269
|
+
expect(out).toContain("no kubectl context for prod");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the GitLab widget excludes unobserved — its three columns cannot express a hole", () => {
|
|
273
|
+
const cs = buildChangeSet("prod", {
|
|
274
|
+
declared: new Set(["a"]),
|
|
275
|
+
observedNow: {},
|
|
276
|
+
observedThen: undefined,
|
|
277
|
+
unobserved: { a: { reason: "read-failed" } },
|
|
278
|
+
});
|
|
279
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
280
|
+
});
|
|
281
|
+
});
|