Skip to content

Commit 87b0c43

Browse files
committed
Add rolling deploys across a fleet of servers
An app that resolves to several servers is now rolled a batch at a time instead of deployed to all of them at once. shipnode does not manage the load balancer and never talks to it. It owns one thing: a readiness endpoint per replica that answers 200 normally and 503 while that replica is being deployed. Every managed LB decides rotation from a health check, so flipping that endpoint is enough to pull a replica out and put it back — no provider APIs, no LB for shipnode to keep alive, and the same code works behind Hetzner, DigitalOcean, an ALB or someone else's nginx. The signal is a sentinel file rather than a rewritten Caddy config. Caddy matches on its existence, so flipping takes effect with no reload and survives a Caddy restart or a reboot — a drained replica stays drained until something undrains it. fleet.drainWait is the number that matters and the one shipnode cannot infer. It has no way to know your LB's check interval or unhealthy threshold, so it cannot detect when traffic has actually stopped; it flips the sentinel, waits the declared seconds, and only then touches the app. Set it too low and the roll drops requests that were already in flight. The dry run prints it as an explicit step for that reason. FleetOrchestrator sits above DeployOrchestrator rather than replacing it. A replica's deploy is the ordinary single-server deploy — same releases, same blue-green, same health check — so the composition is blue-green within a replica, rolling across replicas. The invariant the new layer exists to hold is that no more than `batch` replicas are ever out of rotation, and that a replica is out of rotation before anything touches it. deployApp now takes a fleet-wide releaseId, because a per-replica timestamp makes a converged fleet indistinguishable from a half-rolled one. A failure stops the roll and resolves rather than throwing: a partly-rolled fleet is a real state to report, not an exception to unwind. Updated replicas keep serving the new release, untouched ones the old, drained-but-undeployed batch-mates go back into rotation, and the replica that actually failed stays drained so no traffic reaches it and it can be inspected as-is. Replicas stop claiming the app's public domain, which is the sharp edge of replicating a Caddy config: five replicas all racing Let's Encrypt for one name. TLS terminates at the load balancer and each replica serves plain HTTP bound to its private address. Blue-green still flips that site's upstream, so the two compose. Also: --on <server> scopes deploy/status/logs/restart/stop/env/unlock to one replica, `drain`/`undrain` give manual rotation control for inspecting a replica without deploying it, and watch mode requires --on for a fleet app since it holds one connection and patches one live release. Running it against a real three-server config found four things unit tests did not, each now covered: - The dry run printed a fleet app once per replica, because getAppsForServer deliberately stopped being a partition. - Its Caddy preview showed the public-domain site, which is precisely the file a replica does not write. - The deploy flow omitted the drain steps, hiding the one setting the user has to get right. - `--app api --on db-1` dialled a server api does not run on and failed with an SSH handshake timeout instead of saying so. --on is now checked against what was actually asked for, before any connection is opened. preDeploy still runs once per replica and now warns loudly when it would run N times. Moving migrations to a run-once hook is Phase 2.
1 parent cedfdef commit 87b0c43

17 files changed

Lines changed: 986 additions & 49 deletions

File tree

src/cli/commands/deploy.ts

Lines changed: 202 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import type { AccessoryConfig, ShipnodeConfig, ShipnodeApp } from '../../shared/
99
import { accessoryMounts } from '../../services/accessory.service.js';
1010
import { getPm2Name } from '../../domain/pm2/apps.js';
1111
import { configForAppResult, configForServer, getServerTargets, resolveServerNames, resolveSingleServerNameResult } from '../../domain/servers.js';
12-
import { generateBackendCaddyfile, generateFrontendCaddyfile } from '../../services/caddy.service.js';
12+
import { generateBackendCaddyfile, generateFleetCaddyfile, generateFrontendCaddyfile } from '../../services/caddy.service.js';
13+
import { appStateDir } from '../../domain/deploy/drain.js';
1314
import { type ServerTargetError } from '../../shared/result-errors.js';
1415
import { runDeployWatch } from './deploy-watch.js';
1516
import type { BuildLocation } from '../../domain/deploy/hot-sync.js';
17+
import { deployFleet, type FleetEvent } from '../../domain/deploy/fleet.js';
18+
import { SshConnection } from '../../infrastructure/ssh/connection.js';
1619

