Add deployment grab command
This commit is contained in:
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user