Skip to content

Commit 9c30de7

Browse files
committed
Add run-once fleet hooks and primary-only worker placement
A preDeploy hook runs once per replica, so a migration on a three-server fleet ran three times. .beforeFleet() and .afterFleet() run exactly once per roll — the first replica and the last — at the same points in the lifecycle as preDeploy/postDeploy, so they get the staged release, the dotenv wrapper and the appRoot for free. A roll that dies partway never reaches its last replica, so afterFleet correctly never fires. pm2 apps gain placement: 'primary', which pins a process to the first server the app is declared on. Schedulers are the case that needs it: three replicas each running a cron means every job fires three times. Primary is a property of the server, not of the roll, so narrowing a deploy with --on cannot promote a secondary and start a second copy. The schema rejects placement: 'primary' on a pm2 app with a port — the load balancer would keep routing to replicas serving nothing. --dry-run now names the replica a pinned worker lands on, shows where the run-once hooks fire, and warns when preDeploy would multiply.
1 parent 87b0c43 commit 9c30de7

14 files changed

Lines changed: 468 additions & 35 deletions

File tree

src/cli/commands/deploy.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ async function rollFleetApp(
152152
}
153153

154154
const appConfig = scoped.value;
155-
let replicas = getServerTargets(appConfig).map((target) => target.name);
155+
const allReplicas = getServerTargets(appConfig).map((target) => target.name);
156+
let replicas = allReplicas;
156157
if (options.on) {
157158
if (!replicas.includes(options.on)) {
158159
ui.error(`App '${app.name}' does not run on '${options.on}'. It runs on: ${replicas.join(', ')}`);
@@ -165,29 +166,26 @@ async function rollFleetApp(
165166
ui.banner();
166167
ui.step(`Rolling ${chalk.bold(app.name)} across ${replicas.join(', ')}`);
167168

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-
169+
for (const warning of renderFleetWarnings(app, replicas)) ui.warn(`${app.name}: ${warning}`);
175170
for (const warning of renderDependencyWarnings(config, app)) ui.warn(warning);
176171

177172
const result = await deployFleet({
178173
app,
179174
fleet: app.fleet!,
180175
replicas,
176+
// From the full list, not the narrowed one: `--on web-b` must not promote
177+
// web-b to primary and start a second copy of the scheduler alongside web-a's.
178+
primary: allReplicas[0],
181179
remotePath: appConfig.remotePath,
182180
connect: async (serverName) => {
183181
const ssh = new SshConnection();
184182
await ssh.connect(configForServer(appConfig, serverName).ssh);
185183
return { executor: ssh, close: () => ssh.disconnect() };
186184
},
187-
deployReplica: async ({ serverName, executor, releaseId }) => {
185+
deployReplica: async ({ serverName, executor, releaseId, role }) => {
188186
const replicaConfig = { ...configForServer(appConfig, serverName), apps: [app], accessories: {} };
189187
const deployer = new DeployService(new LoggingExecutor(executor), replicaConfig);
190-
await deployer.execute(cwd, options.skipBuild ?? false, releaseId);
188+
await deployer.execute(cwd, options.skipBuild ?? false, releaseId, role);
191189
},
192190
onEvent: (event) => reportFleetEvent(app, event),
193191
});
@@ -344,11 +342,14 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
344342
serverRows.push([
345343
'PM2 apps',
346344
pm2Apps
347-
.map((a) =>
348-
a.port !== undefined
349-
? `${getPm2Name(namespace, a.name)}(web:${a.port})`
350-
: getPm2Name(namespace, a.name),
351-
)
345+
.map((a) => {
346+
const name = getPm2Name(namespace, a.name);
347+
if (a.port !== undefined) return `${name}(web:${a.port})`;
348+
// Where a pinned worker actually lands is the thing worth checking
349+
// before the first roll — say it rather than leave it implied.
350+
if (a.placement === 'primary' && replicas.length > 1) return `${name}(${replicas[0]} only)`;
351+
return name;
352+
})
352353
.join(', '),
353354
]);
354355
}
@@ -359,6 +360,7 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
359360
if (app.dependsOn?.length) serverRows.push(['Depends on', app.dependsOn.join(', ')]);
360361

361362
const dependencyWarnings = renderDependencyWarnings(config, app);
363+
const fleetWarnings = renderFleetWarnings(app, replicas);
362364

363365
const caddyPreview = renderCaddyPreview(config, app);
364366

@@ -386,13 +388,15 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
386388
'Rsync files',
387389
'Install dependencies',
388390
);
391+
if (app.hooks?.beforeFleet) steps.push('Run beforeFleet hook (first replica only)');
389392
if (app.hooks?.preDeploy) steps.push('Run preDeploy hook');
390393
steps.push('Switch symlink (atomic)');
391394
if (app.appType === 'backend') steps.push('Reload PM2');
392395
if (app.healthCheck.enabled) steps.push(`Health check ${app.healthCheck.path}`);
393-
steps.push('Record release', 'Clean old releases');
396+
steps.push('Record release');
394397
if (app.hooks?.postDeploy) steps.push('Run postDeploy hook');
395-
steps.push('Release lock');
398+
if (app.hooks?.afterFleet) steps.push('Run afterFleet hook (last replica only)');
399+
steps.push('Clean old releases', 'Release lock');
396400
if (app.fleet) {
397401
steps.push(`Undrain (${app.fleet.readyPath} → 200)`, 'Repeat for the next batch');
398402
}
@@ -408,11 +412,30 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
408412
'',
409413
chalk.bold(' Deploy flow'),
410414
...flowRows.map(([k, v]) => ` ${chalk.dim(k.padEnd(4))} ${v}`),
415+
...(fleetWarnings.length ? ['', chalk.bold(' Fleet hints'), ...fleetWarnings.map((line) => ` ${line}`)] : []),
411416
...(dependencyWarnings.length ? ['', chalk.bold(' Dependency hints'), ...dependencyWarnings.map((line) => ` ${line}`)] : []),
412417
...(caddyPreview ? ['', chalk.bold(' Caddy'), caddyPreview.split('\n').map((line) => ` ${line}`).join('\n')] : []),
413418
].join('\n');
414419
}
415420

