Add dual-runtime deploy workflow
This commit is contained in:
parent
73715c8163
commit
df49af1ba2
5 changed files with 795 additions and 102 deletions
17
README.md
17
README.md
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
122
deployment/native/README.md
Normal 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
|
||||||
|
```
|
||||||
170
docs/turns/2026-05-15-dual-runtime-deploy-workflow.html
Normal file
170
docs/turns/2026-05-15-dual-runtime-deploy-workflow.html
Normal 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 verification’s 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>
|
||||||
|
|
@ -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).
|
||||||
--help Show this help text.
|
--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.
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
if (scopeIncludesApi(scope)) {
|
||||||
"Skipping separate public API health check; same-origin mode relies on the public app check plus container-local API verification.",
|
console.log(
|
||||||
);
|
"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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue