cfn-event-tailer
Version:
Tail the events of a CloudFormation stack, including nested stacks
208 lines (189 loc) • 6.06 kB
JavaScript
import {
CloudFormation,
DescribeStackEventsCommand,
} from "@aws-sdk/client-cloudformation";
const cfn = new CloudFormation();
const TERMINAL_EVENT_STATUS_SUFFIXES = ["_COMPLETE", "_FAILED", "_SKIPPED"];
const SUCCESSFUL_EVENT_STATUSES = [
"CREATE_COMPLETE",
"DELETE_COMPLETE",
"UPDATE_COMPLETE",
"IMPORT_COMPLETE",
];
function chunkString(str, length) {
const numChunks = Math.ceil(str.length / length);
const chunks = new Array(numChunks);
for (let i = 0, o = 0; i < numChunks; ++i, o += length) {
chunks[i] = str.substr(o, length);
}
return chunks;
}
/**
* @param {import('aws-sdk').CloudFormation.StackEvent} stackEvent
*/
function logStackEvent(stackEvent) {
const numberOfTerminalColumns =
Math.floor(process.stdout.columns * 0.8) || 132;
const columnWidths = [
numberOfTerminalColumns / 5,
24,
numberOfTerminalColumns / 5,
numberOfTerminalColumns / 5,
numberOfTerminalColumns / 5,
numberOfTerminalColumns / 5,
];
const logLines = [
chunkString(stackEvent.StackName, columnWidths[0]),
chunkString(stackEvent.Timestamp.toISOString(), columnWidths[1]),
chunkString(stackEvent.LogicalResourceId, columnWidths[2]),
chunkString(stackEvent.ResourceType, columnWidths[3]),
chunkString(stackEvent.ResourceStatus, columnWidths[4]),
chunkString(stackEvent.ResourceStatusReason || "", columnWidths[5]),
];
const numberOfLines = logLines
.map((arr) => arr.length)
.sort((a, b) => a - b)
.pop();
for (let i = 0; i < numberOfLines; i++) {
console.log(
[
(logLines?.[0]?.[i] || "").padEnd(columnWidths[0]),
(logLines?.[1]?.[i] || "").padEnd(columnWidths[1]),
(logLines?.[2]?.[i] || "").padEnd(columnWidths[2]),
(logLines?.[3]?.[i] || "").padEnd(columnWidths[3]),
(logLines?.[4]?.[i] || "").padEnd(columnWidths[4]),
(logLines?.[5]?.[i] || "").padEnd(columnWidths[5]),
].join(" "),
);
}
}
/**
* @param {String} stackName
* @param {import('aws-sdk').CloudFormation.StackEvent} event
*/
function isTerminalStackEvent(stackName, event) {
return (
event.ResourceType === "AWS::CloudFormation::Stack" &&
event.LogicalResourceId === stackName &&
!!TERMINAL_EVENT_STATUS_SUFFIXES.find((suffix) =>
event.ResourceStatus.endsWith(suffix),
)
);
}
async function safeDescribeStackEvents(stackName, tries = 0) {
try {
const response = await cfn.send(
new DescribeStackEventsCommand({ StackName: stackName }),
);
return response.StackEvents;
} catch (err) {
if (tries < 5 && err.code === "Throttling") {
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
return safeDescribeStackEvents(stackName, tries + 1);
}
throw err;
}
}
export async function tailStackEvents(stackName) {
/** @type {import('aws-sdk').CloudFormation.StackEvents} */
let stackEventsToLog;
/** @type {import('aws-sdk').CloudFormation.StackEvent} */
let lastLoggedStackEvent;
/** @type {import('aws-sdk').CloudFormation.StackEvent} */
let currentExecutionTerminalEvent;
let lastTerminalEventIndex;
const stackId = stackName;
if (stackName.startsWith("arn:")) {
stackName = stackName.split("/")[1];
}
// kep track of stacks we're tailing
const tailingStacks = {
[stackName]: Promise.resolve(),
};
/** @type {import('aws-sdk').CloudFormation.StackEvents} */
let stackEvents = await safeDescribeStackEvents(stackId);
const lastExecutionTerminalEvent = stackEvents.find((event) =>
isTerminalStackEvent(stackName, event),
);
if (stackEvents.indexOf(lastExecutionTerminalEvent) === 0) {
console.log(`${stackName}: No currently running stack update`);
return;
}
do {
// get stack events
try {
stackEvents = (
await cfn.send(new DescribeStackEventsCommand({ StackName: stackId }))
).StackEvents;
} catch (err) {
if (err?.code === "Throttling") {
continue;
}
throw err;
}
// filter out previous executions
lastTerminalEventIndex = stackEvents
.map((x) => x.EventId)
.indexOf(lastExecutionTerminalEvent?.EventId);
if (lastTerminalEventIndex !== -1) {
stackEventsToLog = stackEvents.slice(
0,
stackEvents
.map((x) => x.EventId)
.indexOf(lastExecutionTerminalEvent?.EventId),
);
} else {
// if this is the first execution of the stack
stackEventsToLog = stackEvents;
}
// trim out old events
if (lastLoggedStackEvent) {
stackEventsToLog = stackEventsToLog.slice(
0,
stackEvents.map((x) => x.EventId).indexOf(lastLoggedStackEvent.EventId),
);
}
// sort events before logging
stackEventsToLog.sort((a, b) => a.Timestamp - b.Timestamp);
// log events
for (const event of stackEventsToLog) {
logStackEvent(event);
lastLoggedStackEvent = event;
// kick off new tail for nested stacks
if (
// is nested stack
event.ResourceType === "AWS::CloudFormation::Stack" &&
event.LogicalResourceId !== stackName &&
event.PhysicalResourceId &&
// not already being tailed
!Object.keys(tailingStacks).includes(event.PhysicalResourceId)
) {
tailingStacks[event.PhysicalResourceId] = tailStackEvents(
event.PhysicalResourceId,
);
}
}
// sleep for 1 second
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
// while we can't find a terminal stack event
currentExecutionTerminalEvent = stackEventsToLog.find((event) =>
isTerminalStackEvent(stackName, event),
);
} while (!currentExecutionTerminalEvent);
await Promise.all(Object.values(tailingStacks));
// check final stack event
if (
!SUCCESSFUL_EVENT_STATUSES.includes(
currentExecutionTerminalEvent.ResourceStatus,
)
) {
console.error(
`${stackName} ${currentExecutionTerminalEvent.ResourceStatus}`,
);
process.exit(1);
}
}