@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.
- package/dist/health.d.ts +61 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +216 -0
- package/dist/integrity.json +3 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-catalog.js +1 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/audit-lineage.js +4 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +2 -0
- package/dist/lint/post-synth/waw070.d.ts +14 -0
- package/dist/lint/post-synth/waw070.d.ts.map +1 -0
- package/dist/lint/post-synth/waw070.js +81 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/WAW070.md +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -0
- package/dist/rules/waw070.ts +89 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +43 -5
- package/package.json +2 -2
- package/src/health.test.ts +136 -0
- package/src/health.ts +281 -0
- package/src/lint/audit-catalog.ts +1 -0
- package/src/lint/audit-lineage.ts +4 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/waw070.test.ts +59 -0
- package/src/lint/post-synth/waw070.ts +89 -0
- package/src/plugin.ts +8 -0
- package/src/serializer-foreign-attr.test.ts +43 -0
- package/src/serializer.ts +58 -5
package/dist/health.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "0caff76f30c615cd569668c41563a7ca22dc2c0a8faa435471c3fa8ae5b7120a",
|
|
5
5
|
"meta.json": "3e18663eb9eb09920882f8014020432330a74ccd480689e7384c2022cbb10f39",
|
|
6
6
|
"types/index.d.ts": "8651f9ae166668f4311eea1fe61c3fe235f345947951c1d23d1df24a4fd3384f",
|
|
7
7
|
"rules/hardcoded-region.ts": "957eadcaa9f39cc17d21437565d7a94607d9b082cf28918b178fa067f07498a9",
|
|
@@ -69,9 +69,10 @@
|
|
|
69
69
|
"rules/waw067.ts": "8e66f3512569c106c4c6868786bb870af1b3b05d3542118ca6bb5c61c57314e7",
|
|
70
70
|
"rules/waw068.ts": "adbfe84e9b3cdafc072346d87a5df81a97ab07bc5171f3d4abb824c29e9a66a0",
|
|
71
71
|
"rules/waw069.ts": "4792bb3943d5e08039f45cbf554f45263703288acf4ca01d88ad778083023d27",
|
|
72
|
+
"rules/waw070.ts": "35bd2281ab072a1d6138e0db9f9d6f4f0b6c5af4deacb28aa693177c654db66a",
|
|
72
73
|
"skills/chant-aws.md": "4d65eb160e001f2af42eb7c622a5b498c2fd1f5afbd6c8155615c010fb265c63",
|
|
73
74
|
"skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe",
|
|
74
75
|
"skills/chant-aws-carve-terraform.md": "8780b94ce60aded935938d5ef20ea667738354051da1bf9ca6d70d9886f65d21"
|
|
75
76
|
},
|
|
76
|
-
"composite": "
|
|
77
|
+
"composite": "57806890c056b83500ed7e1d32c8ccff0869c61229f566321563fb61800ace32"
|
|
77
78
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAa,KAAK,QAAQ,EAAgC,MAAM,gCAAgC,CAAC;AASxG,6CAA6C;AAC7C,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,
|
|
1
|
+
{"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAa,KAAK,QAAQ,EAAgC,MAAM,gCAAgC,CAAC;AASxG,6CAA6C;AAC7C,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA4EpD,CAAC"}
|
|
@@ -82,6 +82,7 @@ export const awsAuditCatalog = {
|
|
|
82
82
|
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" }),
|
|
83
83
|
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" }),
|
|
84
84
|
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" }),
|
|
85
|
+
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" }),
|
|
85
86
|
};
|
|
86
87
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
87
88
|
applyLineage(awsAuditCatalog, awsAuditLineage);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-lineage.d.ts","sourceRoot":"","sources":["../../src/lint/audit-lineage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"audit-lineage.d.ts","sourceRoot":"","sources":["../../src/lint/audit-lineage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAyJrD,CAAC"}
|
|
@@ -147,4 +147,8 @@ export const awsAuditLineage = {
|
|
|
147
147
|
WAW069: [
|
|
148
148
|
{ tool: "cfn-lint", rule: "E8002", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E8002", relation: "equivalent" },
|
|
149
149
|
],
|
|
150
|
+
WAW070: [
|
|
151
|
+
{ tool: "cfn-lint", rule: "E1012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1012", relation: "equivalent" },
|
|
152
|
+
{ tool: "cfn-lint", rule: "E1010", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1010", relation: "overlaps" },
|
|
153
|
+
],
|
|
150
154
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAgEvE,eAAO,MAAM,eAAe,EAAE,cAAc,EA+D3C,CAAC"}
|
|
@@ -59,6 +59,7 @@ import { waw066 } from "./waw066.js";
|
|
|
59
59
|
import { waw067 } from "./waw067.js";
|
|
60
60
|
import { waw068 } from "./waw068.js";
|
|
61
61
|
import { waw069 } from "./waw069.js";
|
|
62
|
+
import { waw070 } from "./waw070.js";
|
|
62
63
|
export const postSynthChecks = [
|
|
63
64
|
cor020,
|
|
64
65
|
ext001,
|
|
@@ -121,4 +122,5 @@ export const postSynthChecks = [
|
|
|
121
122
|
waw067,
|
|
122
123
|
waw068,
|
|
123
124
|
waw069,
|
|
125
|
+
waw070,
|
|
124
126
|
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
export declare function checkDanglingRefs(ctx: PostSynthContext): PostSynthDiagnostic[];
|
|
13
|
+
export declare const waw070: PostSynthCheck;
|
|
14
|
+
//# sourceMappingURL=waw070.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"waw070.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/waw070.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AA8B9G,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,GAAG,mBAAmB,EAAE,CAsC9E;AAED,eAAO,MAAM,MAAM,EAAE,cAOpB,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
import { parseCFTemplate } from "./cf-refs.js";
|
|
12
|
+
function collectRefs(value, out, seen = new Set()) {
|
|
13
|
+
if (value === null || typeof value !== "object" || seen.has(value))
|
|
14
|
+
return;
|
|
15
|
+
seen.add(value);
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
for (const item of value)
|
|
18
|
+
collectRefs(item, out, seen);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const obj = value;
|
|
22
|
+
if (typeof obj.Ref === "string" && Object.keys(obj).length === 1) {
|
|
23
|
+
out.push({ target: obj.Ref, via: "Ref" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
27
|
+
if (getAtt !== undefined && Object.keys(obj).length === 1) {
|
|
28
|
+
const target = Array.isArray(getAtt) ? getAtt[0] : typeof getAtt === "string" ? getAtt.split(".")[0] : undefined;
|
|
29
|
+
if (typeof target === "string")
|
|
30
|
+
out.push({ target, via: "Fn::GetAtt" });
|
|
31
|
+
if (Array.isArray(getAtt))
|
|
32
|
+
collectRefs(getAtt.slice(1), out, seen);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const v of Object.values(obj))
|
|
36
|
+
collectRefs(v, out, seen);
|
|
37
|
+
}
|
|
38
|
+
export function checkDanglingRefs(ctx) {
|
|
39
|
+
const diagnostics = [];
|
|
40
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
41
|
+
if (lexicon !== "aws")
|
|
42
|
+
continue;
|
|
43
|
+
const template = parseCFTemplate(output);
|
|
44
|
+
if (!template?.Resources || typeof template.Resources !== "object")
|
|
45
|
+
continue;
|
|
46
|
+
const resources = new Set(Object.keys(template.Resources));
|
|
47
|
+
const parameters = new Set(Object.keys(template.Parameters ?? {}));
|
|
48
|
+
const report = (where, value) => {
|
|
49
|
+
const refs = [];
|
|
50
|
+
collectRefs(value, refs);
|
|
51
|
+
for (const ref of refs) {
|
|
52
|
+
const known = ref.via === "Ref"
|
|
53
|
+
? resources.has(ref.target) || parameters.has(ref.target) || ref.target.startsWith("AWS::")
|
|
54
|
+
: resources.has(ref.target);
|
|
55
|
+
if (known)
|
|
56
|
+
continue;
|
|
57
|
+
diagnostics.push({
|
|
58
|
+
checkId: "WAW070",
|
|
59
|
+
severity: "error",
|
|
60
|
+
message: `${where} has ${ref.via} "${ref.target}", which is not ${ref.via === "Ref" ? "a resource, a parameter or a pseudo-parameter" : "a resource"} in this template`,
|
|
61
|
+
entity: where.replace(/^(Resource|Output) /, "").replace(/"/g, ""),
|
|
62
|
+
lexicon: "aws",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
67
|
+
report(`Resource "${logicalId}"`, resource.Properties);
|
|
68
|
+
}
|
|
69
|
+
for (const [name, out] of Object.entries(template.Outputs ?? {})) {
|
|
70
|
+
report(`Output "${name}"`, out?.Value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return diagnostics;
|
|
74
|
+
}
|
|
75
|
+
export const waw070 = {
|
|
76
|
+
id: "WAW070",
|
|
77
|
+
description: "Ref or Fn::GetAtt names something the template doesn't declare",
|
|
78
|
+
check(ctx) {
|
|
79
|
+
return checkDanglingRefs(ctx);
|
|
80
|
+
},
|
|
81
|
+
};
|
package/dist/manifest.json
CHANGED
package/dist/okf/index.md
CHANGED
|
@@ -1936,3 +1936,4 @@ okf_version: '0.2'
|
|
|
1936
1936
|
* [WAW067](/rules/WAW067.md) - A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress
|
|
1937
1937
|
* [WAW068](/rules/WAW068.md) - VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity
|
|
1938
1938
|
* [WAW069](/rules/WAW069.md) - Template references a condition it never declares — resource/output Condition keys, Fn::If, and Condition references must name an entry in the Conditions sec...
|
|
1939
|
+
* [WAW070](/rules/WAW070.md) - Ref or Fn::GetAtt names something the template doesn't declare
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: WAW070
|
|
4
|
+
description: Ref or Fn::GetAtt names something the template doesn't declare
|
|
5
|
+
id: WAW070
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: aws
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/aws/rules/
|
|
10
|
+
---
|
|
11
|
+
Ref or Fn::GetAtt names something the template doesn't declare
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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,
|
|
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();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
13
|
+
import { parseCFTemplate } from "./cf-refs";
|
|
14
|
+
|
|
15
|
+
interface DanglingRef {
|
|
16
|
+
target: string;
|
|
17
|
+
via: "Ref" | "Fn::GetAtt";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectRefs(value: unknown, out: DanglingRef[], seen = new Set<unknown>()): void {
|
|
21
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
|
22
|
+
seen.add(value);
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
for (const item of value) collectRefs(item, out, seen);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const obj = value as Record<string, unknown>;
|
|
28
|
+
if (typeof obj.Ref === "string" && Object.keys(obj).length === 1) {
|
|
29
|
+
out.push({ target: obj.Ref, via: "Ref" });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
33
|
+
if (getAtt !== undefined && Object.keys(obj).length === 1) {
|
|
34
|
+
const target = Array.isArray(getAtt) ? getAtt[0] : typeof getAtt === "string" ? getAtt.split(".")[0] : undefined;
|
|
35
|
+
if (typeof target === "string") out.push({ target, via: "Fn::GetAtt" });
|
|
36
|
+
if (Array.isArray(getAtt)) collectRefs(getAtt.slice(1), out, seen);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const v of Object.values(obj)) collectRefs(v, out, seen);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function checkDanglingRefs(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
43
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
44
|
+
|
|
45
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
46
|
+
if (lexicon !== "aws") continue;
|
|
47
|
+
const template = parseCFTemplate(output);
|
|
48
|
+
if (!template?.Resources || typeof template.Resources !== "object") continue;
|
|
49
|
+
|
|
50
|
+
const resources = new Set(Object.keys(template.Resources));
|
|
51
|
+
const parameters = new Set(Object.keys((template.Parameters as Record<string, unknown> | undefined) ?? {}));
|
|
52
|
+
|
|
53
|
+
const report = (where: string, value: unknown) => {
|
|
54
|
+
const refs: DanglingRef[] = [];
|
|
55
|
+
collectRefs(value, refs);
|
|
56
|
+
for (const ref of refs) {
|
|
57
|
+
const known = ref.via === "Ref"
|
|
58
|
+
? resources.has(ref.target) || parameters.has(ref.target) || ref.target.startsWith("AWS::")
|
|
59
|
+
: resources.has(ref.target);
|
|
60
|
+
if (known) continue;
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
checkId: "WAW070",
|
|
63
|
+
severity: "error",
|
|
64
|
+
message: `${where} has ${ref.via} "${ref.target}", which is not ${ref.via === "Ref" ? "a resource, a parameter or a pseudo-parameter" : "a resource"} in this template`,
|
|
65
|
+
entity: where.replace(/^(Resource|Output) /, "").replace(/"/g, ""),
|
|
66
|
+
lexicon: "aws",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
72
|
+
report(`Resource "${logicalId}"`, resource.Properties);
|
|
73
|
+
}
|
|
74
|
+
for (const [name, out] of Object.entries((template.Outputs as Record<string, { Value?: unknown }> | undefined) ?? {})) {
|
|
75
|
+
report(`Output "${name}"`, out?.Value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return diagnostics;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const waw070: PostSynthCheck = {
|
|
83
|
+
id: "WAW070",
|
|
84
|
+
description: "Ref or Fn::GetAtt names something the template doesn't declare",
|
|
85
|
+
|
|
86
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
87
|
+
return checkDanglingRefs(ctx);
|
|
88
|
+
},
|
|
89
|
+
};
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA0nBlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAgF3B,CAAC"}
|