@intentius/chant 0.31.0 → 0.33.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/command-group.d.ts +134 -0
- package/dist/cli/command-group.d.ts.map +1 -0
- package/dist/cli/conflict-check.d.ts +1 -1
- package/dist/cli/conflict-check.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/search.d.ts +58 -0
- package/dist/cli/handlers/search.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +20 -0
- package/dist/graph-declared.d.ts.map +1 -0
- package/dist/graph-effective.d.ts +25 -0
- package/dist/graph-effective.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +17 -3
- 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/lexicon.d.ts +49 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +15 -7
- 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 +14 -6
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +118 -0
- package/dist/managed-fields.d.ts.map +1 -0
- package/dist/owner-chain.d.ts +99 -0
- package/dist/owner-chain.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/command-group.test.ts +208 -0
- package/src/cli/command-group.ts +199 -0
- package/src/cli/conflict-check.test.ts +36 -1
- package/src/cli/conflict-check.ts +22 -1
- package/src/cli/handlers/graph.test.ts +1 -1
- package/src/cli/handlers/graph.ts +33 -11
- package/src/cli/handlers/lifecycle.ts +5 -0
- package/src/cli/handlers/search.test.ts +113 -0
- package/src/cli/handlers/search.ts +263 -0
- package/src/cli/main.ts +114 -27
- package/src/cli/registry.ts +4 -0
- package/src/config.ts +3 -0
- package/src/graph-declared.ts +33 -0
- package/src/graph-effective.test.ts +97 -0
- package/src/graph-effective.ts +110 -0
- package/src/graph-ir-live.test.ts +40 -0
- package/src/graph-ir.ts +32 -7
- package/src/index.ts +1 -0
- package/src/lexicon.ts +50 -1
- package/src/lifecycle/change-set.test.ts +100 -0
- package/src/lifecycle/change-set.ts +39 -10
- package/src/lifecycle/live-diff.test.ts +88 -0
- package/src/lifecycle/live-diff.ts +55 -8
- package/src/lifecycle/observe.test.ts +66 -2
- package/src/lifecycle/observe.ts +79 -18
- package/src/managed-fields.test.ts +179 -0
- package/src/managed-fields.ts +328 -0
- package/src/owner-chain.test.ts +97 -0
- package/src/owner-chain.ts +128 -0
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `chant lifecycle diff --live` computes a three-way comparison — declared now /
|
|
5
5
|
* last snapshot / live now — and prints it. `buildChangeSet` promotes that
|
|
6
|
-
* same signal into a classified create/update/delete/adopt/noop set
|
|
7
|
-
* tooling (reconcile, apply) can act on.
|
|
6
|
+
* same signal into a classified create/update/delete/adopt/runtime/noop set
|
|
7
|
+
* that other tooling (reconcile, apply) can act on.
|
|
8
8
|
*
|
|
9
9
|
* Strictly read-only and pure: no I/O, no mutation. The classification reads
|
|
10
10
|
* ownership from the live marker only (populated downstream); until ownership
|
|
@@ -24,12 +24,17 @@ import { type UnobservedReason } from "../observation.js";
|
|
|
24
24
|
* snapshot.
|
|
25
25
|
* - `adopt` — live but undeclared, ownership not established → a candidate to
|
|
26
26
|
* pull back into source, never an auto-delete.
|
|
27
|
+
* - `runtime` — live but undeclared, and its owner-reference chain reaches a
|
|
28
|
+
* declared entity (#1077): a Pod a declared Deployment's controller
|
|
29
|
+
* created, for instance. Never a delete, never an adopt candidate — it is
|
|
30
|
+
* not drift, just the runtime doing its job. `runtimeOwner` names the
|
|
31
|
+
* declared entity it belongs to.
|
|
27
32
|
* - `noop` — declared and live with no drift, or already reconciled.
|
|
28
33
|
* - `unobserved` — declared, and the lexicon could not look (#1089). Not a
|
|
29
34
|
* proposal at all: it is the plan admitting a hole. Never a create, never a
|
|
30
35
|
* delete. Read `unobservedReason` for which hole.
|
|
31
36
|
*/
|
|
32
|
-
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
|
|
37
|
+
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
|
|
33
38
|
/**
|
|
34
39
|
* Who answers "is this resource chant's?". `unknown` until a live ownership
|
|
35
40
|
* marker is queried (#120). The change set never escalates `unknown` to a
|
|
@@ -66,6 +71,8 @@ export interface ChangeSetEntry {
|
|
|
66
71
|
unobservedReason?: UnobservedReason;
|
|
67
72
|
/** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
|
|
68
73
|
unobservedDetail?: string;
|
|
74
|
+
/** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
|
|
75
|
+
runtimeOwner?: string;
|
|
69
76
|
}
|
|
70
77
|
export interface ChangeSet {
|
|
71
78
|
env: string;
|
|
@@ -91,10 +98,11 @@ export declare function summarize(cs: ChangeSet): Record<ChangeAction, number>;
|
|
|
91
98
|
* GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
|
|
92
99
|
* UI as "N to add, M to change, K to delete". The format is generic — any tool
|
|
93
100
|
* that emits this JSON gets the widget — and the chant plan maps onto it
|
|
94
|
-
* directly. Only the mutating actions count: `adopt`, `noop` and
|
|
95
|
-
* are excluded, since the widget has no column for "live but
|
|
96
|
-
*
|
|
97
|
-
*
|
|
101
|
+
* directly. Only the mutating actions count: `adopt`, `runtime`, `noop` and
|
|
102
|
+
* `unobserved` are excluded, since the widget has no column for "live but
|
|
103
|
+
* undeclared", "expected runtime child" (#1077), "no change", or "could not
|
|
104
|
+
* look" (#1089). The widget is therefore a floor, not a complete plan: read
|
|
105
|
+
* the full change set when entities are unobserved or classified runtime.
|
|
98
106
|
*
|
|
99
107
|
* The widget label reads "Terraform" regardless of producer; that is GitLab's
|
|
100
108
|
* fixed string, not a claim chant makes.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-set.d.ts","sourceRoot":"","sources":["../../src/lifecycle/change-set.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAY,KAAK,eAAe,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAwB,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAE7E
|
|
1
|
+
{"version":3,"file":"change-set.d.ts","sourceRoot":"","sources":["../../src/lifecycle/change-set.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAY,KAAK,eAAe,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAwB,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAE7E;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,YAAY,CAAC;AAExG;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,yBAAyB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,YAAY,CAAC;IACrB,kEAAkE;IAClE,QAAQ,EAAE;QACR,oCAAoC;QACpC,QAAQ,EAAE,OAAO,CAAC;QAClB,oCAAoC;QACpC,UAAU,EAAE,OAAO,CAAC;QACpB,qDAAqD;QACrD,IAAI,EAAE,OAAO,CAAC;QACd;;;;;WAKG;QACH,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC;IACF,6CAA6C;IAC7C,MAAM,CAAC,EAAE,eAAe,EAAE,CAAC;IAC3B,oDAAoD;IACpD,SAAS,EAAE,SAAS,CAAC;IACrB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,gGAAgG;IAChG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oGAAoG;IACpG,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,SAAS,CAiG3E;AAID,gCAAgC;AAChC,wBAAgB,SAAS,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAYrE;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,uEAAuE;AACvE,wBAAgB,cAAc,CAAC,EAAE,EAAE,SAAS,GAAG,cAAc,CAG5D;AAED,sEAAsE;AACtE,wBAAgB,eAAe,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,CA6BrD"}
|
|
@@ -32,6 +32,18 @@ export interface UnobservedResource {
|
|
|
32
32
|
reason: UnobservedReason;
|
|
33
33
|
detail?: string;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A live, undeclared resource whose owner-reference chain reaches a declared
|
|
37
|
+
* entity (#1077) — a Pod a declared Deployment's controller created, for
|
|
38
|
+
* instance. Reported separately from `orphan`: it is expected runtime, not a
|
|
39
|
+
* delete/adopt candidate, and is never counted as drift.
|
|
40
|
+
*/
|
|
41
|
+
export interface RuntimeChildResource {
|
|
42
|
+
name: string;
|
|
43
|
+
type: string;
|
|
44
|
+
/** The declared chant entity this resource's owner chain resolves to. */
|
|
45
|
+
owner: string;
|
|
46
|
+
}
|
|
35
47
|
export interface LiveDiffResult {
|
|
36
48
|
/**
|
|
37
49
|
* Declared in current build, and the provider reported it absent. Entities
|
|
@@ -39,8 +51,20 @@ export interface LiveDiffResult {
|
|
|
39
51
|
* (#1089), so "missing" keeps meaning "confirmed not there".
|
|
40
52
|
*/
|
|
41
53
|
missing: string[];
|
|
42
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Observed in cloud right now, not declared, and either carries no owner
|
|
56
|
+
* chain, or the chain does not reach a declared entity (unowned, foreign,
|
|
57
|
+
* or unresolvable — #1077 never escalates an incomplete chain read to
|
|
58
|
+
* `runtimeChildren`). A resource whose chain *does* reach a declared entity
|
|
59
|
+
* is in `runtimeChildren` instead.
|
|
60
|
+
*/
|
|
43
61
|
orphan: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Observed in cloud right now, not declared, whose owner-reference chain
|
|
64
|
+
* reaches a declared entity (#1077) — expected runtime, not drift. Never a
|
|
65
|
+
* delete/adopt candidate; excluded from `orphan` and from drift counts.
|
|
66
|
+
*/
|
|
67
|
+
runtimeChildren: RuntimeChildResource[];
|
|
44
68
|
/** Was in last snapshot but isn't observed now. */
|
|
45
69
|
disappeared: string[];
|
|
46
70
|
/** Observed now and declared, but not in the previous snapshot. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"live-diff.d.ts","sourceRoot":"","sources":["../../src/lifecycle/live-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEzE,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,gBAAgB,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB
|
|
1
|
+
{"version":3,"file":"live-diff.d.ts","sourceRoot":"","sources":["../../src/lifecycle/live-diff.ts"],"names":[],"mappings":"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"}
|
|
@@ -33,14 +33,22 @@ export interface ObserveResult {
|
|
|
33
33
|
* absent an explicit `stack`) queries a stack that simply doesn't exist there,
|
|
34
34
|
* so the single-call path always observes zero nodes. When `stacks` is
|
|
35
35
|
* present and non-empty, each observing plugin's `describeResources` is
|
|
36
|
-
* called once per stack
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* called once per stack and the returned observations are merged. A stack entry
|
|
37
|
+
* may be a bare name or `{ name, region?, src? }` (#1162): `src` is built
|
|
38
|
+
* SCOPED so the deployed BARE LogicalResourceIds match (the whole-project build
|
|
39
|
+
* disambiguates colliding names to `UsWest1Src…`, which the live ids never
|
|
40
|
+
* carry), and a scoped stack's observed ids are qualified `${stack}::${id}` so
|
|
41
|
+
* the same bare id in two stacks stays distinct. A bare-string stack keeps its
|
|
42
|
+
* bare ids and the tri-state merge (#57). When `stacks` is absent or empty, behavior is
|
|
43
|
+
* exactly the single call of before (no `stack` key at all), so a single-stack
|
|
44
|
+
* project is unaffected.
|
|
41
45
|
*/
|
|
42
46
|
export declare function observeResources(environment: string, plugins: ObservationLexicon[], buildResult: BuildResult, opts?: {
|
|
43
47
|
owned?: boolean;
|
|
44
|
-
stacks?: string
|
|
48
|
+
stacks?: Array<string | {
|
|
49
|
+
name: string;
|
|
50
|
+
region?: string;
|
|
51
|
+
src?: string;
|
|
52
|
+
}>;
|
|
45
53
|
}): Promise<ObserveResult>;
|
|
46
54
|
//# sourceMappingURL=observe.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../src/lifecycle/observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../src/lifecycle/observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAI5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAUnD,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAeD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,EAAE,EAC7B,WAAW,EAAE,WAAW,EACxB,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,GACnG,OAAO,CAAC,aAAa,CAAC,CA6HxB"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes-object-shape utilities shared by every lexicon whose live model
|
|
3
|
+
* is a Kubernetes API object — the k8s lexicon itself (chant #1076) and GCP's
|
|
4
|
+
* Config Connector custom resources (chant #1087).
|
|
5
|
+
*
|
|
6
|
+
* A Config Connector custom resource *is* a Kubernetes object: it carries the
|
|
7
|
+
* same envelope (`status`, `metadata.{uid,resourceVersion,generation,
|
|
8
|
+
* creationTimestamp,managedFields,selfLink}`), the same SSA `fieldsV1`
|
|
9
|
+
* encoding for `metadata.managedFields`, and some CNRM kinds even embed
|
|
10
|
+
* genuinely k8s-shaped substructures (Cloud Run's `RunService` wraps a
|
|
11
|
+
* Knative pod spec with `containers`/`env`/`ports`, keyed the same way a
|
|
12
|
+
* Deployment's are). None of that is specific to chant's k8s *lexicon* — it
|
|
13
|
+
* is a fact about the Kubernetes API that any reader of a Kubernetes-shaped
|
|
14
|
+
* object needs, regardless of which lexicon is doing the reading.
|
|
15
|
+
*
|
|
16
|
+
* This module lives in core rather than in the k8s lexicon for the same
|
|
17
|
+
* reason `./kubectl-context.ts`'s `resolveClusterTarget` does (chant #1100):
|
|
18
|
+
* GCP's observation needs it too, without taking a dependency on the k8s
|
|
19
|
+
* lexicon package. Nothing here is keyed by chant's own k8s entityType
|
|
20
|
+
* catalog (`K8s::Apps::Deployment`, …) or by any lexicon's service-default
|
|
21
|
+
* table — that stays lexicon-specific, layered on top of what's here
|
|
22
|
+
* (`lexicons/k8s/src/deep-observe-hooks.ts`'s `K8S_SERVICE_DEFAULTS`,
|
|
23
|
+
* `lexicons/gcp/src/deep-observe.ts`'s CNRM-specific annotation noise).
|
|
24
|
+
*/
|
|
25
|
+
import type { DeepArrayElement, DeepNode } from "./deep-observation.js";
|
|
26
|
+
/**
|
|
27
|
+
* Paths every Kubernetes API object carries regardless of kind, matched on
|
|
28
|
+
* the exact index-erased pattern (there is exactly one `status`, one
|
|
29
|
+
* `metadata.managedFields`, per object — no per-type variation the way AWS's
|
|
30
|
+
* `Arn`/`RoleId` repeat at every nesting depth).
|
|
31
|
+
*
|
|
32
|
+
* - `status` — the whole subtree is server-computed; no declarative source
|
|
33
|
+
* (chant's k8s manifests, chant's Config Connector CRs) ever authors it.
|
|
34
|
+
* - `metadata.uid`/`resourceVersion`/`generation`/`creationTimestamp` — minted
|
|
35
|
+
* and incremented by the API server, never authored.
|
|
36
|
+
* - `metadata.managedFields` — the bookkeeping the ownership walk below reads
|
|
37
|
+
* to decide everything else. Left in the tree it would report as permanent
|
|
38
|
+
* drift (a timestamp changes on every write) and would recurse into the
|
|
39
|
+
* encoded `fieldsV1` structure as if it were ordinary properties.
|
|
40
|
+
* - `metadata.selfLink` — deprecated API-server bookkeeping some clusters
|
|
41
|
+
* still echo; never a declared field.
|
|
42
|
+
*/
|
|
43
|
+
export declare const K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS: ReadonlySet<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Kubernetes' own well-known list-map-key conventions for the substructures
|
|
46
|
+
* that recur across kinds and across lexicons: containers/initContainers/
|
|
47
|
+
* ephemeralContainers and `env`/`volumes` keyed by `name` — the same field
|
|
48
|
+
* Kubernetes' strategic-merge-patch and SSA's `list-map-keys` key on for
|
|
49
|
+
* these lists — and container ports keyed by `containerPort`+`protocol`,
|
|
50
|
+
* Service ports keyed by `port`+`protocol` (Kubernetes' own SSA
|
|
51
|
+
* `list-map-keys` for each). Both port shapes are handled under one `ports`
|
|
52
|
+
* branch by checking which field is present.
|
|
53
|
+
*
|
|
54
|
+
* Entity-type-agnostic on purpose: whether an array named `containers`
|
|
55
|
+
* belongs to a `K8s::Apps::Deployment` or to a GCP `RunService`'s embedded
|
|
56
|
+
* pod spec, the identity Kubernetes assigns each element is the same.
|
|
57
|
+
*/
|
|
58
|
+
export declare function k8sListMapOrderKey(element: DeepArrayElement): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* The structural shape of one `metadata.managedFields` entry this module
|
|
61
|
+
* needs. Matches `@intentius/chant-k8s-client`'s `ManagedFieldsEntry`
|
|
62
|
+
* (chant #1075) field-for-field, but is declared independently here rather
|
|
63
|
+
* than imported from that package: core must stay reachable from any
|
|
64
|
+
* lexicon's build path, and the k8s client package is deliberately *not*
|
|
65
|
+
* reachable from one (chant #1074's structural boundary,
|
|
66
|
+
* `examples/k8s-client-boundary.test.ts`). A caller that already has a real
|
|
67
|
+
* `ManagedFieldsEntry[]` (the k8s lexicon) passes it straight through —
|
|
68
|
+
* TypeScript's structural typing accepts it with no cast.
|
|
69
|
+
*/
|
|
70
|
+
export interface ManagedFieldsEntryLike {
|
|
71
|
+
manager?: string;
|
|
72
|
+
operation?: string;
|
|
73
|
+
subresource?: string;
|
|
74
|
+
fieldsV1?: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
/** One live object's managed-fields ownership, resolved to chant dot-paths. */
|
|
77
|
+
export interface OwnershipSets {
|
|
78
|
+
/** Paths any chant field manager owns on this object. */
|
|
79
|
+
chantOwned: ReadonlySet<string>;
|
|
80
|
+
/** Paths owned by a manager that is not chant. */
|
|
81
|
+
foreignOwned: ReadonlySet<string>;
|
|
82
|
+
/** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
|
|
83
|
+
foreignContested: ReadonlySet<string>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the three ownership sets for one live object. `entries` is
|
|
87
|
+
* `metadata.managedFields`, already decoded (`@intentius/chant-k8s-client`'s
|
|
88
|
+
* `managedFieldsOf` for the k8s lexicon; a plain `JSON.parse` of `kubectl get
|
|
89
|
+
* -o json` for gcp); `isChantManager` classifies each entry's manager name
|
|
90
|
+
* — matched on the `chant`/`chant:<stack>` family per chant #1075, but the
|
|
91
|
+
* matcher itself is supplied by the caller rather than fixed here, because
|
|
92
|
+
* what counts as "chant" is not the same fact on every lexicon's apply path
|
|
93
|
+
* (see gcp's `deep-observe.ts` module doc for why that matters there).
|
|
94
|
+
*
|
|
95
|
+
* Subresource entries (`status`, `scale`) are excluded: a controller writing
|
|
96
|
+
* a Deployment's `status` is not competing for the spec chant declared, the
|
|
97
|
+
* same reasoning `@intentius/chant-k8s-client`'s `fieldsOwnedBy` default
|
|
98
|
+
* already encodes.
|
|
99
|
+
*/
|
|
100
|
+
export declare function buildOwnershipSets(entries: readonly ManagedFieldsEntryLike[], liveRoot: Record<string, unknown>, declaredRoot: Record<string, unknown>, isChantManager: (manager: string | undefined) => boolean): OwnershipSets;
|
|
101
|
+
/**
|
|
102
|
+
* The three-question managed-fields prune rule, as a predicate over a
|
|
103
|
+
* {@link DeepNode} plus one object's precomputed {@link OwnershipSets} —
|
|
104
|
+
* shared by every lexicon layering a per-resource managed-fields prune on
|
|
105
|
+
* top of its own static rules (k8s's `perResourceHooks`, gcp's equivalent):
|
|
106
|
+
*
|
|
107
|
+
* 1. Chant owns the path (any chant manager) → never pruned by this rule.
|
|
108
|
+
* 2. A different manager owns it, chant does not, and it is not declared →
|
|
109
|
+
* controller-managed noise, pruned.
|
|
110
|
+
* 3. It is declared, regardless of who owns it live → never pruned by this
|
|
111
|
+
* rule, because chant's source is a statement of intent independent of
|
|
112
|
+
* which write currently holds the field.
|
|
113
|
+
*
|
|
114
|
+
* Only applies to the live side — the declared tree carries no managedFields
|
|
115
|
+
* to prune by.
|
|
116
|
+
*/
|
|
117
|
+
export declare function pruneByOwnership(node: DeepNode, sets: OwnershipSets): boolean;
|
|
118
|
+
//# sourceMappingURL=managed-fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"managed-fields.d.ts","sourceRoot":"","sources":["../src/managed-fields.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAIrE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,kCAAkC,EAAE,WAAW,CAAC,MAAM,CAQjE,CAAC;AAwBH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,GAAG,SAAS,CAwBhF;AAID;;;;;;;;;;GAUG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,yDAAyD;IACzD,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAChC,kDAAkD;IAClD,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,8HAA8H;IAC9H,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACvC;AAsHD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,SAAS,sBAAsB,EAAE,EAC1C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrC,cAAc,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,KAAK,OAAO,GACvD,aAAa,CAmBf;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAM7E"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-chain classification (chant #1077).
|
|
3
|
+
*
|
|
4
|
+
* `describeResources()` already lets a lexicon report a live object it never
|
|
5
|
+
* asked about by name — that is how `orphan` has always worked (a resource
|
|
6
|
+
* present in `observedNow` that is not in `declared`). Every existing consumer
|
|
7
|
+
* treats every such object the same way: undeclared, so a delete/adopt
|
|
8
|
+
* candidate.
|
|
9
|
+
*
|
|
10
|
+
* On Kubernetes that conflates two different things. A console-added SNS
|
|
11
|
+
* subscription (the AWS case #1014/#1015 was built for) really is out-of-band
|
|
12
|
+
* drift. A Pod a declared Deployment's controller created is not drift at
|
|
13
|
+
* all — it is the runtime doing its job, and it will be recreated the moment
|
|
14
|
+
* it is deleted. `ownerReferences` is what tells them apart: the Pod's chain
|
|
15
|
+
* of owners terminates at the Deployment, which is declared.
|
|
16
|
+
*
|
|
17
|
+
* This module owns the *category* — the four possible answers to "where does
|
|
18
|
+
* this object's owner chain lead" — and the pure algorithm that walks a chain
|
|
19
|
+
* to one of them. A lexicon supplies the chain (reading `ownerReferences`,
|
|
20
|
+
* possibly across several API reads to walk past an intermediate object chant
|
|
21
|
+
* never declared, e.g. a ReplicaSet between a Pod and its Deployment); this
|
|
22
|
+
* module supplies the bounded, cycle-safe interpretation, so that logic is
|
|
23
|
+
* written and tested once rather than once per lexicon.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Where a live, undeclared resource's owner-reference chain leads.
|
|
27
|
+
*
|
|
28
|
+
* - `declared` — the chain reaches an entity chant's own build declared. This
|
|
29
|
+
* is the whole point of #1077: the diff engine reads this as `runtime`, not
|
|
30
|
+
* `orphan`, and never proposes deleting it.
|
|
31
|
+
* - `unowned` — the resource carries no owner reference at all. A genuinely
|
|
32
|
+
* standalone live object; classifies as `orphan`, unchanged from before this
|
|
33
|
+
* module existed.
|
|
34
|
+
* - `foreign` — the chain fully resolves (every hop was readable, no cycle, no
|
|
35
|
+
* depth bound hit) but terminates at a live root that is not declared.
|
|
36
|
+
* Still `orphan` — it belongs to something real, just not to this build.
|
|
37
|
+
* - `unknown` — some hop could not be resolved: an unreadable owner, a cycle,
|
|
38
|
+
* or the depth bound. Composes with #1168's tri-state precedent: an owner
|
|
39
|
+
* chain chant could not fully verify is not a confirmed anything, so it is
|
|
40
|
+
* never escalated to `declared` and stays routed as `orphan` today, exactly
|
|
41
|
+
* as `foreign`/`unowned` are — never treated as a safer-than-warranted
|
|
42
|
+
* `runtime` classification just because the read was incomplete.
|
|
43
|
+
*/
|
|
44
|
+
export type OwnerChainVerdict = {
|
|
45
|
+
readonly root: "declared";
|
|
46
|
+
readonly entity: string;
|
|
47
|
+
} | {
|
|
48
|
+
readonly root: "unowned";
|
|
49
|
+
} | {
|
|
50
|
+
readonly root: "foreign";
|
|
51
|
+
} | {
|
|
52
|
+
readonly root: "unknown";
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* One node in the owner graph a lexicon assembles for {@link classifyOwnerChain}.
|
|
56
|
+
* Keyed externally (in the `nodes` map passed to the walk) by whatever stable
|
|
57
|
+
* identity the lexicon's provider uses — a Kubernetes UID, for instance.
|
|
58
|
+
*/
|
|
59
|
+
export interface OwnerChainNode {
|
|
60
|
+
/**
|
|
61
|
+
* This node's immediate owner, by its key in the same `nodes` map. Omit
|
|
62
|
+
* (`undefined`) when the object carries no owner reference at all — that is
|
|
63
|
+
* how a chain's *starting* node reports `unowned` rather than `unknown`.
|
|
64
|
+
*/
|
|
65
|
+
ownerId?: string;
|
|
66
|
+
/**
|
|
67
|
+
* True when this node's own owner could not be determined — the read
|
|
68
|
+
* failed, was denied, or the object simply could not be fetched. Distinct
|
|
69
|
+
* from having no owner: this says "unknown", not "none".
|
|
70
|
+
*/
|
|
71
|
+
ownerUnreadable?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The declared chant entity name, when this node corresponds to one. A node
|
|
74
|
+
* with this set ends the walk immediately with `{ root: "declared" }` —
|
|
75
|
+
* whatever `ownerId`/`ownerUnreadable` it might also carry is irrelevant,
|
|
76
|
+
* since the chain already reached what it was looking for.
|
|
77
|
+
*/
|
|
78
|
+
declaredEntity?: string;
|
|
79
|
+
}
|
|
80
|
+
/** Default bound on how many owner hops {@link classifyOwnerChain} will walk
|
|
81
|
+
* before giving up conservatively. Kubernetes' own garbage collector does not
|
|
82
|
+
* bound this at all, but a live read has to — a bound this generous is well
|
|
83
|
+
* past any real ownership depth (Pod → ReplicaSet → Deployment is 2 hops) and
|
|
84
|
+
* exists only to turn a corrupt or adversarial chain into `unknown` rather
|
|
85
|
+
* than an infinite walk. */
|
|
86
|
+
export declare const DEFAULT_MAX_OWNER_CHAIN_DEPTH = 12;
|
|
87
|
+
/**
|
|
88
|
+
* Walk the owner chain starting at `startId` through `nodes`, bounded and
|
|
89
|
+
* cycle-safe. Pure — the caller has already done whatever I/O was needed to
|
|
90
|
+
* populate `nodes`; this function only interprets the graph it was given.
|
|
91
|
+
*
|
|
92
|
+
* `nodes` need not contain every ancestor: a node the caller never resolved
|
|
93
|
+
* (because it gave up, hit the caller's own fetch bound, or the read failed)
|
|
94
|
+
* is simply absent from the map, and a reference to an absent node classifies
|
|
95
|
+
* as `unknown` — the conservative answer, same as an explicit
|
|
96
|
+
* `ownerUnreadable`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function classifyOwnerChain(startId: string, nodes: ReadonlyMap<string, OwnerChainNode>, maxDepth?: number): OwnerChainVerdict;
|
|
99
|
+
//# sourceMappingURL=owner-chain.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"owner-chain.d.ts","sourceRoot":"","sources":["../src/owner-chain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEjC;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;4BAK4B;AAC5B,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,EAC1C,QAAQ,GAAE,MAAsC,GAC/C,iBAAiB,CA2BnB"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
3
|
+
import {
|
|
4
|
+
resolveCommandGroupVerb,
|
|
5
|
+
collectCommandGroups,
|
|
6
|
+
dispatchCommandGroup,
|
|
7
|
+
formatCommandGroupsHelp,
|
|
8
|
+
splitJoinedFlags,
|
|
9
|
+
unknownFlagError,
|
|
10
|
+
RESERVED_COMMAND_NAMES,
|
|
11
|
+
type CommandGroup,
|
|
12
|
+
} from "./command-group";
|
|
13
|
+
|
|
14
|
+
const noopAsync = async () => {};
|
|
15
|
+
|
|
16
|
+
/** Minimal LexiconPlugin — only the fields relevant to a given test. */
|
|
17
|
+
function makePlugin(name: string, group?: CommandGroup): LexiconPlugin {
|
|
18
|
+
const plugin: LexiconPlugin = {
|
|
19
|
+
name,
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
serializer: { name, serialize: () => "" } as any,
|
|
22
|
+
generate: noopAsync,
|
|
23
|
+
validate: noopAsync,
|
|
24
|
+
coverage: noopAsync,
|
|
25
|
+
package: noopAsync,
|
|
26
|
+
};
|
|
27
|
+
if (group) plugin.commands = () => group;
|
|
28
|
+
return plugin;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeGroup(overrides: Partial<CommandGroup> = {}): CommandGroup {
|
|
32
|
+
return {
|
|
33
|
+
name: "kube",
|
|
34
|
+
description: "Kubernetes verb group",
|
|
35
|
+
commands: [
|
|
36
|
+
{ name: "get", description: "Get resources", handler: async () => 0 },
|
|
37
|
+
{ name: "version", description: "Print schema version", handler: async () => 0 },
|
|
38
|
+
],
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("resolveCommandGroupVerb", () => {
|
|
44
|
+
test("mounts: finds the group and verb contributed by a plugin", () => {
|
|
45
|
+
const group = makeGroup();
|
|
46
|
+
const plugins = [makePlugin("k8s", group)];
|
|
47
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "get");
|
|
48
|
+
expect(result.kind).toBe("matched");
|
|
49
|
+
if (result.kind === "matched") {
|
|
50
|
+
expect(result.plugin.name).toBe("k8s");
|
|
51
|
+
expect(result.group).toBe(group);
|
|
52
|
+
expect(result.command.name).toBe("get");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("no-capability lexicon is unaffected: a plugin with no commands() is skipped", () => {
|
|
57
|
+
const plugins = [makePlugin("aws"), makePlugin("k8s", makeGroup())];
|
|
58
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "get");
|
|
59
|
+
expect(result.kind).toBe("matched");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns no-group when nothing claims the namespace", () => {
|
|
63
|
+
const plugins = [makePlugin("aws"), makePlugin("gcp")];
|
|
64
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "get");
|
|
65
|
+
expect(result.kind).toBe("no-group");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns no-group for an empty plugin list", () => {
|
|
69
|
+
expect(resolveCommandGroupVerb([], "kube", "get").kind).toBe("no-group");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns unknown-verb when the group matches but the verb doesn't", () => {
|
|
73
|
+
const plugins = [makePlugin("k8s", makeGroup())];
|
|
74
|
+
const result = resolveCommandGroupVerb(plugins, "kube", "bogus");
|
|
75
|
+
expect(result.kind).toBe("unknown-verb");
|
|
76
|
+
if (result.kind === "unknown-verb") {
|
|
77
|
+
expect(result.group.name).toBe("kube");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns no-verb when the group matches and no verb was given", () => {
|
|
82
|
+
const plugins = [makePlugin("k8s", makeGroup())];
|
|
83
|
+
const result = resolveCommandGroupVerb(plugins, "kube", undefined);
|
|
84
|
+
expect(result.kind).toBe("no-verb");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("never invokes a verb's handler while resolving — registration is data, not execution", () => {
|
|
88
|
+
let invoked = false;
|
|
89
|
+
const group: CommandGroup = {
|
|
90
|
+
name: "kube",
|
|
91
|
+
description: "d",
|
|
92
|
+
commands: [{ name: "get", description: "d", handler: async () => { invoked = true; return 0; } }],
|
|
93
|
+
};
|
|
94
|
+
resolveCommandGroupVerb([makePlugin("k8s", group)], "kube", "get");
|
|
95
|
+
expect(invoked).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("collectCommandGroups", () => {
|
|
100
|
+
test("lists groups from plugins in order; skips plugins without one", () => {
|
|
101
|
+
const g1 = makeGroup({ name: "kube" });
|
|
102
|
+
const g2 = makeGroup({ name: "flycmd", description: "Fly verb group" });
|
|
103
|
+
const groups = collectCommandGroups([makePlugin("aws"), makePlugin("k8s", g1), makePlugin("fly", g2)]);
|
|
104
|
+
expect(groups).toEqual([g1, g2]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("empty when no plugin contributes a group — an absent slot changes nothing", () => {
|
|
108
|
+
expect(collectCommandGroups([makePlugin("aws"), makePlugin("gcp")])).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("dispatchCommandGroup", () => {
|
|
113
|
+
test("dispatches: runs the matched verb's handler and returns its exit code", async () => {
|
|
114
|
+
let seenCtx: unknown;
|
|
115
|
+
const group: CommandGroup = {
|
|
116
|
+
name: "kube",
|
|
117
|
+
description: "d",
|
|
118
|
+
commands: [
|
|
119
|
+
{
|
|
120
|
+
name: "get",
|
|
121
|
+
description: "d",
|
|
122
|
+
handler: async (ctx) => {
|
|
123
|
+
seenCtx = ctx;
|
|
124
|
+
return 3;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
const result = await dispatchCommandGroup([makePlugin("k8s", group)], "kube", "get", ["pods", "-o", "wide"]);
|
|
130
|
+
expect(result).toEqual({ kind: "ran", exitCode: 3 });
|
|
131
|
+
expect(seenCtx).toEqual({ verb: "get", rawArgs: ["pods", "-o", "wide"] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("propagates a no-group result unchanged", async () => {
|
|
135
|
+
const result = await dispatchCommandGroup([makePlugin("aws")], "kube", "get", []);
|
|
136
|
+
expect(result).toEqual({ kind: "no-group" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("unknown verb produces a usage-error listing the group's real verbs", async () => {
|
|
140
|
+
const result = await dispatchCommandGroup([makePlugin("k8s", makeGroup())], "kube", "bogus", []);
|
|
141
|
+
expect(result.kind).toBe("usage-error");
|
|
142
|
+
if (result.kind === "usage-error") {
|
|
143
|
+
expect(result.message).toMatch(/Unknown kube subcommand: bogus/);
|
|
144
|
+
expect(result.hint).toMatch(/get/);
|
|
145
|
+
expect(result.hint).toMatch(/version/);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("bare group with no verb produces a usage-error, not a crash", async () => {
|
|
150
|
+
const result = await dispatchCommandGroup([makePlugin("k8s", makeGroup())], "kube", undefined, []);
|
|
151
|
+
expect(result.kind).toBe("usage-error");
|
|
152
|
+
if (result.kind === "usage-error") {
|
|
153
|
+
expect(result.message).toMatch(/Usage: chant kube <verb>/);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("formatCommandGroupsHelp", () => {
|
|
159
|
+
test("composes group + verb listing for --help", () => {
|
|
160
|
+
const text = formatCommandGroupsHelp([makeGroup()]);
|
|
161
|
+
expect(text).toMatch(/Lexicon commands:/);
|
|
162
|
+
expect(text).toMatch(/kube/);
|
|
163
|
+
expect(text).toMatch(/get/);
|
|
164
|
+
expect(text).toMatch(/version/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("empty string when there are no groups", () => {
|
|
168
|
+
expect(formatCommandGroupsHelp([])).toBe("");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("splitJoinedFlags (#1127 discipline, reused by mounted commands)", () => {
|
|
173
|
+
test("splits a joined --flag=value token into two elements", () => {
|
|
174
|
+
expect(splitJoinedFlags(["--format=json"])).toEqual(["--format", "json"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("splits only at the first =, preserving a value that itself contains =", () => {
|
|
178
|
+
expect(splitJoinedFlags(["--selector=env=prod"])).toEqual(["--selector", "env=prod"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("leaves non-joined tokens untouched", () => {
|
|
182
|
+
expect(splitJoinedFlags(["get", "pods", "-o", "wide"])).toEqual(["get", "pods", "-o", "wide"]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("throws when a declared boolean flag is given a joined value", () => {
|
|
186
|
+
expect(() => splitJoinedFlags(["--watch=true"], new Set(["--watch"]))).toThrow(/--watch is a boolean flag/);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("unknownFlagError (mounted-command unknown-flag error)", () => {
|
|
191
|
+
test("produces the same 'Unknown flag' message shape core's own parser uses", () => {
|
|
192
|
+
const err = unknownFlagError("--bogus");
|
|
193
|
+
expect(err.message).toMatch(/^Unknown flag: --bogus/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("accepts a custom hint for the mounted command's own usage", () => {
|
|
197
|
+
const err = unknownFlagError("--bogus", "chant kube version only accepts --format.");
|
|
198
|
+
expect(err.message).toMatch(/only accepts --format/);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("RESERVED_COMMAND_NAMES", () => {
|
|
203
|
+
test("includes every core top-level word a lexicon must not shadow", () => {
|
|
204
|
+
for (const name of ["build", "lint", "run", "emulator", "lifecycle", "components", "serve", "dev", "carve"]) {
|
|
205
|
+
expect(RESERVED_COMMAND_NAMES.has(name)).toBe(true);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|