Skip to content

Commit ba7f972

Browse files
committed
fix: harden v3 deployment workflows
1 parent af22201 commit ba7f972

30 files changed

Lines changed: 782 additions & 117 deletions

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,8 @@ export default shipnode
229229
| `.pkgManager(pm, opts?)` | auto-detected | `npm` \| `yarn` \| `pnpm` \| `bun`; `opts.installCommand` overrides the install command |
230230
| `.installCommand(cmd)` | derived from pkg manager | Override the install command run on the server (e.g. `'npm ci --legacy-peer-deps'`). Equivalent to `pkgManager(pm, { installCommand: cmd })` |
231231
| `.buildDir(dir)` | auto-detected | Frontend build output dir |
232-
| `.zeroDowntime({ keepReleases? })` | true, 5 | Zero-downtime releases |
233-
| `.legacy()` || Simple in-place deploy |
232+
| `.zeroDowntime(altPort?)` | automatic for Caddy backends | Force blue-green releases and optionally choose the green port |
233+
| `.noZeroDowntime()` || Opt out and recreate PM2 processes during deploy |
234234
| `.healthCheck(path, opts?)` | `/health`, 30s, 3 retries | Post-deploy health check |
235235
| `.noHealthCheck()` || Skip health check |
236236
| `.envFile(f)` | `.env` | Local .env file to upload |
@@ -466,7 +466,11 @@ export default shipnode
466466

467467
## Zero-downtime releases
468468

469-
By default, shipnode uses a Capistrano-style release structure:
469+
Backends with a domain and a PM2 web port use blue-green releases automatically. Shipnode starts the new web process on the idle port, checks it, and reloads Caddy only after it is healthy. Apps without a domain and worker-only apps keep the PM2 recreate path. Use `.noZeroDowntime()` to opt out explicitly; raw configuration may set `zeroDowntime: false`.
470+
471+
Blue-green keeps the previous and current web processes resident, so budget about **2× the web process memory**. On an eligible Caddy backend, `.zeroDowntime(altPort?)` remains available to force the mode and choose the alternate port (default: web port + 1).
472+
473+
Every app still uses a Capistrano-style release structure:
470474

471475
```
472476
/var/www/myapp/
@@ -479,7 +483,7 @@ By default, shipnode uses a Capistrano-style release structure:
479483
current -> releases/20240102T090000
480484
```
481485

482-
PM2 is reloaded against `current/` after the symlink switch. If the health check fails, you can roll back instantly with `shipnode rollback`.
486+
On the first migration, the new release starts on green while the existing uncoloured process keeps serving on blue. After health succeeds and Caddy reloads, Shipnode removes that legacy process. If health fails, Caddy and the legacy process remain untouched.
483487

484488
## Requirements
485489

docs/adr/0005-blue-green-zero-downtime.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Blue-green releases via a two-port colour swap behind Caddy
22

3-
The default deploy strategy stops the whole PM2 process set and starts the new release on the same port (`pm2 delete … && pm2 start …`). Between the delete and the moment the new process is listening — install relink, framework boot, first health probe — the port is closed and inbound requests are refused. For a plain web app that is a multi-second window of dropped requests on every deploy.
4-
5-
`zeroDowntime` opts a backend web app into blue-green releases that close that window.
3+
Backend web apps behind Caddy (a domain plus a PM2 web port) use blue-green releases by default. Worker-only apps and backends without a domain retain the recreate strategy. `noZeroDowntime()` or raw `zeroDowntime: false` explicitly opts a Caddy backend out.
64

75
## Mechanism
86

@@ -13,7 +11,7 @@ Each deploy:
1311
1. Stages the release and switches the `current` symlink (as always). The previously-started colour keeps running the old code in memory — the symlink move doesn't touch a running process.
1412
2. Boots the **idle** colour on its own port (`pm2 start ecosystem.web.cjs`), reaping any stale same-colour instance first. The active colour is never touched. Workers are **not** reloaded yet.
1513
3. Health-checks the new colour on **its** port and colour-suffixed pm2 name (`api-green`).
16-
4. Only on success: reloads the single worker set against the new release, then rewrites the Caddy site to the new colour's port and `systemctl reload caddy` — a graceful reload that drains in-flight requests, so the flip drops nothing. Then persists the new active colour.
14+
4. Only on success: reloads the single worker set against the new release, then rewrites the Caddy site to the new colour's port and `systemctl reload caddy` — a graceful reload that drains in-flight requests. Then persists the new active colour. On the first migration only, the legacy uncoloured process is removed after this sequence.
1715

