Add deployment grab command

This commit is contained in:
balthazarbk
2026-05-20 10:37:33 +02:00
parent d2bd1a6a8f
commit 770bb4f930
@@ -0,0 +1,414 @@
import { GluegunMenuToolbox } from '@lenne.tech/gluegun-menu';
import chalk = require('chalk');
const { Confirm } = require('enquirer');
import { Octokit } from '@octokit/core';
import { exit } from 'process';
import {
defaultMenuSettings,
getCurrentBranch,
getSettings,
sleep,
} from '../../../../globals';
import { getTestStacksInfo, Stack } from '../../../../common/testservers';
import { getIssueFromRepo } from '../../../../services/github_rest';
const { spawn } = require('child_process');
const EXCLUDED_NAMED_STACKS = ['photowall-test-ingrid', 'photowall-test-usa'];
const EXCLUDED_STATUSES = [
'UPDATE_FAILED',
'CREATE_FAILED',
'ROLLBACK_COMPLETE',
'UPDATE_ROLLBACK_FAILED',
'UPDATE_ROLLBACK_COMPLETE',
'UPDATE_IN_PROGRESS',
'CREATE_IN_PROGRESS',
];
const ONE_MONTH_SECONDS = 2592000;
type IssueState = 'open' | 'closed' | 'unknown';
type Candidate = {
stack: Stack;
tier: number;
reason: string;
ageSeconds: number;
};
const previewUrl = (stackName: string): string => {
const suffix = stackName.split('photowall-test-')[1];
return `https://test-${suffix}.photowall-test.se`;
};
module.exports = {
name: 'grab',
alias: ['g'],
description:
'Auto-pick a free testserver and update it with current branch (g)',
hidden: false,
run: async (toolbox: GluegunMenuToolbox) => {
const { system, print } = toolbox;
const jsonOutput = toolbox.parameters.options.json;
const skipConfirm =
toolbox.parameters.options['skip-confirm'] ||
toolbox.parameters.options.y ||
!!jsonOutput;
const dryRun = toolbox.parameters.options['dry-run'];
const fail = (message: string) => {
if (jsonOutput) {
console.log(JSON.stringify({ success: false, message }, null, 2));
} else {
print.error(message);
}
};
const config = await getSettings(toolbox);
if (!config || !config.photowall_repo || config.photowall_repo === '') {
fail(
'The grab command requires that you set an absolute path to your local photowall repository.',
);
if (!jsonOutput) {
await toolbox.menu.showMenu('setup', defaultMenuSettings);
}
return;
}
const currentBranch = await getCurrentBranch(
config.photowall_repo,
toolbox,
);
if (!currentBranch) {
fail('Could not determine current git branch.');
return;
}
const octokit = new Octokit({ auth: config.gh_token });
try {
await octokit.request('GET /repos/{owner}/{repo}/branches/{branch}', {
owner: 'photowall',
repo: 'photowall',
branch: currentBranch,
});
} catch (e) {
fail(
`Branch ${currentBranch} is not on the photowall remote — push it first.`,
);
return;
}
const testStacks = await getTestStacksInfo(toolbox);
const alreadyDeployed = testStacks.find((s) => s.branch === currentBranch);
if (alreadyDeployed) {
const msg = `Your branch ${currentBranch} is already on ${alreadyDeployed.name} — nothing to do.`;
if (jsonOutput) {
console.log(
JSON.stringify(
{
success: false,
message: msg,
stack: alreadyDeployed.name,
branch: currentBranch,
url: previewUrl(alreadyDeployed.name),
},
null,
2,
),
);
} else {
print.info(
`Your branch ${chalk.green(currentBranch)} is already on ${chalk.yellow(
alreadyDeployed.name,
)} — nothing to do.`,
);
print.info(`Live at ${chalk.cyan(previewUrl(alreadyDeployed.name))}`);
}
return;
}
const issueStateByNumber = new Map<string, IssueState>();
await Promise.all(
testStacks.map(async (stack) => {
const match = stack.branch.match(/^(\d+)/);
const issueNumber = match ? match[1] : null;
if (!issueNumber || issueStateByNumber.has(issueNumber)) return;
const issueData = await getIssueFromRepo(
toolbox,
config.gh_token,
'photowall',
issueNumber,
);
issueStateByNumber.set(
issueNumber,
issueData ? (issueData.state as IssueState) : 'unknown',
);
}),
);
const now = Date.now();
const candidates: Candidate[] = [];
for (const stack of testStacks) {
if (EXCLUDED_NAMED_STACKS.includes(stack.name)) continue;
if (EXCLUDED_STATUSES.includes(stack.status)) continue;
const updatedTs = new Date(stack.updated).getTime();
const ageSeconds = isNaN(updatedTs)
? Number.POSITIVE_INFINITY
: (now - updatedTs) / 1000;
const match = stack.branch.match(/^(\d+)/);
const issueNumber = match ? match[1] : null;
const issueState: IssueState = issueNumber
? (issueStateByNumber.get(issueNumber) ?? 'unknown')
: 'unknown';
if (stack.branch === 'master') {
candidates.push({
stack,
tier: 1,
reason: 'branch is master',
ageSeconds,
});
} else if (issueState === 'closed') {
candidates.push({
stack,
tier: 2,
reason: `issue #${issueNumber} closed`,
ageSeconds,
});
} else if (ageSeconds > ONE_MONTH_SECONDS) {
candidates.push({
stack,
tier: 3,
reason: 'last commit >1 month ago',
ageSeconds,
});
} else if (issueState === 'unknown') {
candidates.push({
stack,
tier: 4,
reason: 'no linked issue in branch name',
ageSeconds,
});
}
}
if (candidates.length === 0) {
if (jsonOutput) {
console.log(
JSON.stringify(
{
success: false,
message: 'No free testservers — all are claimed.',
},
null,
2,
),
);
return;
}
print.info('No free testservers — all are claimed. Current state:');
await require('../status/status').run(toolbox);
return;
}
candidates.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier;
return b.ageSeconds - a.ageSeconds;
});
const pick = candidates[0];
const stackSuffix = pick.stack.name.replace('photowall-', '');
const url = previewUrl(pick.stack.name);
if (dryRun) {
if (jsonOutput) {
console.log(
JSON.stringify(
{
success: true,
dryRun: true,
stack: pick.stack.name,
branch: currentBranch,
reason: pick.reason,
url,
},
null,
2,
),
);
} else {
print.info(
`Would deploy ${chalk.green(currentBranch)}${chalk.yellow(
pick.stack.name,
)}. Reason: ${pick.reason}.`,
);
}
return;
}
let shouldRun = skipConfirm;
if (!skipConfirm) {
const confirm = new Confirm({
name: 'confirm',
message: `Will deploy ${chalk.green(currentBranch)}${chalk.yellow(
pick.stack.name,
)}. Reason: ${pick.reason}.`,
});
try {
shouldRun = await confirm.run();
} catch (e) {
shouldRun = false;
}
}
if (!shouldRun) {
if (!jsonOutput) print.info('Grab cancelled');
if (toolbox.fromMenu()) {
await toolbox.menu.showMenu(
'deployment testservers',
defaultMenuSettings,
);
}
return;
}
try {
const cmd = `make update-stack STACK_ENVIRONMENT_NAME=${stackSuffix} GITHUB_BRANCH=${currentBranch}`;
if (!jsonOutput) {
print.fancy(
`Running [${chalk.yellow(
cmd,
)}] in folder [${`${config.photowall_repo}/cloudformation/`}]`,
);
}
const child = spawn(
'make',
[
'update-stack',
`STACK_ENVIRONMENT_NAME=${stackSuffix}`,
`GITHUB_BRANCH=${currentBranch}`,
],
{
cwd: `${config.photowall_repo}/cloudformation/`,
},
);
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
if (!jsonOutput) {
process.stdout.write(data);
}
});
child.stderr.on('data', (data) => {
if (!jsonOutput) {
process.stderr.write(data);
}
});
await new Promise((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) {
resolve(0);
} else {
reject(new Error(`make exited with code ${code}`));
}
});
});
const spinner = jsonOutput
? null
: print.spin(`Waiting for stack to update`);
let currentStackStatus = '';
while (currentStackStatus !== 'UPDATE_COMPLETE') {
const stackCmd = `aws cloudformation describe-stacks --stack-name ${pick.stack.name}`;
const response = await system.run(stackCmd, { trim: true });
const stackData = JSON.parse(response);
currentStackStatus = stackData.Stacks[0].StackStatus;
if (!jsonOutput) {
let statusOutput = '';
switch (currentStackStatus) {
case 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS':
statusOutput = `[${chalk.yellow(
currentStackStatus,
)}]: Almost done, cleanup in progress`;
break;
case 'UPDATE_COMPLETE':
statusOutput = `[${chalk.green(
currentStackStatus,
)}]: Update complete`;
break;
default:
statusOutput = `[${chalk.red(
currentStackStatus,
)}]: Updating the stack`;
break;
}
spinner.text = statusOutput;
}
if (currentStackStatus !== 'UPDATE_COMPLETE') {
await sleep(1000);
}
}
if (!jsonOutput) {
spinner.text = `Will start the pipeline now [${chalk.yellow(
`${pick.stack.name}Pipeline`,
)}]`;
}
await system.run(
`aws codepipeline start-pipeline-execution --name ${pick.stack.name}Pipeline`,
);
await sleep(2000);
if (jsonOutput) {
console.log(
JSON.stringify(
{
success: true,
message: `Successfully updated ${pick.stack.name} with branch ${currentBranch} and started pipeline`,
stack: pick.stack.name,
branch: currentBranch,
reason: pick.reason,
pipeline: `${pick.stack.name}Pipeline`,
url,
},
null,
2,
),
);
} else {
spinner.stop();
print.fancy('Pipeline started, bye! 🚀');
print.info(`Will be deployed soon on ${chalk.cyan(url)}`);
}
} catch (e) {
if (jsonOutput) {
console.log(
JSON.stringify(
{
success: false,
message: `Error during grab: ${e.message || e}`,
error: e.message || String(e),
},
null,
2,
),
);
} else {
console.log(`e`, e);
}
}
if (jsonOutput) return;
if (toolbox.fromMenu()) {
await toolbox.menu.showMenu(
'deployment testservers',
defaultMenuSettings,
);
} else {
exit();
}
},
};