@intentius/chant 0.27.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/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- 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-run.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/discovery/sandbox/fork.d.ts +25 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/run.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 +100 -0
- package/dist/kubectl-context.d.ts.map +1 -0
- 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/lint/config.d.ts +80 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +8 -2
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -1
- package/dist/lint/post-synth.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 +9 -4
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +190 -0
- package/src/cli/commands/build.ts +34 -2
- package/src/cli/commands/lint.ts +17 -25
- 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-boundary.test.ts +55 -1
- package/src/discovery/sandbox/config-run.ts +3 -0
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/discovery/sandbox/fork.ts +75 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
- package/src/discovery/sandbox/policy-run.ts +15 -1
- package/src/discovery/sandbox/run.test.ts +85 -1
- package/src/discovery/sandbox/run.ts +3 -0
- 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.test.ts +94 -0
- package/src/kubectl-context.ts +207 -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/lint/config.test.ts +93 -1
- package/src/lint/config.ts +108 -0
- package/src/lint/policy.test.ts +90 -0
- package/src/lint/policy.ts +17 -5
- package/src/lint/post-synth.test.ts +4 -0
- package/src/lint/post-synth.ts +30 -1
- 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 +76 -3
- package/src/stack-output.ts +59 -26
package/src/lint/post-synth.ts
CHANGED
|
@@ -35,6 +35,19 @@ export function getPrimaryOutput(output: string | SerializerResult): string {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* A diagnostic from a post-synthesis check.
|
|
38
|
+
*
|
|
39
|
+
* chant #1138 — deliberately carries no `file`/`line` the way `LintDiagnostic`
|
|
40
|
+
* (`./rule.ts`) does. A post-synth check runs over `ctx.outputs` — the
|
|
41
|
+
* SYNTHESIZED output text (a CloudFormation template, a Kubernetes manifest) —
|
|
42
|
+
* not a `ts.SourceFile`, so there is no AST position to report in the first
|
|
43
|
+
* place. `entity` (below) is the closest thing to a locator and is NOT a
|
|
44
|
+
* substitute: it names a resource in that synthesized output (a CFN logical
|
|
45
|
+
* id, a k8s `metadata.name`), which several checks in this repo never even
|
|
46
|
+
* set (a cross-cutting check with no single implicated resource), and which
|
|
47
|
+
* is not guaranteed to match a `ctx.entities` map key. This is why source-
|
|
48
|
+
* comment (`chant-disable`) suppression is out of scope for post-synth
|
|
49
|
+
* findings — see `./config.ts`'s `applyConfiguredSeverity` doc for the full
|
|
50
|
+
* reasoning and what suppression surface post-synth findings get instead.
|
|
38
51
|
*/
|
|
39
52
|
export interface PostSynthDiagnostic {
|
|
40
53
|
/** ID of the check that produced this diagnostic */
|
|
@@ -43,7 +56,11 @@ export interface PostSynthDiagnostic {
|
|
|
43
56
|
severity: Severity;
|
|
44
57
|
/** Human-readable message */
|
|
45
58
|
message: string;
|
|
46
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Optional resource name related to this diagnostic — a name from the
|
|
61
|
+
* SYNTHESIZED OUTPUT (a CFN logical id, a k8s `metadata.name`), not a
|
|
62
|
+
* source file/line. See this interface's doc comment.
|
|
63
|
+
*/
|
|
47
64
|
entity?: string;
|
|
48
65
|
/** Optional lexicon related to this diagnostic */
|
|
49
66
|
lexicon?: string;
|
|
@@ -96,3 +113,15 @@ export function runPostSynthChecks(
|
|
|
96
113
|
}
|
|
97
114
|
return diagnostics;
|
|
98
115
|
}
|
|
116
|
+
|
|
117
|
+
// chant #1138 — `applyConfiguredSeverity` (the `lint.rules` severity-override
|
|
118
|
+
// pass over a set of `PostSynthDiagnostic`s) lives in `./config.ts`, not here,
|
|
119
|
+
// even though it operates on this module's own type. This file is a leaf:
|
|
120
|
+
// every lexicon's post-synth checks import it as a real runtime module (not
|
|
121
|
+
// just for types — `getPrimaryOutput` above is a plain function several
|
|
122
|
+
// checks call directly), so it has to stay cheap to load. `./config.ts` is
|
|
123
|
+
// not cheap — it resolves built-in preset paths via the runtime adapter at
|
|
124
|
+
// module scope — and pulling that into every lexicon's check barrel merely to
|
|
125
|
+
// share one filter function is the wrong trade. `applyConfiguredSeverity`
|
|
126
|
+
// only needs this module's TYPE (`PostSynthDiagnostic`), which costs nothing
|
|
127
|
+
// at runtime, so the dependency runs the other way instead.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { applyLiveEndpoint, zeroResourcesWarning, LEXICON_ENDPOINT_ENV_VAR } from "./live-endpoint";
|
|
3
|
+
import type { EnvironmentDeclaration } from "./config";
|
|
4
|
+
|
|
5
|
+
describe("applyLiveEndpoint (#1166)", () => {
|
|
6
|
+
test("no-op — and no notice — when the environment declares no endpoint at all", () => {
|
|
7
|
+
const env: NodeJS.ProcessEnv = {};
|
|
8
|
+
const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
|
|
9
|
+
expect(result.notice).toBeUndefined();
|
|
10
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
11
|
+
result.restore(); // always safe, even as a no-op
|
|
12
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
|
|
16
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
17
|
+
const env: NodeJS.ProcessEnv = {};
|
|
18
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
19
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
20
|
+
expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
|
|
21
|
+
expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
|
|
25
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
26
|
+
const env: NodeJS.ProcessEnv = {};
|
|
27
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
28
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
29
|
+
result.restore();
|
|
30
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
|
|
34
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
35
|
+
const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
|
|
36
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
37
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
|
|
38
|
+
expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
|
|
39
|
+
result.restore();
|
|
40
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // restore never touches what it didn't set
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a bare-string environment entry has no endpoint to apply", () => {
|
|
44
|
+
const env: NodeJS.ProcessEnv = {};
|
|
45
|
+
const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
|
|
46
|
+
expect(result.notice).toBeUndefined();
|
|
47
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("only applies to lexicons actually observing, and only those with a known endpoint var", () => {
|
|
51
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
52
|
+
const env: NodeJS.ProcessEnv = {};
|
|
53
|
+
// k8s has no ambient-var knob (config-resolved instead) — nothing to set.
|
|
54
|
+
const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
|
|
55
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
56
|
+
expect(result.notice).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
|
|
60
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
61
|
+
const env: NodeJS.ProcessEnv = {};
|
|
62
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
|
|
63
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
64
|
+
expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
|
|
65
|
+
result.restore();
|
|
66
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
67
|
+
expect(env.FLY_FLAPS_BASE_URL).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
|
|
71
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
72
|
+
const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
|
|
73
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
|
|
74
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
|
|
75
|
+
expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
|
|
76
|
+
expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
|
|
77
|
+
expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("audited endpoint-knob registry: only aws and fly (gcp/k8s/azure/temporal resolve via config, not an ambient var)", () => {
|
|
81
|
+
expect(LEXICON_ENDPOINT_ENV_VAR).toEqual({ aws: "AWS_ENDPOINT_URL", fly: "FLY_FLAPS_BASE_URL" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a name that isn't declared at all has no endpoint to apply", () => {
|
|
85
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
86
|
+
const env: NodeJS.ProcessEnv = {};
|
|
87
|
+
const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
|
|
88
|
+
expect(result.notice).toBeUndefined();
|
|
89
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("zeroResourcesWarning (#1166)", () => {
|
|
94
|
+
test("undefined when nothing was declared to look for", () => {
|
|
95
|
+
expect(zeroResourcesWarning("aws", "floci", 0, { resources: {}, unobserved: {} })).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("undefined when resources were actually observed", () => {
|
|
99
|
+
expect(
|
|
100
|
+
zeroResourcesWarning("aws", "floci", 2, { resources: { a: {} }, unobserved: {} }),
|
|
101
|
+
).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("undefined when the emptiness is already explained by #1089 unobserved", () => {
|
|
105
|
+
expect(
|
|
106
|
+
zeroResourcesWarning("aws", "floci", 2, { resources: {}, unobserved: { a: { reason: "no-binding" } } }),
|
|
107
|
+
).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("warns with declared count and the check-endpoint hint when truly empty and unexplained", () => {
|
|
111
|
+
expect(zeroResourcesWarning("aws", "floci", 3, { resources: {}, unobserved: {} })).toBe(
|
|
112
|
+
'aws: 0 live resources for env "floci" (3 declared) — check the endpoint/credentials',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #1166 — let a declared `environment` carry its own endpoint so
|
|
3
|
+
* `--live --env <name>` is self-sufficient.
|
|
4
|
+
*
|
|
5
|
+
* The bug this closes: `chant graph --live --env floci` (and `lifecycle
|
|
6
|
+
* diff`/`plan`) observe a stack by shelling out through each lexicon's
|
|
7
|
+
* `describeResources()`. For AWS that shell-out honors the ambient
|
|
8
|
+
* `AWS_ENDPOINT_URL` env var — when a project's `floci` environment is a local
|
|
9
|
+
* emulator (`http://localhost:4566`) but the invoking shell never exported
|
|
10
|
+
* that var, the AWS CLI silently targets real AWS instead. The stack named
|
|
11
|
+
* after the environment doesn't exist there, so `describeResources` hits its
|
|
12
|
+
* `stackDoesNotExist` branch and returns an empty, unremarkable "nothing is
|
|
13
|
+
* deployed" — indistinguishable from the truthful answer. This cost real
|
|
14
|
+
* debugging time validating #1162's live overlay: the observation code was
|
|
15
|
+
* right, the manual repro just never set the var.
|
|
16
|
+
*
|
|
17
|
+
* The fix: `environments` in `chant.config.ts` can name an endpoint per
|
|
18
|
+
* environment (`config.ts`'s `EnvironmentDeclaration`), and {@link
|
|
19
|
+
* applyLiveEndpoint} injects it into the ambient env var each observing
|
|
20
|
+
* lexicon's CLI shell-out actually reads — but only for a var that isn't
|
|
21
|
+
* already set. Ambient always wins: a shell that already exports
|
|
22
|
+
* `AWS_ENDPOINT_URL` sees no change in behavior.
|
|
23
|
+
*
|
|
24
|
+
* Audited (#1166) which lexicons have an ambient-env-var endpoint knob at
|
|
25
|
+
* all, since that's the specific footgun — a lexicon whose environment
|
|
26
|
+
* binding is resolved from `chant.config` itself (not an ambient var) has
|
|
27
|
+
* nothing to inject here:
|
|
28
|
+
*
|
|
29
|
+
* - **aws** — `AWS_ENDPOINT_URL`, read directly by
|
|
30
|
+
* `lexicons/aws/src/components/cloud-executor.ts` / `plugin.ts` before
|
|
31
|
+
* every `aws …` shell-out (`applyAwsEndpoint`/`applyAwsEndpointArgv`).
|
|
32
|
+
* - **fly** — `FLY_FLAPS_BASE_URL`, read by `resolveEndpoint()` in
|
|
33
|
+
* `lexicons/fly/src/op/activities/fly-apply.ts`, the same seam
|
|
34
|
+
* `describeResources` (`../describe-resources.ts`) calls through.
|
|
35
|
+
* - **gcp**, **k8s** — resolve their live target from `chant.config` itself
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Per-lexicon ambient env var a `--live` read honors for its endpoint. Only
|
|
50
|
+
* lexicons with a genuine ambient-var footgun are listed — see the module doc
|
|
51
|
+
* for the full audit (gcp/k8s/azure/temporal resolve their target from
|
|
52
|
+
* `chant.config` instead, so they have nothing to inject).
|
|
53
|
+
*/
|
|
54
|
+
export const LEXICON_ENDPOINT_ENV_VAR: Record<string, string> = {
|
|
55
|
+
aws: "AWS_ENDPOINT_URL",
|
|
56
|
+
fly: "FLY_FLAPS_BASE_URL",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Result of {@link applyLiveEndpoint} — always call `restore()`, even when nothing was applied (it is then a no-op). */
|
|
60
|
+
export interface AppliedEndpoint {
|
|
61
|
+
/**
|
|
62
|
+
* One line describing what happened, or `undefined` when the environment
|
|
63
|
+
* declares no endpoint at all (nothing to say). Present whether the
|
|
64
|
+
* declared endpoint was applied OR an ambient var already won — #1166's
|
|
65
|
+
* "no silent anything" stance: an operator should never have to guess which
|
|
66
|
+
* target a `--live` read actually used.
|
|
67
|
+
*/
|
|
68
|
+
notice?: string;
|
|
69
|
+
/** Undo whatever ambient env vars this call set. Always safe to call. */
|
|
70
|
+
restore: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve `environment`'s declared endpoint (if any) from `config.environments`
|
|
75
|
+
* and apply it to the ambient env var of every lexicon in `lexicons` that has
|
|
76
|
+
* one ({@link LEXICON_ENDPOINT_ENV_VAR}) — but only when that var isn't
|
|
77
|
+
* already set. Ambient always wins (#1166): behavior for a shell that already
|
|
78
|
+
* exports `AWS_ENDPOINT_URL` is unchanged.
|
|
79
|
+
*
|
|
80
|
+
* Call before a `--live` describe/enrich pass; `restore()` in a `finally` so
|
|
81
|
+
* the injected value never leaks into a later invocation in the same process
|
|
82
|
+
* (tests, or a long-lived host like the MCP server).
|
|
83
|
+
*/
|
|
84
|
+
export function applyLiveEndpoint(
|
|
85
|
+
environments: EnvironmentDeclaration[] | undefined,
|
|
86
|
+
environment: string,
|
|
87
|
+
lexicons: readonly string[],
|
|
88
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
89
|
+
): AppliedEndpoint {
|
|
90
|
+
const endpoint = environmentEndpoint(environments, environment);
|
|
91
|
+
if (!endpoint) return { restore: () => {} };
|
|
92
|
+
|
|
93
|
+
const applied: string[] = [];
|
|
94
|
+
const overridden: string[] = [];
|
|
95
|
+
const seen = new Set<string>(); // a var shared by two lexicons is only reported once
|
|
96
|
+
for (const lexicon of lexicons) {
|
|
97
|
+
const varName = LEXICON_ENDPOINT_ENV_VAR[lexicon];
|
|
98
|
+
if (!varName || seen.has(varName)) continue;
|
|
99
|
+
seen.add(varName);
|
|
100
|
+
if (env[varName]) {
|
|
101
|
+
overridden.push(varName);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
env[varName] = endpoint;
|
|
105
|
+
applied.push(varName);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const notices: string[] = [];
|
|
109
|
+
if (applied.length > 0) {
|
|
110
|
+
notices.push(
|
|
111
|
+
`environment "${environment}" declares endpoint ${endpoint} — applied to ${applied.join(", ")} for this read`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (overridden.length > 0) {
|
|
115
|
+
notices.push(
|
|
116
|
+
`ambient ${overridden.join(", ")} already set — keeping it over environment "${environment}"'s declared endpoint (${endpoint})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
notice: notices.length > 0 ? notices.join("; ") : undefined,
|
|
122
|
+
restore: () => {
|
|
123
|
+
for (const varName of applied) delete env[varName];
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* #1166 acceptance: when a `--live` describe comes back with zero resources
|
|
130
|
+
* for a lexicon that had declared entities to look for, and nothing was
|
|
131
|
+
* already reported NOT-OBSERVED (#1089) either, that is either "genuinely
|
|
132
|
+
* nothing is deployed yet" or a misconfigured endpoint/credentials — the two
|
|
133
|
+
* are visually identical, so a caller must say so rather than stay quiet.
|
|
134
|
+
* Returns `undefined` when there is nothing to declare (no declared entities
|
|
135
|
+
* to have asked about, or the emptiness is already explained by #1089's
|
|
136
|
+
* `unobserved`).
|
|
137
|
+
*/
|
|
138
|
+
export function zeroResourcesWarning(
|
|
139
|
+
lexicon: string,
|
|
140
|
+
environment: string,
|
|
141
|
+
declaredCount: number,
|
|
142
|
+
observed: { resources: Record<string, unknown>; unobserved: Record<string, unknown> },
|
|
143
|
+
): string | undefined {
|
|
144
|
+
if (declaredCount === 0) return undefined;
|
|
145
|
+
if (Object.keys(observed.resources).length > 0) return undefined;
|
|
146
|
+
if (Object.keys(observed.unobserved).length > 0) return undefined;
|
|
147
|
+
return `${lexicon}: 0 live resources for env "${environment}" (${declaredCount} declared) — check the endpoint/credentials`;
|
|
148
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observation contract itself (#1089) — the normalizer, the envelope
|
|
3
|
+
* discriminant, and the multi-stack merge.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
UNOBSERVED_REASONS,
|
|
8
|
+
formatUnobserved,
|
|
9
|
+
isObservationResult,
|
|
10
|
+
isUnobservedReason,
|
|
11
|
+
mergeObservations,
|
|
12
|
+
normalizeObservation,
|
|
13
|
+
observation,
|
|
14
|
+
unobservedAll,
|
|
15
|
+
unobservedReasonText,
|
|
16
|
+
} from "./observation";
|
|
17
|
+
import type { ResourceMetadata } from "./lexicon";
|
|
18
|
+
|
|
19
|
+
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
20
|
+
type: "Fake::Resource",
|
|
21
|
+
status: "OK",
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("normalizeObservation", () => {
|
|
26
|
+
test("a bare map means 'I looked at everything'", () => {
|
|
27
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {} });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the envelope carries both halves", () => {
|
|
31
|
+
const value = observation({ a: meta() }, { b: { reason: "read-failed" } });
|
|
32
|
+
expect(normalizeObservation(value)).toEqual({
|
|
33
|
+
resources: { a: meta() },
|
|
34
|
+
unobserved: { b: { reason: "read-failed" } },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("undefined normalizes to two empty maps", () => {
|
|
39
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {} });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an entity literally named `observation` cannot be mistaken for the envelope", () => {
|
|
43
|
+
const bare = { observation: meta({ type: "Odd::Name" }) };
|
|
44
|
+
expect(isObservationResult(bare)).toBe(false);
|
|
45
|
+
expect(normalizeObservation(bare).resources.observation.type).toBe("Odd::Name");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the envelope omits an empty unobserved map", () => {
|
|
49
|
+
expect(observation({ a: meta() }, {})).toEqual({ observation: "v1", resources: { a: meta() } });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("unobservedAll", () => {
|
|
54
|
+
test("marks every named entity with one reason, carrying declared types", () => {
|
|
55
|
+
const entities = new Map([["a", { entityType: "AWS::S3::Bucket" }]]);
|
|
56
|
+
expect(unobservedAll(["a", "b"], "no-credentials", "token expired", entities)).toEqual({
|
|
57
|
+
a: { reason: "no-credentials", type: "AWS::S3::Bucket", detail: "token expired" },
|
|
58
|
+
b: { reason: "no-credentials", detail: "token expired" },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("mergeObservations (multi-stack)", () => {
|
|
64
|
+
test("present beats not-observed beats absent", () => {
|
|
65
|
+
const merged = mergeObservations([
|
|
66
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } } },
|
|
67
|
+
{ resources: { a: meta() }, unobserved: {} },
|
|
68
|
+
]);
|
|
69
|
+
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
70
|
+
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("an entity nobody looked for in any stack stays absent", () => {
|
|
74
|
+
const merged = mergeObservations([
|
|
75
|
+
{ resources: { a: meta() }, unobserved: {} },
|
|
76
|
+
{ resources: { b: meta() }, unobserved: {} },
|
|
77
|
+
]);
|
|
78
|
+
expect(merged.unobserved).toEqual({});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("reason totality", () => {
|
|
83
|
+
test("every reason has human text and passes the guard", () => {
|
|
84
|
+
for (const reason of UNOBSERVED_REASONS) {
|
|
85
|
+
expect(isUnobservedReason(reason)).toBe(true);
|
|
86
|
+
expect(unobservedReasonText(reason).length).toBeGreaterThan(0);
|
|
87
|
+
}
|
|
88
|
+
expect(isUnobservedReason("made-up")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("formatUnobserved names the entity, the type and the detail", () => {
|
|
92
|
+
expect(
|
|
93
|
+
formatUnobserved("widget", { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no mapping" }),
|
|
94
|
+
).toBe("widget (K8s::X::Widget) — no reader for this resource kind: no mapping");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observation contract (#1089) — what a lexicon's `describeResources()`
|
|
3
|
+
* is allowed to mean.
|
|
4
|
+
*
|
|
5
|
+
* Before this module there were two ways for an observation to return nothing
|
|
6
|
+
* for a declared entity and no way to tell them apart:
|
|
7
|
+
*
|
|
8
|
+
* 1. The provider was asked and said the resource does not exist.
|
|
9
|
+
* 2. The lexicon never looked — no reader for that kind, the read errored,
|
|
10
|
+
* no credentials, no cluster/subscription binding.
|
|
11
|
+
*
|
|
12
|
+
* Both arrived at the change set as "absent", and absent + declared classifies
|
|
13
|
+
* as `create`. So `chant lifecycle plan` proposed creating a Kubernetes CRD
|
|
14
|
+
* that already existed in the cluster, with nothing in the plan to say the tool
|
|
15
|
+
* had simply not looked (a warn on stderr is not a signal in a change set).
|
|
16
|
+
*
|
|
17
|
+
* The contract is a tri-state, per declared entity:
|
|
18
|
+
*
|
|
19
|
+
* - **OBSERVED-PRESENT** — a key in `resources`. The provider returned it.
|
|
20
|
+
* - **OBSERVED-ABSENT** — in neither map. The lexicon looked and the provider
|
|
21
|
+
* reported it missing. This is the only shape that may become a `create`.
|
|
22
|
+
* - **NOT-OBSERVED** — a key in `unobserved`, carrying a total
|
|
23
|
+
* {@link UnobservedReason}. Never a `create`, never a `delete`; consumers
|
|
24
|
+
* surface it and stop.
|
|
25
|
+
*
|
|
26
|
+
* Compatibility: `describeResources()` may still return the bare
|
|
27
|
+
* `name → ResourceMetadata` map it always did — that means "everything I was
|
|
28
|
+
* asked about, I looked at" and normalizes to an empty `unobserved`. Reporting
|
|
29
|
+
* NOT-OBSERVED requires the explicitly versioned {@link ObservationResult}
|
|
30
|
+
* envelope, which is discriminated by its literal `observation: "v1"` field (a
|
|
31
|
+
* bare map's value at any key is a `ResourceMetadata` object, never that
|
|
32
|
+
* string, so the two shapes can never be confused).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ResourceMetadata } from "./lexicon";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a declared entity was not observed. Total: a lexicon that cannot observe
|
|
39
|
+
* an entity must pick one of these, and consumers may switch exhaustively.
|
|
40
|
+
*
|
|
41
|
+
* - `read-failed` — the provider was reached and the read errored (a non-zero
|
|
42
|
+
* CLI exit, a 5xx, an unparseable response).
|
|
43
|
+
* - `no-credentials` — no usable credentials/authorization for the target.
|
|
44
|
+
* - `no-binding` — the environment resolves to no concrete target (no kubectl
|
|
45
|
+
* context, no subscription, no stack, no endpoint).
|
|
46
|
+
* - `unsupported-kind` — the lexicon has no reader for this entity type (the
|
|
47
|
+
* K8s CRD case, Azure's nested ARM types). The resource may well exist.
|
|
48
|
+
* - `filtered` — the entity was reached but withheld by a caller-requested
|
|
49
|
+
* filter (`owned: true` against a resource carrying no chant marker). The
|
|
50
|
+
* result deliberately says nothing about it, which is still not absence: a
|
|
51
|
+
* declared resource that exists but is foreign must not classify as `create`.
|
|
52
|
+
*/
|
|
53
|
+
export type UnobservedReason =
|
|
54
|
+
| "read-failed"
|
|
55
|
+
| "no-credentials"
|
|
56
|
+
| "no-binding"
|
|
57
|
+
| "unsupported-kind"
|
|
58
|
+
| "filtered";
|
|
59
|
+
|
|
60
|
+
/** Every legal {@link UnobservedReason}, for validation and conformance checks. */
|
|
61
|
+
export const UNOBSERVED_REASONS: readonly UnobservedReason[] = [
|
|
62
|
+
"read-failed",
|
|
63
|
+
"no-credentials",
|
|
64
|
+
"no-binding",
|
|
65
|
+
"unsupported-kind",
|
|
66
|
+
"filtered",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** True when `value` is a legal {@link UnobservedReason}. */
|
|
70
|
+
export function isUnobservedReason(value: unknown): value is UnobservedReason {
|
|
71
|
+
return typeof value === "string" && (UNOBSERVED_REASONS as readonly string[]).includes(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One declared entity the lexicon could not observe, and why. */
|
|
75
|
+
export interface UnobservedEntity {
|
|
76
|
+
/** Declared entity type, when the lexicon knows it (it usually does — the entity is declared). */
|
|
77
|
+
type?: string;
|
|
78
|
+
/** Total verdict. */
|
|
79
|
+
reason: UnobservedReason;
|
|
80
|
+
/** Human-readable detail: the command that failed, the missing binding key, the unsupported kind. */
|
|
81
|
+
detail?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The observation envelope — a `describeResources()` return value that can say
|
|
86
|
+
* "I did not look at this one". Explicitly versioned: `observation: "v1"`.
|
|
87
|
+
*/
|
|
88
|
+
export interface ObservationResult {
|
|
89
|
+
/** Discriminant + wire version. Distinguishes the envelope from the bare `name → ResourceMetadata` map. */
|
|
90
|
+
readonly observation: "v1";
|
|
91
|
+
/** OBSERVED-PRESENT, keyed by chant entity name. */
|
|
92
|
+
resources: Record<string, ResourceMetadata>;
|
|
93
|
+
/** NOT-OBSERVED, keyed by chant entity name. Omit or leave empty when everything asked about was looked at. */
|
|
94
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What `describeResources()` may return: the bare map (pre-#1089, still valid —
|
|
99
|
+
* "I looked at everything") or the {@link ObservationResult} envelope.
|
|
100
|
+
*/
|
|
101
|
+
export type DescribeResourcesResult = Record<string, ResourceMetadata> | ObservationResult;
|
|
102
|
+
|
|
103
|
+
/** Normalized form every consumer works with. Both maps always present. */
|
|
104
|
+
export interface NormalizedObservation {
|
|
105
|
+
resources: Record<string, ResourceMetadata>;
|
|
106
|
+
unobserved: Record<string, UnobservedEntity>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when `value` is the versioned {@link ObservationResult} envelope. */
|
|
110
|
+
export function isObservationResult(value: unknown): value is ObservationResult {
|
|
111
|
+
return (
|
|
112
|
+
typeof value === "object" &&
|
|
113
|
+
value !== null &&
|
|
114
|
+
(value as { observation?: unknown }).observation === "v1"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build an {@link ObservationResult}. Lexicons use this rather than writing the
|
|
120
|
+
* discriminant by hand.
|
|
121
|
+
*/
|
|
122
|
+
export function observation(
|
|
123
|
+
resources: Record<string, ResourceMetadata>,
|
|
124
|
+
unobserved?: Record<string, UnobservedEntity>,
|
|
125
|
+
): ObservationResult {
|
|
126
|
+
return {
|
|
127
|
+
observation: "v1",
|
|
128
|
+
resources,
|
|
129
|
+
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalize either accepted return shape. `undefined` (a lexicon that returned
|
|
135
|
+
* nothing at all) normalizes to two empty maps — which reads as "everything was
|
|
136
|
+
* observed absent", so callers that mean "the read failed" must say so with
|
|
137
|
+
* {@link unobservedAll} rather than returning nothing.
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
|
|
140
|
+
if (!value) return { resources: {}, unobserved: {} };
|
|
141
|
+
if (isObservationResult(value)) {
|
|
142
|
+
return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {} };
|
|
143
|
+
}
|
|
144
|
+
return { resources: value, unobserved: {} };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Mark every named entity NOT-OBSERVED with one reason — the whole-lexicon
|
|
149
|
+
* failure case (the provider CLI is missing, the cluster binding refused, the
|
|
150
|
+
* credentials are gone). Core applies this when `describeResources()` throws,
|
|
151
|
+
* so a thrown read degrades to an honest "did not look" for each declared
|
|
152
|
+
* entity instead of an empty map that classifies as N creates.
|
|
153
|
+
*/
|
|
154
|
+
export function unobservedAll(
|
|
155
|
+
names: Iterable<string>,
|
|
156
|
+
reason: UnobservedReason,
|
|
157
|
+
detail?: string,
|
|
158
|
+
types?: Map<string, { entityType: string }> | Record<string, string>,
|
|
159
|
+
): Record<string, UnobservedEntity> {
|
|
160
|
+
const typeOf = (name: string): string | undefined => {
|
|
161
|
+
if (!types) return undefined;
|
|
162
|
+
if (types instanceof Map) return types.get(name)?.entityType;
|
|
163
|
+
return types[name];
|
|
164
|
+
};
|
|
165
|
+
const out: Record<string, UnobservedEntity> = {};
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
const type = typeOf(name);
|
|
168
|
+
out[name] = { reason, ...(type ? { type } : {}), ...(detail ? { detail } : {}) };
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Union several observations of the same lexicon (the multi-stack read, where
|
|
175
|
+
* `describeResources` runs once per stack). Precedence is
|
|
176
|
+
* present > not-observed > absent: a resource found in any stack is present; an
|
|
177
|
+
* entity nobody could look at stays not-observed; an entity every reader looked
|
|
178
|
+
* for and did not find is absent.
|
|
179
|
+
*/
|
|
180
|
+
export function mergeObservations(parts: Iterable<NormalizedObservation>): NormalizedObservation {
|
|
181
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
182
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
183
|
+
for (const part of parts) {
|
|
184
|
+
Object.assign(resources, part.resources);
|
|
185
|
+
Object.assign(unobserved, part.unobserved);
|
|
186
|
+
}
|
|
187
|
+
// Present wins: a stack that could not be read does not un-observe a resource
|
|
188
|
+
// another stack returned.
|
|
189
|
+
for (const name of Object.keys(resources)) delete unobserved[name];
|
|
190
|
+
return { resources, unobserved };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One-line human phrasing of a reason, for CLI output. */
|
|
194
|
+
export function unobservedReasonText(reason: UnobservedReason): string {
|
|
195
|
+
switch (reason) {
|
|
196
|
+
case "read-failed":
|
|
197
|
+
return "read failed";
|
|
198
|
+
case "no-credentials":
|
|
199
|
+
return "no credentials";
|
|
200
|
+
case "no-binding":
|
|
201
|
+
return "no binding for this environment";
|
|
202
|
+
case "unsupported-kind":
|
|
203
|
+
return "no reader for this resource kind";
|
|
204
|
+
case "filtered":
|
|
205
|
+
return "withheld by the --owned filter";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `name — reason (detail)`, the shared rendering for CLI and plan output. */
|
|
210
|
+
export function formatUnobserved(name: string, entry: UnobservedEntity): string {
|
|
211
|
+
const base = `${name}${entry.type ? ` (${entry.type})` : ""} — ${unobservedReasonText(entry.reason)}`;
|
|
212
|
+
return entry.detail ? `${base}: ${entry.detail}` : base;
|
|
213
|
+
}
|