1816
A failed health check throws before step 4: Caddy is untouched, workers stay on the previous release, the old colour is still serving, `current` is reverted to the previous release when one exists, and the failed colour is reaped by the next deploy. Zero user impact on a bad release — the main prize.
1917

@@ -29,5 +27,5 @@ Because the previous colour is still running, rollback is an instant Caddy flip
2927

3028
- **~2× memory for the web app** — both colours are resident between deploys. Documented; the price of instant rollback.
3129
- **The port pair is fixed at the first deploy** and persisted. Later changes to the web port or `altPort` in config are ignored until the state file is cleared, so a running colour is never silently re-homed.
32-
- **Migration costs one restart.** The first `zeroDowntime` deploy deletes the pre-existing uncoloured web process (which holds the blue == web port) by name so the target colour can bind. After that, every deploy is zero-drop.
33-
- **Opt-in, default off.** The recreate path is byte-for-byte unchanged, so existing deployments are unaffected until they set `zeroDowntime`.
30+
- **The first migration targets green.** A pre-existing uncoloured process can continue serving on the configured blue port through health and the Caddy reload; it is cleaned up only after the flip succeeds.
31+
- **Default on for Caddy backends.** Backends without a domain and worker-only apps keep recreate semantics. `.noZeroDowntime()` is the explicit builder opt-out.

src/cli/commands/ci.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { resolve } from 'path';
2-
import { readFile, writeFile } from 'node:fs/promises';
1+
import { relative, resolve, sep } from 'path';
2+
import { readFile, realpath, writeFile } from 'node:fs/promises';
33
import { pathExists, ensureDir } from 'fs-extra';
44
import { execa } from 'execa';
55
import { loadConfig } from '../../config/loader.js';
@@ -56,21 +56,35 @@ async function hasBuildScript(cwd: string): Promise<boolean> {
5656
if (!(await pathExists(pkgPath))) return false;
5757
try {
5858
const raw = await readFile(pkgPath, 'utf8');
59-
const pkg = JSON.parse(raw) as { scripts?: Record<string, string> };
60-
return Boolean(pkg.scripts?.build);
59+
const pkg: unknown = JSON.parse(raw);
60+
if (typeof pkg !== 'object' || pkg === null || !('scripts' in pkg)) return false;
61+
const scripts = pkg.scripts;
62+
return typeof scripts === 'object' && scripts !== null && 'build' in scripts;
6163
} catch {
6264
return false;
6365
}
6466
}
6567

66-
function generateWorkflow(pm: PkgManagerInfo, withBuild: boolean): string {
68+
function yamlSingleQuoted(value: string): string {
69+
return `'${value.replace(/'/g, "''")}'`;
70+
}
71+
72+
function generateWorkflow(
73+
pm: PkgManagerInfo,
74+
withBuild: boolean,
75+
packageSpec: string,
76+
deployDirectory?: string,
77+
): string {
6778
const setupPmStep = pm.setupAction
6879
? ` - name: Setup ${pm.id}\n uses: ${pm.setupAction}\n\n`
6980
: '';
7081

7182
const buildStep = withBuild
7283
? `\n - name: Build\n run: ${pm.id} run build\n`
7384
: '';
85+
const deployWorkingDirectory = deployDirectory
86+
? `\n working-directory: ${yamlSingleQuoted(deployDirectory)}`
87+
: '';
7488

7589
return `name: Deploy via Shipnode
7690
@@ -109,25 +123,29 @@ ${buildStep}
109123
echo "\${{ secrets.SHIPNODE_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
110124
111125
- name: Install Shipnode
112-
run: npm install -g shipnode
126+
run: npm install -g ${packageSpec}
113127
114128
- name: Deploy
115-
run: shipnode deploy
129+
run: shipnode deploy${deployWorkingDirectory}
116130
`;
117131
}
118132

119133
// ── cmdCiGithub ───────────────────────────────────────────────────────────────
120134

121135
export async function cmdCiGithub(cwd: string): Promise<void> {
122-
const pm = await detectPkgManager(cwd);
123-
const withBuild = await hasBuildScript(cwd);
136+
const canonicalCwd = await realpath(cwd);
137+
const repositoryRoot = await resolveRepositoryRoot(canonicalCwd);
138+
const deployDirectory = relative(repositoryRoot, canonicalCwd).split(sep).join('/') || undefined;
139+
const pm = await detectPkgManager(repositoryRoot);
140+
const withBuild = await hasBuildScript(repositoryRoot);
141+
const packageSpec = await currentPackageSpec();
124142

125143
ui.info(`Detected package manager: ${pm.id}`);
126144
if (withBuild) {
127145
ui.info('Found scripts.build in package.json — build step will be included.');
128146
}
129147

130-
const workflowDir = resolve(cwd, '.github', 'workflows');
148+
const workflowDir = resolve(repositoryRoot, '.github', 'workflows');
131149
const workflowPath = resolve(workflowDir, 'shipnode-deploy.yml');
132150

133151
if (await pathExists(workflowPath)) {
@@ -139,7 +157,7 @@ export async function cmdCiGithub(cwd: string): Promise<void> {
139157
}
140158

141159
await ensureDir(workflowDir);
142-
const content = generateWorkflow(pm, withBuild);
160+
const content = generateWorkflow(pm, withBuild, packageSpec, deployDirectory);
143161
await writeFile(workflowPath, content, 'utf8');
144162

145163
ui.success(`Workflow written to .github/workflows/shipnode-deploy.yml`);
@@ -155,6 +173,32 @@ export async function cmdCiGithub(cwd: string): Promise<void> {
155173
console.log(' Only the private key must be kept as a secret.');
156174
}
157175

176+
async function resolveRepositoryRoot(cwd: string): Promise<string> {
177+
try {
178+
const result = await execa('git', ['rev-parse', '--show-toplevel'], { cwd });
179+
const root = result.stdout.trim();
180+
return root || cwd;
181+
} catch {
182+
return cwd;
183+
}
184+
}
185+
186+
async function currentPackageSpec(): Promise<string> {
187+
const raw = await readFile(new URL('../../../package.json', import.meta.url), 'utf8');
188+
const metadata: unknown = JSON.parse(raw);
189+
if (
190+
typeof metadata !== 'object' ||
191+
metadata === null ||
192+
!('name' in metadata) ||
193+
!('version' in metadata) ||
194+
typeof metadata.name !== 'string' ||
195+
typeof metadata.version !== 'string'
196+
) {
197+
throw new Error('Shipnode package metadata is missing name or version');
198+
}
199+
return `${metadata.name}@${metadata.version}`;
200+
}
201+
158202
// ── cmdCiEnvSync ──────────────────────────────────────────────────────────────
159203

160204
interface EnvVar {

src/cli/commands/doctor.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ function checkLocal(config: ShipnodeConfig): void {
5151
}
5252
}
5353

54-
async function checkRemote(
54+
/** Run remote dependency diagnostics through the same executor used by the CLI. */
55+
export async function checkRemote(
5556
config: ShipnodeConfig,
5657
executor: { exec: (cmd: string, opts?: { timeout?: number }) => Promise<{ stdout: string; exitCode: number }> },
5758
): Promise<void> {
@@ -62,9 +63,11 @@ async function checkRemote(
6263
const needsCaddy = config.apps.some((app) => app.domain);
6364
const needsDocker = Object.keys(config.accessories ?? {}).length > 0;
6465

66+
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
67+
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
6568
const checks = [
66-
...(needsNode ? [{ name: 'Node', cmd: 'node --version' }] : []),
67-
...(needsPm2 ? [{ name: 'PM2', cmd: 'pm2 --version' }] : []),
69+
...(needsNode ? [{ name: 'Node', cmd: `${mise}; mise exec "node@${nodeVersion}" -- node --version` }] : []),
70+
...(needsPm2 ? [{ name: 'PM2', cmd: `${mise}; mise exec "node@${nodeVersion}" -- pm2 --version` }] : []),
6871
...(needsCaddy ? [{ name: 'Caddy', cmd: 'caddy version' }] : []),
6972
...(needsDocker ? [{ name: 'Docker', cmd: 'docker --version' }] : []),
7073
{ name: 'rsync', cmd: 'rsync --version' },
@@ -95,29 +98,35 @@ async function checkRemote(
9598
}
9699
}
97100

98-
async function checkSecurity(
101+
/** Run the security audit and write each complete diagnostic section to `write`. */
102+
export async function checkSecurity(
99103
config: { remotePath: string },
100104
executor: { exec: (cmd: string) => Promise<{ stdout: string }> },
105+
write: (output: string) => void = console.log,
101106
): Promise<void> {
102107
ui.info('Running security audit...');
103108

104109
const sshdResult = await executor.exec(
105110
'sudo grep -E "^(PermitRootLogin|PasswordAuthentication|Port)" /etc/ssh/sshd_config 2>/dev/null || echo "not found"',
106111
);
107112
ui.info('SSH Configuration:');
108-
console.log(` ${sshdResult.stdout.replace(/\n/g, '\n ')}`);
113+
write(indentDiagnosticOutput(sshdResult.stdout));
109114

110115
const firewallResult = await executor.exec(
111116
'sudo ufw status 2>/dev/null || sudo iptables -L -n 2>/dev/null || echo "no firewall detected"',
112117
);
113118
ui.info('Firewall Status:');
114-
console.log(` ${firewallResult.stdout.split('\n').slice(0, 5).join('\n ')}`);
119+
write(indentDiagnosticOutput(firewallResult.stdout));
115120

116121
const fail2banResult = await executor.exec('sudo fail2ban-client status 2>/dev/null || echo "fail2ban not installed"');
117122
ui.info('Fail2ban:');
118-
console.log(` ${fail2banResult.stdout.split('\n')[0]}`);
123+
write(` ${fail2banResult.stdout.split('\n')[0]}`);
119124

120125
const permResult = await executor.exec(`stat -c "%a %U:%G" "${config.remotePath}/shared/.env" 2>/dev/null || echo "no .env found"`);
121126
ui.info('Env file permissions:');
122-
console.log(` ${permResult.stdout}`);
127+
write(` ${permResult.stdout}`);
128+
}
129+
130+
function indentDiagnosticOutput(output: string): string {
131+
return ` ${output.replace(/\n/g, '\n ')}`;
123132
}

src/cli/commands/harden.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,12 @@ export async function cmdHarden(cwd: string, options: { config?: string }): Prom
9797

9898
ui.heading('Fail2ban');
9999
const f2b = (await executor.exec(sec.fail2banCheckActiveCommand())).stdout;
100-
if (!f2b.includes('ACTIVE')) {
100+
if (!sec.isFail2banActive(f2b)) {
101101
ui.warn('Fail2ban not active.');
102102
if (await confirm('Install and configure fail2ban?')) {
103-
await executor.exec(sec.fail2banInstallCommand());
104-
await executor.exec(sec.fail2banApplyConfigCommand());
105-
await executor.exec(sec.fail2banEnableCommand());
103+
await executor.execOrThrow(sec.fail2banInstallCommand());
104+
await executor.execOrThrow(sec.fail2banApplyConfigCommand());
105+
await executor.execOrThrow(sec.fail2banEnableCommand());
106106
ui.success('Fail2ban installed');
107107
changes.push('Fail2ban: installed, sshd jail enabled');
108108
}

src/cli/commands/setup.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync, readFileSync } from 'fs';
2+
import { Result, type Result as ResultType } from 'better-result';
23
import { Listr } from 'listr2';
34
import { runRemoteCommandForTargets } from '../runner.js';
45
import { ui } from '../ui.js';
@@ -13,6 +14,7 @@ import {
1314
buildRedisProbeCommand,
1415
} from '../../infrastructure/provisioning/commands.js';
1516
import { loadUsersYml, saveUsersYml, syncUsers, upsertUser } from './user.js';
17+
import { Pm2StartupError } from '../../shared/result-errors.js';
1618

1719
const DEPLOY_USER = 'deploy';
1820

@@ -92,13 +94,17 @@ function asUser(user: string | null, cmd: string): string {
9294
return `sudo -u "${user}" bash -lc '${escaped}'`;
9395
}
9496

97+
function shellQuote(value: string): string {
98+
return `'${value.replace(/'/g, `'"'"'`)}'`;
99+
}
100+
95101
export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser: string | null) {
96102
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
97103
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
98104
// When a deploy user was bootstrapped, install mise/node/pm2 into their home
99105
// so PM2 processes run as that user and `pm2-<user>.service` matches. Without
100106
// this, everything lands in root's home and deploy has no pm2 on their PATH.
101-
const targetHome = ownerUser ? `/home/${ownerUser}` : '$HOME';
107+
const effectiveUser = ownerUser ?? config.ssh.user;
102108
const hasApps = config.apps.length > 0;
103109
const hasAccessories = Object.keys(config.accessories ?? {}).length > 0;
104110

@@ -169,17 +175,16 @@ export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, own
169175
// shims tools it manages itself), so we resolve pm2's real path via
170176
// `mise exec node@X -- which pm2` and pass that to `env PATH=... pm2`.
171177
title: `Configure systemd startup (${ownerUser ?? '$USER'})`,
172-
task: () => executor.execOrThrow(
173-
(ownerUser
174-
? `PM2_BIN=$(sudo -u "${ownerUser}" bash -lc '${mise}; mise exec "node@${nodeVersion}" -- which pm2'); ` +
175-
`[ -n "$PM2_BIN" ] && env PATH="$(dirname "$PM2_BIN"):$PATH" "$PM2_BIN" startup systemd -u "${ownerUser}" --hp "${targetHome}" || true`
176-
: `${mise}; mise exec "node@${nodeVersion}" -- pm2 startup systemd -u $USER --hp $HOME || true`) +
177-
` && ` +
178-
asUser(
178+
task: async () => {
179+
const result = await configurePm2Startup(
180+
executor,
181+
effectiveUser,
179182
ownerUser,
180-
`${mise}; mise exec "node@${nodeVersion}" -- pm2 save --force || true`,
181-
),
182-
),
183+
nodeVersion,
184+
mise,
185+
);
186+
if (result.isErr()) throw result.error;
187+
},
183188
},
184189
], { concurrent: false }),
185190
}] : []),
@@ -259,11 +264,10 @@ export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, own
259264
title: `Create release structure at ${config.remotePath}`,
260265
task: () => executor.execOrThrow(
261266
'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ' +
262-
`$SUDO mkdir -p "${config.remotePath}" && ` +
263-
(ownerUser
264-
? `$SUDO chown "${ownerUser}:${ownerUser}" "${config.remotePath}" && ` +
265-
`sudo -u "${ownerUser}" mkdir -p "${config.remotePath}/releases" "${config.remotePath}/shared" "${config.remotePath}/.shipnode"`
266-
: `mkdir -p "${config.remotePath}/releases" "${config.remotePath}/shared" "${config.remotePath}/.shipnode"`),
267+
`OWNER=${shellQuote(effectiveUser)}; GROUP=$(id -gn "$OWNER"); ` +
268+
`$SUDO install -d -m 0755 -o "$OWNER" -g "$GROUP" ` +
269+
`${shellQuote(config.remotePath)} ${shellQuote(`${config.remotePath}/releases`)} ` +
270+
`${shellQuote(`${config.remotePath}/shared`)} ${shellQuote(`${config.remotePath}/.shipnode`)}`,
267271
),
268272
},
269273
...(config.apps.some((app) => app.domain) ? [{
@@ -282,3 +286,36 @@ export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, own
282286
{ rendererOptions: { collapseErrors: false } },
283287
);
284288
}
289+
290+
async function configurePm2Startup(
291+
executor: RemoteExecutor,
292+
effectiveUser: string,
293+
runAsUser: string | null,
294+
nodeVersion: string,
295+
mise: string,
296+
): Promise<ResultType<void, Pm2StartupError>> {
297+
const resolvePm2 = runAsUser
298+
? `sudo -u "${runAsUser}" bash -lc '${mise}; mise exec "node@${nodeVersion}" -- which pm2'`
299+
: `${mise}; mise exec "node@${nodeVersion}" -- which pm2`;
300+
const savePm2 = asUser(
301+
runAsUser,
302+
`${mise}; mise exec "node@${nodeVersion}" -- pm2 save --force`,
303+
);
304+
const unit = `pm2-${effectiveUser}.service`;
305+
const command =
306+
'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ' +
307+
`OWNER=${shellQuote(effectiveUser)}; HOME_DIR=$(getent passwd "$OWNER" | cut -d: -f6); ` +
308+
`PM2_BIN=$(${resolvePm2}); ` +
309+
`[ -n "$HOME_DIR" ] && [ -x "$PM2_BIN" ] && ` +
310+
`$SUDO env PATH="$(dirname "$PM2_BIN"):$PATH" "$PM2_BIN" startup systemd -u "$OWNER" --hp "$HOME_DIR" && ` +
311+
`$SUDO test -f ${shellQuote(`/etc/systemd/system/${unit}`)} && ` +
312+
`$SUDO systemctl is-enabled --quiet ${shellQuote(unit)} && ` +
313+
savePm2;
314+
315+
const result = await executor.exec(command);
316+
if (result.exitCode !== 0) {
317+
const detail = (result.stderr || result.stdout).trim() || `command exited with ${result.exitCode}`;
318+
return Result.err(new Pm2StartupError({ user: effectiveUser, detail }));
319+
}
320+
return Result.ok(undefined);
321+
}

0 commit comments

Comments
 (0)