Skip to content

Commit 49fa285

Browse files
authored
fix(deploy): show CloudFormation resource failure after rollback (#2760)
* fix(deploy): show CloudFormation resource failure after rollback * fix(deploy): report resource failures for NoStack errors
1 parent f745e85 commit 49fa285

2 files changed

Lines changed: 152 additions & 3 deletions

File tree

‎src/core/project/backends/cdk/toolkit.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,116 @@ describe("Toolkit loading", () => {
307307

308308
expect(lines).toEqual(["CREATE_COMPLETE | AWS::IAM::Role"]);
309309
});
310+
311+
test("reports a resource failure when Toolkit only throws NoStack after rollback", async () => {
312+
const { loaded } = loadedToolkit();
313+
const reason = "Agent with agentId: staging-agent not found";
314+
const runner = createCdkRunner(createSilentLogger(), async (ioHost) => {
315+
loaded.toolkit.deploy = async () => {
316+
for (const event of [
317+
{
318+
ResourceType: "AWS::IAM::Role",
319+
LogicalResourceId: "RuntimeRole",
320+
ResourceStatus: "CREATE_FAILED",
321+
ResourceStatusReason: "Resource creation cancelled",
322+
},
323+
{
324+
ResourceType: "AWS::BedrockAgentCore::RuntimeEndpoint",
325+
LogicalResourceId: "RuntimeEndpointProd",
326+
ResourceStatus: "CREATE_FAILED",
327+
ResourceStatusReason: reason,
328+
},
329+
{
330+
ResourceType: "AWS::CloudFormation::Stack",
331+
LogicalResourceId: "AgentCore-orders-staging",
332+
ResourceStatus: "CREATE_FAILED",
333+
ResourceStatusReason: "The following resource(s) failed",
334+
},
335+
]) {
336+
await ioHost.notify({
337+
...message("rollback progress"),
338+
code: "CDK_TOOLKIT_I5502",
339+
data: { event },
340+
});
341+
}
342+
const error = new Error("CloudFormationStack object does not hold a stack");
343+
error.name = "NoStack";
344+
throw error;
345+
};
346+
return loaded;
347+
});
348+
349+
await expect(
350+
runner({ kind: "deploy", stackArtifactId: "AgentCore-orders-staging" }, runOptions()),
351+
).rejects.toThrow(
352+
`CloudFormationStack object does not hold a stack\n\n` +
353+
`CloudFormation failure: RuntimeEndpointProd (CREATE_FAILED): ${reason}`,
354+
);
355+
});
356+
357+
test("reports a bootstrap resource failure when Toolkit throws NoStack", async () => {
358+
const { loaded } = loadedToolkit();
359+
const reason = "Bootstrap bucket could not be created";
360+
const runner = createCdkRunner(createSilentLogger(), async (ioHost) => {
361+
loaded.toolkit.bootstrap = async () => {
362+
await ioHost.notify({
363+
...message("bootstrap rollback progress"),
364+
action: "bootstrap",
365+
code: "CDK_TOOLKIT_I5502",
366+
data: {
367+
event: {
368+
ResourceType: "AWS::S3::Bucket",
369+
LogicalResourceId: "StagingBucket",
370+
ResourceStatus: "CREATE_FAILED",
371+
ResourceStatusReason: reason,
372+
},
373+
},
374+
});
375+
const error = new Error("CloudFormationStack object does not hold a stack");
376+
error.name = "NoStack";
377+
throw error;
378+
};
379+
return loaded;
380+
});
381+
382+
await expect(
383+
runner({ kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, runOptions()),
384+
).rejects.toThrow(
385+
`CloudFormationStack object does not hold a stack\n\n` +
386+
`CloudFormation failure: StagingBucket (CREATE_FAILED): ${reason}`,
387+
);
388+
});
389+
390+
test("does not repeat a resource failure already diagnosed by Toolkit", async () => {
391+
const { loaded } = loadedToolkit();
392+
const reason = `The resource failed: ${"validation error ".repeat(12)}`;
393+
const original = new Error(
394+
`The stack failed to deploy:\n ${reason.slice(0, 120)}\n ${reason.slice(120)}`,
395+
);
396+
const runner = createCdkRunner(createSilentLogger(), async (ioHost) => {
397+
loaded.toolkit.deploy = async () => {
398+
await ioHost.notify({
399+
...message("update failed"),
400+
code: "CDK_TOOLKIT_I5502",
401+
data: {
402+
event: {
403+
ResourceType: "AWS::BedrockAgentCore::RuntimeEndpoint",
404+
LogicalResourceId: "RuntimeEndpointProd",
405+
ResourceStatus: "UPDATE_FAILED",
406+
ResourceStatusReason: reason,
407+
},
408+
},
409+
});
410+
throw original;
411+
};
412+
return loaded;
413+
});
414+
415+
await expect(
416+
runner({ kind: "deploy", stackArtifactId: "AgentCore-orders-staging" }, runOptions()),
417+
).rejects.toBe(original);
418+
expect(original.message).not.toContain("CloudFormation failure:");
419+
});
310420
});
311421

