cfn-event-tailer
Version:
Tail the events of a CloudFormation stack, including nested stacks
235 lines (213 loc) • 7.83 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"];
/**
* @typedef {import('@aws-sdk/client-cloudformation').StackEvent} StackEvent
*/
/**
* @param {string | undefined} str
* @param {number} length
*/
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 {StackEvent} stackEvent
*/
function logStackEvent(stackEvent) {
let numberOfTerminalColumns = Math.floor(process.stdout.columns - 10);
if (Number.isNaN(numberOfTerminalColumns)) numberOfTerminalColumns = 80;
const timeWidth = 8;
const columnWidths = [
Math.floor((numberOfTerminalColumns - timeWidth) / 5),
timeWidth,
Math.floor((numberOfTerminalColumns - timeWidth) / 5),
Math.floor((numberOfTerminalColumns - timeWidth) / 5),
Math.floor((numberOfTerminalColumns - timeWidth) / 5),
];
const remainingWidth = numberOfTerminalColumns - columnWidths.reduce((a, b) => a + b, 0);
columnWidths.push(remainingWidth);
const timeDate = new Date(stackEvent.Timestamp ?? 0);
const timestamp = `${timeDate.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
second: "2-digit",
minute: "2-digit",
})}`;
const reason = chunkString(stackEvent.ResourceStatusReason || "", columnWidths[5]);
const logLines = [
chunkString(stackEvent.StackName, columnWidths[0]),
chunkString(timestamp, columnWidths[1]),
chunkString(stackEvent.LogicalResourceId, columnWidths[2]),
chunkString(stackEvent.ResourceType, columnWidths[3]),
chunkString(stackEvent.ResourceStatus, columnWidths[4]),
reason,
];
const numberOfLines = logLines
.map((arr) => arr.length)
.sort((a, b) => a - b)
.pop();
for (let i = 0; i < (numberOfLines ?? 0); 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 {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))
);
}
/**
* @param {string} stackName
* @param {number} tries
*/
async function safeDescribeStackEvents(stackName, tries = 0) {
try {
const response = await cfn.send(new DescribeStackEventsCommand({ StackName: stackName }));
return response.StackEvents;
} catch (err) {
if (tries < 5 && /** @type {*} */ (err)?.Code === "Throttling") {
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
return safeDescribeStackEvents(stackName, tries + 1);
}
throw err;
}
}
/**
* @param {string} stackName
*/
export async function tailStackEvents(stackName) {
/** @type {StackEvent[]} */
let stackEventsToLog;
/** @type {StackEvent | undefined} */
let lastLoggedStackEvent;
/** @type {StackEvent | undefined} */
let currentExecutionTerminalEvent;
let lastTerminalEventIndex;
let stackId = stackName;
if (stackName.startsWith("arn:")) {
stackName = stackName.split("/")[1];
}
const describeStacksResponse = await cfn.describeStacks({ StackName: stackName });
if (!describeStacksResponse.Stacks || describeStacksResponse.Stacks.length === 0) {
throw new Error(`Stack ${stackName} not found`);
}
stackId = describeStacksResponse.Stacks[0].StackId ?? stackName;
// kep track of stacks we're tailing
const tailingStacks = {
[stackName]: Promise.resolve(),
};
const currentStackEvents = await safeDescribeStackEvents(stackId);
if (!currentStackEvents || currentStackEvents.length === 0) {
console.log(`${stackName}: No stack events found`);
return;
}
/** @type {StackEvent[] | undefined} */
let stackEvents = currentStackEvents;
let lastExecutionTerminalEvent = stackEvents.find((event) => isTerminalStackEvent(stackName, event));
if (!lastExecutionTerminalEvent) {
for (const event of stackEvents) {
if (
event.ResourceType === "AWS::CloudFormation::Stack" &&
event.LogicalResourceId === stackName &&
["REVIEW_IN_PROGRESS", "CREATE_IN_PROGRESS"].includes(/** @type {string} */ (event.ResourceStatus))
) {
lastExecutionTerminalEvent = event;
}
}
}
if (!lastExecutionTerminalEvent || 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 (/** @type {*} */ (err)?.Code === "Throttling") {
continue;
}
// if (/** @type {*} */ (err)?.Code === "ValidationError") {
// if (/** @type {*} */ (err)?.message?.includes("does not exist")) {
// return;
// }
// }
throw err;
}
if (!stackEvents || stackEvents.length === 0) {
console.log(`${stackName}: No stack events found`);
return;
}
// 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) => new Date(a?.Timestamp ?? 0).getTime() - new Date(b?.Timestamp ?? 0).getTime());
// 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);
}
}