(core) Fix flyctl cleanup; move/copy scripts to support preview deploys in grist-core

Summary:
- Fly flyctl cleanup which has been failing; deleting volumes before app no longer seems needed
- Move fly-deploy and add workflow scripts to make fly preview work in grist-core too

Test Plan: Tested that previews still work in this repo; and tested with a copy in grist-core.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3928
This commit is contained in:
Dmitry S 2023-06-21 17:19:55 -04:00
parent 3fa5125cf7
commit 331aa3152f
4 changed files with 275 additions and 0 deletions

23
.github/workflows/fly-cleanup.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Fly Cleanup
on:
schedule:
# Once a day, clean up jobs marked as expired
- cron: '50 12 * * *'
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
jobs:
clean:
name: Clean stale deployed apps
runs-on: ubuntu-latest
if: github.repository_owner == 'gristlabs'
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.0.525
- run: node buildtools/fly-deploy.js clean

64
.github/workflows/fly.yml vendored Normal file
View File

@ -0,0 +1,64 @@
name: Fly Deploy
on:
pull_request:
branches: [ main ]
types: [labeled, unlabeled, closed, opened, synchronize, reopened]
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
# Deploy when the 'preview' label is added, or when PR is updated with this label present.
if: |
github.repository_owner == 'gristlabs' &&
github.event_name == 'pull_request' && (
github.event.action == 'labeled' ||
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened'
) &&
contains(github.event.pull_request.labels.*.name, 'preview')
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.0.525
- id: fly_deploy
run: |
node buildtools/fly-deploy.js deploy
flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Deployed as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
})
destroy:
name: Remove app
runs-on: ubuntu-latest
# Remove the deployment when 'preview' label is removed, or the PR is closed.
if: |
github.repository_owner == 'gristlabs' &&
github.event_name == 'pull_request' &&
(github.event.action == 'closed' ||
(github.event.action == 'unlabeled' && github.event.label.name == 'preview'))
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.0.525
- id: fly_destroy
run: node buildtools/fly-deploy.js destroy

142
buildtools/fly-deploy.js Normal file
View File

@ -0,0 +1,142 @@
const util = require('util');
const childProcess = require('child_process');
const fs = require('fs/promises');
const {existsSync} = require('fs');
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, '-');
const getVolumeName = () => "grist_vol_" + getBranchName().toLowerCase().replace(/\W+/g, '_');
const getBranchName = () => {
if (!process.env.BRANCH_NAME) { console.log('Usage: Need BRANCH_NAME env var'); process.exit(1); }
return process.env.BRANCH_NAME;
}
async function main() {
if (process.argv[2] === 'deploy') {
const appRoot = process.argv[3] || ".";
if (!existsSync(`${appRoot}/Dockerfile`)) {
console.log(`Dockerfile not found in appRoot of ${appRoot}`);
process.exit(1);
}
const name = getAppName();
const volName = getVolumeName();
if (!await appExists(name)) {
await appCreate(name);
await appScale(name);
await volCreate(name, volName);
} 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);
}
}
await prepConfig(name, appRoot, volName);
await appDeploy(name, appRoot);
} else if (process.argv[2] === 'destroy') {
const name = getAppName();
if (await appExists(name)) {
await appDestroy(name);
}
} else if (process.argv[2] === 'clean') {
const staleApps = await findStaleApps();
for (const appName of staleApps) {
await appDestroy(appName);
}
} else {
console.log(`Usage:
deploy [appRoot]:
create (if needed) and deploy fly app grist-{BRANCH_NAME}.
appRoot may specify the working directory that contains the Dockerfile to build.
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
`);
process.exit(1);
}
}
const appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true).catch(() => false);
const appCreate = (name) => runAction(`flyctl apps create ${name} -o ${org}`);
const appScale = (name) => runAction(`flyctl scale memory 1024 -a ${name}`);
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));
const appDeploy = (name, appRoot) => runAction(`flyctl deploy ${appRoot} --remote-only`, {shell: true, stdio: 'inherit'});
async function appDestroy(name) {
await runAction(`flyctl apps destroy ${name} -y`);
}
async function prepConfig(name, appRoot, volName) {
const configPath = `${appRoot}/fly.toml`;
const configTemplatePath = `${appRoot}/buildtools/fly-template.toml`;
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); });

View File

@ -0,0 +1,46 @@
app = "{APP_NAME}"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[env]
APP_DOC_URL="https://{APP_NAME}.fly.dev"
APP_HOME_URL="https://{APP_NAME}.fly.dev"
APP_STATIC_URL="https://{APP_NAME}.fly.dev"
GRIST_SINGLE_ORG="docs"
PORT = "8080"
FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}"
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
http_checks = []
internal_port = 8080
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
force_https = true
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
[mounts]
source="{VOLUME_NAME}"
destination="/persist"