17-
export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBuild?: boolean; app?: string; config?: string; watch?: boolean; build?: string }): Promise<void> {
20+
export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBuild?: boolean; app?: string; config?: string; watch?: boolean; build?: string; on?: string }): Promise<void> {
1821
let config: ShipnodeConfig;
1922
let targetConfig: ShipnodeConfig;
2023
try {
@@ -52,18 +55,45 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
5255
return;
5356
}
5457

58+
// Check --on against what was actually asked for, before opening any
59+
// connection. Otherwise `--app api --on db-1` dials a server api does not run
60+
// on and fails with an SSH timeout instead of saying so.
61+
if (options.on) {
62+
const reachable = getServerTargets(targetConfig).map((target) => target.name);
63+
if (!reachable.includes(options.on)) {
64+
ui.error(
65+
options.app
66+
? `App '${options.app}' does not run on '${options.on}'. It runs on: ${reachable.join(', ')}`
67+
: `Unknown server target '${options.on}'. Known targets: ${reachable.join(', ')}`,
68+
);
69+
process.exit(1);
70+
return;
71+
}
72+
}
73+
5574
// The per-server config the callback receives only carries that server's own
5675
// accessories, so cross-server dependencies are invisible from inside it.
5776
// Dependency warnings have to be resolved against the whole workspace.
5877
const workspaceConfig = config;
59-
60-
await runRemoteCommandForTargets(
78+
const fleetApps = targetConfig.apps.filter((candidate) => candidate.fleet);
79+
const soloApps = targetConfig.apps.filter((candidate) => !candidate.fleet);
80+
81+
// Naming a fleet app leaves the fan-out with nothing to do — rolling it is
82+
// the whole job, and visiting other servers would deploy things not asked for.
83+
const fanOut = soloApps.length > 0 || !options.app;
84+
85+
// Accessories and single-server apps go server by server, as they always
86+
// have. Fleet apps cannot: they appear on several servers at once and must be
87+
// rolled one batch at a time, so they are held back and handled after — which
88+
// also means the accessories they depend on are already up.
89+
if (fanOut) await runRemoteCommandForTargets(
6190
cwd,
6291
async ({ config, executor, serverName }) => {
63-
const app = options.app ? config.apps.find((candidate) => candidate.name === options.app) : undefined;
92+
const serverApps = config.apps.filter((candidate) => !candidate.fleet);
93+
const app = options.app ? serverApps.find((candidate) => candidate.name === options.app) : undefined;
6494
const deployConfig = options.app
6595
? { ...config, apps: app ? [app] : [] }
66-
: config;
96+
: { ...config, apps: serverApps };
6797
if (deployConfig.apps.length === 0 && Object.keys(deployConfig.accessories ?? {}).length === 0) return;
6898

6999
ui.banner();
@@ -88,12 +118,116 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
88118
}
89119

90120
ui.note(lines.join('\n'), 'Done');
91-
ui.outro('Run shipnode status to check your app.');
92121
},
93-
{ configPath: options.config },
122+
{ configPath: options.config, serverName: options.on, appName: options.app },
123+
);
124+
125+
for (const fleetApp of fleetApps) {
126+
await rollFleetApp(cwd, workspaceConfig, fleetApp, options);
127+
}
128+
129+
ui.outro('Run shipnode status to check your app.');
130+
}
131+
132+
/**
133+
* Roll one app across its replicas.
134+
*
135+
* Each replica gets the workspace narrowed to just this app and just that
136+
* server, so the deploy that runs on it is exactly the single-server deploy —
137+
* same orchestrator, same blue-green, same health check. Accessories are
138+
* stripped because the fan-out above has already ensured them; leaving them in
139+
* would restart the database once per replica.
140+
*/
141+
async function rollFleetApp(
142+
cwd: string,
143+
config: ShipnodeConfig,
144+
app: ShipnodeApp,
145+
options: { skipBuild?: boolean; on?: string },
146+
): Promise<void> {
147+
const scoped = configForAppResult(config, app.name);
148+
if (scoped.isErr()) {
149+
ui.error(scoped.error.message);
150+
process.exit(1);
151+
return;
152+
}
153+
154+
const appConfig = scoped.value;
155+
let replicas = getServerTargets(appConfig).map((target) => target.name);
156+
if (options.on) {
157+
if (!replicas.includes(options.on)) {
158+
ui.error(`App '${app.name}' does not run on '${options.on}'. It runs on: ${replicas.join(', ')}`);
159+
process.exit(1);
160+
return;
161+
}
162+
replicas = [options.on];
163+
}
164+
165+
ui.banner();
166+
ui.step(`Rolling ${chalk.bold(app.name)} across ${replicas.join(', ')}`);
167+
168+
if (app.hooks?.preDeploy) {
169+
ui.warn(
170+
`${app.name} declares a preDeploy hook, which runs once per replica — ` +
171+
`${replicas.length} times for this roll. Database migrations belong somewhere that runs once.`,
172+
);
173+
}
174+
175+
for (const warning of renderDependencyWarnings(config, app)) ui.warn(warning);
176+
177+
const result = await deployFleet({
178+
app,
179+
fleet: app.fleet!,
180+
replicas,
181+
remotePath: appConfig.remotePath,
182+
connect: async (serverName) => {
183+
const ssh = new SshConnection();
184+
await ssh.connect(configForServer(appConfig, serverName).ssh);
185+
return { executor: ssh, close: () => ssh.disconnect() };
186+
},
187+
deployReplica: async ({ serverName, executor, releaseId }) => {
188+
const replicaConfig = { ...configForServer(appConfig, serverName), apps: [app], accessories: {} };
189+
const deployer = new DeployService(new LoggingExecutor(executor), replicaConfig);
190+
await deployer.execute(cwd, options.skipBuild ?? false, releaseId);
191+
},
192+
onEvent: (event) => reportFleetEvent(app, event),
193+
});
194+
195+
if (result.failed) {
196+
ui.error(
197+
`${app.name}: ${result.failed.server} failed and is out of rotation. ` +
198+
`${result.deployed.length ? `${result.deployed.join(', ')} now on ${result.releaseId}; ` : ''}` +
199+
`${result.skipped.length ? `${result.skipped.join(', ')} still on the previous release. ` : ''}` +
200+
`The fleet is running mixed versions.`,
201+
);
202+
process.exit(1);
203+
return;
204+
}
205+
206+
ui.note(
207+
[`release ${result.releaseId}`, `servers ${result.deployed.join(', ')}`, ...(app.domain ? [`url https://${app.domain}`] : [])].join('\n'),
208+
`${app.name} rolled`,
94209
);
95210
}
96211