421+
/**
422+
* Caveats that only bite once an app has more than one replica, and that the
423+
* config itself cannot decide for you.
424+
*/
425+
function renderFleetWarnings(app: ShipnodeApp, replicas: string[]): string[] {
426+
if (replicas.length < 2) return [];
427+
const warnings: string[] = [];
428+
429+
if (app.hooks?.preDeploy) {
430+
warnings.push(
431+
`preDeploy runs once per replica — ${replicas.length} times for this roll. ` +
432+
`Move database migrations to .beforeFleet(), which runs once.`,
433+
);
434+
}
435+
436+
return warnings;
437+
}
438+
416439
function renderDependencyWarnings(config: ShipnodeConfig, app: ShipnodeApp): string[] {
417440
const dependencies = app.dependsOn ?? [];
418441
if (dependencies.length === 0) return [];

src/config/builder.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
BackupConfig,
1212
CloudflareConfig,
1313
HookFn,
14+
HooksConfig,
1415
PkgManager,
1516
} from '../shared/types.js';
1617
import { assembleConfig } from './assembly.js';
@@ -45,7 +46,7 @@ type BuilderState = {
4546
sharedDirs?: string[];
4647
sharedFiles?: string[];
4748
appRoot?: string;
48-
hooks?: { preDeploy?: HookFn; postDeploy?: HookFn };
49+
hooks?: HooksConfig;
4950
dependsOn?: string[];
5051
apps?: Partial<ShipnodeApp>[];
5152
};
@@ -260,6 +261,18 @@ export class ShipnodeBuilder {
260261
return this;
261262
}
262263

