@intentius/chant-lexicon-aws 0.124.32 → 0.124.34

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.
@@ -3,6 +3,7 @@ import { ownershipEntries } from "@intentius/chant/ownership";
3
3
  import { isEffectReceipt, receiptExpectation, referenceInputPaths, } from "@intentius/chant/effect-receipt";
4
4
  import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "./ownership.js";
5
5
  import { AWS_EFFECT_RECEIPT_ENTITY_TYPE, EFFECT_RECEIPTS_METADATA_KEY, RECEIPT_UNRESOLVED_VALUE_NOTE, receiptParameterName, } from "./effect-receipt-row.js";
6
+ import { sanitizeLogicalId } from "@intentius/chant/lexicon-output";
6
7
  import { walkValue } from "@intentius/chant/serializer-walker";
7
8
  import { isChildProject } from "@intentius/chant/child-project";
8
9
  import { isStackOutput } from "@intentius/chant/stack-output";
@@ -31,12 +32,34 @@ const PARAMETER_CONSTRAINT_KEYS = [
31
32
  ["maxValue", "MaxValue"],
32
33
  ["noEcho", "NoEcho"],
33
34
  ];
35
+ /** Per template (keyed by its entity map): the foreign attributes its values read, by parameter name. */
36
+ const foreignAttrs = new WeakMap();
37
+ const namesIn = new WeakMap();
38
+ function holds(entityNames, name) {
39
+ let names = namesIn.get(entityNames);
40
+ if (!names) {
41
+ names = new Set(entityNames.values());
42
+ namesIn.set(entityNames, names);
43
+ }
44
+ return names.has(name);
45
+ }
34
46
  /**
35
47
  * CloudFormation-specific visitor for the generic serializer walker.
36
48
  */
37
49
  function cfnVisitor(entityNames) {
38
50
  return {
39
- attrRef: (name, attr) => ({ "Fn::GetAtt": [name, attr] }),
51
+ attrRef: (name, attr) => {
52
+ if (holds(entityNames, name))
53
+ return { "Fn::GetAtt": [name, attr] };
54
+ const param = sanitizeLogicalId(name, attr);
55
+ let foreign = foreignAttrs.get(entityNames);
56
+ if (!foreign) {
57
+ foreign = new Map();
58
+ foreignAttrs.set(entityNames, foreign);
59
+ }
60
+ foreign.set(param, { entity: name, attribute: attr });
61
+ return { Ref: param };
62
+ },
40
63
  resourceRef: (name) => ({ Ref: name }),
41
64
  propertyDeclarable: (entity, walk) => {
42
65
  if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
@@ -152,7 +175,7 @@ function renderReceiptRows(receipts, ownership) {
152
175
  /**
153
176
  * Serialize a set of entities into a CFTemplate object (without JSON.stringify).
154
177
  */
155
- function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, ownership, receiptRows) {
178
+ function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, ownership, receiptRows, warnings = []) {
156
179
  const template = {
157
180
  AWSTemplateFormatVersion: "2010-09-09",
158
181
  Resources: {},
@@ -435,6 +458,17 @@ function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, o
435
458
  if (extraOutputs && Object.keys(extraOutputs).length > 0) {
436
459
  template.Outputs = { ...template.Outputs, ...extraOutputs };
437
460
  }
461
+ // Values read from resources this template doesn't hold become parameters.
462
+ for (const [param, ref] of foreignAttrs.get(entityNames) ?? []) {
463
+ if (template.Resources[param] || template.Parameters?.[param])
464
+ continue;
465
+ template.Parameters = template.Parameters ?? {};
466
+ template.Parameters[param] = {
467
+ Type: "String",
468
+ Description: `${ref.entity}.${ref.attribute}, from another lexicon's output "${param}"`,
469
+ };
470
+ warnings.push(`aws: "${ref.entity}" is not in this template, so its ${ref.attribute} is the parameter "${param}". Deploy "${ref.entity}" first and pass its output "${param}" in.`);
471
+ }
438
472
  refuseImportEnvelopes(template);
439
473
  return template;
440
474
  }
@@ -483,8 +517,10 @@ export const awsSerializer = {
483
517
  }
484
518
  // No nested stacks — use the simple path
485
519
  if (!hasChildProjects) {
486
- const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows);
487
- return JSON.stringify(template, null, 2);
520
+ const warnings = [];
521
+ const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows, warnings);
522
+ const primary = JSON.stringify(template, null, 2);
523
+ return warnings.length > 0 ? { primary, warnings } : primary;
488
524
  }
489
525
  // Has child projects — produce multi-file output
490
526
  const allFiles = {};
@@ -519,11 +555,13 @@ export const awsSerializer = {
519
555
  }
520
556
  }
521
557
  // Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
522
- const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows);
558
+ const warnings = [];
559
+ const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows, warnings);
523
560
  const primary = JSON.stringify(parentTemplate, null, 2);
