Add safe VPS deploy entrypoint
This commit is contained in:
parent
883ad1ce5b
commit
39bac1ee8c
8 changed files with 404 additions and 10 deletions
287
scripts/deploy.ts
Normal file
287
scripts/deploy.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type DeployMode = "main" | "current-branch";
|
||||
|
||||
const REMOTE_HOST = "delta@152.53.80.229";
|
||||
const REMOTE_REPO = "/home/delta/islandflow";
|
||||
const REMOTE_DEPLOYMENT = "/home/delta/islandflow/deployment/docker";
|
||||
const SSH_KEY = path.join(process.env.HOME ?? "", ".ssh", "delta_ed25519");
|
||||
const SSH_OPTIONS = ["-i", SSH_KEY, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes"];
|
||||
const ALLOWED_REMOTE_UNTRACKED = "deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz";
|
||||
const API_CONTAINER = "islandflow-vps-api-1";
|
||||
const WEB_CONTAINER = "islandflow-vps-web-1";
|
||||
const PUBLIC_APP_URL = "https://flow.deltaisland.io";
|
||||
const PUBLIC_API_HEALTH_URL = "https://api.flow.deltaisland.io/health";
|
||||
const LOG_SERVICES = ["api", "web", "compute", "candles", "ingest-options", "ingest-equities"];
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
|
||||
function usage(exitCode = 1): never {
|
||||
console.error(`Usage:
|
||||
./deploy main [--force-recreate]
|
||||
./deploy current-branch [--force-recreate]
|
||||
./deploy current branch [--force-recreate]
|
||||
|
||||
Modes:
|
||||
main Deploy origin/main to the live server checkout.
|
||||
current-branch Push the current local branch, switch the server to it, and deploy it.
|
||||
|
||||
Options:
|
||||
--force-recreate Escalation path for docker compose when a normal refresh is not enough.
|
||||
--help Show this help text.`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function section(title: string): void {
|
||||
console.log(`\n== ${title} ==`);
|
||||
}
|
||||
|
||||
function formatCommand(command: string, args: string[]): string {
|
||||
return [command, ...args]
|
||||
.map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function runChecked(command: string, args: string[], options: SpawnSyncOptions = {}): void {
|
||||
console.log(`$ ${formatCommand(command, args)}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
...options
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function captureChecked(command: string, args: string[], options: SpawnSyncOptions = {}): string {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
...options
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr ?? "");
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function runRemoteScript(title: string, script: string, args: string[] = []): void {
|
||||
section(title);
|
||||
const sshArgs = [...SSH_OPTIONS, REMOTE_HOST, "bash", "-s", "--", ...args];
|
||||
console.log(`$ ${formatCommand("ssh", sshArgs)}`);
|
||||
const result = spawnSync("ssh", sshArgs, {
|
||||
cwd: repoRoot,
|
||||
input: script,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "inherit", "inherit"]
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]): { mode: DeployMode; forceRecreate: boolean } {
|
||||
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
||||
usage(0);
|
||||
}
|
||||
|
||||
const forceRecreate = rawArgs.includes("--force-recreate");
|
||||
const positional = rawArgs.filter((arg) => arg !== "--force-recreate");
|
||||
|
||||
if (positional.length === 1 && positional[0] === "main") {
|
||||
return { mode: "main", forceRecreate };
|
||||
}
|
||||
|
||||
if (
|
||||
(positional.length === 1 && positional[0] === "current-branch") ||
|
||||
(positional.length === 2 && positional[0] === "current" && positional[1] === "branch")
|
||||
) {
|
||||
return { mode: "current-branch", forceRecreate };
|
||||
}
|
||||
|
||||
usage();
|
||||
}
|
||||
|
||||
function assertSshKeyExists(): void {
|
||||
if (!existsSync(SSH_KEY)) {
|
||||
console.error(`Missing SSH key: ${SSH_KEY}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function localMainPrecheck(): void {
|
||||
section("Local Precheck");
|
||||
runChecked("git", ["fetch", "origin"]);
|
||||
runChecked("git", ["status", "--short", "--branch"]);
|
||||
runChecked("git", ["rev-parse", "--verify", "HEAD"]);
|
||||
runChecked("git", ["rev-parse", "origin/main"]);
|
||||
}
|
||||
|
||||
function currentBranchName(): string {
|
||||
const branch = captureChecked("git", ["branch", "--show-current"]).trim();
|
||||
if (!branch) {
|
||||
console.error("Refusing branch deployment from a detached HEAD.");
|
||||
process.exit(1);
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
function localBranchPrecheck(branch: string): void {
|
||||
section("Local Precheck");
|
||||
runChecked("git", ["branch", "--show-current"]);
|
||||
runChecked("git", ["status", "--short", "--branch"]);
|
||||
runChecked("git", ["fetch", "origin"]);
|
||||
|
||||
const porcelain = captureChecked("git", ["status", "--porcelain=v1"]).trim();
|
||||
if (porcelain) {
|
||||
console.error(
|
||||
`Refusing to deploy ${branch} with uncommitted local changes. Commit the intended state first.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function publishCurrentBranch(branch: string): void {
|
||||
section("Local Publish");
|
||||
const upstreamResult = spawnSync(
|
||||
"git",
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["inherit", "pipe", "pipe"]
|
||||
}
|
||||
);
|
||||
|
||||
if (upstreamResult.status === 0) {
|
||||
runChecked("git", ["push", "origin", branch]);
|
||||
return;
|
||||
}
|
||||
|
||||
runChecked("git", ["push", "-u", "origin", branch]);
|
||||
}
|
||||
|
||||
function remotePrecheck(): void {
|
||||
runRemoteScript(
|
||||
"Remote Precheck",
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "${REMOTE_REPO}"
|
||||
status="$(git status --porcelain=v1 --branch)"
|
||||
git status --short --branch
|
||||
git branch --show-current
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
case "$line" in
|
||||
'## '*)
|
||||
;;
|
||||
'?? ${ALLOWED_REMOTE_UNTRACKED}')
|
||||
;;
|
||||
'?? '*)
|
||||
echo "Refusing rollout: unexpected untracked path on server: \${line#?? }" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Refusing rollout: tracked local modifications on server: $line" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done <<< "$status"
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function remoteRollout(mode: DeployMode, branch: string | null, forceRecreate: boolean): void {
|
||||
const composeArgs = forceRecreate ? "up -d --build --force-recreate" : "up -d --build";
|
||||
const switchCommand =
|
||||
mode === "main"
|
||||
? `git switch main
|
||||
git pull --ff-only origin main`
|
||||
: `git switch ${shellEscape(branch!)} || git switch -c ${shellEscape(branch!)} --track origin/${shellEscape(branch!)}
|
||||
git pull --ff-only origin ${shellEscape(branch!)}`;
|
||||
|
||||
runRemoteScript(
|
||||
"Remote Rollout",
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "${REMOTE_REPO}"
|
||||
git fetch origin
|
||||
${switchCommand}
|
||||
|
||||
cd "${REMOTE_DEPLOYMENT}"
|
||||
docker compose ${composeArgs}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function remoteVerification(): void {
|
||||
runRemoteScript(
|
||||
"Remote Verification",
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "${REMOTE_DEPLOYMENT}"
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 ${LOG_SERVICES.join(" ")}
|
||||
docker exec ${API_CONTAINER} bun -e 'const r = await fetch("http://127.0.0.1:4000/health"); console.log(await r.text())'
|
||||
docker exec ${WEB_CONTAINER} bun -e 'const r = await fetch("http://127.0.0.1:3000/"); console.log(r.status)'
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function publicVerification(): void {
|
||||
section("Public Verification");
|
||||
runChecked("curl", ["-I", "-fksS", PUBLIC_APP_URL]);
|
||||
runChecked("curl", ["-fksS", PUBLIC_API_HEALTH_URL]);
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { mode, forceRecreate } = parseArgs(process.argv.slice(2));
|
||||
assertSshKeyExists();
|
||||
|
||||
console.log(
|
||||
mode === "main"
|
||||
? "Deploying origin/main to the existing Islandflow VPS checkout."
|
||||
: "Deploying the current local branch to the existing Islandflow VPS checkout."
|
||||
);
|
||||
|
||||
if (mode === "main") {
|
||||
localMainPrecheck();
|
||||
remotePrecheck();
|
||||
remoteRollout(mode, null, forceRecreate);
|
||||
} else {
|
||||
const branch = currentBranchName();
|
||||
localBranchPrecheck(branch);
|
||||
publishCurrentBranch(branch);
|
||||
remotePrecheck();
|
||||
remoteRollout(mode, branch, forceRecreate);
|
||||
}
|
||||
|
||||
remoteVerification();
|
||||
publicVerification();
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Add table
Add a link
Reference in a new issue