Skip to content

Instantly share code, notes, and snippets.

@gamedevsam
Last active October 23, 2023 15:45
Show Gist options
  • Save gamedevsam/c5fe6eb375528d8c0c12f3c87db5548b to your computer and use it in GitHub Desktop.
Save gamedevsam/c5fe6eb375528d8c0c12f3c87db5548b to your computer and use it in GitHub Desktop.
Script to deploy docker file to dokku (requires node 18 or greater + node-ssh and xid-js npm packages)
import 'dotenv/config';
import { NodeSSH } from 'node-ssh';
import { mkdir } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import { next as generate_xid } from 'xid-js';
import { printDuration, spawnPromise } from './utilities/spawn_promise.mjs';
const { values: argv } = parseArgs({
options: {
// (required) name of app to deploy, defaults to app-dev
app_name: {
type: 'string',
default: 'app-dev',
short: 'a'
},
// (optional) directory that will store locally created docker images
local_directory: {
type: 'string',
default: 'dist',
short: 'l'
},
// (optional) directory of the remote ssh machine that will store uploaded docker images
remote_directory: {
type: 'string',
default: 'tmp/docker_images',
short: 'r'
}
}
});
const ssh = new NodeSSH();
const app_name = argv.app_name;
const tag = generate_xid();
const dockerImageTag = `dokku/${app_name}:${tag}`;
async function sshCommand(cmd, options) {
const startTime = performance.now();
await ssh.execCommand(cmd, {
onStdout: (chunk) => console.log(chunk.toString('utf8')),
onStderr: (chunk) => console.error(chunk.toString('utf8')),
...options
});
console.log(`[deploy_dokku]: ${cmd} completed in ${printDuration(startTime)}`);
}
try {
await ssh.connect({
host: process.env.SSH_HOST,
username: process.env.SSH_USERNAME,
password: process.env.SSH_PASSWORD,
privateKeyPath: process.env.SSH_PRIVATE_KEY_PATH,
passphrase: process.env.SSH_PRIVATE_KEY_PASSWORD
});
// Build docker image
console.log(`[deploy_dokku]: Building docker image: '${app_name}:${tag}', please wait...`);
await spawnPromise(`docker image build --label=com.dokku.app-name=${app_name} --tag=${dockerImageTag} .`, {
outputPrefix: '[deploy_dokku]: ',
forwardParams: false
});
// Save docker image to disk
console.log(`[deploy_dokku]: Creating archive from docker image, please wait...`);
await mkdir(argv.local_directory, { recursive: true });
await spawnPromise(`docker image save ${dockerImageTag} | gzip > ${argv.local_directory}/${app_name}_${tag}.tar.gz`, {
outputPrefix: '[deploy_dokku]: ',
forwardParams: false
});
// Upload docker image archive to remote
await sshCommand(`mkdir -p ${argv.remote_directory}`);
console.log(`[deploy_dokku]: Uploading '${app_name}_${tag}.tar.gz', please wait...`);
const startTime = performance.now();
await ssh.putFiles([
{
local: `${process.cwd()}/${argv.local_directory}/${app_name}_${tag}.tar.gz`,
remote: `${argv.remote_directory}/${app_name}_${tag}.tar.gz`
}
]);
console.log(`[deploy_dokku]: Upload of '${app_name}_${tag}.tar.gz' completed in ${printDuration(startTime)}`);
// Unpack & load docker image on remote
await sshCommand(`docker load < ${app_name}_${tag}.tar.gz`, { cwd: argv.remote_directory });
// Disable dokku builder before we deploy the pre-built docker image
await sshCommand(`dokku builder:set ${app_name} selected null`);
// Deploy docker image on remote
await sshCommand(`dokku git:from-image ${app_name} ${dockerImageTag}`);
} catch (error) {
console.error(error);
} finally {
// Post-deployment cleanup (local commands must occur before remote commands)
try {
// Local commands (before remote)
await spawnPromise(`rm ${argv.local_directory}/${app_name}_${tag}.tar.gz`);
// Remote commands (after local)
await sshCommand(`rm ${argv.remote_directory}/${app_name}_${tag}.tar.gz`);
// Ensure subsquent git based deployments work successfully
// https://github.com/dokku/dokku/issues/5963#issuecomment-1615836280
await sshCommand(`dokku git:set ${app_name} source-image`);
// Restore dokku builder after we finish deploying the app
// https://dokku.com/docs/deployment/builders/builder-management/?h=builder#overriding-the-auto-selected-builder
await sshCommand(`dokku builder:set ${app_name} selected`);
} catch {}
ssh.dispose();
}
import { spawn } from 'child_process';
/**
* @typedef {import('child_process').SpawnOptionsWithoutStdio & {
* silent?: boolean,
* outputPrefix?: string,
* forwardParams?: boolean
* }} SpawnPromiseOptions
* */
/**
* @param {number} startTime
*/
export function printDuration(startTime) {
const elapsedMs = performance.now() - startTime;
if (elapsedMs < 60_000) {
return `${(elapsedMs / 1_000).toFixed(2)}s`;
}
const minutes = Math.floor(elapsedMs / 60000);
const seconds = ((elapsedMs % 60000) / 1000).toFixed(0);
return seconds === '60' ? '1m:00s' : `${minutes}m:${(Number(seconds) < 10 ? '0' : '') + seconds}s`;
}
/**
* @param {string} command
* @param {SpawnPromiseOptions} [options]
*/
export const spawnPromise = (command, options) =>
new Promise((resolve, reject) => {
/** @type {string} */
let cmd;
if (options?.forwardParams !== false) {
const args = process.argv.slice(2);
cmd = `${command}${args.length > 0 ? ` ${args.join(' ')}` : ''}`;
} else {
cmd = command;
}
if (!options?.silent) {
console.log(`${options?.outputPrefix ?? ''}BEGIN: ${cmd}`);
}
const startTime = performance.now();
spawn('sh', ['-c', cmd], {
stdio: 'inherit',
...options
}).on('close', (code) => {
if (code) {
reject(`${options?.outputPrefix ?? ''}'${cmd}'FAILED with code: ${code} (${printDuration(startTime)})`);
} else {
if (!options?.silent) {
console.log(`${options?.outputPrefix ?? ''}END: ${cmd} (${printDuration(startTime)})`);
}
resolve(code);
}
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment