Add dual-runtime deploy workflow

This commit is contained in:
dirtydishes 2026-05-15 19:47:09 -04:00
parent 73715c8163
commit df49af1ba2
5 changed files with 795 additions and 102 deletions

View file

@ -116,6 +116,23 @@ Start web only:
- `bun run dev:web` - `bun run dev:web`
Recommended fast iteration loop:
- `bun run dev:infra` for Docker-backed infra only
- `bun run dev:services` for native Bun backend services
- `bun run dev:web` for the local Next.js UI
This keeps Docker in the local workflow where it helps most (NATS, ClickHouse, Redis) without forcing the app services themselves into slower container rebuild/restart loops.
## Deployment Workflow
- `./deploy main` keeps the current VPS Docker rollout path as the default.
- `./deploy main --runtime native` targets a host-native Bun + systemd deployment.
- `./deploy current-branch` and `./deploy current-branch --runtime native` keep branch deploys available during the transition.
- Partial deploys are supported with `--web-only`, `--api-only`, `--services-only`, and `--no-build`.
- Docker runtime details live in `deployment/docker/README.md`.
- Native runtime expectations live in `deployment/native/README.md`.
## Desktop Shell ## Desktop Shell
Islandflow also includes a thin Electron desktop shell in `apps/desktop`. Islandflow also includes a thin Electron desktop shell in `apps/desktop`.

View file

@ -1,8 +1,13 @@
# Docker Deployment # Docker Deployment
This directory is the supported VPS deployment path for Islandflow. This directory contains the Docker runtime for Islandflow VPS deployments.
The repo no longer ships or supports a separate `deployment/npm` stack. Docker Compose is the deployment surface; if you want a reverse proxy, point it at the host ports published by this stack. Docker remains the default server rollout path, but the repo-root `deploy` helper can now target either:
- `--runtime docker` for this Docker Compose stack
- `--runtime native` for a host-native Bun + systemd rollout described in `deployment/native/README.md`
The repo no longer ships or supports a separate `deployment/npm` stack. If you want a reverse proxy, point it at the host ports published by this stack.
It is separate from the repo-root `docker-compose.yml`, which remains the lightweight local infra stack for development. It is separate from the repo-root `docker-compose.yml`, which remains the lightweight local infra stack for development.
@ -198,6 +203,7 @@ It preserves the current Docker Compose project and avoids destructive cleanup o
```bash ```bash
./deploy main ./deploy main
./deploy main --runtime docker
``` ```
This flow: This flow:
@ -213,6 +219,7 @@ This flow:
```bash ```bash
./deploy current-branch ./deploy current-branch
./deploy current-branch --runtime docker
``` ```
Alias: Alias:
@ -229,13 +236,24 @@ This flow:
- switches the server checkout to that same branch and keeps it there until you intentionally move it back - switches the server checkout to that same branch and keeps it there until you intentionally move it back
- runs the same rebuild and verification steps as `main` - runs the same rebuild and verification steps as `main`
### Partial Docker rollouts
Examples:
```bash
./deploy main --runtime docker --web-only
./deploy main --runtime docker --api-only
./deploy current-branch --runtime docker --services-only
./deploy main --runtime docker --web-only --no-build
```
### Escalation path ### Escalation path
Use force recreate only when a normal refresh does not update the services cleanly: Use force recreate only when a normal refresh does not update the services cleanly:
```bash ```bash
./deploy main --force-recreate ./deploy main --runtime docker --force-recreate
./deploy current-branch --force-recreate ./deploy current-branch --runtime docker --force-recreate
``` ```
### Return the server to `main` ### Return the server to `main`
@ -244,6 +262,7 @@ If the live checkout is on a branch deploy and you want normal production tracki
```bash ```bash
./deploy main ./deploy main
./deploy main --runtime docker
``` ```
The helper always does the final public verification against: The helper always does the final public verification against:

122
deployment/native/README.md Normal file
View file

