@intentius/chant-lexicon-aws 0.124.32 → 0.124.33

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.
@@ -0,0 +1,61 @@
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
+ import type { HealthEntry, HealthReadOptions, HealthReadResult } from "@intentius/chant/lexicon";
18
+ import { type AwsReadClientOptions } from "./api/read-client.js";
19
+ export declare const AWS_HEALTH_TYPES: readonly ["AWS::Route53::HealthCheck", "AWS::ECS::Service"];
20
+ /** Injectable HTTP for both calls, so tests never reach the network. */
21
+ export type AwsHealthHttp = (method: "GET" | "POST", url: string, init: {
22
+ headers: Record<string, string>;
23
+ body?: string;
24
+ }) => Promise<{
25
+ status: number;
26
+ text: string;
27
+ }>;
28
+ export interface AwsHealthOptions extends HealthReadOptions {
29
+ http?: AwsHealthHttp;
30
+ /** Read client for the stack list and the signing decision. Tests inject `http` and `env` here. */
31
+ client?: AwsReadClientOptions;
32
+ now?: () => Date;
33
+ }
34
+ /** One checker's report, as `GetHealthCheckStatus` returns it. */
35
+ export interface CheckerObservation {
36
+ region?: string;
37
+ ipAddress?: string;
38
+ status: string;
39
+ checkedTime?: string;
40
+ }
41
+ /** Parse `GetHealthCheckStatusResponse`'s observations. */
42
+ export declare function parseHealthCheckStatus(xml: string): CheckerObservation[];
43
+ /** Route 53's verdict over its checkers' observations. */
44
+ export declare function healthCheckEntry(observations: CheckerObservation[], observedAt: string): HealthEntry;
45
+ /** The parts of an ECS service `DescribeServices` returns that decide health. */
46
+ export interface EcsServiceState {
47
+ status?: string;
48
+ desiredCount?: number;
49
+ runningCount?: number;
50
+ pendingCount?: number;
51
+ deployments?: Array<{
52
+ rolloutState?: string;
53
+ updatedAt?: number | string;
54
+ }>;
55
+ }
56
+ /** Running against desired. Scaled to zero is `unknown`: nothing is serving, by choice. */
57
+ export declare function ecsServiceEntry(service: EcsServiceState, observedAt: string): HealthEntry;
58
+ /** The cluster a service ARN names: `service/<cluster>/<name>`, or the default cluster for the old `service/<name>` form. */
59
+ export declare function clusterOfServiceArn(arn: string): string;
60
+ export declare function observeHealthAws(options: AwsHealthOptions): Promise<HealthReadResult>;
61
+ //# sourceMappingURL=health.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../src/health.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,gBAAgB,EAAoB,MAAM,0BAA0B,CAAC;AACnH,OAAO,EASL,KAAK,oBAAoB,EAC1B,MAAM,mBAAmB,CAAC;AAI3B,eAAO,MAAM,gBAAgB,YAAI,2BAA2B,EAAE,mBAAmB,CAAU,CAAC;AAE5F,wEAAwE;AACxE,MAAM,MAAM,aAAa,GAAG,CAC1B,MAAM,EAAE,KAAK,GAAG,MAAM,EACtB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAO/C,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,mGAAmG;IACnG,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAKD,kEAAkE;AAClE,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,2DAA2D;AAC3D,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAcxE;AAOD,0DAA0D;AAC1D,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,kBAAkB,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAepG;AAED,iFAAiF;AACjF,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,KAAK,CAAC;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;CAC7E;AAED,2FAA2F;AAC3F,wBAAgB,eAAe,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,WAAW,CAazF;AAED,6HAA6H;AAC7H,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAkED,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgF3F"}
package/dist/health.js ADDED
@@ -0,0 +1,216 @@
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
+ import { AwsReadError, describeStackResources, requestHeaders, serviceUrl, withChainCredentials, withEndpointOverride, xmlError, xmlLeaves, } from "./api/read-client.js";
18
+ import { resolveCredentials, signRequest } from "./api/sigv4.js";
19
+ import { stackDoesNotExist } from "./stack-errors.js";
20
+ export const AWS_HEALTH_TYPES = ["AWS::Route53::HealthCheck", "AWS::ECS::Service"];
21
+ const defaultHttp = async (method, url, init) => {
22
+ const res = await fetch(url, { method, headers: init.headers, ...(init.body !== undefined ? { body: init.body } : {}) });
23
+ return { status: res.status, text: await res.text() };
24
+ };
25
+ /** Route 53 treats an endpoint as healthy when more than this share of checkers says so. */
26
+ const ROUTE53_HEALTHY_SHARE = 0.18;
27
+ /** Parse `GetHealthCheckStatusResponse`'s observations. */
28
+ export function parseHealthCheckStatus(xml) {
29
+ const out = [];
30
+ for (const m of xml.matchAll(/<HealthCheckObservation>([\s\S]*?)<\/HealthCheckObservation>/g)) {
31
+ const body = m[1] ?? "";
32
+ const report = xmlLeaves(body.match(/<StatusReport>([\s\S]*?)<\/StatusReport>/)?.[1] ?? "");
33
+ const top = xmlLeaves(body.replace(/<StatusReport>[\s\S]*?<\/StatusReport>/, ""));
34
+ out.push({
35
+ ...(top.Region ? { region: top.Region } : {}),
36
+ ...(top.IPAddress ? { ipAddress: top.IPAddress } : {}),
37
+ status: report.Status ?? "",
38
+ ...(report.CheckedTime ? { checkedTime: report.CheckedTime } : {}),
39
+ });
40
+ }
41
+ return out;
42
+ }
43
+ /** A checker's status text starts with `Success` when it found the endpoint healthy. */
44
+ function checkerHealthy(o) {
45
+ return /^success/i.test(o.status.trim());
46
+ }
47
+ /** Route 53's verdict over its checkers' observations. */
48
+ export function healthCheckEntry(observations, observedAt) {
49
+ const type = "AWS::Route53::HealthCheck";
50
+ if (observations.length === 0) {
51
+ return { type, state: "unknown", detail: "no checker has reported yet", observedAt };
52
+ }
53
+ const healthy = observations.filter(checkerHealthy).length;
54
+ const latest = [...observations].sort((a, b) => (b.checkedTime ?? "").localeCompare(a.checkedTime ?? ""))[0];
55
+ const state = healthy / observations.length > ROUTE53_HEALTHY_SHARE ? "healthy" : "unhealthy";
56
+ return {
57
+ type,
58
+ state,
59
+ detail: `${healthy}/${observations.length} checkers healthy; last: ${latest.status}`,
60
+ observedAt,
61
+ ...(latest.checkedTime ? { checkedAt: latest.checkedTime } : {}),
62
+ };
63
+ }
64
+ /** Running against desired. Scaled to zero is `unknown`: nothing is serving, by choice. */
65
+ export function ecsServiceEntry(service, observedAt) {
66
+ const type = "AWS::ECS::Service";
67
+ const desired = service.desiredCount ?? 0;
68
+ const running = service.runningCount ?? 0;
69
+ const pending = service.pendingCount ?? 0;
70
+ const counts = `${running}/${desired} running${pending > 0 ? `, ${pending} pending` : ""}`;
71
+ const rollout = service.deployments?.find((d) => d.rolloutState)?.rolloutState;
72
+ const detail = `${counts}${rollout ? `, rollout ${rollout}` : ""}`;
73
+ if (service.status && service.status !== "ACTIVE") {
74
+ return { type, state: "unhealthy", detail: `service is ${service.status}; ${detail}`, observedAt };
75
+ }
76
+ if (desired === 0)
77
+ return { type, state: "unknown", detail: `desired count is 0; ${detail}`, observedAt };
78
+ return { type, state: running >= desired ? "healthy" : "unhealthy", detail, observedAt };
79
+ }
80
+ /** The cluster a service ARN names: `service/<cluster>/<name>`, or the default cluster for the old `service/<name>` form. */
81
+ export function clusterOfServiceArn(arn) {
82
+ const resource = arn.split(":").slice(5).join(":");
83
+ const parts = resource.split("/");
84
+ return parts.length >= 3 ? parts[1] : "default";
85
+ }
86
+ function holeFrom(type, err) {
87
+ const detail = err instanceof AwsReadError && err.code ? `${err.code}: ${err.message}` : err instanceof Error ? err.message : String(err);
88
+ const reason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail) ? "no-credentials" : "read-failed";
89
+ return { type, reason, detail: detail.slice(0, 200) };
90
+ }
91
+ async function getHealthCheckStatus(id, client, http) {
92
+ const options = await withChainCredentials(withEndpointOverride("route53", client));
93
+ // Route 53 is global: one host, signed in us-east-1.
94
+ const base = (options.endpoint ?? "https://route53.amazonaws.com").replace(/\/$/, "");
95
+ const url = `${base}/2013-04-01/healthcheck/${encodeURIComponent(id)}/status`;
96
+ const env = options.env ?? process.env;
97
+ const credentials = resolveCredentials(options.credentials, env);
98
+ const signable = credentials && (!options.endpoint || options.signEndpointOverride === true);
99
+ const headers = signable
100
+ ? signRequest({ method: "GET", url, headers: {}, body: "", service: "route53", region: "us-east-1", credentials, ...(options.now ? { now: options.now } : {}) })
101
+ : {};
102
+ const res = await http("GET", url, { headers });
103
+ const err = xmlError(res.text);
104
+ if (err || res.status >= 400) {
105
+ throw new AwsReadError(err?.message ?? `GetHealthCheckStatus failed with HTTP ${res.status}`, res.status, err?.code);
106
+ }
107
+ return parseHealthCheckStatus(res.text);
108
+ }
109
+ async function describeServices(cluster, arns, client, http) {
110
+ const options = await withChainCredentials(withEndpointOverride("ecs", client));
111
+ const url = serviceUrl("ecs", options.endpoint, options.region);
112
+ const body = JSON.stringify({ cluster, services: arns });
113
+ const headers = requestHeaders("ecs", url, body, { "content-type": "application/x-amz-json-1.1", "x-amz-target": "AmazonEC2ContainerServiceV20141113.DescribeServices" }, options);
114
+ const res = await http("POST", url, { headers, body });
115
+ let parsed = {};
116
+ try {
117
+ const value = JSON.parse(res.text);
118
+ if (value && typeof value === "object" && !Array.isArray(value))
119
+ parsed = value;
120
+ }
121
+ catch {
122
+ throw new AwsReadError("unparseable DescribeServices response", res.status);
123
+ }
124
+ const errType = typeof parsed.__type === "string" ? parsed.__type.split("#").pop() : undefined;
125
+ if (errType || res.status >= 400) {
126
+ const message = typeof parsed.message === "string" ? parsed.message : `DescribeServices failed with HTTP ${res.status}`;
127
+ throw new AwsReadError(message, res.status, errType);
128
+ }
129
+ return {
130
+ services: Array.isArray(parsed.services) ? parsed.services : [],
131
+ failures: Array.isArray(parsed.failures) ? parsed.failures : [],
132
+ };
133
+ }
134
+ export async function observeHealthAws(options) {
135
+ const http = options.http ?? defaultHttp;
136
+ const observedAt = (options.now?.() ?? new Date()).toISOString();
137
+ const client = { ...(options.client ?? {}), ...(options.region ? { region: options.region } : {}) };
138
+ const entities = {};
139
+ const unobserved = {};
140
+ const absent = [];
141
+ if (options.entities.size === 0)
142
+ return { entities, unobserved, absent };
143
+ const stackName = options.stack ?? options.environment;
144
+ let physical;
145
+ try {
146
+ const resources = await describeStackResources(stackName, client);
147
+ physical = new Map(resources.filter((r) => r.physicalId).map((r) => [r.logicalId, r.physicalId]));
148
+ }
149
+ catch (err) {
150
+ if (err instanceof AwsReadError && stackDoesNotExist(err.message))
151
+ return { entities, unobserved, absent: [...options.entities.keys()] };
152
+ for (const [name, { entityType }] of options.entities) {
153
+ unobserved[name] = holeFrom(entityType, err);
154
+ unobserved[name].detail = `DescribeStackResources failed for stack "${stackName}": ${unobserved[name].detail}`;
155
+ }
156
+ return { entities, unobserved, absent };
157
+ }
158
+ const services = new Map();
159
+ const checks = [];
160
+ for (const [name, { entityType }] of options.entities) {
161
+ const id = physical.get(name);
162
+ if (!id) {
163
+ absent.push(name);
164
+ continue;
165
+ }
166
+ if (entityType === "AWS::ECS::Service") {
167
+ const cluster = clusterOfServiceArn(id);
168
+ const list = services.get(cluster) ?? [];
169
+ list.push({ name, arn: id });
170
+ services.set(cluster, list);
171
+ }
172
+ else if (entityType === "AWS::Route53::HealthCheck") {
173
+ checks.push({ name, id });
174
+ }
175
+ }
176
+ await Promise.all(checks.map(async ({ name, id }) => {
177
+ try {
178
+ entities[name] = healthCheckEntry(await getHealthCheckStatus(id, client, http), observedAt);
179
+ }
180
+ catch (err) {
181
+ if (err instanceof AwsReadError && err.code === "NoSuchHealthCheck")
182
+ absent.push(name);
183
+ else
184
+ unobserved[name] = holeFrom("AWS::Route53::HealthCheck", err);
185
+ }
186
+ }));
187
+ await Promise.all([...services].map(async ([cluster, members]) => {
188
+ // DescribeServices takes at most ten services per call.
189
+ for (let i = 0; i < members.length; i += 10) {
190
+ const batch = members.slice(i, i + 10);
191
+ try {
192
+ const { services: found, failures } = await describeServices(cluster, batch.map((m) => m.arn), client, http);
193
+ for (const member of batch) {
194
+ const service = found.find((s) => s.serviceArn === member.arn || s.serviceArn?.endsWith(`/${member.arn.split("/").pop()}`));
195
+ const failure = failures.find((f) => f.arn === member.arn);
196
+ if (service)
197
+ entities[member.name] = ecsServiceEntry(service, observedAt);
198
+ else if (failure?.reason === "MISSING")
199
+ absent.push(member.name);
200
+ else {
201
+ unobserved[member.name] = {
202
+ type: "AWS::ECS::Service",
203
+ reason: "read-failed",
204
+ detail: failure?.reason ? `DescribeServices: ${failure.reason}` : "DescribeServices did not return the service",
205
+ };
206
+ }
207
+ }
208
+ }
209
+ catch (err) {
210
+ for (const member of batch)
211
+ unobserved[member.name] = holeFrom("AWS::ECS::Service", err);
212
+ }
213
+ }
214
+ }));
215
+ return { entities, unobserved, absent };
216
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "8b5c3aa77344860ff255fdac525e40f63480383c88a4343a6b81d670be7e9621",
4
+ "manifest.json": "0ee5d3480fac8e9a938968dd5ddcc859a7e84cafa9bea700cedfa550dd4ef346",
5
5
  "meta.json": "3e18663eb9eb09920882f8014020432330a74ccd480689e7384c2022cbb10f39",
6
6
  "types/index.d.ts": "8651f9ae166668f4311eea1fe61c3fe235f345947951c1d23d1df24a4fd3384f",
7
7
  "rules/hardcoded-region.ts": "957eadcaa9f39cc17d21437565d7a94607d9b082cf28918b178fa067f07498a9",
@@ -73,5 +73,5 @@
73
73
  "skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe",
74
74
  "skills/chant-aws-carve-terraform.md": "8780b94ce60aded935938d5ef20ea667738354051da1bf9ca6d70d9886f65d21"
75
75
  },