212+
function reportFleetEvent(app: ShipnodeApp, event: FleetEvent): void {
213+
switch (event.type) {
214+
case 'drained':
215+
ui.info(`${event.servers.join(', ')} draining — waiting ${event.waitSeconds}s for the load balancer`);
216+
break;
217+
case 'deploying':
218+
ui.step(`${app.name}${event.server}`);
219+
break;
220+
case 'undrained':
221+
ui.success(`${event.server} back in rotation`);
222+
break;
223+
case 'failed':
224+
ui.error(`${event.server}: ${event.message}`);
225+
break;
226+
default:
227+
break;
228+
}
229+
}
230+
97231
/**
98232
* Where each watch cycle builds.
99233
*
@@ -122,7 +256,7 @@ function resolveBuildLocation(
122256
async function startWatch(
123257
cwd: string,
124258
config: ShipnodeConfig,
125-
options: { skipBuild?: boolean; app?: string; build?: string },
259+
options: { skipBuild?: boolean; app?: string; build?: string; on?: string },
126260
): Promise<void> {
127261
const appName = options.app ?? (config.apps.length === 1 ? config.apps[0]?.name : undefined);
128262

@@ -144,9 +278,32 @@ async function startWatch(
144278
return;
145279
}
146280

147-
const watchConfig = scoped.value;
281+
let watchConfig = scoped.value;
148282
const app = watchConfig.apps[0];
149283

284+
// A watch session holds one SSH connection and reloads one process set, so a
285+
// fleet app has to be narrowed to a single replica. Patching the live release
286+
// of every replica in lockstep is not something this loop can promise.
287+
const replicas = getServerTargets(watchConfig).map((target) => target.name);
288+
if (replicas.length > 1) {
289+
if (!options.on) {
290+
ui.error(
291+
`'${appName}' runs on ${replicas.length} servers (${replicas.join(', ')}). ` +
292+
`Watch mode patches one live release — pick a replica with --on <server>.`,
293+
);
294+
process.exit(1);
295+
return;
296+
}
297+
if (!replicas.includes(options.on)) {
298+
ui.error(`'${appName}' does not run on '${options.on}'. It runs on: ${replicas.join(', ')}`);
299+
process.exit(1);
300+
return;
301+
}
302+
}
303+
if (options.on) {
304+
watchConfig = configForServer(watchConfig, options.on);
305+
}
306+
150307
const buildLocation = resolveBuildLocation(options, app);
151308
if (!buildLocation) {
152309
ui.error(`--build must be one of: remote, local, none (got "${options.build}")`);
@@ -215,19 +372,30 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
215372
buildRows.push(['', chalk.dim('runs on remote server')]);
216373
}
217374

218-
const steps: string[] = [
375+
const steps: string[] = [];
376+
if (app.fleet) {
377+
const batch = app.fleet.batch === 1 ? 'one replica' : `${app.fleet.batch} replicas`;
378+
steps.push(
379+
`Drain ${batch} (${app.fleet.readyPath} → 503)`,
380+
`Wait ${app.fleet.drainWait}s for the load balancer to notice`,
381+
);
382+
}
383+
steps.push(
219384
'Acquire deploy lock',
220385
'Create release directory',
221386
'Rsync files',
222387
'Install dependencies',
223-
];
388+
);
224389
if (app.hooks?.preDeploy) steps.push('Run preDeploy hook');
225390
steps.push('Switch symlink (atomic)');
226391
if (app.appType === 'backend') steps.push('Reload PM2');
227392
if (app.healthCheck.enabled) steps.push(`Health check ${app.healthCheck.path}`);
228393
steps.push('Record release', 'Clean old releases');
229394
if (app.hooks?.postDeploy) steps.push('Run postDeploy hook');
230395
steps.push('Release lock');
396+
if (app.fleet) {
397+
steps.push(`Undrain (${app.fleet.readyPath} → 200)`, 'Repeat for the next batch');
398+
}
231399

232400
const flowRows: [string, string][] = steps.map((step, i) => [`${i + 1}.`, step]);
233401

@@ -268,11 +436,27 @@ function renderDependencyWarnings(config: ShipnodeConfig, app: ShipnodeApp): str
268436
}
269437

270438
function renderCaddyPreview(config: ShipnodeConfig, app: ShipnodeApp): string | null {
439+
const servePath = `${config.remotePath}/${app.name}/current`;
440+
const web = app.pm2?.apps.find((pm2App) => pm2App.port !== undefined);
441+
442+
// A replica serves a private port and never claims the domain — showing the
443+
// public site here would preview a file shipnode is not going to write.
444+
if (app.fleet) {
445+
const [firstReplica] = resolveServerNames(config, app.on);
446+
return generateFleetCaddyfile(app, {
447+
listen: app.fleet.port,
448+
bind: firstReplica ? config.servers[firstReplica]?.privateHost : undefined,
449+
upstream: web?.port,
450+
servePath: app.appType === 'frontend' ? servePath : undefined,
451+
readyPath: app.fleet.readyPath,
452+
stateDir: appStateDir(config.remotePath, app.name),
453+
});
454+
}
455+
271456
if (!app.domain) return null;
272457
if (app.appType === 'frontend') {
273-
return generateFrontendCaddyfile(app, `${config.remotePath}/${app.name}/current`);
458+
return generateFrontendCaddyfile(app, servePath);
274459
}
275-
const web = app.pm2?.apps.find((pm2App) => pm2App.port !== undefined);
276460
if (!web?.port) return null;
277461
return generateBackendCaddyfile(app, web.port);
278462
}
@@ -289,9 +473,10 @@ export function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
289473
` ${chalk.dim('Apps'.padEnd(14))} ${config.apps.map((a) => a.name).join(', ')}`,
290474
].join('\n');
291475

292-
const perApp = getServerTargets(config)
293-
.map((target) => configForServer(config, target.name))
294-
.flatMap((targetConfig) => targetConfig.apps.map((app) => renderAppPlan(config, app, skipBuild)))
476+
// Per app, not per server: a fleet app runs on several servers and would
477+
// otherwise be printed once for each of them.
478+
const perApp = config.apps
479+
.map((app) => renderAppPlan(config, app, skipBuild))
295480
.join('\n\n');
296481

297482
const accessories = renderAccessoriesPlan(config);

src/cli/commands/drain.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { runRemoteCommandForTargets } from '../runner.js';
2+
import { ui } from '../ui.js';
3+
import { drain, isDrained, undrain } from '../../domain/deploy/drain.js';
4+
5+
/**
6+
* Manual rotation control.
7+
*
8+
* Deploying already drains and undrains each replica as it rolls. These exist
9+
* for the times you want a replica out of the pool without deploying it —
10+
* reproducing a bug on live data, or holding a replica back after a failed
11+
* roll left it drained.
12+
*/
13+
export async function cmdDrain(
14+
cwd: string,
15+
options: { app?: string; on?: string; config?: string },
16+
): Promise<void> {
17+
await setRotation(cwd, options, true);
18+
}
19+
20+
export async function cmdUndrain(
21+
cwd: string,
22+
options: { app?: string; on?: string; config?: string },
23+
): Promise<void> {
24+
await setRotation(cwd, options, false);
25+
}
26+
27+
async function setRotation(
28+
cwd: string,
29+
options: { app?: string; on?: string; config?: string },
30+
drained: boolean,
31+
): Promise<void> {
32+
let touched = 0;
33+
34+
await runRemoteCommandForTargets(
35+
cwd,
36+
async ({ config, executor, serverName }) => {
37+
for (const app of config.apps) {
38+
if (options.app && app.name !== options.app) continue;
39+
if (!app.fleet) {
40+
ui.warn(`${app.name} is not behind a load balancer — nothing to drain.`);
41+
continue;
42+
}
43+
44+
if (drained) {
45+
await drain(executor, config.remotePath, app.name);
46+
ui.success(`${app.name} on ${serverName} is draining (${app.fleet.readyPath} now answers 503)`);
47+
} else {
48+
await undrain(executor, config.remotePath, app.name);
49+
ui.success(`${app.name} on ${serverName} is back in rotation`);
50+
}
51+
touched += 1;
52+
}
53+
},
54+
{ configPath: options.config, appName: options.app, serverName: options.on },
55+
);
56+
57+
if (touched === 0) {
58+
ui.warn('No fleet apps matched.');
59+
return;
60+
}
61+
62+
if (drained) {
63+
ui.info('Your load balancer removes it once its own health check notices — that is what fleet.drainWait covers.');
64+
}
65+
}
66+
67+
/** Drain state per replica, for `status`. */
68+
export async function readDrainState(
69+
cwd: string,
70+
options: { app?: string; config?: string },
71+
): Promise<void> {
72+
await runRemoteCommandForTargets(
73+
cwd,
74+
async ({ config, executor, serverName }) => {
75+
for (const app of config.apps) {
76+
if (options.app && app.name !== options.app) continue;
77+
if (!app.fleet) continue;
78+
const out = await isDrained(executor, config.remotePath, app.name);
79+
ui.info(`${app.name} on ${serverName}: ${out ? 'draining' : 'in rotation'}`);
80+
}
81+
},
82+
{ configPath: options.config, appName: options.app },
83+
);
84+
}

0 commit comments

Comments
 (0)