264+
/** Runs once per roll, on the first replica. Migrations belong here, not in `preDeploy`. */
265+
beforeFleet(fn: HookFn): this {
266+
this.config.hooks = { ...(this.config.hooks ?? {}), beforeFleet: fn };
267+
return this;
268+
}
269+
270+
/** Runs once per roll, on the last replica, after the whole fleet is on the new release. */
271+
afterFleet(fn: HookFn): this {
272+
this.config.hooks = { ...(this.config.hooks ?? {}), afterFleet: fn };
273+
return this;
274+
}
275+
263276
aliases(map: Record<string, string>): this {
264277
this.config.aliases = { ...(this.config.aliases ?? {}), ...map };
265278
return this;
@@ -464,6 +477,18 @@ export class ShipnodeAppBuilder {
464477
return this;
465478
}
466479

480+
/** Runs once per roll, on the first replica. Migrations belong here, not in `preDeploy`. */
481+
beforeFleet(fn: HookFn): this {
482+
this.state.hooks = { ...(this.state.hooks ?? {}), beforeFleet: fn };
483+
return this;
484+
}
485+
486+
/** Runs once per roll, on the last replica, after the whole fleet is on the new release. */
487+
afterFleet(fn: HookFn): this {
488+
this.state.hooks = { ...(this.state.hooks ?? {}), afterFleet: fn };
489+
return this;
490+
}
491+
467492
dependsOn(accessories: string[]): this {
468493
this.state.dependsOn = accessories;
469494
return this;

src/config/schema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,13 @@ export const Pm2AppSchema = z.object({
7474
maxMemory: z.string().optional(),
7575
port: z.number().int().min(1).max(65535).optional(),
7676
env: z.record(z.string(), z.string()).optional(),
77-
});
77+
placement: z.enum(['all', 'primary']).optional(),
78+
}).refine(
79+
// Pinning the web process to one replica would defeat the point of the fleet:
80+
// the load balancer would send traffic to replicas running nothing.
81+
(app) => !(app.placement === 'primary' && app.port !== undefined),
82+
{ message: "placement 'primary' is for workers — a pm2 app with a port must run on every replica", path: ['placement'] },
83+
);
7884

