@intentius/chant 0.37.2 → 0.39.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/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
- package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.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-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +26 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +85 -37
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +21 -22
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/dist/op/emulator-freshness.d.ts +44 -0
- package/dist/op/emulator-freshness.d.ts.map +1 -0
- package/dist/op/emulator-lifecycle.d.ts +36 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +33 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/serializer.d.ts +15 -0
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +58 -6
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
- package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
- package/src/cli/commands/check-lexicon-mcp.ts +103 -0
- package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
- package/src/cli/commands/check-lexicon-plugin.ts +115 -0
- package/src/cli/commands/check-lexicon.ts +176 -26
- package/src/cli/handlers/components.test.ts +17 -0
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/emulator.ts +12 -8
- package/src/cli/handlers/graph.test.ts +71 -12
- package/src/cli/handlers/graph.ts +46 -5
- package/src/cli/handlers/lifecycle.test.ts +25 -4
- package/src/cli/handlers/lifecycle.ts +19 -3
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/mcp/server.test.ts +82 -0
- package/src/cli/mcp/server.ts +40 -5
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon-doc-coverage.test.ts +128 -0
- package/src/lexicon-seams.test.ts +113 -0
- package/src/lexicon.ts +88 -38
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/live-endpoint.test.ts +51 -12
- package/src/live-endpoint.ts +32 -33
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
- package/src/op/emulator-declaration.test.ts +63 -0
- package/src/op/emulator-freshness.test.ts +135 -0
- package/src/op/emulator-freshness.ts +102 -0
- package/src/op/emulator-lifecycle.ts +49 -0
- package/src/op/index.ts +4 -2
- package/src/ownership.ts +41 -0
- package/src/serializer.ts +16 -0
package/src/lifecycle/replay.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { readEnvironmentSnapshots } from "./git";
|
|
18
|
+
import { unqualifiedKey } from "./identity";
|
|
18
19
|
import type { LiveObservation } from "../graph-ir";
|
|
19
20
|
import type { LifecycleSnapshot } from "./types";
|
|
20
21
|
|
|
@@ -72,16 +73,35 @@ export async function replaySnapshots(
|
|
|
72
73
|
// account-level: the default security group three stacks each recorded is one
|
|
73
74
|
// group, not three. Managed resources are stack-qualified below and cannot
|
|
74
75
|
// collide, so only the unqualified ones need this.
|
|
76
|
+
//
|
|
77
|
+
// "Account-level" is the part that needed qualifying (#1416): a resource that
|
|
78
|
+
// records a region is regional, and two regions' copies are two resources
|
|
79
|
+
// however equal their ids look. `unqualifiedKey` keys those by region, so the
|
|
80
|
+
// set below still collapses one region's resource seen from two stacks and no
|
|
81
|
+
// longer collapses two regions'.
|
|
75
82
|
const seenUnqualified = new Set<string>();
|
|
83
|
+
// One recorded stack is one region, so there is nothing to merge and nothing
|
|
84
|
+
// to disambiguate — its ids stay exactly what they were recorded as, which is
|
|
85
|
+
// also what `chant search --live` gives that project.
|
|
86
|
+
const merging = stored.size > 1;
|
|
76
87
|
// A stack's snapshot could only exclude what THAT stack manages, so a stack
|
|
77
88
|
// declaring no security groups reported the neighbouring stack's as ambient.
|
|
78
89
|
// The union is only knowable here, with every snapshot in hand.
|
|
79
90
|
const managedPhysicalIds = new Set<string>();
|
|
91
|
+
// Where a managed resource ends up, by physical id. A duplicate is dropped
|
|
92
|
+
// below rather than rendered twice, and anything that pointed at the
|
|
93
|
+
// duplicate has to be re-pointed at the survivor — dropping the node alone
|
|
94
|
+
// would take its edges with it, since `buildLiveGraphIr` discards an edge
|
|
95
|
+
// whose endpoints were not both observed.
|
|
96
|
+
const managedKeyOf = new Map<string, string>();
|
|
80
97
|
for (const content of stored.values()) {
|
|
81
98
|
const snap = JSON.parse(content) as LifecycleSnapshot;
|
|
82
|
-
|
|
99
|
+
const snapStack = snap.stack;
|
|
100
|
+
const snapQualifies = snapStack !== undefined && scopedStacks.has(snapStack);
|
|
101
|
+
for (const [id, meta] of Object.entries(snap.resources ?? {})) {
|
|
83
102
|
if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
|
|
84
103
|
managedPhysicalIds.add(meta.physicalId);
|
|
104
|
+
managedKeyOf.set(meta.physicalId, snapQualifies ? `${snapStack}::${id}` : id);
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
}
|
|
@@ -97,32 +117,60 @@ export async function replaySnapshots(
|
|
|
97
117
|
// collide, and none of them join the declared canvas, which qualifies.
|
|
98
118
|
const stack = snapshot.stack;
|
|
99
119
|
const qualify = stack !== undefined && scopedStacks.has(stack);
|
|
100
|
-
// Dependencies (#1273) are keyed by physical
|
|
101
|
-
// default VPC's route table is one
|
|
102
|
-
//
|
|
103
|
-
// edges into it.
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
// Dependencies (#1273) and ambient resources (#1278) are keyed by physical
|
|
121
|
+
// id and are NOT stack-qualified: the default VPC's route table is one
|
|
122
|
+
// resource however many stacks route through it, and qualifying it would
|
|
123
|
+
// split it per stack and break the edges into it.
|
|
124
|
+
const unqualifiedByNature = (meta: { referencedBy?: string[]; ambient?: boolean }): boolean =>
|
|
125
|
+
Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
|
|
126
|
+
// Region qualification (#1416) applies to exactly those, and never to a
|
|
127
|
+
// managed resource: an unscoped project's declared canvas joins on bare
|
|
128
|
+
// ids, so re-keying `web` to `us-east-1::web` would unjoin it from its own
|
|
129
|
+
// declaration.
|
|
130
|
+
const keyOf = (id: string, meta: { referencedBy?: string[]; ambient?: boolean; attributes?: Record<string, unknown> }): string => {
|
|
131
|
+
if (unqualifiedByNature(meta)) return merging ? unqualifiedKey(id, meta) : id;
|
|
132
|
+
return qualify ? `${stack}::${id}` : id;
|
|
133
|
+
};
|
|
108
134
|
const resources: Record<string, (typeof snapshot.resources)[string]> = {};
|
|
135
|
+
// Duplicates dropped here, and where they went, so edges can follow.
|
|
136
|
+
const merged = new Map<string, string>();
|
|
109
137
|
for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
|
|
110
|
-
const key =
|
|
111
|
-
if (
|
|
112
|
-
// Ambient means "nothing manages this"
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
const key = keyOf(id, meta);
|
|
139
|
+
if (!qualify || unqualifiedByNature(meta)) {
|
|
140
|
+
// Ambient means "nothing manages this", and being referenced means "the
|
|
141
|
+
// estate reaches this" — neither stops it being managed. When a stack
|
|
142
|
+
// manages the same physical resource, this entry is that resource seen
|
|
143
|
+
// from outside, and rendering both inflates any count over it.
|
|
144
|
+
//
|
|
145
|
+
// The dependency half of that was missing, and it was not cosmetic: a
|
|
146
|
+
// subnet declared by its stack was also recorded as `subnet-9af06b90`
|
|
147
|
+
// with `referencedBy`, so an estate with 13 subnets replayed as 16 and
|
|
148
|
+
// "which subnets have no network interfaces" answered 11 where the
|
|
149
|
+
// truth was 8. The three extra were occupied subnets whose second copy
|
|
150
|
+
// had no interface pointing at it — a duplicate reads as empty, because
|
|
151
|
+
// the edges resolved to the other one.
|
|
152
|
+
const duplicate = Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
|
|
153
|
+
if (duplicate && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) {
|
|
154
|
+
const survivor = managedKeyOf.get(meta.physicalId);
|
|
155
|
+
if (survivor && survivor !== key) merged.set(id, survivor);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Unqualified: one resource per key, so first sighting wins and the
|
|
159
|
+
// rest are the same resource seen again from another stack's snapshot.
|
|
160
|
+
// The key carries the region when there is one, so "the same resource"
|
|
161
|
+
// no longer spans regions.
|
|
162
|
+
if (seenUnqualified.has(key)) continue;
|
|
163
|
+
seenUnqualified.add(key);
|
|
119
164
|
}
|
|
120
165
|
resources[key] = meta;
|
|
121
166
|
}
|
|
122
167
|
const known = new Set(Object.keys(snapshot.resources ?? {}));
|
|
123
168
|
const requalify = (id: string): string => {
|
|
169
|
+
// A dropped duplicate's edges belong to the resource that survived it.
|
|
170
|
+
const survivor = merged.get(id);
|
|
171
|
+
if (survivor) return survivor;
|
|
124
172
|
const meta = (snapshot.resources ?? {})[id];
|
|
125
|
-
return known.has(id) && meta ?
|
|
173
|
+
return known.has(id) && meta ? keyOf(id, meta) : id;
|
|
126
174
|
};
|
|
127
175
|
const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
|
|
128
176
|
observations.push({
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -97,6 +97,18 @@ export interface ResourceDigest {
|
|
|
97
97
|
export interface BuildDigest {
|
|
98
98
|
/** Per-resource digest keyed by logical name */
|
|
99
99
|
resources: Record<string, ResourceDigest>;
|
|
100
|
+
/**
|
|
101
|
+
* chant #1442 — lexicon name → version that produced this build.
|
|
102
|
+
*
|
|
103
|
+
* Recorded once per lexicon rather than on every {@link ResourceDigest},
|
|
104
|
+
* which would repeat one string across every resource of a stack for no
|
|
105
|
+
* added information.
|
|
106
|
+
*
|
|
107
|
+
* Optional because a digest read back from an older snapshot will not have
|
|
108
|
+
* it. Absent and empty are different: absent means "recorded before this
|
|
109
|
+
* existed", empty means "recorded, and no plugins were loaded".
|
|
110
|
+
*/
|
|
111
|
+
lexiconVersions?: Record<string, string>;
|
|
100
112
|
/** Resource-level dependency graph */
|
|
101
113
|
dependencies: Record<string, string[]>;
|
|
102
114
|
/** Cross-lexicon output bridges from BuildManifest */
|
|
@@ -117,4 +129,18 @@ export interface DigestDiff {
|
|
|
117
129
|
changed: string[];
|
|
118
130
|
/** Resources where propsHash matches */
|
|
119
131
|
unchanged: string[];
|
|
132
|
+
/**
|
|
133
|
+
* chant #1442 — lexicons whose VERSION moved between the two digests, even
|
|
134
|
+
* where every resource is unchanged.
|
|
135
|
+
*
|
|
136
|
+
* A build whose only difference is the lexicon that emitted it is a real
|
|
137
|
+
* difference: the lexicon is a generated artifact pinned to an upstream
|
|
138
|
+
* spec, so a bump can change output with no source change. Reported
|
|
139
|
+
* separately from `changed` because no resource's declaration moved — the
|
|
140
|
+
* interpreter did.
|
|
141
|
+
*
|
|
142
|
+
* Empty when neither digest recorded versions, so a comparison against a
|
|
143
|
+
* pre-#1442 snapshot reports nothing rather than inventing a change.
|
|
144
|
+
*/
|
|
145
|
+
lexiconVersionChanges: Array<{ lexicon: string; previous?: string; current?: string }>;
|
|
120
146
|
}
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { applyLiveEndpoint, zeroResourcesWarning,
|
|
2
|
+
import { applyLiveEndpoint, zeroResourcesWarning, endpointEnvVarsFor } from "./live-endpoint";
|
|
3
|
+
import type { EmulatorCapability } from "./op/emulator-lifecycle";
|
|
4
|
+
|
|
5
|
+
/** A lexicon standing in for one whose emulator names an endpoint var. */
|
|
6
|
+
function lexicon(name: string, ...vars: string[]): { name: string; emulator?: EmulatorCapability } {
|
|
7
|
+
if (vars.length === 0) return { name };
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
emulator: {
|
|
11
|
+
spec: { name: `chant-${name}`, image: `${name}:0`, containerPort: 1, healthPath: "/h" },
|
|
12
|
+
// Credentials come back alongside the endpoint, as they do for real:
|
|
13
|
+
// only the vars carrying the endpoint itself should be injected.
|
|
14
|
+
env: (endpoint) => ({
|
|
15
|
+
...Object.fromEntries(vars.map((v) => [v, endpoint])),
|
|
16
|
+
[`${name.toUpperCase()}_ACCESS_KEY_ID`]: "test",
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
3
21
|
import type { EnvironmentDeclaration } from "./config";
|
|
4
22
|
|
|
5
23
|
describe("applyLiveEndpoint (#1166)", () => {
|
|
6
24
|
test("no-op — and no notice — when the environment declares no endpoint at all", () => {
|
|
7
25
|
const env: NodeJS.ProcessEnv = {};
|
|
8
|
-
const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
|
|
26
|
+
const result = applyLiveEndpoint(["floci", "prod"], "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
|
|
9
27
|
expect(result.notice).toBeUndefined();
|
|
10
28
|
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
11
29
|
result.restore(); // always safe, even as a no-op
|
|
@@ -15,7 +33,7 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
15
33
|
test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
|
|
16
34
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
17
35
|
const env: NodeJS.ProcessEnv = {};
|
|
18
|
-
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
36
|
+
const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
|
|
19
37
|
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
20
38
|
expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
|
|
21
39
|
expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
|
|
@@ -24,7 +42,7 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
24
42
|
test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
|
|
25
43
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
26
44
|
const env: NodeJS.ProcessEnv = {};
|
|
27
|
-
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
45
|
+
const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
|
|
28
46
|
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
29
47
|
result.restore();
|
|
30
48
|
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
@@ -33,7 +51,7 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
33
51
|
test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
|
|
34
52
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
35
53
|
const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
|
|
36
|
-
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
54
|
+
const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
|
|
37
55
|
expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
|
|
38
56
|
expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
|
|
39
57
|
result.restore();
|
|
@@ -42,7 +60,7 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
42
60
|
|
|
43
61
|
test("a bare-string environment entry has no endpoint to apply", () => {
|
|
44
62
|
const env: NodeJS.ProcessEnv = {};
|
|
45
|
-
const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
|
|
63
|
+
const result = applyLiveEndpoint(["floci"], "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
|
|
46
64
|
expect(result.notice).toBeUndefined();
|
|
47
65
|
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
48
66
|
});
|
|
@@ -51,7 +69,7 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
51
69
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
52
70
|
const env: NodeJS.ProcessEnv = {};
|
|
53
71
|
// k8s has no ambient-var knob (config-resolved instead) — nothing to set.
|
|
54
|
-
const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
|
|
72
|
+
const result = applyLiveEndpoint(environments, "floci", [lexicon("k8s")], env);
|
|
55
73
|
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
56
74
|
expect(result.notice).toBeUndefined();
|
|
57
75
|
});
|
|
@@ -59,7 +77,7 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
59
77
|
test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
|
|
60
78
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
61
79
|
const env: NodeJS.ProcessEnv = {};
|
|
62
|
-
const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
|
|
80
|
+
const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL"), lexicon("fly", "FLY_FLAPS_BASE_URL")], env);
|
|
63
81
|
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
64
82
|
expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
|
|
65
83
|
result.restore();
|
|
@@ -70,21 +88,42 @@ describe("applyLiveEndpoint (#1166)", () => {
|
|
|
70
88
|
test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
|
|
71
89
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
72
90
|
const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
|
|
73
|
-
const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
|
|
91
|
+
const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL"), lexicon("fly", "FLY_FLAPS_BASE_URL")], env);
|
|
74
92
|
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
|
|
75
93
|
expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
|
|
76
94
|
expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
|
|
77
95
|
expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
|
|
78
96
|
});
|
|
79
97
|
|
|
80
|
-
test("
|
|
81
|
-
expect(
|
|
98
|
+
test("the endpoint vars come from the lexicon's own emulator, not a map in core (#1345)", () => {
|
|
99
|
+
expect(endpointEnvVarsFor(lexicon("aws", "AWS_ENDPOINT_URL"))).toEqual(["AWS_ENDPOINT_URL"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("credentials the emulator also needs are not treated as endpoint vars", () => {
|
|
103
|
+
// `env()` returns keys and secrets beside the endpoint; injecting those
|
|
104
|
+
// into the ambient shell for a `--live` read is not this function's job.
|
|
105
|
+
expect(endpointEnvVarsFor(lexicon("aws", "AWS_ENDPOINT_URL"))).not.toContain("AWS_ACCESS_KEY_ID");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a lexicon with no emulator contributes no var — it resolves its target from config", () => {
|
|
109
|
+
expect(endpointEnvVarsFor(lexicon("k8s"))).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a lexicon declaring two emulators contributes both vars", () => {
|
|
113
|
+
const fly = {
|
|
114
|
+
name: "fly",
|
|
115
|
+
emulator: [
|
|
116
|
+
lexicon("mudflaps", "FLY_FLAPS_BASE_URL").emulator!,
|
|
117
|
+
lexicon("spritzer", "SPRITES_BASE_URL").emulator!,
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
expect(endpointEnvVarsFor(fly).sort()).toEqual(["FLY_FLAPS_BASE_URL", "SPRITES_BASE_URL"]);
|
|
82
121
|
});
|
|
83
122
|
|
|
84
123
|
test("a name that isn't declared at all has no endpoint to apply", () => {
|
|
85
124
|
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
86
125
|
const env: NodeJS.ProcessEnv = {};
|
|
87
|
-
const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
|
|
126
|
+
const result = applyLiveEndpoint(environments, "prod", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
|
|
88
127
|
expect(result.notice).toBeUndefined();
|
|
89
128
|
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
90
129
|
});
|
package/src/live-endpoint.ts
CHANGED
|
@@ -24,38 +24,36 @@
|
|
|
24
24
|
* Audited (#1166) which lexicons have an ambient-env-var endpoint knob at
|
|
25
25
|
* all, since that's the specific footgun — a lexicon whose environment
|
|
26
26
|
* binding is resolved from `chant.config` itself (not an ambient var) has
|
|
27
|
-
* nothing to inject here
|
|
27
|
+
* nothing to inject here.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* (`k8s.profiles.<env>.context` via `resolveClusterTarget`,
|
|
37
|
-
* `packages/core/src/kubectl-context.ts`), not an ambient var. Nothing to
|
|
38
|
-
* inject: the config *is* the binding already.
|
|
39
|
-
* - **azure** — resolves via the `az` CLI's own logged-in
|
|
40
|
-
* subscription/session context; no ambient endpoint var exists to miss.
|
|
41
|
-
* - **temporal** — resolves its connection from `temporal.profiles.<env>`
|
|
42
|
-
* (`resolveProfile`, `lexicons/temporal/src/describe-resources.ts`), the
|
|
43
|
-
* same "config is the binding" shape as k8s/gcp.
|
|
29
|
+
* Which var that is per lexicon is no longer written down twice (#1345). It is
|
|
30
|
+
* derived from the lexicon's own {@link EmulatorCapability.env}, which already
|
|
31
|
+
* has to name the var that points tooling at a booted emulator. The map this
|
|
32
|
+
* replaced listed aws and fly, and its prose asserted azure had no ambient
|
|
33
|
+
* endpoint var — while `lexicons/azure/src/describe-resources.ts` and
|
|
34
|
+
* `deep-observe.ts` both read `AZURE_ENDPOINT_URL` on every call, so a
|
|
35
|
+
* `--live --env floci` read against azure silently went to real Azure.
|
|
44
36
|
*/
|
|
45
37
|
|
|
46
38
|
import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
|
|
39
|
+
import { emulatorsOf, endpointEnvVars, type EmulatorDeclaration } from "./op/emulator-lifecycle";
|
|
47
40
|
|
|
48
41
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
42
|
+
* The ambient endpoint vars a lexicon honors, from its emulator capability.
|
|
43
|
+
*
|
|
44
|
+
* A lexicon with no emulator contributes nothing, which is the same answer the
|
|
45
|
+
* hand-maintained map gave for k8s, gcp and temporal — they resolve their live
|
|
46
|
+
* target from `chant.config` itself, so there is nothing to inject.
|
|
53
47
|
*/
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
48
|
+
export function endpointEnvVarsFor(lexicon: EndpointLexicon): string[] {
|
|
49
|
+
return emulatorsOf(lexicon.emulator).flatMap((cap) => endpointEnvVars(cap));
|
|
50
|
+
}
|
|
58
51
|
|
|
52
|
+
/** What {@link applyLiveEndpoint} needs of a plugin: its name and its emulators. */
|
|
53
|
+
export interface EndpointLexicon {
|
|
54
|
+
name: string;
|
|
55
|
+
emulator?: EmulatorDeclaration;
|
|
56
|
+
}
|
|
59
57
|
/** Result of {@link applyLiveEndpoint} — always call `restore()`, even when nothing was applied (it is then a no-op). */
|
|
60
58
|
export interface AppliedEndpoint {
|
|
61
59
|
/**
|
|
@@ -84,7 +82,7 @@ export interface AppliedEndpoint {
|
|
|
84
82
|
export function applyLiveEndpoint(
|
|
85
83
|
environments: EnvironmentDeclaration[] | undefined,
|
|
86
84
|
environment: string,
|
|
87
|
-
lexicons: readonly
|
|
85
|
+
lexicons: readonly EndpointLexicon[],
|
|
88
86
|
env: NodeJS.ProcessEnv = process.env,
|
|
89
87
|
): AppliedEndpoint {
|
|
90
88
|
const endpoint = environmentEndpoint(environments, environment);
|
|
@@ -94,15 +92,16 @@ export function applyLiveEndpoint(
|
|
|
94
92
|
const overridden: string[] = [];
|
|
95
93
|
const seen = new Set<string>(); // a var shared by two lexicons is only reported once
|
|
96
94
|
for (const lexicon of lexicons) {
|
|
97
|
-
const varName
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
95
|
+
for (const varName of endpointEnvVarsFor(lexicon)) {
|
|
96
|
+
if (seen.has(varName)) continue;
|
|
97
|
+
seen.add(varName);
|
|
98
|
+
if (env[varName]) {
|
|
99
|
+
overridden.push(varName);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
env[varName] = endpoint;
|
|
103
|
+
applied.push(varName);
|
|
103
104
|
}
|
|
104
|
-
env[varName] = endpoint;
|
|
105
|
-
applied.push(varName);
|
|
106
105
|
}
|
|
107
106
|
|
|
108
107
|
const notices: string[] = [];
|
|
@@ -155,6 +155,11 @@ describe("pruneByOwnership — the shared three-question rule", () => {
|
|
|
155
155
|
chantOwned: new Set(["metadata.labels.tier"]),
|
|
156
156
|
foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
|
|
157
157
|
foreignContested: new Set(["spec.replicas"]),
|
|
158
|
+
owners: new Map([
|
|
159
|
+
["metadata.labels.tier", "chant"],
|
|
160
|
+
["spec.replicas", "hpa-controller"],
|
|
161
|
+
["metadata.annotations.noise", "kube-controller-manager"],
|
|
162
|
+
]),
|
|
158
163
|
};
|
|
159
164
|
|
|
160
165
|
test("never prunes the declared side", () => {
|
|
@@ -177,3 +182,48 @@ describe("pruneByOwnership — the shared three-question rule", () => {
|
|
|
177
182
|
expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
|
|
178
183
|
});
|
|
179
184
|
});
|
|
185
|
+
|
|
186
|
+
// #1189 — the three sets answer "which category owns this path". A reader needs
|
|
187
|
+
// the other question: `hpa-controller` and `kubectl-client-side-apply` are the
|
|
188
|
+
// same category and mean opposite things to an operator.
|
|
189
|
+
describe("buildOwnershipSets — owning manager per path (#1189)", () => {
|
|
190
|
+
const entries = [
|
|
191
|
+
{ manager: "chant", operation: "Apply", fieldsV1: { "f:metadata": { "f:labels": { "f:tier": {} } } } },
|
|
192
|
+
{ manager: "hpa-controller", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } },
|
|
193
|
+
{
|
|
194
|
+
manager: "kubectl-client-side-apply",
|
|
195
|
+
operation: "Update",
|
|
196
|
+
fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": {} } } } },
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
test("records which manager owns each path", () => {
|
|
201
|
+
const sets = buildOwnershipSets(
|
|
202
|
+
entries,
|
|
203
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
|
|
204
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
|
|
205
|
+
(m) => m === "chant",
|
|
206
|
+
);
|
|
207
|
+
expect(sets.owners.get("metadata.labels.tier")).toBe("chant");
|
|
208
|
+
expect(sets.owners.get("spec.replicas")).toBe("hpa-controller");
|
|
209
|
+
expect(sets.owners.get("spec.template.spec.containers")).toBe("kubectl-client-side-apply");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the categories are unchanged by recording owners", () => {
|
|
213
|
+
const sets = buildOwnershipSets(
|
|
214
|
+
entries,
|
|
215
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
|
|
216
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
|
|
217
|
+
(m) => m === "chant",
|
|
218
|
+
);
|
|
219
|
+
expect(sets.chantOwned.has("metadata.labels.tier")).toBe(true);
|
|
220
|
+
expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
|
|
221
|
+
// Declared AND foreign-owned — contested, so still diffable.
|
|
222
|
+
expect(sets.foreignContested.has("spec.replicas")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("is empty when the object carries no managedFields at all", () => {
|
|
226
|
+
const sets = buildOwnershipSets([], { spec: {} }, { spec: {} }, (m) => m === "chant");
|
|
227
|
+
expect(sets.owners.size).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/managed-fields.ts
CHANGED
|
@@ -144,6 +144,17 @@ export interface OwnershipSets {
|
|
|
144
144
|
foreignOwned: ReadonlySet<string>;
|
|
145
145
|
/** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
|
|
146
146
|
foreignContested: ReadonlySet<string>;
|
|
147
|
+
/**
|
|
148
|
+
* Path → the name of the manager that owns it (#1189).
|
|
149
|
+
*
|
|
150
|
+
* The three sets above answer *which category* owns a path, which is all the
|
|
151
|
+
* prune rule needs. A reader needs the other question — "owned by
|
|
152
|
+
* `kubectl-client-side-apply`" and "owned by `hpa-controller`" are the same
|
|
153
|
+
* category and mean very different things to an operator. Last writer wins
|
|
154
|
+
* where several managers touch one path, matching what the API server itself
|
|
155
|
+
* reports.
|
|
156
|
+
*/
|
|
157
|
+
owners: ReadonlyMap<string, string>;
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
function sameJson(a: unknown, b: unknown): boolean {
|
|
@@ -188,6 +199,9 @@ function walkOwnership(
|
|
|
188
199
|
path: string,
|
|
189
200
|
owned: Set<string>,
|
|
190
201
|
contested: Set<string>,
|
|
202
|
+
/** Records path → manager as it walks (#1189); omitted by callers that only need the sets. */
|
|
203
|
+
owners?: Map<string, string>,
|
|
204
|
+
manager?: string,
|
|
191
205
|
): void {
|
|
192
206
|
if (fieldsNode === null || typeof fieldsNode !== "object" || Array.isArray(fieldsNode)) return;
|
|
193
207
|
|
|
@@ -196,6 +210,7 @@ function walkOwnership(
|
|
|
196
210
|
if (path !== "") {
|
|
197
211
|
owned.add(path);
|
|
198
212
|
if (declaredNode !== undefined) contested.add(path);
|
|
213
|
+
if (owners && manager) owners.set(path, manager);
|
|
199
214
|
}
|
|
200
215
|
continue;
|
|
201
216
|
}
|
|
@@ -208,7 +223,8 @@ function walkOwnership(
|
|
|
208
223
|
const childPath = joinField(path, name);
|
|
209
224
|
owned.add(childPath);
|
|
210
225
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
211
|
-
|
|
226
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
227
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
212
228
|
continue;
|
|
213
229
|
}
|
|
214
230
|
|
|
@@ -220,7 +236,8 @@ function walkOwnership(
|
|
|
220
236
|
const childPath = joinIndex(path, idx);
|
|
221
237
|
owned.add(childPath);
|
|
222
238
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
223
|
-
|
|
239
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
240
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
224
241
|
continue;
|
|
225
242
|
}
|
|
226
243
|
|
|
@@ -255,7 +272,8 @@ function walkOwnership(
|
|
|
255
272
|
const childPath = joinIndex(path, liveIdx);
|
|
256
273
|
owned.add(childPath);
|
|
257
274
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
258
|
-
|
|
275
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
276
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
259
277
|
continue;
|
|
260
278
|
}
|
|
261
279
|
// An unrecognized prefix (a future fieldsV1 encoding) — skip.
|
|
@@ -286,6 +304,7 @@ export function buildOwnershipSets(
|
|
|
286
304
|
const chantOwned = new Set<string>();
|
|
287
305
|
const foreignOwned = new Set<string>();
|
|
288
306
|
const foreignContested = new Set<string>();
|
|
307
|
+
const owners = new Map<string, string>();
|
|
289
308
|
|
|
290
309
|
for (const entry of entries) {
|
|
291
310
|
if (typeof entry.manager !== "string" || entry.manager.length === 0) continue;
|
|
@@ -294,13 +313,13 @@ export function buildOwnershipSets(
|
|
|
294
313
|
if (isChantManager(entry.manager)) {
|
|
295
314
|
// Chant-owned paths are always diffable, regardless of who else is
|
|
296
315
|
// involved — "contested" only matters for a *foreign* owner.
|
|
297
|
-
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set());
|
|
316
|
+
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set(), owners, entry.manager);
|
|
298
317
|
} else {
|
|
299
|
-
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested);
|
|
318
|
+
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested, owners, entry.manager);
|
|
300
319
|
}
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
return { chantOwned, foreignOwned, foreignContested };
|
|
322
|
+
return { chantOwned, foreignOwned, foreignContested, owners };
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
/**
|