312422
describe("bootstrap template loading", () => {

‎src/core/project/backends/cdk/toolkit.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdtemp, rm, writeFile } from "node:fs/promises";
22
import { join } from "node:path";
33
import { tmpdir } from "node:os";
4+
import type { StackEvent } from "@aws-sdk/client-cloudformation";
45
import type {
56
IActionAwareIoHost,
67
IIoHost,
@@ -120,7 +121,13 @@ export async function loadBootstrapTemplate(
120121
};
121122
}
122123

123-
export function createCdkIoHost(logger: Logger, onLine?: (line: string) => void): IIoHost {
124+
type ResourceFailure = { logicalId: string; status: string; reason: string };
125+
126+
export function createCdkIoHost(
127+
logger: Logger,
128+
onLine?: (line: string) => void,
129+
onResourceFailure?: (failure: ResourceFailure) => void,
130+
): IIoHost {
124131
const toolkitLogger = logger.child({ component: "cdk-toolkit" });
125132
const notify = async (message: IoMessage<unknown>): Promise<void> => {
126133
toolkitLogger
@@ -130,6 +137,26 @@ export function createCdkIoHost(logger: Logger, onLine?: (line: string) => void)
130137
...(message.code && { code: message.code }),
131138
})
132139
.debug(message.message);
140+
// I5502 carries each stack activity event. Toolkit 1.38.2 loses its resource
141+
// diagnosis on first-create rollback when it reads a missing stack before
142+
// formatting the collected failures; use these events for that NoStack case.
143+
if (message.code === "CDK_TOOLKIT_I5502" && onResourceFailure) {
144+
const event = (message.data as { event?: StackEvent } | undefined)?.event;
145+
if (
146+
event?.ResourceStatus?.endsWith("_FAILED") &&
147+
event.ResourceType !== "AWS::CloudFormation::Stack" &&
148+
event.LogicalResourceId &&
149+
event.ResourceStatusReason &&
150+
// Match Toolkit's cancellation filter so the initiating failure wins.
151+
!event.ResourceStatusReason.includes("cancelled")
152+
) {
153+
onResourceFailure({
154+
logicalId: event.LogicalResourceId,
155+
status: event.ResourceStatus,
156+
reason: event.ResourceStatusReason,
157+
});
158+
}
159+
}
133160
// Toolkit messages can span lines (diffs, stack traces); split so the sink
134161
// always receives displayable single lines.
135162
if (onLine) {
@@ -245,8 +272,20 @@ export function createCdkRunner(
245272
return async (operation, options) => {
246273
// A fresh ioHost per operation, so each operation's messages can flow into
247274
// its own onOutput sink rather than only the construction-time logger.
248-
const ioHost = createCdkIoHost(logger, options.onOutput);
275+
let firstFailure: ResourceFailure | undefined;
276+
const ioHost = createCdkIoHost(logger, options.onOutput, (failure) => {
277+
firstFailure ??= failure;
278+
});
249279
const loaded = await load(ioHost, options.region, options.credentials);
250-
return performCdkOperation(loaded, operation, options);
280+
try {
281+
return await performCdkOperation(loaded, operation, options);
282+
} catch (error) {
283+
if (error instanceof Error && error.name === "NoStack" && firstFailure) {
284+
error.message +=
285+
`\n\nCloudFormation failure: ${firstFailure.logicalId} ` +
286+
`(${firstFailure.status}): ${firstFailure.reason}`;
287+
}
288+
throw error;
289+
}
251290
};
252291
}

0 commit comments

Comments
 (0)