diff --git a/.github/workflows/fly-cleanup.yml b/.github/workflows/fly-cleanup.yml new file mode 100644 index 00000000..89d5e3c4 --- /dev/null +++ b/.github/workflows/fly-cleanup.yml @@ -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 diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml new file mode 100644 index 00000000..7687b85a --- /dev/null +++ b/.github/workflows/fly.yml @@ -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 diff --git a/buildtools/fly-deploy.js b/buildtools/fly-deploy.js new file mode 100644 index 00000000..29dc79e8 --- /dev/null +++ b/buildtools/fly-deploy.js @@ -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); }); diff --git a/buildtools/fly-template.toml b/buildtools/fly-template.toml new file mode 100644 index 00000000..b1fca821 --- /dev/null +++ b/buildtools/fly-template.toml @@ -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"