@ -0,0 +1,122 @@
# Native Deployment
This directory documents the host-native Islandflow rollout path used by:
```bash
./deploy main --runtime native
./deploy current-branch --runtime native
```
This runtime is intended for faster server iteration during the transition away from Docker-only app rollouts. Local development should still prefer:
- Docker for infra (`bun run dev:infra`)
- native Bun services (`bun run dev:services`)
- native Next.js web (`bun run dev:web`)
## What native deploy means here
The checked-in `deploy` helper assumes:
- the live repo checkout is still `/home/delta/islandflow`
- Bun is installed on the VPS
- app processes are managed by `systemd`
- infrastructure services such as NATS, ClickHouse, and Redis are already reachable from the host
- the web app runs from `apps/web` and is served with `next start -p 3000`
The deploy script updates the repo checkout, optionally runs `bun install --frozen-lockfile`, optionally rebuilds the web app, restarts the target systemd units, and then verifies the services locally on the VPS plus through the public app URL.
## Expected unit names
Default unit names used by `scripts/deploy.ts`:
- `islandflow-web`
- `islandflow-api`
- `islandflow-compute`
- `islandflow-candles`
- `islandflow-ingest-options`
- `islandflow-ingest-equities`
Override them from your local shell before running `./deploy` if the server uses different names:
```bash
export DEPLOY_NATIVE_WEB_UNIT=my-web-unit
export DEPLOY_NATIVE_API_UNIT=my-api-unit
```
Available overrides:
- `DEPLOY_NATIVE_WEB_UNIT`
- `DEPLOY_NATIVE_API_UNIT`
- `DEPLOY_NATIVE_COMPUTE_UNIT`
- `DEPLOY_NATIVE_CANDLES_UNIT`
- `DEPLOY_NATIVE_INGEST_OPTIONS_UNIT`
- `DEPLOY_NATIVE_INGEST_EQUITIES_UNIT`
## systemctl invocation
By default the deploy helper uses:
```bash
sudo systemctl
```
If the server uses user units or another wrapper, override it locally before invoking `./deploy`:
```bash
export DEPLOY_NATIVE_SYSTEMCTL_PREFIX="systemctl --user"
./deploy main --runtime native
```
## Partial native rollouts
Examples:
```bash
./deploy main --runtime native --web-only
./deploy main --runtime native --api-only
./deploy current-branch --runtime native --services-only
./deploy main --runtime native --web-only --no-build
```
Scope behavior:
- default: restart web + API + backend services
- `--web-only`: rebuild/restart only the web unit
- `--api-only`: restart only the API unit
- `--services-only`: restart API + backend units without touching the web unit
- `--no-build`: skip `bun install --frozen-lockfile` and skip the web build step
## Server preparation checklist
Before the first native rollout, ensure the VPS has:
1. Bun installed and on `PATH`
2. a working `/home/delta/islandflow/.env` (or unit-managed equivalent env source)
3. systemd units for each target service
4. the web unit configured to serve the built app on port `3000`
5. the API unit configured to serve health checks on port `4000`
6. infrastructure endpoints configured so the native services can reach NATS, ClickHouse, and Redis
## Verification
Native deploys verify:
- target units are active via `systemctl`
- recent unit status and journal output can be collected
- local `http://127.0.0.1:4000/health` when API scope is included
- local `http://127.0.0.1:3000/` when web scope is included
- the public app URL from the local machine after the rollout finishes
## Rollback
Rollback remains manual for now:
1. switch the server checkout back to the last known-good branch or commit
2. rerun the appropriate native deploy command
3. if needed, restart only the affected units with `systemctl`
Docker remains available as the fallback runtime during the transition:
```bash
./deploy main --runtime docker
```

View file

