2023-06-21 21:19:55 +00:00
|
|
|
const util = require('util');
|
|
|
|
const childProcess = require('child_process');
|
|
|
|
const fs = require('fs/promises');
|
|
|
|
|
|
|
|
const exec = util.promisify(childProcess.exec);
|
|
|
|
|
|
|
|
const org = "grist-labs";
|
|
|
|
const expirationSec = 30 * 24 * 60 * 60; // 30 days
|
|
|
|
|
|
|
|
const getAppName = () => "grist-" + getBranchName().toLowerCase().replace(/[\W|_]+/g, '-');
|
2023-09-07 13:16:07 +00:00
|
|
|
const getVolumeName = () => ("gv_" + getBranchName().toLowerCase().replace(/\W+/g, '_')).substring(0, 30);
|
2023-06-21 21:19:55 +00:00
|
|
|
|
|
|
|
const getBranchName = () => {
|
|
|
|
if (!process.env.BRANCH_NAME) { console.log('Usage: Need BRANCH_NAME env var'); process.exit(1); }
|
|
|
|
return process.env.BRANCH_NAME;
|
2023-09-07 13:16:07 +00:00
|
|
|
};
|
2023-06-21 21:19:55 +00:00
|
|
|
|
|
|
|
async function main() {
|
2024-07-03 19:36:17 +00:00
|
|
|
switch (process.argv[2]) {
|
|
|
|
case "deploy": {
|
|
|
|
const name = getAppName();
|
|
|
|
const volName = getVolumeName();
|
|
|
|
if (!await appExists(name)) {
|
|
|
|
await appCreate(name);
|
2023-06-21 21:19:55 +00:00
|
|
|
await volCreate(name, volName);
|
2024-07-03 19:36:17 +00:00
|
|
|
} else {
|
|
|
|
// Check if volume exists, and create it if not. This is needed because there was an API
|
|
|
|
// change in flyctl (mandatory -y flag) and some apps were created without a volume.
|
|
|
|
if (!(await volList(name)).length) {
|
|
|
|
await volCreate(name, volName);
|
|
|
|
}
|
2023-06-21 21:19:55 +00:00
|
|
|
}
|
2024-07-03 19:36:17 +00:00
|
|
|
await prepConfig(name, volName);
|
|
|
|
await appDeploy(name);
|
|
|
|
break;
|
2023-06-21 21:19:55 +00:00
|
|
|
}
|
2024-07-03 19:36:17 +00:00
|
|
|
case "destroy": {
|
|
|
|
const name = getAppName();
|
|
|
|
if (await appExists(name)) {
|
|
|
|
await appDestroy(name);
|
|
|
|
}
|
|
|
|
break;
|
2023-06-21 21:19:55 +00:00
|
|
|
}
|
2024-07-03 19:36:17 +00:00
|
|
|
case "clean": {
|
|
|
|
const staleApps = await findStaleApps();
|
|
|
|
for (const appName of staleApps) {
|
|
|
|
await appDestroy(appName);
|
|
|
|
}
|
|
|
|
break;
|
2023-06-21 21:19:55 +00:00
|
|
|
}
|
2024-07-03 19:36:17 +00:00
|
|
|
default: {
|
|
|
|
console.log(`Usage:
|
|
|
|
deploy: create (if needed) and deploy fly app grist-{BRANCH_NAME}.
|
2023-06-21 21:19:55 +00:00
|
|
|
destroy: destroy fly app grist-{BRANCH_NAME}
|
|
|
|
clean: destroy all grist-* fly apps whose time has come
|
|
|
|
(according to FLY_DEPLOY_EXPIRATION env var set at deploy time)
|
|
|
|
|
|
|
|
DRYRUN=1 in environment will show what would be done
|
|
|
|
`);
|
2024-07-03 19:36:17 +00:00
|
|
|
process.exit(1);
|
|
|
|
}
|
2023-06-21 21:19:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-03 19:36:17 +00:00
|
|
|
function getDockerTag(name) {
|
|
|
|
return `registry.fly.io/${name}:latest`;
|
|
|
|
}
|
|
|
|
|
2023-06-21 21:19:55 +00:00
|
|
|
const appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true).catch(() => false);
|
2024-07-03 19:36:17 +00:00
|
|
|
// We do not deploy at the create stage, since the Docker image isn't ready yet.
|
|
|
|
// Assigning --image prevents flyctl from making inferences based on the codebase and provisioning unnecessary postgres/redis instances.
|
|
|
|
const appCreate = (name) => runAction(`flyctl launch --no-deploy --auto-confirm --image ${getDockerTag(name)} --name ${name} -r ewr -o ${org}`);
|
2023-06-21 21:19:55 +00:00
|
|
|
const volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`);
|
|
|
|
const volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout));
|
2024-07-03 19:36:17 +00:00
|
|
|
const appDeploy = async (name) => {
|
|
|
|
try {
|
|
|
|
await runAction("flyctl auth docker")
|
|
|
|
await runAction(`docker image tag grist-core:preview ${getDockerTag(name)}`);
|
|
|
|
await runAction(`docker push ${getDockerTag(name)}`);
|
|
|
|
await runAction(`flyctl deploy --app ${name} --image ${getDockerTag(name)}`);
|
|
|
|
} catch (e) {
|
|
|
|
console.log(`Error occurred when deploying: ${e}`);
|
|
|
|
process.exit(1);
|
|
|
|
}
|
|
|
|
};
|
2023-06-21 21:19:55 +00:00
|
|
|
|
|
|
|
async function appDestroy(name) {
|
|
|
|
await runAction(`flyctl apps destroy ${name} -y`);
|
|
|
|
}
|
|
|
|
|
2024-07-03 19:36:17 +00:00
|
|
|
async function prepConfig(name, volName) {
|
|
|
|
const configPath = "./fly.toml";
|
|
|
|
const configTemplatePath = "./buildtools/fly-template.toml";
|
2023-06-21 21:19:55 +00:00
|
|
|
const template = await fs.readFile(configTemplatePath, {encoding: 'utf8'});
|
|
|
|
|
|
|
|
// Calculate the time when we can destroy the app, used by findStaleApps.
|
|
|
|
const expiration = new Date(Date.now() + expirationSec * 1000).toISOString();
|
|
|
|
const config = template
|
|
|
|
.replace(/{APP_NAME}/g, name)
|
|
|
|
.replace(/{VOLUME_NAME}/g, volName)
|
|
|
|
.replace(/{FLY_DEPLOY_EXPIRATION}/g, expiration);
|
|
|
|
await fs.writeFile(configPath, config);
|
|
|
|
}
|
|
|
|
|
|
|
|
function runFetch(cmd) {
|
|
|
|
console.log(`Running: ${cmd}`);
|
|
|
|
return exec(cmd);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function runAction(cmd) {
|
|
|
|
if (process.env.DRYRUN) {
|
|
|
|
console.log(`Would run: ${cmd}`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
console.log(`Running: ${cmd}`);
|
|
|
|
const cp = childProcess.spawn(cmd, {shell: true, stdio: 'inherit'});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
cp.on('error', reject);
|
|
|
|
cp.on('exit', function (code) {
|
|
|
|
if (code === 0) {
|
|
|
|
resolve();
|
|
|
|
} else {
|
|
|
|
reject(new Error(`exited with code ${code}`));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function findStaleApps() {
|
|
|
|
const {stdout} = await runFetch(`flyctl apps list -j`);
|
|
|
|
const list = JSON.parse(stdout);
|
|
|
|
const appNames = [];
|
|
|
|
for (const app of list) {
|
|
|
|
if (app.Organization?.Slug !== org) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
const {stdout} = await runFetch(`flyctl config display -a ${app.Name}`);
|
|
|
|
const expiration = JSON.parse(stdout).env?.FLY_DEPLOY_EXPIRATION;
|
|
|
|
if (!expiration) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
const expired = (Date.now() > Number(new Date(expiration)));
|
|
|
|
if (isNaN(expired)) {
|
|
|
|
console.warn(`Skipping ${app.Name} with invalid expiration ${expiration}`);
|
|
|
|
} else if (!expired) {
|
|
|
|
console.log(`Skipping ${app.Name}; not reached expiration of ${expiration}`);
|
|
|
|
} else {
|
|
|
|
console.log(`Will clean ${app.Name}; expired at ${expiration}`);
|
|
|
|
appNames.push(app.Name);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return appNames;
|
|
|
|
}
|
|
|
|
|
|
|
|
main().catch(err => { console.warn("ERROR", err); process.exit(1); });
|