76
- "composite": "bcbf6790bd0bbc10747eba452f59e6df06b46b350f8add6932ddf6f178ee1e0b"
76
+ "composite": "183d8f9b49de872c352a4daa986b8710ef256e152f04bea4d3b805515c53f657"
77
77
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws",
3
- "version": "0.124.32",
3
+ "version": "0.124.33",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "AWS",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAqQ,MAAM,0BAA0B,CAAC;AAwCjU;yEACyE;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAiBnD,eAAO,MAAM,SAAS,EAAE,aAujCvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAqQ,MAAM,0BAA0B,CAAC;AAwCjU;yEACyE;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAiBnD,eAAO,MAAM,SAAS,EAAE,aA+jCvB,CAAC"}
package/dist/plugin.js CHANGED
@@ -791,6 +791,12 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
791
791
  },
792
792
  /** The noise rules the deep pass applies to both the live and declared trees. */
793
793
  deepNormalizationHooks: awsDeepNormalizationHooks,
794
+ /** Runtime health (#4177): Route 53 health checks and ECS services. Implementation in ./health.ts. */
795
+ healthTypes: ["AWS::Route53::HealthCheck", "AWS::ECS::Service"],
796
+ async observeHealth(options) {
797
+ const { observeHealthAws } = await import("./health.js");
798
+ return observeHealthAws(options);
799
+ },
794
800
  async describeStackStatus(options) {
795
801
  const { getRuntime } = await import("@intentius/chant/runtime-adapter");
796
802
  const rt = getRuntime();
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.33",
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.33"
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
+ }
package/src/plugin.ts CHANGED
@@ -911,6 +911,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
911
911
  /** The noise rules the deep pass applies to both the live and declared trees. */
912
912
  deepNormalizationHooks: awsDeepNormalizationHooks,
913
913
 
914
+ /** Runtime health (#4177): Route 53 health checks and ECS services. Implementation in ./health.ts. */
915
+ healthTypes: ["AWS::Route53::HealthCheck", "AWS::ECS::Service"],
916
+
917
+ async observeHealth(options) {
918
+ const { observeHealthAws } = await import("./health");
919
+ return observeHealthAws(options);
920
+ },
921
+
914
922
  async describeStackStatus(options: { environment: string; stack: string }): Promise<StackStatusObservation | null> {
915
923
  const { getRuntime } = await import("@intentius/chant/runtime-adapter");
916
924
  const rt = getRuntime();