7985
export const Pm2ConfigSchema = z.object({
8086
apps: z.array(Pm2AppSchema).min(1, 'pm2.apps must contain at least one entry'),
@@ -158,6 +164,8 @@ const HookFnSchema = z
158164
export const HooksConfigSchema = z.object({
159165
preDeploy: HookFnSchema.optional(),
160166
postDeploy: HookFnSchema.optional(),
167+
beforeFleet: HookFnSchema.optional(),
168+
afterFleet: HookFnSchema.optional(),
161169
}).optional();
162170

163171
// Per-app fields — anything that differs between two apps deployed to the same server.

src/domain/deploy/backend-strategy.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import chalk from 'chalk';
12
import { execa } from 'execa';
23
import { pathExists } from 'fs-extra';
34
import { resolve } from 'path';
@@ -115,9 +116,29 @@ export class BackendStrategy implements DeploymentStrategy {
115116
}
116117
}
117118

119+
/**
120+
* The pm2 processes this replica is supposed to run.
121+
*
122+
* `placement: 'primary'` pins a process to one server — a scheduler running on
123+
* three replicas fires every job three times — so on every other replica it is
124+
* filtered out. This is the only place that decision is made; the namespace is
125+
* still taken from the full declared list so `pm2 list` grouping matches
126+
* across replicas.
127+
*/
128+
private placedApps(ctx: StrategyContext): Pm2App[] {
129+
const apps = this.app.pm2?.apps ?? [];
130+
if (ctx.primaryReplica !== false) return apps;
131+
return apps.filter((app) => app.placement !== 'primary');
132+
}
133+
118134
async startApp(ctx: StrategyContext): Promise<void> {
119135
if (!this.app.pm2) return;
120136

137+
if (this.placedApps(ctx).length === 0) {
138+
console.log(chalk.dim(` every pm2 process is placement: 'primary' — nothing to start on this replica`));
139+
return;
140+
}
141+
121142
const pkgManager = await this.resolvePkgManager();
122143
const cdPath = `${this.appPath}/current`;
123144
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
@@ -141,7 +162,7 @@ export class BackendStrategy implements DeploymentStrategy {
141162
cdPath: string,
142163
mise: string,
143164
): Promise<void> {
144-
const ecosystemContent = this.generateEcosystemFile(pkgManager);
165+
const ecosystemContent = this.generateEcosystemForApps(this.placedApps(ctx), pkgManager);
145166
// Ecosystem lives inside the release directory (per-release snapshot, ADR-0001).
146167
// PM2 references it via the `current` symlink so it always resolves to the active release.
147168
const ecosystemWritePath = `${ctx.workDir}/ecosystem.config.cjs`;
@@ -198,7 +219,7 @@ export class BackendStrategy implements DeploymentStrategy {
198219
if (!webApp) {
199220
throw new Error('Blue-green start requires one PM2 app with a port');
200221
}
201-
const workers = pm2.apps.filter((a) => a.port === undefined);
222+
const workers = this.placedApps(ctx).filter((a) => a.port === undefined);
202223
const coloredName = coloredWebName(namespace, webApp.name, target.color);
203224

204225
// Web ecosystem: just the web app, coloured name, target-colour port.
@@ -237,7 +258,7 @@ export class BackendStrategy implements DeploymentStrategy {
237258
async afterHealthy(ctx: StrategyContext): Promise<void> {
238259
if (!this.app.zeroDowntime || !ctx.deployTarget || !this.app.pm2) return;
239260

240-
const workers = this.app.pm2.apps.filter((a) => a.port === undefined);
261+
const workers = this.placedApps(ctx).filter((a) => a.port === undefined);
241262
if (workers.length === 0) return;
242263

243264
const cdPath = `${this.appPath}/current`;
@@ -313,11 +334,6 @@ export class BackendStrategy implements DeploymentStrategy {
313334
}
314335
}
315336

316-
private generateEcosystemFile(pkgManager: string): string {
317-
if (!this.app.pm2) return '';
318-
return this.generateEcosystemForApps(this.app.pm2.apps, pkgManager);
319-
}
320-
321337
/**
322338
* Render an ecosystem file for a subset of the declared pm2 apps.
323339
*

src/domain/deploy/fleet.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { FleetConfig, ShipnodeApp } from '../../shared/types.js';
22
import type { RemoteExecutor } from '../remote/executor.js';
33
import { drain, undrain } from './drain.js';
4-
import { newReleaseId } from './orchestrator.js';
4+
import { newReleaseId, type ReplicaRole } from './orchestrator.js';
55

66
/**
77
* Rolling one app across the servers it runs on.
@@ -39,7 +39,24 @@ export interface FleetDeployOptions {
3939
remotePath: string;
4040
connect: ReplicaConnector;
4141
/** Deploy this app on one replica — normally `DeployService.execute`. */
42-
deployReplica: (ctx: { serverName: string; executor: RemoteExecutor; releaseId: string }) => Promise<void>;
42+
deployReplica: (ctx: {
43+
serverName: string;
44+
executor: RemoteExecutor;
45+
releaseId: string;
46+
/**
47+
* This replica's position in the roll, which decides where the run-once
48+
* `beforeFleet` / `afterFleet` hooks fire. A roll that dies partway never
49+
* reaches its last replica, so `afterFleet` correctly never runs.
50+
*/
51+
role: ReplicaRole;
52+
}) => Promise<void>;
53+
/**
54+
* The fleet's primary server, where `placement: 'primary'` processes run.
55+
* Defaults to the first replica of this roll — pass it explicitly when the
56+
* roll has been narrowed (`--on`), so a partial roll cannot promote a
57+
* secondary replica and start a second scheduler.
58+
*/
59+
primary?: string;
4360
releaseId?: string;
4461
onEvent?: (event: FleetEvent) => void;
4562
/** Injected so tests do not wait out a real drain. */
@@ -108,7 +125,16 @@ export async function deployFleet(options: FleetDeployOptions): Promise<FleetDep
108125
onEvent?.({ type: 'deploying', server });
109126

110127
try {
111-
await deployReplica({ serverName: server, executor: session.executor, releaseId });
128+
await deployReplica({
129+
serverName: server,
130+
executor: session.executor,
131+
releaseId,
132+
role: {
133+
first: server === replicas[0],
134+
last: server === replicas[replicas.length - 1],
135+
primary: server === (options.primary ?? replicas[0]),
136+
},
137+
});
112138
} catch (error) {
113139
failure = { server, message: error instanceof Error ? error.message : String(error) };
114140
onEvent?.({ type: 'failed', server, message: failure.message });

0 commit comments

Comments
 (0)