@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>2026-05-15: Dual-runtime deploy workflow</title>
<style>
:root {
color-scheme: dark;
--bg: #10131a;
--panel: #171c25;
--panel-2: #1e2531;
--text: #e8edf5;
--muted: #9fb0c8;
--accent: #7cc4ff;
--border: #2d3848;
--good: #7dd3a7;
--warn: #f6c177;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 16px/1.6 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #0c1016, var(--bg));
color: var(--text);
}
main {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px 72px;
}
h1, h2 { line-height: 1.15; }
h1 { margin: 0 0 12px; font-size: 2rem; }
h2 { margin: 0 0 14px; font-size: 1.2rem; }
p, li { color: var(--text); }
.lede { color: var(--muted); max-width: 70ch; }
section {
margin-top: 24px;
padding: 22px 24px;
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 18px;
}
code, pre {
font: 13px/1.5 "SFMono-Regular", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
code {
padding: 0.15rem 0.35rem;
border-radius: 8px;
background: rgba(124, 196, 255, 0.12);
color: var(--accent);
}
pre {
margin: 14px 0 0;
padding: 14px 16px;
overflow: auto;
border-radius: 14px;
border: 1px solid var(--border);
background: #0d1219;
color: #d7e7ff;
}
ul { margin: 0; padding-left: 1.2rem; }
.meta {
display: inline-flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.chip {
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: rgba(255,255,255,0.03);
}
.good { color: var(--good); }
.warn { color: var(--warn); }
a { color: var(--accent); }
</style>
</head>
<body>
<main>
<div class="meta">
<span class="chip">Turn document</span>
<span class="chip">2026-05-15</span>
<span class="chip">Issue: islandflow-qh7</span>
</div>
<h1>Dual-runtime deploy workflow</h1>
<p class="lede">
Updated the root deploy flow so it can target either the existing Docker Compose VPS runtime or a new host-native Bun + systemd runtime, while also adding partial deploy scopes for faster iteration.
</p>
<section>
<h2>Summary</h2>
<p>
The deploy helper now supports <code>--runtime docker</code> and <code>--runtime native</code>, keeps Docker as the default, and adds <code>--web-only</code>, <code>--api-only</code>, <code>--services-only</code>, and <code>--no-build</code>. Documentation now clearly separates fast local development from VPS rollout options.
</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Refactored <code>scripts/deploy.ts</code> into shared git/publish logic plus runtime-specific precheck, rollout, and verification paths.</li>
<li>Removed Docker verifications dependence on hardcoded container names and switched to <code>docker compose exec</code>.</li>
<li>Added native deployment support that assumes Bun plus systemd-managed units on the VPS.</li>
<li>Added a new operator guide at <code>deployment/native/README.md</code>.</li>
<li>Updated <code>README.md</code> to emphasize the preferred fast local loop: Docker infra only, native Bun services, native web dev.</li>
<li>Updated <code>deployment/docker/README.md</code> to document Docker as the default runtime and show new partial rollout examples.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>
The repo already separated local infra from application processes: the root <code>docker-compose.yml</code> is infra-only, while services and the web app run through Bun scripts. The old deploy helper still assumed every server rollout was Docker-only. This change makes the deploy workflow match the new operating model: fast native iteration locally, Docker still available in production, and a native VPS path available during transition.
</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>Docker remains the default runtime, so <code>./deploy main</code> still works without extra flags.</li>
<li>Native rollouts are invoked with <code>./deploy main --runtime native</code> or <code>./deploy current-branch --runtime native</code>.</li>
<li>Partial scopes are mutually exclusive and intentionally simple:</li>
</ul>
<pre>./deploy main --runtime docker --web-only
./deploy main --runtime native --api-only
./deploy current-branch --runtime docker --services-only
./deploy main --runtime native --web-only --no-build</pre>
<ul style="margin-top:14px;">
<li>Docker workspace snapshot validation now runs only when a Docker rollout will build images.</li>
<li>Native rollouts assume systemd unit names like <code>islandflow-web</code> and <code>islandflow-api</code>, but those names can be overridden with environment variables such as <code>DEPLOY_NATIVE_WEB_UNIT</code>.</li>
<li>The native path also allows overriding the systemctl wrapper via <code>DEPLOY_NATIVE_SYSTEMCTL_PREFIX</code>, which is useful for <code>systemctl --user</code> setups.</li>
</ul>
</section>
<section>
<h2>Validation</h2>
<ul>
<li class="good">Passed: <code>bun run scripts/deploy.ts --help</code></li>
<li class="good">Passed: <code>bun run check:docker-workspace</code></li>
<li class="good">Passed: invalid-flag guard for <code>--runtime native --force-recreate</code></li>
<li class="good">Passed: conflicting-scope guard for <code>--web-only --api-only</code></li>
</ul>
<pre>bun run scripts/deploy.ts --help
bun run check:docker-workspace
bun run scripts/deploy.ts main --runtime native --force-recreate
bun run scripts/deploy.ts main --web-only --api-only</pre>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li><span class="warn">Native deploys assume server-side systemd units already exist.</span> Mitigation: added <code>deployment/native/README.md</code> documenting expected unit names and override variables.</li>
<li><span class="warn">Rollback is still manual.</span> Mitigation: both Docker and native docs now frame runtime selection as a transition path, with Docker preserved as a fallback.</li>
<li><span class="warn">No native service unit files were added in this change.</span> This keeps the scope focused on the deploy workflow itself.</li>
<li><span class="warn">Public verification still centers on the hosted app URL.</span> API verification remains local-to-runtime unless <code>DEPLOY_PUBLIC_API_HEALTH_URL</code> is configured.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Implementation tracked in <code>islandflow-qh7</code> is complete for the deploy helper itself.</li>
<li>Open follow-up: <code>islandflow-38p</code>, add checked-in native deployment unit templates and rollback helpers.</li>
</ul>
</section>
</main>
</body>
</html>

View file

@ -6,10 +6,20 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
type DeployMode = "main" | "current-branch"; type DeployMode = "main" | "current-branch";
type DeployRuntime = "docker" | "native";
type DeployScope = "full" | "web" | "api" | "services";
type DeployOptions = {
mode: DeployMode;
runtime: DeployRuntime;
scope: DeployScope;
forceRecreate: boolean;
noBuild: boolean;
};
const REMOTE_HOST = "delta@152.53.80.229"; const REMOTE_HOST = "delta@152.53.80.229";
const REMOTE_REPO = "/home/delta/islandflow"; const REMOTE_REPO = "/home/delta/islandflow";
const REMOTE_DEPLOYMENT = "/home/delta/islandflow/deployment/docker"; const REMOTE_DOCKER_DEPLOYMENT = "/home/delta/islandflow/deployment/docker";
const SSH_KEY = path.join(process.env.HOME ?? "", ".ssh", "delta_ed25519"); const SSH_KEY = path.join(process.env.HOME ?? "", ".ssh", "delta_ed25519");
const SSH_OPTIONS = [ const SSH_OPTIONS = [
"-i", "-i",
@ -17,47 +27,83 @@ const SSH_OPTIONS = [
"-o", "-o",
"IdentitiesOnly=yes", "IdentitiesOnly=yes",
"-o", "-o",
"BatchMode=yes", "BatchMode=yes"
]; ];
const ALLOWED_REMOTE_UNTRACKED = new Set([ const ALLOWED_REMOTE_UNTRACKED = new Set([
"deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz", "deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz",
"deployment/npm/", "deployment/npm/"
]); ]);
const API_CONTAINER = "islandflow-vps-api-1";
const WEB_CONTAINER = "islandflow-vps-web-1";
const PUBLIC_APP_URL = const PUBLIC_APP_URL =
process.env.DEPLOY_PUBLIC_APP_URL?.trim() || "https://flow.deltaisland.io"; process.env.DEPLOY_PUBLIC_APP_URL?.trim() || "https://flow.deltaisland.io";
const PUBLIC_API_HEALTH_URL = const PUBLIC_API_HEALTH_URL =
process.env.DEPLOY_PUBLIC_API_HEALTH_URL?.trim() || null; process.env.DEPLOY_PUBLIC_API_HEALTH_URL?.trim() || null;
const LOG_SERVICES = [ const NATIVE_SYSTEMCTL_PREFIX =
process.env.DEPLOY_NATIVE_SYSTEMCTL_PREFIX?.trim() || "sudo systemctl";
const NATIVE_UNITS = {
web: process.env.DEPLOY_NATIVE_WEB_UNIT?.trim() || "islandflow-web",
api: process.env.DEPLOY_NATIVE_API_UNIT?.trim() || "islandflow-api",
compute: process.env.DEPLOY_NATIVE_COMPUTE_UNIT?.trim() || "islandflow-compute",
candles: process.env.DEPLOY_NATIVE_CANDLES_UNIT?.trim() || "islandflow-candles",
ingestOptions:
process.env.DEPLOY_NATIVE_INGEST_OPTIONS_UNIT?.trim() || "islandflow-ingest-options",
ingestEquities:
process.env.DEPLOY_NATIVE_INGEST_EQUITIES_UNIT?.trim() || "islandflow-ingest-equities"
} as const;
const DOCKER_CORE_SERVICES = [
"api", "api",
"web", "web",
"compute", "compute",
"candles", "candles",
"ingest-options", "ingest-options",
"ingest-equities", "ingest-equities"
]; ] as const;
const DOCKER_BACKEND_SERVICES = [
"api",
"compute",
"candles",
"ingest-options",
"ingest-equities"
] as const;
const scriptPath = fileURLToPath(import.meta.url); const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(scriptPath), ".."); const repoRoot = path.resolve(path.dirname(scriptPath), "..");
function usage(exitCode = 1): never { function usage(exitCode = 1): never {
console.error(`Usage: console.error(`Usage:
./deploy main [--force-recreate] ./deploy main [--runtime docker|native] [--web-only|--api-only|--services-only] [--no-build] [--force-recreate]
./deploy current-branch [--force-recreate] ./deploy current-branch [--runtime docker|native] [--web-only|--api-only|--services-only] [--no-build] [--force-recreate]
./deploy current branch [--force-recreate] ./deploy current branch [--runtime docker|native] [--web-only|--api-only|--services-only] [--no-build] [--force-recreate]
Modes: Modes:
main Deploy origin/main to the live server checkout. main Deploy origin/main to the live server checkout.
current-branch Push the current local branch, switch the server to it, and deploy it. current-branch Push the current local branch, switch the server to it, and deploy it.
Runtimes:
docker Roll out from deployment/docker with Docker Compose (default).
native Roll out host-native Bun services managed by systemd.
Scopes:
default Full rollout (web + API + backend services).
--web-only Deploy only the Next.js web surface.
--api-only Deploy only the API service.
--services-only Deploy API + backend services without the web service.
Options: Options:
--force-recreate Escalation path for docker compose when a normal refresh is not enough. --runtime <name> Explicit runtime selector (docker or native).
--no-build Skip docker image builds or native bun install/web build steps.
--force-recreate Docker-only escalation path for docker compose when a normal refresh is not enough.
--help Show this help text. --help Show this help text.
Environment: Environment:
DEPLOY_PUBLIC_APP_URL Override the public app URL (default: https://flow.deltaisland.io). DEPLOY_PUBLIC_APP_URL Override the public app URL (default: https://flow.deltaisland.io).
DEPLOY_PUBLIC_API_HEALTH_URL Optional separate public API health URL for two-origin deployments.`); DEPLOY_PUBLIC_API_HEALTH_URL Optional separate public API health URL for two-origin deployments.
DEPLOY_NATIVE_SYSTEMCTL_PREFIX Override systemctl invocation for native rollouts (default: sudo systemctl).
DEPLOY_NATIVE_WEB_UNIT Override native web systemd unit name.
DEPLOY_NATIVE_API_UNIT Override native api systemd unit name.
DEPLOY_NATIVE_COMPUTE_UNIT Override native compute systemd unit name.
DEPLOY_NATIVE_CANDLES_UNIT Override native candles systemd unit name.
DEPLOY_NATIVE_INGEST_OPTIONS_UNIT Override native ingest-options systemd unit name.
DEPLOY_NATIVE_INGEST_EQUITIES_UNIT Override native ingest-equities systemd unit name.`);
process.exit(exitCode); process.exit(exitCode);
} }
@ -74,13 +120,13 @@ function formatCommand(command: string, args: string[]): string {
function runChecked( function runChecked(
command: string, command: string,
args: string[], args: string[],
options: SpawnSyncOptions = {}, options: SpawnSyncOptions = {}
): void { ): void {
console.log(`$ ${formatCommand(command, args)}`); console.log(`$ ${formatCommand(command, args)}`);
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
cwd: repoRoot, cwd: repoRoot,
stdio: "inherit", stdio: "inherit",
...options, ...options
}); });
if (result.status !== 0) { if (result.status !== 0) {
@ -91,13 +137,13 @@ function runChecked(
function captureChecked( function captureChecked(
command: string, command: string,
args: string[], args: string[],
options: SpawnSyncOptions = {}, options: SpawnSyncOptions = {}
): string { ): string {
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
cwd: repoRoot, cwd: repoRoot,
encoding: "utf8", encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"], stdio: ["inherit", "pipe", "pipe"],
...options, ...options
}); });
if (result.status !== 0) { if (result.status !== 0) {
@ -111,7 +157,7 @@ function captureChecked(
function runRemoteScript( function runRemoteScript(
title: string, title: string,
script: string, script: string,
args: string[] = [], args: string[] = []
): void { ): void {
section(title); section(title);
const sshArgs = [...SSH_OPTIONS, REMOTE_HOST, "bash", "-s", "--", ...args]; const sshArgs = [...SSH_OPTIONS, REMOTE_HOST, "bash", "-s", "--", ...args];
@ -120,7 +166,7 @@ function runRemoteScript(
cwd: repoRoot, cwd: repoRoot,
input: script, input: script,
encoding: "utf8", encoding: "utf8",
stdio: ["pipe", "inherit", "inherit"], stdio: ["pipe", "inherit", "inherit"]
}); });
if (result.status !== 0) { if (result.status !== 0) {
@ -128,28 +174,85 @@ function runRemoteScript(
} }
} }
function parseArgs(rawArgs: string[]): { function parseRuntime(rawArgs: string[]): DeployRuntime {
mode: DeployMode; for (let index = 0; index < rawArgs.length; index += 1) {
forceRecreate: boolean; const arg = rawArgs[index];
} { if (arg === "--runtime") {
const value = rawArgs[index + 1];
if (value === "docker" || value === "native") {
return value;
}
usage();
}
if (arg.startsWith("--runtime=")) {
const value = arg.slice("--runtime=".length);
if (value === "docker" || value === "native") {
return value;
}
usage();
}
}
return "docker";
}
function parseScope(rawArgs: string[]): DeployScope {
const scopes = [
rawArgs.includes("--web-only") ? "web" : null,
rawArgs.includes("--api-only") ? "api" : null,
rawArgs.includes("--services-only") ? "services" : null
].filter((value): value is Exclude<DeployScope, "full"> => value !== null);
if (scopes.length > 1) {
console.error("Choose only one deploy scope flag: --web-only, --api-only, or --services-only.");
process.exit(1);
}
return scopes[0] ?? "full";
}
function parseArgs(rawArgs: string[]): DeployOptions {
if (rawArgs.includes("--help") || rawArgs.includes("-h")) { if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
usage(0); usage(0);
} }
const runtime = parseRuntime(rawArgs);
const scope = parseScope(rawArgs);
const forceRecreate = rawArgs.includes("--force-recreate"); const forceRecreate = rawArgs.includes("--force-recreate");
const positional = rawArgs.filter((arg) => arg !== "--force-recreate"); const noBuild = rawArgs.includes("--no-build");
const positional = rawArgs.filter(
(arg, index) =>
arg !== "--force-recreate" &&
arg !== "--no-build" &&
arg !== "--web-only" &&
arg !== "--api-only" &&
arg !== "--services-only" &&
arg !== "--runtime" &&
rawArgs[index - 1] !== "--runtime" &&
!arg.startsWith("--runtime=")
);
if (forceRecreate && runtime !== "docker") {
console.error("--force-recreate is only supported with --runtime docker.");
process.exit(1);
}
if (positional.length === 1 && positional[0] === "main") { if (positional.length === 1 && positional[0] === "main") {
return { mode: "main", forceRecreate }; return { mode: "main", runtime, scope, forceRecreate, noBuild };
} }
if ( if (
(positional.length === 1 && positional[0] === "current-branch") || (positional.length === 1 && positional[0] === "current-branch") ||
(positional.length === 2 && (positional.length === 2 && positional[0] === "current" && positional[1] === "branch")
positional[0] === "current" &&
positional[1] === "branch")
) { ) {
return { mode: "current-branch", forceRecreate }; return {
mode: "current-branch",
runtime,
scope,
forceRecreate,
noBuild
};
} }
usage(); usage();
@ -162,28 +265,122 @@ function assertSshKeyExists(): void {
} }
} }
function localWorkspaceSnapshotPrecheck(): void { function shellEscape(value: string): string {
if (value.length === 0) {
return "''";
}
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function shellPattern(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function describeRuntime(runtime: DeployRuntime): string {
return runtime === "docker" ? "Docker Compose" : "native systemd/Bun";
}
function describeScope(scope: DeployScope): string {
switch (scope) {
case "web":
return "web only";
case "api":
return "api only";
case "services":
return "api + backend services";
default:
return "full stack";
}
}
function scopeIncludesWeb(scope: DeployScope): boolean {
return scope === "full" || scope === "web";
}
function scopeIncludesApi(scope: DeployScope): boolean {
return scope === "full" || scope === "api" || scope === "services";
}
function dockerServicesForScope(scope: DeployScope): string[] {
switch (scope) {
case "web":
return ["web"];
case "api":
return ["api"];
case "services":
return [...DOCKER_BACKEND_SERVICES];
default:
return [];
}
}
function dockerLogServicesForScope(scope: DeployScope): string[] {
switch (scope) {
case "web":
return ["web"];
case "api":
return ["api"];
case "services":
return [...DOCKER_BACKEND_SERVICES];
default:
return [...DOCKER_CORE_SERVICES];
}
}
function nativeUnitsForScope(scope: DeployScope): string[] {
switch (scope) {
case "web":
return [NATIVE_UNITS.web];
case "api":
return [NATIVE_UNITS.api];
case "services":
return [
NATIVE_UNITS.api,
NATIVE_UNITS.compute,
NATIVE_UNITS.candles,
NATIVE_UNITS.ingestOptions,
NATIVE_UNITS.ingestEquities
];
default:
return [
NATIVE_UNITS.web,
NATIVE_UNITS.api,
NATIVE_UNITS.compute,
NATIVE_UNITS.candles,
NATIVE_UNITS.ingestOptions,
NATIVE_UNITS.ingestEquities
];
}
}
function localDockerWorkspaceSnapshotPrecheck(): void {
console.log("$ bun run check:docker-workspace"); console.log("$ bun run check:docker-workspace");
const result = spawnSync("bun", ["run", "check:docker-workspace"], { const result = spawnSync("bun", ["run", "check:docker-workspace"], {
cwd: repoRoot, cwd: repoRoot,
stdio: "inherit", stdio: "inherit"
}); });
if (result.status !== 0) { if (result.status !== 0) {
console.error( console.error(
"Refusing deploy: deployment/docker/workspace-root is out of sync. Run `bun run sync:docker-workspace`, commit updated snapshot files, then retry deploy.", "Refusing docker deploy: deployment/docker/workspace-root is out of sync. Run `bun run sync:docker-workspace`, commit updated snapshot files, then retry deploy."
); );
process.exit(result.status ?? 1); process.exit(result.status ?? 1);
} }
} }
function localMainPrecheck(): void { function localRuntimePrecheck(runtime: DeployRuntime, noBuild: boolean): void {
if (runtime === "docker" && !noBuild) {
localDockerWorkspaceSnapshotPrecheck();
}
}
function localMainPrecheck(runtime: DeployRuntime, noBuild: boolean): void {
section("Local Precheck"); section("Local Precheck");
runChecked("git", ["fetch", "origin"]); runChecked("git", ["fetch", "origin"]);
runChecked("git", ["status", "--short", "--branch"]); runChecked("git", ["status", "--short", "--branch"]);
runChecked("git", ["rev-parse", "--verify", "HEAD"]); runChecked("git", ["rev-parse", "--verify", "HEAD"]);
runChecked("git", ["rev-parse", "origin/main"]); runChecked("git", ["rev-parse", "origin/main"]);
localWorkspaceSnapshotPrecheck(); localRuntimePrecheck(runtime, noBuild);
} }
function currentBranchName(): string { function currentBranchName(): string {
@ -195,7 +392,11 @@ function currentBranchName(): string {
return branch; return branch;
} }
function localBranchPrecheck(branch: string): void { function localBranchPrecheck(
branch: string,
runtime: DeployRuntime,
noBuild: boolean
): void {
section("Local Precheck"); section("Local Precheck");
runChecked("git", ["branch", "--show-current"]); runChecked("git", ["branch", "--show-current"]);
runChecked("git", ["status", "--short", "--branch"]); runChecked("git", ["status", "--short", "--branch"]);
@ -204,12 +405,12 @@ function localBranchPrecheck(branch: string): void {
const porcelain = captureChecked("git", ["status", "--porcelain=v1"]).trim(); const porcelain = captureChecked("git", ["status", "--porcelain=v1"]).trim();
if (porcelain) { if (porcelain) {
console.error( console.error(
`Refusing to deploy ${branch} with uncommitted local changes. Commit the intended state first.`, `Refusing to deploy ${branch} with uncommitted local changes. Commit the intended state first.`
); );
process.exit(1); process.exit(1);
} }
localWorkspaceSnapshotPrecheck(); localRuntimePrecheck(runtime, noBuild);
} }
function publishCurrentBranch(branch: string): void { function publishCurrentBranch(branch: string): void {
@ -220,8 +421,8 @@ function publishCurrentBranch(branch: string): void {
{ {
cwd: repoRoot, cwd: repoRoot,
encoding: "utf8", encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"], stdio: ["inherit", "pipe", "pipe"]
}, }
); );
if (upstreamResult.status === 0) { if (upstreamResult.status === 0) {
@ -232,9 +433,9 @@ function publishCurrentBranch(branch: string): void {
runChecked("git", ["push", "-u", "origin", branch]); runChecked("git", ["push", "-u", "origin", branch]);
} }
function remotePrecheck(): void { function remoteGitPrecheck(): void {
const allowedRemoteUntrackedPattern = Array.from(ALLOWED_REMOTE_UNTRACKED) const allowedRemoteUntrackedPattern = Array.from(ALLOWED_REMOTE_UNTRACKED)
.map((path) => shellPattern(path)) .map((value) => shellPattern(value))
.join("|"); .join("|");
runRemoteScript( runRemoteScript(
@ -242,7 +443,7 @@ function remotePrecheck(): void {
`#!/usr/bin/env bash `#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "${REMOTE_REPO}" cd ${shellEscape(REMOTE_REPO)}
status="$(git status --porcelain=v1 --branch)" status="$(git status --porcelain=v1 --branch)"
git status --short --branch git status --short --branch
git branch --show-current git branch --show-current
@ -269,104 +470,268 @@ while IFS= read -r line; do
;; ;;
esac esac
done <<< "$status" done <<< "$status"
`, `
); );
} }
function remoteRollout( function remoteRuntimePrecheck(runtime: DeployRuntime, scope: DeployScope): void {
mode: DeployMode, if (runtime === "docker") {
branch: string | null, runRemoteScript(
forceRecreate: boolean, "Remote Runtime Precheck",
): void { `#!/usr/bin/env bash
const composeArgs = forceRecreate set -euo pipefail
? "up -d --build --force-recreate"
: "up -d --build"; cd ${shellEscape(REMOTE_DOCKER_DEPLOYMENT)}
command -v docker >/dev/null 2>&1
docker compose version >/dev/null
`
);
return;
}
const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" ");
runRemoteScript(
"Remote Runtime Precheck",
`#!/usr/bin/env bash
set -euo pipefail
cd ${shellEscape(REMOTE_REPO)}
command -v bun >/dev/null 2>&1
command -v systemctl >/dev/null 2>&1
declare -a units=(${units})
for unit in "\${units[@]}"; do
load_state="$(${NATIVE_SYSTEMCTL_PREFIX} show --property=LoadState --value "$unit" 2>/dev/null || true)"
if [[ -z "$load_state" || "$load_state" == "not-found" ]]; then
echo "Refusing native rollout: missing systemd unit $unit" >&2
echo "See deployment/native/README.md for expected unit names and overrides." >&2
exit 1
fi
done
`
);
}
function remoteGitUpdateScript(mode: DeployMode, branch: string | null): string {
const escapedBranch = branch ? shellEscape(branch) : null;
const switchCommand = const switchCommand =
mode === "main" mode === "main"
? `git switch main ? `git switch main\ngit pull --ff-only origin main`
git pull --ff-only origin main` : `git switch ${escapedBranch} || git switch -c ${escapedBranch} --track origin/${escapedBranch}\ngit pull --ff-only origin ${escapedBranch}`;
: `git switch ${shellEscape(branch!)} || git switch -c ${shellEscape(branch!)} --track origin/${shellEscape(branch!)}
git pull --ff-only origin ${shellEscape(branch!)}`; return `cd ${shellEscape(REMOTE_REPO)}\ngit fetch origin\n${switchCommand}`;
}
function remoteDockerRollout(
mode: DeployMode,
branch: string | null,
scope: DeployScope,
forceRecreate: boolean,
noBuild: boolean
): void {
const services = dockerServicesForScope(scope);
const args = ["up", "-d"];
if (!noBuild) {
args.push("--build");
}
if (forceRecreate) {
args.push("--force-recreate");
}
const command = `docker compose ${[...args, ...services].join(" ")}`;
runRemoteScript( runRemoteScript(
"Remote Rollout", "Remote Rollout",
`#!/usr/bin/env bash `#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "${REMOTE_REPO}" ${remoteGitUpdateScript(mode, branch)}
git fetch origin
${switchCommand}
cd "${REMOTE_DEPLOYMENT}" cd ${shellEscape(REMOTE_DOCKER_DEPLOYMENT)}
docker compose ${composeArgs} ${command}
`, `
); );
} }
function remoteVerification(): void { function remoteNativeRollout(
mode: DeployMode,
branch: string | null,
scope: DeployScope,
noBuild: boolean
): void {
const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" ");
const buildSteps: string[] = [];
if (!noBuild) {
buildSteps.push("bun install --frozen-lockfile");
if (scopeIncludesWeb(scope)) {
buildSteps.push("bun --cwd=apps/web run build");
}
}
buildSteps.push(`${NATIVE_SYSTEMCTL_PREFIX} restart ${nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" ")}`);
runRemoteScript(
"Remote Rollout",
`#!/usr/bin/env bash
set -euo pipefail
${remoteGitUpdateScript(mode, branch)}
cd ${shellEscape(REMOTE_REPO)}
${buildSteps.join("\n")}
declare -a units=(${units})
for unit in "\${units[@]}"; do
${NATIVE_SYSTEMCTL_PREFIX} is-active --quiet "$unit"
done
`
);
}
function remoteRollout(
mode: DeployMode,
runtime: DeployRuntime,
branch: string | null,
scope: DeployScope,
forceRecreate: boolean,
noBuild: boolean
): void {
if (runtime === "docker") {
remoteDockerRollout(mode, branch, scope, forceRecreate, noBuild);
return;
}
remoteNativeRollout(mode, branch, scope, noBuild);
}
function remoteDockerVerification(scope: DeployScope): void {
const psServices = dockerServicesForScope(scope);
const logServices = dockerLogServicesForScope(scope);
const psCommand =
psServices.length > 0
? `docker compose ps ${psServices.join(" ")}`
: "docker compose ps";
const logCommand = `docker compose logs --tail=100 ${logServices.join(" ")}`;
const checks: string[] = [];
if (scopeIncludesApi(scope)) {
checks.push(
`docker compose exec -T api bun -e 'const r = await fetch("http://127.0.0.1:4000/health"); if (!r.ok) throw new Error("api healthcheck failed: " + r.status); console.log(await r.text())'`
);
}
if (scopeIncludesWeb(scope)) {
checks.push(
`docker compose exec -T web bun -e 'const r = await fetch("http://127.0.0.1:3000/"); if (!r.ok) throw new Error("web healthcheck failed: " + r.status); console.log(r.status)'`
);
}
runRemoteScript( runRemoteScript(
"Remote Verification", "Remote Verification",
`#!/usr/bin/env bash `#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "${REMOTE_DEPLOYMENT}" cd ${shellEscape(REMOTE_DOCKER_DEPLOYMENT)}
docker compose ps ${psCommand}
docker compose logs --tail=100 ${LOG_SERVICES.join(" ")} ${logCommand}
docker exec ${API_CONTAINER} bun -e 'const r = await fetch("http://127.0.0.1:4000/health"); console.log(await r.text())' ${checks.join("\n")}
docker exec ${WEB_CONTAINER} bun -e 'const r = await fetch("http://127.0.0.1:3000/"); console.log(r.status)' `
`,
); );
} }
function publicVerification(): void { function remoteNativeVerification(scope: DeployScope): void {
const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" ");
const checks: string[] = [];
if (scopeIncludesApi(scope)) {
checks.push('curl -fksS http://127.0.0.1:4000/health');
}
if (scopeIncludesWeb(scope)) {
checks.push('curl -I -fksS http://127.0.0.1:3000/');
}
runRemoteScript(
"Remote Verification",
`#!/usr/bin/env bash
set -euo pipefail
declare -a units=(${units})
for unit in "\${units[@]}"; do
${NATIVE_SYSTEMCTL_PREFIX} is-active --quiet "$unit"
${NATIVE_SYSTEMCTL_PREFIX} status --no-pager "$unit" || true
journalctl -u "$unit" -n 50 --no-pager || true
done
${checks.join("\n")}
`
);
}
function remoteVerification(runtime: DeployRuntime, scope: DeployScope): void {
if (runtime === "docker") {
remoteDockerVerification(scope);
return;
}
remoteNativeVerification(scope);
}
function publicVerification(scope: DeployScope): void {
section("Public Verification"); section("Public Verification");
runChecked("curl", ["-I", "-fksS", PUBLIC_APP_URL]); runChecked("curl", ["-I", "-fksS", PUBLIC_APP_URL]);
if (PUBLIC_API_HEALTH_URL) { if (scopeIncludesApi(scope) && PUBLIC_API_HEALTH_URL) {
runChecked("curl", ["-fksS", PUBLIC_API_HEALTH_URL]); runChecked("curl", ["-fksS", PUBLIC_API_HEALTH_URL]);
return; return;
} }
if (scopeIncludesApi(scope)) {
console.log( console.log(
"Skipping separate public API health check; same-origin mode relies on the public app check plus container-local API verification.", "Skipping separate public API health check; same-origin mode relies on the public app check plus runtime-local API verification."
); );
}
function shellEscape(value: string): string {
if (value.length === 0) {
return "''";
} }
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
function shellPattern(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
} }
function main(): void { function main(): void {
const { mode, forceRecreate } = parseArgs(process.argv.slice(2)); const options = parseArgs(process.argv.slice(2));
assertSshKeyExists(); assertSshKeyExists();
console.log( console.log(
mode === "main" `Deploying ${options.mode === "main" ? "origin/main" : "the current local branch"} ` +
? "Deploying origin/main to the existing Islandflow VPS checkout." `via ${describeRuntime(options.runtime)} (${describeScope(options.scope)}).`
: "Deploying the current local branch to the existing Islandflow VPS checkout.",
); );
if (mode === "main") { if (options.mode === "main") {
localMainPrecheck(); localMainPrecheck(options.runtime, options.noBuild);
remotePrecheck(); remoteGitPrecheck();
remoteRollout(mode, null, forceRecreate); remoteRuntimePrecheck(options.runtime, options.scope);
remoteRollout(
options.mode,
options.runtime,
null,
options.scope,
options.forceRecreate,
options.noBuild
);
} else { } else {
const branch = currentBranchName(); const branch = currentBranchName();
localBranchPrecheck(branch); localBranchPrecheck(branch, options.runtime, options.noBuild);
publishCurrentBranch(branch); publishCurrentBranch(branch);
remotePrecheck(); remoteGitPrecheck();
remoteRollout(mode, branch, forceRecreate); remoteRuntimePrecheck(options.runtime, options.scope);
remoteRollout(
options.mode,
options.runtime,
branch,
options.scope,
options.forceRecreate,
options.noBuild
);
} }
remoteVerification(); remoteVerification(options.runtime, options.scope);
publicVerification(); publicVerification(options.scope);
} }
main(); main();