@intentius/chant 0.25.0 → 0.26.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.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +14 -9
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +16 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +29 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +18 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +77 -1
- package/src/build.ts +15 -2
- package/src/cli/commands/build.ts +33 -5
- package/src/cli/main.test.ts +93 -17
- package/src/cli/main.ts +101 -11
- package/src/discovery/entity-wire-codec.ts +14 -9
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-wire.ts +21 -0
- package/src/discovery/sandbox/driver.ts +132 -0
- package/src/discovery/sandbox/fork.ts +39 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +36 -0
- package/src/lexicon-output.ts +12 -2
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- package/src/stack-output.ts +21 -5
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { realpathSync, rmSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { PostSynthDiagnostic } from "../../lint/post-synth";
|
|
4
|
+
import { bundleDriver } from "./bundle";
|
|
5
|
+
import { generatePolicyDriverSource } from "./driver";
|
|
6
|
+
import { forkSandboxed } from "./fork";
|
|
7
|
+
import {
|
|
8
|
+
encodePolicyBuildResult,
|
|
9
|
+
formatPolicyWireOffenders,
|
|
10
|
+
type EncodablePolicyBuildResult,
|
|
11
|
+
type PolicyDiagnosticOffender,
|
|
12
|
+
} from "./policy-wire";
|
|
13
|
+
import { ENV_VAR } from "../../env";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* chant #1131 — runs a project's `lint.policies` checks inside a sandboxed
|
|
17
|
+
* child, over the build result the parent has already merged and serialized,
|
|
18
|
+
* and brings back plain `PostSynthDiagnostic`s.
|
|
19
|
+
*
|
|
20
|
+
* This closes the last residual chant #1093 and #1113 documented. `--sandbox`
|
|
21
|
+
* isolated project *source* (#1045), then project *fold-time* code (#1093),
|
|
22
|
+
* then the project's `chant.config.ts` (#1113) — but `chant build` still
|
|
23
|
+
* imported every `lint.policies` module and called its `check` function in the
|
|
24
|
+
* CLI's own process, after discovery, with the CLI's full filesystem, network,
|
|
25
|
+
* environment and process-spawn access. The config crossing as data did not
|
|
26
|
+
* take the policies with it: they are declared in it as *paths*.
|
|
27
|
+
*
|
|
28
|
+
* ## Why a second child rather than the first one
|
|
29
|
+
*
|
|
30
|
+
* A policy is a callback over the *whole* resolved build, and no such thing
|
|
31
|
+
* exists inside the #1045 discovery child. That child sees only the
|
|
32
|
+
* run-fallback subset — folded files are collected in the parent, the merge
|
|
33
|
+
* happens in the parent, and serialization (which is what most policies
|
|
34
|
+
* actually read) happens later still. Running policies there would hand them a
|
|
35
|
+
* partial, unserialized view and call it the same check. So the boundary moves
|
|
36
|
+
* to where the complete result exists: after the merge, in a child of its own.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately the same machinery, not a parallel one — the same three modules
|
|
39
|
+
* `./config-run.ts` reuses:
|
|
40
|
+
* - `./bundle.ts` bundles the generated driver (`./driver.ts`'s
|
|
41
|
+
* `generatePolicyDriverSource`) with esbuild, so the child needs no runtime
|
|
42
|
+
* module resolution and no TypeScript loader.
|
|
43
|
+
* - `./fork.ts` spawns it with the identical `--permission` profile the
|
|
44
|
+
* run-fallback and config children get — one function, so the three cannot
|
|
45
|
+
* drift.
|
|
46
|
+
* - `./child-errors.ts` (inside the driver) classifies whatever it throws, so
|
|
47
|
+
* a permission denial names the policy file instead of leaking
|
|
48
|
+
* `ERR_ACCESS_DENIED`.
|
|
49
|
+
*
|
|
50
|
+
* The one addition is direction: the policy child receives its input over the
|
|
51
|
+
* same IPC channel it answers on (`SandboxForkOptions.send`). See that field's
|
|
52
|
+
* doc for why sending before the child boots is safe.
|
|
53
|
+
*
|
|
54
|
+
* ## What the child can and cannot do
|
|
55
|
+
*
|
|
56
|
+
* Exactly what a run-fallback file can: read the bundle directory and the
|
|
57
|
+
* project directory, nothing else; no writes, no spawning, no worker threads;
|
|
58
|
+
* an environment of `PATH` plus `CHANT_ENV` when set. That last one matches
|
|
59
|
+
* `./config-run.ts` for the same reason — `--env` is a value the user typed on
|
|
60
|
+
* the command line, and a policy that branches on the environment is the
|
|
61
|
+
* documented pattern (`ctx.env` carries it too, so this is belt-and-braces for
|
|
62
|
+
* a policy reading `process.env.CHANT_ENV` directly rather than a new
|
|
63
|
+
* capability).
|
|
64
|
+
*
|
|
65
|
+
* A policy that writes a report file, shells out to a scanner, or reads
|
|
66
|
+
* `~/.aws/credentials` therefore now FAILS, loudly, naming the file and the
|
|
67
|
+
* operation. That is the point of the flag, and it is a real behavior change
|
|
68
|
+
* for such a policy under `--sandbox` — see `docs/.../architecture/sandbox.mdx`.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** A policy child is one bundle, one import per policy module, and a pass over data. Generous relative to that, tight enough that a hung policy is reported rather than waited on. */
|
|
72
|
+
const POLICY_CHILD_TIMEOUT_MS = 120_000;
|
|
73
|
+
|
|
74
|
+
interface PolicyChildResponse {
|
|
75
|
+
kind: "chant-policy";
|
|
76
|
+
ok: boolean;
|
|
77
|
+
diagnostics?: PostSynthDiagnostic[];
|
|
78
|
+
offenders?: PolicyDiagnosticOffender[];
|
|
79
|
+
error?: { name: string; file: string; message: string; type: string };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPolicyChildResponse(value: unknown): value is PolicyChildResponse {
|
|
83
|
+
return (
|
|
84
|
+
typeof value === "object" &&
|
|
85
|
+
value !== null &&
|
|
86
|
+
(value as { kind?: unknown }).kind === "chant-policy" &&
|
|
87
|
+
typeof (value as { ok?: unknown }).ok === "boolean"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SandboxPolicyResult {
|
|
92
|
+
/** What the policy pack reported — plain data, validated inside the child before it crossed (see `./policy-wire.ts`'s `scanPolicyDiagnostics`). */
|
|
93
|
+
diagnostics: PostSynthDiagnostic[];
|
|
94
|
+
/** esbuild bundling wall-clock time. */
|
|
95
|
+
bundleMs: number;
|
|
96
|
+
/** Bundle size in bytes. */
|
|
97
|
+
bundleBytes: number;
|
|
98
|
+
/** Encoded build-result payload size in bytes — what crossed the IPC channel. */
|
|
99
|
+
payloadBytes: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SandboxPolicyOptions {
|
|
103
|
+
/** Absolute paths to the project's `lint.policies` modules, in declaration order. */
|
|
104
|
+
policyPaths: readonly string[];
|
|
105
|
+
/** The merged, serialized build result the checks run over. */
|
|
106
|
+
buildResult: EncodablePolicyBuildResult;
|
|
107
|
+
/** The environment/stack this build was evaluated for (`--env`, else `ownership.env`) — becomes `ctx.env`. */
|
|
108
|
+
env?: string;
|
|
109
|
+
/** Directory the child is granted `--allow-fs-read` for: the project root (the `chant.config.*` directory, which `lint.policies` paths are resolved against). */
|
|
110
|
+
projectRoot: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Evaluate `policyPaths` against `buildResult` inside a sandboxed child.
|
|
115
|
+
*
|
|
116
|
+
* Throws — rather than degrading to an in-process run — when the policies
|
|
117
|
+
* cannot be evaluated inside the boundary or their diagnostics cannot cross it
|
|
118
|
+
* as JSON. Under `--sandbox` a policy pack that "almost" ran is not a safe
|
|
119
|
+
* thing to proceed with, and quietly falling back would give away the property
|
|
120
|
+
* the flag exists to provide.
|
|
121
|
+
*/
|
|
122
|
+
export async function runPoliciesSandboxed(options: SandboxPolicyOptions): Promise<SandboxPolicyResult> {
|
|
123
|
+
const { policyPaths, buildResult, env, projectRoot } = options;
|
|
124
|
+
|
|
125
|
+
// Encoded FIRST, in the parent, before anything is bundled or spawned: this
|
|
126
|
+
// is where a build result that cannot cross is rejected by name (a
|
|
127
|
+
// `nestedStack()` child project, a serializer output that isn't data), and
|
|
128
|
+
// there is no point paying for a bundle to find that out.
|
|
129
|
+
const payload = {
|
|
130
|
+
kind: "chant-policy-request" as const,
|
|
131
|
+
buildResult: encodePolicyBuildResult(buildResult),
|
|
132
|
+
env: env ?? null,
|
|
133
|
+
};
|
|
134
|
+
const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf-8");
|
|
135
|
+
|
|
136
|
+
const driverSource = generatePolicyDriverSource(policyPaths);
|
|
137
|
+
const { bundlePath, bundleDir, externalReadPaths, durationMs, bytes } = await bundleDriver(driverSource);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
let projectRealpath: string;
|
|
141
|
+
try {
|
|
142
|
+
projectRealpath = realpathSync(resolve(projectRoot));
|
|
143
|
+
} catch {
|
|
144
|
+
projectRealpath = resolve(projectRoot);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const childEnv: Record<string, string> = { PATH: process.env.PATH ?? "" };
|
|
148
|
+
const activeEnv = process.env[ENV_VAR];
|
|
149
|
+
if (activeEnv) childEnv[ENV_VAR] = activeEnv;
|
|
150
|
+
|
|
151
|
+
const response = await forkSandboxed(
|
|
152
|
+
{
|
|
153
|
+
bundlePath,
|
|
154
|
+
bundleDir,
|
|
155
|
+
projectRealpath,
|
|
156
|
+
externalReadPaths,
|
|
157
|
+
env: childEnv,
|
|
158
|
+
timeoutMs: POLICY_CHILD_TIMEOUT_MS,
|
|
159
|
+
label: `sandboxed evaluation of lint.policies (${policyPaths.length} module(s))`,
|
|
160
|
+
send: payload,
|
|
161
|
+
},
|
|
162
|
+
isPolicyChildResponse,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
if (response.offenders && response.offenders.length > 0) {
|
|
167
|
+
throw new Error(formatPolicyWireOffenders("a policy check's return value", response.offenders));
|
|
168
|
+
}
|
|
169
|
+
throw new Error(
|
|
170
|
+
response.error?.message
|
|
171
|
+
? `Failed to run lint.policies inside the --sandbox boundary: ${response.error.message}`
|
|
172
|
+
: `Failed to run lint.policies inside the --sandbox boundary`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { diagnostics: response.diagnostics ?? [], bundleMs: durationMs, bundleBytes: bytes, payloadBytes };
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(bundleDir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { AttrRef } from "../../attrref";
|
|
3
|
+
import { CHILD_PROJECT_MARKER } from "../../child-project";
|
|
4
|
+
import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
|
|
5
|
+
import { INTRINSIC_MARKER, type Intrinsic } from "../../intrinsic";
|
|
6
|
+
import { DiscoveryError } from "../../errors";
|
|
7
|
+
import { createResource } from "../../runtime";
|
|
8
|
+
import { resolveAttrRefs } from "../resolve";
|
|
9
|
+
import { isAttrRefLike } from "../../utils";
|
|
10
|
+
import type { PostSynthCheck } from "../../lint/post-synth";
|
|
11
|
+
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
12
|
+
import {
|
|
13
|
+
decodePolicyBuildResult,
|
|
14
|
+
encodePolicyBuildResult,
|
|
15
|
+
scanPolicyDiagnostics,
|
|
16
|
+
type EncodablePolicyBuildResult,
|
|
17
|
+
} from "./policy-wire";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* chant #1131 — the contract between the CLI process and the sandboxed policy
|
|
21
|
+
* child, in both directions.
|
|
22
|
+
*
|
|
23
|
+
* These tests are the "document the difference precisely and pin it" half of
|
|
24
|
+
* the brief. A policy check under `--sandbox` no longer sees the parent's own
|
|
25
|
+
* live objects; it sees what survived `encodePolicyBuildResult` →
|
|
26
|
+
* `JSON.parse(JSON.stringify(...))` → `decodePolicyBuildResult`. Everything
|
|
27
|
+
* that is the SAME is asserted here so a regression is caught, and everything
|
|
28
|
+
* that is NARROWER is asserted here too, so the narrowing can never quietly
|
|
29
|
+
* change without a test failing.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** What the IPC channel actually does to the payload. Every test goes through it — an in-memory hand-off would prove nothing. */
|
|
33
|
+
function overTheWire(result: EncodablePolicyBuildResult) {
|
|
34
|
+
const wire = encodePolicyBuildResult(result);
|
|
35
|
+
return decodePolicyBuildResult(JSON.parse(JSON.stringify(wire)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function emptyResult(overrides: Partial<EncodablePolicyBuildResult> = {}): EncodablePolicyBuildResult {
|
|
39
|
+
return {
|
|
40
|
+
outputs: new Map(),
|
|
41
|
+
entities: new Map(),
|
|
42
|
+
warnings: [],
|
|
43
|
+
errors: [],
|
|
44
|
+
sourceFileCount: 0,
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("the build result a sandboxed policy sees (chant #1131)", () => {
|
|
50
|
+
test("outputs — the surface the reference policy corpus actually reads — cross exactly", () => {
|
|
51
|
+
const result = emptyResult({
|
|
52
|
+
outputs: new Map<string, string | { primary: string; files?: Record<string, string>; warnings?: string[] }>([
|
|
53
|
+
["k8s", "apiVersion: v1\nkind: Service\n"],
|
|
54
|
+
["aws", { primary: '{"Resources":{}}', files: { "net.template.json": "{}" }, warnings: ["dropped a key"] }],
|
|
55
|
+
]),
|
|
56
|
+
warnings: ["a build warning"],
|
|
57
|
+
sourceFileCount: 3,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const decoded = overTheWire(result);
|
|
61
|
+
|
|
62
|
+
expect(decoded.outputs.get("k8s")).toBe("apiVersion: v1\nkind: Service\n");
|
|
63
|
+
expect(decoded.outputs.get("aws")).toEqual({
|
|
64
|
+
primary: '{"Resources":{}}',
|
|
65
|
+
files: { "net.template.json": "{}" },
|
|
66
|
+
warnings: ["dropped a key"],
|
|
67
|
+
});
|
|
68
|
+
expect(decoded.outputs).toBeInstanceOf(Map);
|
|
69
|
+
expect(decoded.warnings).toEqual(["a build warning"]);
|
|
70
|
+
expect(decoded.sourceFileCount).toBe(3);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("entities cross as a live Map of Declarables, with AttrRefs still resolved and still AttrRefs", () => {
|
|
74
|
+
const Vpc = createResource("Test::Vpc", "test", { vpcId: "VpcId" });
|
|
75
|
+
const Subnet = createResource("Test::Subnet", "test", { subnetId: "SubnetId" });
|
|
76
|
+
const vpc = new Vpc({ CidrBlock: "10.0.0.0/16" });
|
|
77
|
+
const subnet = new Subnet({ VpcId: (vpc as unknown as Record<string, AttrRef>).vpcId });
|
|
78
|
+
const entities = new Map<string, Declarable>([
|
|
79
|
+
["Vpc", vpc as unknown as Declarable],
|
|
80
|
+
["Subnet", subnet as unknown as Declarable],
|
|
81
|
+
]);
|
|
82
|
+
resolveAttrRefs(entities);
|
|
83
|
+
|
|
84
|
+
const decoded = overTheWire(emptyResult({ entities }));
|
|
85
|
+
|
|
86
|
+
expect([...decoded.entities.keys()]).toEqual(["Vpc", "Subnet"]);
|
|
87
|
+
const decodedSubnet = decoded.entities.get("Subnet") as unknown as { props: { VpcId: unknown } };
|
|
88
|
+
expect(isAttrRefLike(decodedSubnet.props.VpcId)).toBe(true);
|
|
89
|
+
// A real AttrRef, not a `{__attrRef}` envelope — several chant call sites
|
|
90
|
+
// (and any policy doing the same) test `instanceof`, not duck typing.
|
|
91
|
+
expect(decodedSubnet.props.VpcId).toBeInstanceOf(AttrRef);
|
|
92
|
+
expect((decodedSubnet.props.VpcId as AttrRef).getLogicalName()).toBe("Vpc");
|
|
93
|
+
// The WeakRef points at the DECODED parent — self-consistent inside the
|
|
94
|
+
// child, which is all a check over `ctx.entities` can observe.
|
|
95
|
+
expect((decodedSubnet.props.VpcId as AttrRef).parent.deref()).toBe(decoded.entities.get("Vpc"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a decoded entity's own enumerable keys match the original's", () => {
|
|
99
|
+
// `createResource` defines lexicon/entityType/kind/props/attributes
|
|
100
|
+
// non-enumerable and the per-attribute AttrRefs enumerable; the decoder
|
|
101
|
+
// reproduces exactly that split. So `Object.keys(entity)` — which a policy
|
|
102
|
+
// walking an entity generically would use — is the same on both sides.
|
|
103
|
+
const Bucket = createResource("Test::Bucket", "test", { arn: "Arn", name: "Name" });
|
|
104
|
+
const bucket = new Bucket({ Versioning: true });
|
|
105
|
+
const entities = new Map<string, Declarable>([["Bucket", bucket as unknown as Declarable]]);
|
|
106
|
+
resolveAttrRefs(entities);
|
|
107
|
+
|
|
108
|
+
const decoded = overTheWire(emptyResult({ entities }));
|
|
109
|
+
|
|
110
|
+
expect(Object.keys(decoded.entities.get("Bucket") as object)).toEqual(Object.keys(bucket as object));
|
|
111
|
+
expect((decoded.entities.get("Bucket") as unknown as { props: unknown }).props).toEqual({ Versioning: true });
|
|
112
|
+
expect(DECLARABLE_MARKER in (decoded.entities.get("Bucket") as object)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("encoding is idempotent over an already-decoded entity — the run-fallback subset pays nothing extra", () => {
|
|
116
|
+
// Under `--sandbox` the parent's merged entities ALREADY include decoded
|
|
117
|
+
// ones for every run-fallback file (`./run.ts`). If a second round trip
|
|
118
|
+
// narrowed them further, this child would be doing real damage to part of
|
|
119
|
+
// the set. It does not: encode∘decode∘encode === encode.
|
|
120
|
+
const Queue = createResource("Test::Queue", "test", { url: "Url" });
|
|
121
|
+
const Fn = createResource("Test::Fn", "test", {});
|
|
122
|
+
const queue = new Queue({ Fifo: true });
|
|
123
|
+
const fn = new Fn({ QueueUrl: (queue as unknown as Record<string, AttrRef>).url });
|
|
124
|
+
const entities = new Map<string, Declarable>([
|
|
125
|
+
["Queue", queue as unknown as Declarable],
|
|
126
|
+
["Fn", fn as unknown as Declarable],
|
|
127
|
+
]);
|
|
128
|
+
resolveAttrRefs(entities);
|
|
129
|
+
|
|
130
|
+
const once = encodePolicyBuildResult(emptyResult({ entities }));
|
|
131
|
+
const twice = encodePolicyBuildResult(emptyResult({ entities: decodePolicyBuildResult(once).entities }));
|
|
132
|
+
|
|
133
|
+
expect(twice.entities).toEqual(once.entities);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("NARROWER: an intrinsic decodes to a toJSON()-bearing wrapper, not to its own class", () => {
|
|
137
|
+
class Sub implements Intrinsic {
|
|
138
|
+
readonly [INTRINSIC_MARKER] = true as const;
|
|
139
|
+
constructor(readonly template: string) {}
|
|
140
|
+
toJSON(): unknown {
|
|
141
|
+
return { "Fn::Sub": this.template };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const Fn = createResource("Test::Fn", "test", {});
|
|
145
|
+
const fn = new Fn({ Name: new Sub("${AWS::StackName}-fn") });
|
|
146
|
+
const entities = new Map<string, Declarable>([["Fn", fn as unknown as Declarable]]);
|
|
147
|
+
resolveAttrRefs(entities);
|
|
148
|
+
|
|
149
|
+
const decoded = overTheWire(emptyResult({ entities }));
|
|
150
|
+
const name = (decoded.entities.get("Fn") as unknown as { props: { Name: Intrinsic & { template?: string } } }).props.Name;
|
|
151
|
+
|
|
152
|
+
// What still works: the marker, and the serialized form every serializer
|
|
153
|
+
// and every output-reading check goes through.
|
|
154
|
+
expect(INTRINSIC_MARKER in (name as object)).toBe(true);
|
|
155
|
+
expect(name.toJSON()).toEqual({ "Fn::Sub": "${AWS::StackName}-fn" });
|
|
156
|
+
// What does NOT: the class, and its own fields. A policy that reaches into
|
|
157
|
+
// an intrinsic's internals rather than its `toJSON()` sees nothing under
|
|
158
|
+
// `--sandbox`. Documented in docs/.../architecture/sandbox.mdx.
|
|
159
|
+
expect(name).not.toBeInstanceOf(Sub);
|
|
160
|
+
expect(name.template).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("NARROWER: errors cross as plain objects, not Error instances", () => {
|
|
164
|
+
const result = emptyResult({ errors: [new DiscoveryError("/p/a.ts", "boom", "import")] });
|
|
165
|
+
|
|
166
|
+
const decoded = overTheWire(result);
|
|
167
|
+
|
|
168
|
+
expect(decoded.errors).toEqual([
|
|
169
|
+
{ name: "DiscoveryError", file: "/p/a.ts", message: "boom", type: "import" },
|
|
170
|
+
]);
|
|
171
|
+
expect(decoded.errors[0]).not.toBeInstanceOf(Error);
|
|
172
|
+
// Never observable in practice: `chant build` runs policies only when the
|
|
173
|
+
// build produced no errors at all, so this array is always empty there.
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("REFUSED, not dropped: a nestedStack() child project has no wire form", () => {
|
|
177
|
+
const child = { [DECLARABLE_MARKER]: true, [CHILD_PROJECT_MARKER]: true, lexicon: "test", entityType: "Test::Child" };
|
|
178
|
+
const entities = new Map<string, Declarable>([["Child", child as unknown as Declarable]]);
|
|
179
|
+
|
|
180
|
+
expect(() => encodePolicyBuildResult(emptyResult({ entities }))).toThrow(/child project \(nestedStack\(\)\)/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("REFUSED, not dropped: a serializer output that is not data is named by key path", () => {
|
|
184
|
+
const outputs = new Map<string, string | { primary: string }>([
|
|
185
|
+
["weird", { primary: "ok", extra: () => 1 } as unknown as { primary: string }],
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
// `encodeOutput` copies only the declared fields, so a stray function on a
|
|
189
|
+
// SerializerResult never reaches the wire in the first place. The scan is
|
|
190
|
+
// the backstop for the fields that ARE carried.
|
|
191
|
+
const encoded = encodePolicyBuildResult(emptyResult({ outputs }));
|
|
192
|
+
expect(encoded.outputs).toEqual([["weird", { primary: "ok" }]]);
|
|
193
|
+
|
|
194
|
+
expect(() =>
|
|
195
|
+
encodePolicyBuildResult(emptyResult({ manifest: { generatedAt: new Date(0) } })),
|
|
196
|
+
).toThrow(/buildResult\.manifest\.generatedAt: a Date/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the undeclared-but-carried fields (dependencies, manifest, foldDecisions, buildParams) cross too", () => {
|
|
200
|
+
const result = emptyResult({
|
|
201
|
+
dependencies: new Map([["Subnet", new Set(["Vpc"])]]),
|
|
202
|
+
manifest: { lexicons: ["test"], outputs: {}, deployOrder: ["test"] },
|
|
203
|
+
foldDecisions: [{ file: "/p/a.ts", mode: "fold", resourceCount: 1 }],
|
|
204
|
+
buildParams: [{ name: "tier", value: "prod", source: "cli" }],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const decoded = overTheWire(result) as unknown as {
|
|
208
|
+
dependencies: Map<string, Set<string>>;
|
|
209
|
+
manifest: unknown;
|
|
210
|
+
foldDecisions: unknown[];
|
|
211
|
+
buildParams: unknown[];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
expect(decoded.dependencies.get("Subnet")).toEqual(new Set(["Vpc"]));
|
|
215
|
+
expect(decoded.manifest).toEqual({ lexicons: ["test"], outputs: {}, deployOrder: ["test"] });
|
|
216
|
+
expect(decoded.foldDecisions).toEqual([{ file: "/p/a.ts", mode: "fold", resourceCount: 1 }]);
|
|
217
|
+
expect(decoded.buildParams).toEqual([{ name: "tier", value: "prod", source: "cli" }]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a check run over the decoded result produces what it produces over the original", () => {
|
|
221
|
+
const Ingress = createResource("Test::Ingress", "test", {});
|
|
222
|
+
const entities = new Map<string, Declarable>([
|
|
223
|
+
["Ing", new Ingress({ tls: [] }) as unknown as Declarable],
|
|
224
|
+
]);
|
|
225
|
+
resolveAttrRefs(entities);
|
|
226
|
+
const result = emptyResult({ entities, outputs: new Map([["test", "kind: Ingress\n"]]) });
|
|
227
|
+
|
|
228
|
+
const check: PostSynthCheck = {
|
|
229
|
+
id: "T",
|
|
230
|
+
description: "reads both surfaces",
|
|
231
|
+
check(ctx) {
|
|
232
|
+
const out: Array<{ checkId: string; severity: "error"; message: string; entity?: string }> = [];
|
|
233
|
+
for (const [name, entity] of ctx.entities) {
|
|
234
|
+
const props = (entity as unknown as { props?: { tls?: unknown[] } }).props;
|
|
235
|
+
if (Array.isArray(props?.tls) && props.tls.length === 0) {
|
|
236
|
+
out.push({ checkId: "T", severity: "error", message: `${name} has no TLS in ${ctx.env}`, entity: name });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const [, o] of ctx.outputs) {
|
|
240
|
+
if (typeof o === "string" && o.includes("Ingress")) {
|
|
241
|
+
out.push({ checkId: "T", severity: "error", message: "output mentions Ingress" });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const inProcess = runPostSynthChecks([check], result as unknown as Parameters<typeof runPostSynthChecks>[1], "prod");
|
|
249
|
+
const sandboxed = runPostSynthChecks([check], overTheWire(result), "prod");
|
|
250
|
+
|
|
251
|
+
expect(sandboxed).toEqual(inProcess);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("what a policy is allowed to return (chant #1131)", () => {
|
|
256
|
+
const P = "/p/policies/org.ts";
|
|
257
|
+
|
|
258
|
+
test("plain diagnostics pass", () => {
|
|
259
|
+
expect(
|
|
260
|
+
scanPolicyDiagnostics(
|
|
261
|
+
[
|
|
262
|
+
{ checkId: "A", severity: "error", message: "m", entity: "E", lexicon: "k8s" },
|
|
263
|
+
{ checkId: "B", severity: "info", message: "n" },
|
|
264
|
+
],
|
|
265
|
+
P,
|
|
266
|
+
),
|
|
267
|
+
).toEqual([]);
|
|
268
|
+
expect(scanPolicyDiagnostics([], P)).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a function on a diagnostic is named, with the policy that produced it", () => {
|
|
272
|
+
const offenders = scanPolicyDiagnostics([{ checkId: "A", severity: "error", message: "m", fix: () => 1 }], P);
|
|
273
|
+
|
|
274
|
+
expect(offenders).toEqual([{ policy: P, path: "[0].fix", found: "a function" }]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a Date, a class instance and a circular reference are each named", () => {
|
|
278
|
+
expect(scanPolicyDiagnostics([{ checkId: "A", severity: "error", message: "m", at: new Date(0) }], P)[0]).toMatchObject({
|
|
279
|
+
path: "[0].at",
|
|
280
|
+
found: "a Date",
|
|
281
|
+
});
|
|
282
|
+
expect(scanPolicyDiagnostics([{ checkId: "A", severity: "error", message: "m", err: new Error("x") }], P)[0]).toMatchObject({
|
|
283
|
+
found: "an Error",
|
|
284
|
+
});
|
|
285
|
+
const circular: Record<string, unknown> = { checkId: "A", severity: "error", message: "m" };
|
|
286
|
+
circular.self = circular;
|
|
287
|
+
expect(scanPolicyDiagnostics([circular], P)[0]).toMatchObject({ found: "a circular reference" });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("a value that is data but is not a diagnostic is named too", () => {
|
|
291
|
+
expect(scanPolicyDiagnostics([{ severity: "error", message: "m" }], P)).toEqual([
|
|
292
|
+
{ policy: P, path: "[0].checkId", found: "not a non-empty string" },
|
|
293
|
+
]);
|
|
294
|
+
expect(scanPolicyDiagnostics([{ checkId: "A", severity: "fatal", message: "m" }], P)).toEqual([
|
|
295
|
+
{ policy: P, path: "[0].severity", found: `not one of "error", "warning", "info"` },
|
|
296
|
+
]);
|
|
297
|
+
expect(scanPolicyDiagnostics(["just a string"], P)).toEqual([
|
|
298
|
+
{ policy: P, path: "[0]", found: "a string" },
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a check that does not return an array at all is named", () => {
|
|
303
|
+
expect(scanPolicyDiagnostics(undefined, P)).toEqual([
|
|
304
|
+
{ policy: P, path: "<return value>", found: "undefined" },
|
|
305
|
+
]);
|
|
306
|
+
expect(scanPolicyDiagnostics({ checkId: "A" }, P)).toEqual([
|
|
307
|
+
{ policy: P, path: "<return value>", found: "a object" },
|
|
308
|
+
]);
|
|
309
|
+
});
|
|
310
|
+
});
|