524
561
  return {
525
562
  primary,
526
563
  files: allFiles,
564
+ ...(warnings.length > 0 ? { warnings } : {}),
527
565
  };
528
566
  },
529
567
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.124.32",
3
+ "version": "0.124.34",
4
4
  "description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -80,6 +80,6 @@
80
80
  "typescript": "^5.9.3"
81
81
  },
82
82
  "peerDependencies": {
83
- "@intentius/chant": "^0.124.32"
83
+ "@intentius/chant": "^0.124.34"
84
84
  }
85
85
  }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ clusterOfServiceArn,
4
+ ecsServiceEntry,
5
+ healthCheckEntry,
6
+ observeHealthAws,
7
+ parseHealthCheckStatus,
8
+ type AwsHealthHttp,
9
+ } from "./health";
10
+ import type { AwsReadHttp } from "./api/read-client";
11
+ import { awsPlugin } from "./plugin";
12
+
13
+ const AT = "2026-10-20T21:00:00.000Z";
14
+ const now = () => new Date(AT);
15
+
16
+ function stackXml(members: Array<{ logical: string; type: string; physical: string }>): string {
17
+ const body = members
18
+ .map((m) => `<member><LogicalResourceId>${m.logical}</LogicalResourceId><ResourceType>${m.type}</ResourceType><PhysicalResourceId>${m.physical}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus></member>`)
19
+ .join("");
20
+ return `<DescribeStackResourcesResponse><DescribeStackResourcesResult><StackResources>${body}</StackResources></DescribeStackResourcesResult></DescribeStackResourcesResponse>`;
21
+ }
22
+
23
+ function statusXml(obs: Array<{ region: string; status: string; time: string }>): string {
24
+ const body = obs
25
+ .map((o) => `<HealthCheckObservation><Region>${o.region}</Region><IPAddress>1.2.3.4</IPAddress><StatusReport><Status>${o.status}</Status><CheckedTime>${o.time}</CheckedTime></StatusReport></HealthCheckObservation>`)
26
+ .join("");
27
+ return `<GetHealthCheckStatusResponse><HealthCheckObservations>${body}</HealthCheckObservations></GetHealthCheckStatusResponse>`;
28
+ }
29
+
30
+ const ENV = { AWS_ENDPOINT_URL: "http://localhost:4566" };
31
+
32
+ describe("aws health (#4177)", () => {
33
+ test("parses checker observations", () => {
34
+ const obs = parseHealthCheckStatus(statusXml([{ region: "us-east-1", status: "Success: HTTP Status Code 200, OK", time: "2026-10-20T20:59:00Z" }]));
35
+ expect(obs).toEqual([{ region: "us-east-1", ipAddress: "1.2.3.4", status: "Success: HTTP Status Code 200, OK", checkedTime: "2026-10-20T20:59:00Z" }]);
36
+ });
37
+
38
+ test("a health check is healthy only when more than 18% of checkers say so", () => {
39
+ const mk = (ok: number, total: number) =>
40
+ Array.from({ length: total }, (_, i) => ({ status: i < ok ? "Success: HTTP 200" : "Failure: timed out", checkedTime: `2026-10-20T20:5${i}:00Z` }));
41
+ expect(healthCheckEntry(mk(2, 10), AT).state).toBe("healthy");
42
+ expect(healthCheckEntry(mk(1, 10), AT).state).toBe("unhealthy");
43
+ const e = healthCheckEntry(mk(2, 10), AT);
44
+ expect(e.detail).toBe("2/10 checkers healthy; last: Failure: timed out");
45
+ expect(e.checkedAt).toBe("2026-10-20T20:59:00Z");
46
+ expect(healthCheckEntry([], AT).state).toBe("unknown");
47
+ });
48
+
49
+ test("an ECS service is running against desired", () => {
50
+ expect(ecsServiceEntry({ status: "ACTIVE", desiredCount: 2, runningCount: 2 }, AT)).toMatchObject({ state: "healthy", detail: "2/2 running" });
51
+ expect(ecsServiceEntry({ status: "ACTIVE", desiredCount: 3, runningCount: 1, pendingCount: 2 }, AT)).toMatchObject({ state: "unhealthy", detail: "1/3 running, 2 pending" });
52
+ expect(ecsServiceEntry({ status: "ACTIVE", desiredCount: 0, runningCount: 0 }, AT).state).toBe("unknown");
53
+ expect(ecsServiceEntry({ status: "DRAINING", desiredCount: 1, runningCount: 1 }, AT).state).toBe("unhealthy");
54
+ });
55
+
56
+ test("the cluster comes from the service ARN", () => {
57
+ expect(clusterOfServiceArn("arn:aws:ecs:us-east-1:000000000000:service/app/web")).toBe("app");
58
+ expect(clusterOfServiceArn("arn:aws:ecs:us-east-1:000000000000:service/web")).toBe("default");
59
+ });
60
+
61
+ test("reads both kinds through the endpoint override, keyed by logical id", async () => {
62
+ const cfn: AwsReadHttp = async (url) => {
63
+ expect(url).toBe("http://localhost:4566/");
64
+ return {
65
+ status: 200,
66
+ text: stackXml([
67
+ { logical: "web", type: "AWS::ECS::Service", physical: "arn:aws:ecs:us-east-1:000000000000:service/app/web" },
68
+ { logical: "webCheck", type: "AWS::Route53::HealthCheck", physical: "abc-123" },
69
+ ]),
70
+ };
71
+ };
72
+ const calls: string[] = [];
73
+ const http: AwsHealthHttp = async (method, url, init) => {
74
+ calls.push(`${method} ${url}`);
75
+ if (url.includes("/healthcheck/")) {
76
+ return { status: 200, text: statusXml([{ region: "us-east-1", status: "Success: HTTP 200", time: "2026-10-20T20:58:00Z" }]) };
77
+ }
78
+ expect(JSON.parse(init.body!)).toEqual({ cluster: "app", services: ["arn:aws:ecs:us-east-1:000000000000:service/app/web"] });
79
+ return {
80
+ status: 200,
81
+ text: JSON.stringify({ services: [{ serviceArn: "arn:aws:ecs:us-east-1:000000000000:service/app/web", status: "ACTIVE", desiredCount: 2, runningCount: 2 }], failures: [] }),
82
+ };
83
+ };
84
+ const r = await observeHealthAws({
85
+ environment: "dev",
86
+ buildOutput: "",
87
+ entities: new Map([
88
+ ["web", { entityType: "AWS::ECS::Service", props: {} }],
89
+ ["webCheck", { entityType: "AWS::Route53::HealthCheck", props: {} }],
90
+ ["later", { entityType: "AWS::ECS::Service", props: {} }],
91
+ ]),
92
+ http,
93
+ client: { http: cfn, env: ENV },
94
+ now,
95
+ });
96
+ expect(r.entities.web).toMatchObject({ state: "healthy", observedAt: AT });
97
+ expect(r.entities.webCheck).toMatchObject({ state: "healthy", checkedAt: "2026-10-20T20:58:00Z" });
98
+ expect(r.absent).toEqual(["later"]);
99
+ expect(calls).toContain("GET http://localhost:4566/2013-04-01/healthcheck/abc-123/status");
100
+ });
101
+
102
+ test("a missing stack is absent; a failed stack read is a hole for every entity", async () => {
103
+ const entities = new Map([["web", { entityType: "AWS::ECS::Service", props: {} }]]);
104
+ const missing: AwsReadHttp = async () => ({
105
+ status: 400,
106
+ text: "<ErrorResponse><Error><Code>ValidationError</Code><Message>Stack with id dev does not exist</Message></Error></ErrorResponse>",
107
+ });
108
+ const gone = await observeHealthAws({ environment: "dev", buildOutput: "", entities, client: { http: missing, env: ENV } });
109
+ expect(gone.absent).toEqual(["web"]);
110
+
111
+ const broken: AwsReadHttp = async () => {
112
+ throw new Error("connect ECONNREFUSED");
113
+ };
114
+ const hole = await observeHealthAws({ environment: "dev", buildOutput: "", entities, client: { http: broken, env: ENV } });
115
+ expect(hole.entities).toEqual({});
116
+ expect(hole.unobserved.web?.reason).toBe("read-failed");
117
+ });
118
+
119
+ test("a failed service read is a hole, never unhealthy", async () => {
120
+ const cfn: AwsReadHttp = async () => ({ status: 200, text: stackXml([{ logical: "web", type: "AWS::ECS::Service", physical: "arn:aws:ecs:us-east-1:0:service/app/web" }]) });
121
+ const http: AwsHealthHttp = async () => ({ status: 400, text: JSON.stringify({ __type: "ClusterNotFoundException", message: "Cluster not found." }) });
122
+ const r = await observeHealthAws({
123
+ environment: "dev",
124
+ buildOutput: "",
125
+ entities: new Map([["web", { entityType: "AWS::ECS::Service", props: {} }]]),
126
+ http,
127
+ client: { http: cfn, env: ENV },
128
+ });
129
+ expect(r.unobserved.web).toMatchObject({ reason: "read-failed", detail: "ClusterNotFoundException: Cluster not found." });
130
+ });
131
+
132
+ test("the plugin names the types it reads", () => {
133
+ expect(awsPlugin.healthTypes).toEqual(["AWS::Route53::HealthCheck", "AWS::ECS::Service"]);
134
+ expect(typeof awsPlugin.observeHealth).toBe("function");
135
+ });
136
+ });
package/src/health.ts ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Runtime health for AWS (#4177): Route 53 health checks and ECS services.
3
+ *
4
+ * The stack's own resource list maps each logical id (the graph's node id) to
5
+ * the physical id the health APIs take. A stack that does not exist yet means
6
+ * nothing is deployed, so every entity is absent. Any other stack failure is a
7
+ * hole for every entity, never `unhealthy`.
8
+ *
9
+ * - `AWS::Route53::HealthCheck`: `GetHealthCheckStatus`, aggregated as Route 53
10
+ * does: healthy when more than 18% of the checkers report healthy.
11
+ * - `AWS::ECS::Service`: `DescribeServices`, `runningCount` against
12
+ * `desiredCount`.
13
+ *
14
+ * Both go through the read transport's endpoint rule, so `AWS_ENDPOINT_URL`
15
+ * (Floci) answers them.
16
+ */
17
+
18
+ import type { HealthEntry, HealthReadOptions, HealthReadResult, UnobservedEntity } from "@intentius/chant/lexicon";
19
+ import {
20
+ AwsReadError,
21
+ describeStackResources,
22
+ requestHeaders,
23
+ serviceUrl,
24
+ withChainCredentials,
25
+ withEndpointOverride,
26
+ xmlError,
27
+ xmlLeaves,
28
+ type AwsReadClientOptions,
29
+ } from "./api/read-client";
30
+ import { resolveCredentials, signRequest } from "./api/sigv4";
31
+ import { stackDoesNotExist } from "./stack-errors";
32
+
33
+ export const AWS_HEALTH_TYPES = ["AWS::Route53::HealthCheck", "AWS::ECS::Service"] as const;
34
+
35
+ /** Injectable HTTP for both calls, so tests never reach the network. */
36
+ export type AwsHealthHttp = (
37
+ method: "GET" | "POST",
38
+ url: string,
39
+ init: { headers: Record<string, string>; body?: string },
40
+ ) => Promise<{ status: number; text: string }>;
41
+
42
+ const defaultHttp: AwsHealthHttp = async (method, url, init) => {
43
+ const res = await fetch(url, { method, headers: init.headers, ...(init.body !== undefined ? { body: init.body } : {}) });
44
+ return { status: res.status, text: await res.text() };
45
+ };
46
+
47
+ export interface AwsHealthOptions extends HealthReadOptions {
48
+ http?: AwsHealthHttp;
49
+ /** Read client for the stack list and the signing decision. Tests inject `http` and `env` here. */
50
+ client?: AwsReadClientOptions;
51
+ now?: () => Date;
52
+ }
53
+
54
+ /** Route 53 treats an endpoint as healthy when more than this share of checkers says so. */
55
+ const ROUTE53_HEALTHY_SHARE = 0.18;
56
+
57
+ /** One checker's report, as `GetHealthCheckStatus` returns it. */
58
+ export interface CheckerObservation {
59
+ region?: string;
60
+ ipAddress?: string;
61
+ status: string;
62
+ checkedTime?: string;
63
+ }
64
+
65
+ /** Parse `GetHealthCheckStatusResponse`'s observations. */
66
+ export function parseHealthCheckStatus(xml: string): CheckerObservation[] {
67
+ const out: CheckerObservation[] = [];
68
+ for (const m of xml.matchAll(/<HealthCheckObservation>([\s\S]*?)<\/HealthCheckObservation>/g)) {
69
+ const body = m[1] ?? "";
70
+ const report = xmlLeaves(body.match(/<StatusReport>([\s\S]*?)<\/StatusReport>/)?.[1] ?? "");
71
+ const top = xmlLeaves(body.replace(/<StatusReport>[\s\S]*?<\/StatusReport>/, ""));
72
+ out.push({
73
+ ...(top.Region ? { region: top.Region } : {}),
74
+ ...(top.IPAddress ? { ipAddress: top.IPAddress } : {}),
75
+ status: report.Status ?? "",
76
+ ...(report.CheckedTime ? { checkedTime: report.CheckedTime } : {}),
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** A checker's status text starts with `Success` when it found the endpoint healthy. */
83
+ function checkerHealthy(o: CheckerObservation): boolean {
84
+ return /^success/i.test(o.status.trim());
85
+ }
86
+
87
+ /** Route 53's verdict over its checkers' observations. */
88
+ export function healthCheckEntry(observations: CheckerObservation[], observedAt: string): HealthEntry {
89
+ const type = "AWS::Route53::HealthCheck";
90
+ if (observations.length === 0) {
91
+ return { type, state: "unknown", detail: "no checker has reported yet", observedAt };
92
+ }
93
+ const healthy = observations.filter(checkerHealthy).length;
94
+ const latest = [...observations].sort((a, b) => (b.checkedTime ?? "").localeCompare(a.checkedTime ?? ""))[0]!;
95
+ const state = healthy / observations.length > ROUTE53_HEALTHY_SHARE ? "healthy" : "unhealthy";
96
+ return {
97
+ type,
98
+ state,
99
+ detail: `${healthy}/${observations.length} checkers healthy; last: ${latest.status}`,
100
+ observedAt,
101
+ ...(latest.checkedTime ? { checkedAt: latest.checkedTime } : {}),
102
+ };
103
+ }
104
+
105
+ /** The parts of an ECS service `DescribeServices` returns that decide health. */
106
+ export interface EcsServiceState {
107
+ status?: string;
108
+ desiredCount?: number;
109
+ runningCount?: number;
110
+ pendingCount?: number;
111
+ deployments?: Array<{ rolloutState?: string; updatedAt?: number | string }>;
112
+ }
113
+
114
+ /** Running against desired. Scaled to zero is `unknown`: nothing is serving, by choice. */
115
+ export function ecsServiceEntry(service: EcsServiceState, observedAt: string): HealthEntry {
116
+ const type = "AWS::ECS::Service";
117
+ const desired = service.desiredCount ?? 0;
118
+ const running = service.runningCount ?? 0;
119
+ const pending = service.pendingCount ?? 0;
120
+ const counts = `${running}/${desired} running${pending > 0 ? `, ${pending} pending` : ""}`;
121
+ const rollout = service.deployments?.find((d) => d.rolloutState)?.rolloutState;
122
+ const detail = `${counts}${rollout ? `, rollout ${rollout}` : ""}`;
123
+ if (service.status && service.status !== "ACTIVE") {
124
+ return { type, state: "unhealthy", detail: `service is ${service.status}; ${detail}`, observedAt };
125
+ }
126
+ if (desired === 0) return { type, state: "unknown", detail: `desired count is 0; ${detail}`, observedAt };
127
+ return { type, state: running >= desired ? "healthy" : "unhealthy", detail, observedAt };
128
+ }
129
+
130
+ /** The cluster a service ARN names: `service/<cluster>/<name>`, or the default cluster for the old `service/<name>` form. */
131
+ export function clusterOfServiceArn(arn: string): string {
132
+ const resource = arn.split(":").slice(5).join(":");
133
+ const parts = resource.split("/");
134
+ return parts.length >= 3 ? parts[1]! : "default";
135
+ }
136
+
137
+ function holeFrom(type: string, err: unknown): UnobservedEntity {
138
+ const detail = err instanceof AwsReadError && err.code ? `${err.code}: ${err.message}` : err instanceof Error ? err.message : String(err);
139
+ const reason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail) ? "no-credentials" : "read-failed";
140
+ return { type, reason, detail: detail.slice(0, 200) };
141
+ }
142
+
143
+ async function getHealthCheckStatus(
144
+ id: string,
145
+ client: AwsReadClientOptions,
146
+ http: AwsHealthHttp,
147
+ ): Promise<CheckerObservation[]> {
148
+ const options = await withChainCredentials(withEndpointOverride("route53", client));
149
+ // Route 53 is global: one host, signed in us-east-1.
150
+ const base = (options.endpoint ?? "https://route53.amazonaws.com").replace(/\/$/, "");
151
+ const url = `${base}/2013-04-01/healthcheck/${encodeURIComponent(id)}/status`;
152
+ const env = options.env ?? process.env;
153
+ const credentials = resolveCredentials(options.credentials, env);
154
+ const signable = credentials && (!options.endpoint || options.signEndpointOverride === true);
155
+ const headers = signable
156
+ ? signRequest({ method: "GET", url, headers: {}, body: "", service: "route53", region: "us-east-1", credentials, ...(options.now ? { now: options.now } : {}) })
157
+ : {};
158
+ const res = await http("GET", url, { headers });
159
+ const err = xmlError(res.text);
160
+ if (err || res.status >= 400) {
161
+ throw new AwsReadError(err?.message ?? `GetHealthCheckStatus failed with HTTP ${res.status}`, res.status, err?.code);
162
+ }
163
+ return parseHealthCheckStatus(res.text);
164
+ }
165
+
166
+ async function describeServices(
167
+ cluster: string,
168
+ arns: string[],
169
+ client: AwsReadClientOptions,
170
+ http: AwsHealthHttp,
171
+ ): Promise<{ services: Array<EcsServiceState & { serviceArn?: string }>; failures: Array<{ arn?: string; reason?: string }> }> {
172
+ const options = await withChainCredentials(withEndpointOverride("ecs", client));
173
+ const url = serviceUrl("ecs", options.endpoint, options.region);
174
+ const body = JSON.stringify({ cluster, services: arns });
175
+ const headers = requestHeaders(
176
+ "ecs",
177
+ url,
178
+ body,
179
+ { "content-type": "application/x-amz-json-1.1", "x-amz-target": "AmazonEC2ContainerServiceV20141113.DescribeServices" },
180
+ options,
181
+ );
182
+ const res = await http("POST", url, { headers, body });
183
+ let parsed: Record<string, unknown> = {};
184
+ try {
185
+ const value: unknown = JSON.parse(res.text);
186
+ if (value && typeof value === "object" && !Array.isArray(value)) parsed = value as Record<string, unknown>;
187
+ } catch {
188
+ throw new AwsReadError("unparseable DescribeServices response", res.status);
189
+ }
190
+ const errType = typeof parsed.__type === "string" ? parsed.__type.split("#").pop() : undefined;
191
+ if (errType || res.status >= 400) {
192
+ const message = typeof parsed.message === "string" ? parsed.message : `DescribeServices failed with HTTP ${res.status}`;
193
+ throw new AwsReadError(message, res.status, errType);
194
+ }
195
+ return {
196
+ services: Array.isArray(parsed.services) ? (parsed.services as Array<EcsServiceState & { serviceArn?: string }>) : [],
197
+ failures: Array.isArray(parsed.failures) ? (parsed.failures as Array<{ arn?: string; reason?: string }>) : [],
198
+ };
199
+ }
200
+
201
+ export async function observeHealthAws(options: AwsHealthOptions): Promise<HealthReadResult> {
202
+ const http = options.http ?? defaultHttp;
203
+ const observedAt = (options.now?.() ?? new Date()).toISOString();
204
+ const client: AwsReadClientOptions = { ...(options.client ?? {}), ...(options.region ? { region: options.region } : {}) };
205
+ const entities: Record<string, HealthEntry> = {};
206
+ const unobserved: Record<string, UnobservedEntity> = {};
207
+ const absent: string[] = [];
208
+ if (options.entities.size === 0) return { entities, unobserved, absent };
209
+
210
+ const stackName = options.stack ?? options.environment;
211
+ let physical: Map<string, string>;
212
+ try {
213
+ const resources = await describeStackResources(stackName, client);
214
+ physical = new Map(resources.filter((r) => r.physicalId).map((r) => [r.logicalId, r.physicalId!]));
215
+ } catch (err) {
216
+ if (err instanceof AwsReadError && stackDoesNotExist(err.message)) return { entities, unobserved, absent: [...options.entities.keys()] };
217
+ for (const [name, { entityType }] of options.entities) {
218
+ unobserved[name] = holeFrom(entityType, err);
219
+ unobserved[name]!.detail = `DescribeStackResources failed for stack "${stackName}": ${unobserved[name]!.detail}`;
220
+ }
221
+ return { entities, unobserved, absent };
222
+ }
223
+
224
+ const services = new Map<string, Array<{ name: string; arn: string }>>();
225
+ const checks: Array<{ name: string; id: string }> = [];
226
+ for (const [name, { entityType }] of options.entities) {
227
+ const id = physical.get(name);
228
+ if (!id) {
229
+ absent.push(name);
230
+ continue;
231
+ }
232
+ if (entityType === "AWS::ECS::Service") {
233
+ const cluster = clusterOfServiceArn(id);
234
+ const list = services.get(cluster) ?? [];
235
+ list.push({ name, arn: id });
236
+ services.set(cluster, list);
237
+ } else if (entityType === "AWS::Route53::HealthCheck") {
238
+ checks.push({ name, id });
239
+ }
240
+ }
241
+
242
+ await Promise.all(
243
+ checks.map(async ({ name, id }) => {
244
+ try {
245
+ entities[name] = healthCheckEntry(await getHealthCheckStatus(id, client, http), observedAt);
246
+ } catch (err) {
247
+ if (err instanceof AwsReadError && err.code === "NoSuchHealthCheck") absent.push(name);
248
+ else unobserved[name] = holeFrom("AWS::Route53::HealthCheck", err);
249
+ }
250
+ }),
251
+ );
252
+
253
+ await Promise.all(
254
+ [...services].map(async ([cluster, members]) => {
255
+ // DescribeServices takes at most ten services per call.
256
+ for (let i = 0; i < members.length; i += 10) {
257
+ const batch = members.slice(i, i + 10);
258
+ try {
259
+ const { services: found, failures } = await describeServices(cluster, batch.map((m) => m.arn), client, http);
260
+ for (const member of batch) {
261
+ const service = found.find((s) => s.serviceArn === member.arn || s.serviceArn?.endsWith(`/${member.arn.split("/").pop()}`));
262
+ const failure = failures.find((f) => f.arn === member.arn);
263
+ if (service) entities[member.name] = ecsServiceEntry(service, observedAt);
264
+ else if (failure?.reason === "MISSING") absent.push(member.name);
265
+ else {
266
+ unobserved[member.name] = {
267
+ type: "AWS::ECS::Service",
268
+ reason: "read-failed",
269
+ detail: failure?.reason ? `DescribeServices: ${failure.reason}` : "DescribeServices did not return the service",
270
+ };
271
+ }
272
+ }
273
+ } catch (err) {
274
+ for (const member of batch) unobserved[member.name] = holeFrom("AWS::ECS::Service", err);
275
+ }
276
+ }
277
+ }),
278
+ );
279
+
280
+ return { entities, unobserved, absent };
281
+ }
@@ -90,6 +90,7 @@ export const awsAuditCatalog: Record<string, RuleMeta> = {
90
90
  WAW067: auditRule("WAW067", "report-only", "guidance", "Single-AZ NAT gateway serves multi-AZ private subnets", "Add one NAT gateway per Availability Zone and point each AZ's subnets at its own.", { category: "best-practice" }),
91
91
  WAW068: auditRule("WAW068", "report-only", "guidance", "VPN Gateway or Transit Gateway has only one attached VPN Connection", "Attach a second VPNConnection (ideally to a separate Customer Gateway) for redundancy.", { category: "best-practice" }),
92
92
  WAW069: auditRule("WAW069", "merge-worthy", "guidance", "Template references a condition it never declares", "Declare the condition in the Conditions section (new Condition(...)), or fix the referenced name.", { category: "correctness" }),
93
+ WAW070: auditRule("WAW070", "merge-worthy", "guidance", "Ref or Fn::GetAtt names something the template doesn't declare", "Reference a resource or parameter in this template. A value from another lexicon's resource arrives as a parameter.", { category: "correctness" }),
93
94
  };
94
95
 
95
96
  // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
@@ -156,4 +156,8 @@ export const awsAuditLineage: Record<string, Lineage[]> = {
156
156
  WAW069: [
157
157
  { tool: "cfn-lint", rule: "E8002", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E8002", relation: "equivalent" },
158
158
  ],
159
+ WAW070: [
160
+ { tool: "cfn-lint", rule: "E1012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1012", relation: "equivalent" },
161
+ { tool: "cfn-lint", rule: "E1010", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1010", relation: "overlaps" },
162
+ ],
159
163
  };
@@ -61,6 +61,7 @@ import { waw066 } from "./waw066";
61
61
  import { waw067 } from "./waw067";
62
62
  import { waw068 } from "./waw068";
63
63
  import { waw069 } from "./waw069";
64
+ import { waw070 } from "./waw070";
64
65
 
65
66
  export const postSynthChecks: PostSynthCheck[] = [
66
67
  cor020,
@@ -124,4 +125,5 @@ export const postSynthChecks: PostSynthCheck[] = [
124
125
  waw067,
125
126
  waw068,
126
127
  waw069,
128
+ waw070,
127
129
  ];
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw070, checkDanglingRefs } from "./waw070";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW070: Ref or Fn::GetAtt names something the template doesn't declare", () => {
10
+ test("check metadata", () => {
11
+ expect(waw070.id).toBe("WAW070");
12
+ });
13
+
14
+ test("Fn::GetAtt to a resource not in the template → error", () => {
15
+ const diags = checkDanglingRefs(makeCtx({
16
+ Resources: {
17
+ Record: { Type: "AWS::Route53::RecordSet", Properties: { ResourceRecords: [{ "Fn::GetAtt": ["app", "id"] }] } },
18
+ },
19
+ }));
20
+ expect(diags).toHaveLength(1);
21
+ expect(diags[0].severity).toBe("error");
22
+ expect(diags[0].message).toContain('Fn::GetAtt "app"');
23
+ expect(diags[0].entity).toBe("Record");
24
+ });
25
+
26
+ test("Ref to something undeclared in an output → error", () => {
27
+ const diags = checkDanglingRefs(makeCtx({
28
+ Resources: { Bucket: { Type: "AWS::S3::Bucket" } },
29
+ Outputs: { Name: { Value: { Ref: "Missing" } } },
30
+ }));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].message).toContain('Output "Name" has Ref "Missing"');
33
+ });
34
+
35
+ test("resources, parameters, pseudo-parameters and the string GetAtt form are fine", () => {
36
+ const diags = checkDanglingRefs(makeCtx({
37
+ Parameters: { AppFqdn: { Type: "String" } },
38
+ Resources: {
39
+ Bucket: { Type: "AWS::S3::Bucket" },
40
+ Record: {
41
+ Type: "AWS::Route53::RecordSet",
42
+ Properties: {
43
+ ResourceRecords: [{ Ref: "AppFqdn" }],
44
+ Comment: { "Fn::Join": ["", [{ Ref: "AWS::Region" }, { "Fn::GetAtt": "Bucket.Arn" }, { Ref: "Bucket" }]] },
45
+ },
46
+ },
47
+ },
48
+ }));
49
+ expect(diags).toEqual([]);
50
+ });
51
+
52
+ test("Fn::GetAtt to a parameter → error", () => {
53
+ const diags = checkDanglingRefs(makeCtx({
54
+ Parameters: { P: { Type: "String" } },
55
+ Resources: { R: { Type: "AWS::SNS::Topic", Properties: { TopicName: { "Fn::GetAtt": ["P", "X"] } } } },
56
+ }));
57
+ expect(diags).toHaveLength(1);
58
+ });
59
+ });