Skip to content

Commit 3bc1c32

Browse files
committed
Fix five bugs found by deploying a real fleet
Deployed a two-replica fleet to Linux VMs behind a Caddy load balancer doing active health checks. Every one of these passed the unit suite and the dry run, and every one broke a real deploy. setup ran deploy-user commands from root's home. sudo inherits the caller's working directory, and setup normally runs over SSH as root whose home is mode 700. Node's spawn() returns EACCES from an unreadable cwd, so `pm2 startup` failed on the documented first-run path with a message naming the node binary rather than the cause. Now runs with -H from the target user's home. --no-deploy-user did nothing. Commander turns it into deployUser: false, not noDeployUser: true, so the option was read as undefined and the user was created anyway. Caddy was never installed on fleet replicas. Both install gates tested app.domain, and a fleet replica deliberately has none — but it still needs Caddy for the readiness endpoint. The first deploy died writing its site file into a directory that was never created. The health check required primary-pinned processes on every replica. A placement: 'primary' worker is absent from secondary replicas by design, so placement working correctly is what failed the health check. configureAll wrote Caddy sites and never reloaded. Only the blue-green path reloaded, and it does so for its own colour flip — so a frontend, a recreate backend, and every fleet replica wrote a site that never took effect. Pre-existing, not fleet-specific. Also: a fleet replica now answers to the app's domain as well as its private address. A load balancer forwards the client's Host, which matched neither the private-address site nor anything else, so real traffic fell through to Caddy's welcome page while the health check passed. The domain is bound http:// only, so no replica requests a certificate and the ACME race the design avoids stays avoided.
1 parent bdf8d36 commit 3bc1c32

9 files changed

Lines changed: 293 additions & 21 deletions

File tree

src/cli/commands/setup.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,40 @@ async function bootstrapDeployUser(
8888
* user is already root, and the conditional `$SUDO` fallback breaks on `sudo -u`
8989
* because it leaves `-u` as the leading token when SUDO="".
9090
*/
91+
/**
92+
* Run a command as another user, from a directory that user can actually read.
93+
*
94+
* `sudo` inherits the caller's working directory, and setup normally runs over
95+
* SSH as root, whose home is mode 700. Node's `spawn()` returns EACCES when the
96+
* working directory is unreadable, so every pm2 command that starts the daemon
97+
* failed — reporting `spawn /home/deploy/.../node EACCES`, which names the node
98+
* binary and hides the real cause. `-H` sets HOME to the target user's rather
99+
* than leaving root's.
100+
*/
91101
function asUser(user: string | null, cmd: string): string {
92102
if (!user) return cmd;
93-
const escaped = cmd.replace(/'/g, "'\\''");
94-
return `sudo -u "${user}" bash -lc '${escaped}'`;
103+
const escaped = `cd "$HOME" 2>/dev/null || cd /; ${cmd}`.replace(/'/g, "'\\''");
104+
return `sudo -u "${user}" -H bash -lc '${escaped}'`;
95105
}
96106

97107
function shellQuote(value: string): string {
98108
return `'${value.replace(/'/g, `'"'"'`)}'`;
99109
}
100110

111+
/**
112+
* Whether this server needs Caddy.
113+
*
114+
* A domain is the obvious case. A fleet replica is the non-obvious one: it
115+
* deliberately has *no* domain — five replicas claiming one name would race
116+
* Let's Encrypt — but it still needs Caddy to serve the `/_shipnode/ready`
117+
* endpoint the load balancer health-checks, and to reverse-proxy the app. Gating
118+
* on `domain` alone left fleet replicas without Caddy, and the first deploy died
119+
* writing its site file into a directory that was never created.
120+
*/
121+
function needsCaddy(config: ShipnodeConfig): boolean {
122+
return config.apps.some((app) => app.domain !== undefined || app.fleet !== undefined);
123+
}
124+
101125
export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser: string | null) {
102126
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
103127
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
@@ -188,7 +212,7 @@ export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, own
188212
},
189213
], { concurrent: false }),
190214
}] : []),
191-
...(hasApps && config.apps.some((app) => app.domain) ? [{
215+
...(hasApps && needsCaddy(config) ? [{
192216
title: 'Caddy',
193217
task: () =>
194218
executor.execOrThrow(
@@ -270,7 +294,7 @@ export function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, own
270294
`${shellQuote(`${config.remotePath}/shared`)} ${shellQuote(`${config.remotePath}/.shipnode`)}`,
271295
),
272296
},
273-
...(config.apps.some((app) => app.domain) ? [{
297+
...(needsCaddy(config) ? [{
274298
title: 'Configure Caddy include',
275299
task: () => executor.execOrThrow(
276300
'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ' +
@@ -294,9 +318,7 @@ async function configurePm2Startup(
294318
nodeVersion: string,
295319
mise: string,
296320
): 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`;
321+
const resolvePm2 = asUser(runAsUser, `${mise}; mise exec "node@${nodeVersion}" -- which pm2`);
300322
const savePm2 = asUser(
301323
runAsUser,
302324
`${mise}; mise exec "node@${nodeVersion}" -- pm2 save --force`,

src/cli/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ program
6565
.description('Setup a new server with required dependencies')
6666
.option('--no-deploy-user', 'Skip creating the deploy user (advanced)')
6767
.option('--config <path>', 'Use a specific config file')
68-
.action((opts) => cmdSetup(process.cwd(), opts));
68+
// Commander turns `--no-deploy-user` into `deployUser: false`, not
69+
// `noDeployUser: true`, so passing opts straight through silently ignored the
70+
// flag and created the user anyway.
71+
.action((opts) => cmdSetup(process.cwd(), { ...opts, noDeployUser: opts.deployUser === false }));
6972

7073
program
7174
.command('deploy')

src/domain/deploy/orchestrator.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,10 @@ export class DeployOrchestrator {
161161
let healthResponseMs: number | undefined;
162162

163163
if (app.appType === 'backend' && app.healthCheck.enabled) {
164-
const healthResult = await this.healthCheck.perform(app, this.healthOpts(app, target));
164+
const healthResult = await this.healthCheck.perform(
165+
app,
166+
this.healthOpts(app, target, fleetRole.primary),
167+
);
165168
healthAttempts = healthResult.attempts;
166169
healthResponseMs = healthResult.responseMs;
167170
}
@@ -261,12 +264,24 @@ export class DeployOrchestrator {
261264
/**
262265
* Health-check options that point the probe at the blue-green target colour
263266
* (its port and colour-suffixed pm2 name) instead of the static config.
267+
*
268+
* `primaryReplica` also narrows which processes must be online. The status
269+
* check requires every declared pm2 app to be running, and a `placement:
270+
* 'primary'` process is deliberately absent from every other replica — so
271+
* without this, placement working correctly is what fails the health check.
264272
*/
265273
private healthOpts(
266274
app: ShipnodeApp,
267275
target: DeployTarget | undefined,
276+
primaryReplica: boolean,
268277
): { httpPort?: number; resolvePm2Name?: (a: Pm2App) => string; pm2Apps?: Pm2App[] } | undefined {
269-
if (!target) return undefined;
278+
if (!target) {
279+
const declared = app.pm2?.apps ?? [];
280+
const placed = declared.filter((a) => primaryReplica || a.placement !== 'primary');
281+
// Only override when placement actually removed something, so the default
282+
// path stays byte-identical for every config that does not use it.
283+
return placed.length === declared.length ? undefined : { pm2Apps: placed };
284+
}
270285
const namespace = app.pm2?.apps[0]?.name ?? app.name;
271286
// Workers are still on the previous release until afterHealthy — only the
272287
// new web colour must be online for this probe.

src/services/caddy.service.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ ${append}}`;
3232
* The readiness endpoint is what the load balancer polls. It answers 503 while
3333
* the drain sentinel exists and 200 otherwise, which is the whole of shipnode's
3434
* LB integration — matching on the file means no reload is needed to flip it.
35+
*
36+
* The block answers to two addresses, because two different Hosts arrive on this
37+
* port. The load balancer's health check dials the replica directly, so it sends
38+
* the private address; forwarded client traffic carries whatever Host the client
39+
* asked for, which is the app's domain. Binding only the private address made
40+
* the health check pass while every real request fell through to whatever else
41+
* held port 80 — on a stock install, Caddy's welcome page.
42+
*
43+
* `http://` on the domain is load-bearing: without the scheme Caddy would try to
44+
* provision a certificate for it, which is the ACME race this design exists to
45+
* avoid.
3546
*/
3647
export function generateFleetCaddyfile(
3748
app: ShipnodeApp,
@@ -49,7 +60,9 @@ export function generateFleetCaddyfile(
4960
stateDir: string;
5061
},
5162
): string {
52-
const address = `http://${options.bind ?? ''}:${options.listen}`;
63+
const addresses = [`http://${options.bind ?? ''}:${options.listen}`];
64+
if (app.domain) addresses.push(`http://${app.domain}:${options.listen}`);
65+
const address = addresses.join(', ');
5366
const append = renderCaddyAppend(app);
5467

5568
const body = options.servePath
@@ -104,6 +117,8 @@ export class CaddyService {
104117
) {}
105118

106119
async configureAll(): Promise<void> {
120+
let wrote = false;
121+
107122
for (const app of this.config.apps) {
108123
// A fleet replica serves a private port and never claims the public
109124
// domain, so it is configured even without one.
@@ -118,7 +133,15 @@ export class CaddyService {
118133
} else {
119134
await this.configureFrontend(app);
120135
}
136+
wrote = true;
121137
}
138+
139+
// Writing the site file changes nothing until Caddy re-reads it. Only the
140+
// blue-green path reloaded, and it reloads for its own colour flip — so a
141+
// frontend, a recreate backend, and every fleet replica (which has no domain
142+
// and is therefore never blue-green) wrote a site that never took effect.
143+
// Once at the end rather than per app: one reload covers every site written.
144+
if (wrote) await this.reload();
122145
}
123146

124147
/** Where the drain sentinel for an app lives on this host. */

tests/unit/caddy-snippets.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,63 @@ describe('CaddyService snippets', () => {
6666
}`);
6767
});
6868
});
69+
70+
describe('configureAll reloads what it writes', () => {
71+
const fleetApp: ShipnodeApp = {
72+
...baseApp,
73+
domain: undefined,
74+
zeroDowntime: false,
75+
fleet: { batch: 1, port: 80, drainWait: 8, readyPath: '/_shipnode/ready' },
76+
};
77+
78+
it('reloads Caddy after writing a fleet replica site', async () => {
79+
// The site file changes nothing until Caddy re-reads it. Only the blue-green
80+
// path reloaded, and a fleet replica has no domain so is never blue-green —
81+
// its readiness endpoint 404'd and the load balancer took it out of rotation.
82+
const executor = new FakeRemoteExecutor();
83+
const fleetConfig: ShipnodeConfig = {
84+
...config,
85+
servers: { 'web-a': { host: '1.2.3.4', user: 'deploy', port: 22, privateHost: '10.0.0.11' } },
86+
ssh: { host: '1.2.3.4', user: 'deploy', port: 22, privateHost: '10.0.0.11' },
87+
apps: [fleetApp],
88+
};
89+
90+
await new CaddyService(executor, fleetConfig).configureAll();
91+
92+
const commands = executor.getHistory().map((entry) => entry.command);
93+
const wrote = commands.findIndex((c) => c.includes('/etc/caddy/conf.d/api.caddy'));
94+
const reloaded = commands.findIndex((c) => c.includes('systemctl reload caddy'));
95+
96+
expect(wrote).toBeGreaterThanOrEqual(0);
97+
expect(reloaded).toBeGreaterThan(wrote);
98+
});
99+
100+
it('reloads once, not once per app', async () => {
101+
const executor = new FakeRemoteExecutor();
102+
const twoApps: ShipnodeConfig = {
103+
...config,
104+
apps: [
105+
{ ...baseApp, name: 'api', zeroDowntime: false },
106+
{ ...baseApp, name: 'web', appType: 'frontend', pm2: undefined, domain: 'example.com' },
107+
],
108+
};
109+
110+
await new CaddyService(executor, twoApps).configureAll();
111+
112+
const reloads = executor.getHistory()
113+
.filter((entry) => entry.command.includes('systemctl reload caddy'));
114+
expect(reloads).toHaveLength(1);
115+
});
116+
117+
it('does not reload when there is nothing to write', async () => {
118+
const executor = new FakeRemoteExecutor();
119+
const noSites: ShipnodeConfig = {
120+
...config,
121+
apps: [{ ...baseApp, domain: undefined, fleet: undefined }],
122+
};
123+
124+
await new CaddyService(executor, noSites).configureAll();
125+
126+
expect(executor.getHistory().some((e) => e.command.includes('reload caddy'))).toBe(false);
127+
});
128+
});

tests/unit/deploy-command.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,11 @@ describe('deploy command dry run', () => {
180180
expect(output).toContain('Wait 20s for the load balancer to notice');
181181
expect(output).toContain('Undrain (/_shipnode/ready → 200)');
182182

183-
// A replica serves its private port; claiming the domain would make every
184-
// replica race the others for the same certificate.
185-
expect(output).toContain('http://10.0.0.11:80 {');
186-
expect(output).not.toContain('api.example.com {');
183+
// A replica answers to the domain so forwarded traffic reaches it, but over
184+
// plain HTTP — claiming it for TLS would make every replica race the others
185+
// for the same certificate.
186+
expect(output).toContain('http://10.0.0.11:80, http://api.example.com:80 {');
187+
expect(output).not.toMatch(/(?<!http:\/\/)api\.example\.com \{/);
187188
});
188189

189190
it('says where a pinned worker lands and where the run-once hooks fire', async () => {

tests/unit/drain.test.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,22 @@ const config: ShipnodeConfig = {
3030
};
3131

3232
describe('fleet Caddy site', () => {
33-
it('never claims the public domain', async () => {
34-
// Every replica writing `api.example.com { ... }` means every replica
35-
// races the others for a Let's Encrypt certificate on the same name.
33+
it('answers to the domain over plain HTTP, never claiming it for TLS', async () => {
34+
// Two different Hosts arrive on this port: the load balancer's health check
35+
// dials the private address, while forwarded client traffic carries the
36+
// app's domain. Binding only the private address made the health check pass
37+
// while every real request fell through to whatever else held port 80.
38+
//
39+
// The `http://` scheme is what keeps this safe — a bare `api.example.com {`
40+
// would have every replica racing the others for one Let's Encrypt cert.
3641
const executor = new FakeRemoteExecutor();
3742

3843
await new CaddyService(executor, config).configureBackend(fleetApp);
3944

4045
const written = executor.getLastCommand()?.command ?? '';
41-
expect(written).not.toContain('api.example.com {');
42-
expect(written).toContain('http://10.0.0.11:80 {');
46+
expect(written).toContain('http://10.0.0.11:80, http://api.example.com:80 {');
47+
expect(written).not.toContain('api.example.com {\n');
48+
expect(written).not.toMatch(/(?<!http:\/\/)api\.example\.com \{/);
4349
});
4450

4551
it('serves a readiness endpoint gated on the drain sentinel', () => {
@@ -77,7 +83,20 @@ describe('fleet Caddy site', () => {
7783
stateDir: '/state',
7884
});
7985

80-
expect(site).toContain('http://:8080 {');
86+
expect(site).toContain('http://:8080, http://api.example.com:8080 {');
87+
});
88+
89+
it('binds only the private address when the app declares no domain', () => {
90+
const site = generateFleetCaddyfile({ ...fleetApp, domain: undefined }, {
91+
listen: 80,
92+
bind: '10.0.0.11',
93+
upstream: 3000,
94+
readyPath: '/ready',
95+
stateDir: '/state',
96+
});
97+
98+
expect(site).toContain('http://10.0.0.11:80 {');
99+
expect(site).not.toContain(',');
81100
});
82101

83102
it('serves static files for a frontend replica', async () => {

tests/unit/orchestrator.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,75 @@ describe('DeployOrchestrator', () => {
198198
});
199199
});
200200

201+
describe('health check and worker placement', () => {
202+
async function deployOn(primary: boolean): Promise<string[]> {
203+
const executor = new FakeRemoteExecutor();
204+
const config = makeConfig({
205+
pm2: {
206+
apps: [
207+
{ name: 'app', port: 3000 },
208+
{ name: 'cron', command: 'node cron.js', placement: 'primary' },
209+
],
210+
},
211+
healthCheck: { enabled: true, path: '/health', timeout: 30, retries: 3, startupDelay: 0 },
212+
});
213+
214+
executor
215+
.when((cmd) => cmd.includes('deploy.lock'), { stdout: 'OK', exitCode: 0 })
216+
.when((cmd) => cmd.includes('mkdir -p'), { stdout: '', exitCode: 0 })
217+
.when((cmd) => cmd.includes('ln -sfn'), { stdout: '', exitCode: 0 })
218+
.when((cmd) => cmd.includes('mv -Tf'), { stdout: '', exitCode: 0 })
219+
.when((cmd) => cmd.includes('cat') && cmd.includes('releases.json'), { stdout: '[]', exitCode: 0 })
220+
.when((cmd) => cmd.includes('releases.json') && cmd.includes('base64'), { stdout: '', exitCode: 0 })
221+
.when((cmd) => cmd.includes('ls -1t'), { stdout: '', exitCode: 0 })
222+
.when((cmd) => cmd.includes('date') && cmd.includes('curl'), { stdout: '200 42', exitCode: 0 })
223+
// Only the web process is running: the cron is pinned to the primary and
224+
// genuinely absent everywhere else.
225+
.when((cmd) => cmd.includes('pm2 jlist'), {
226+
stdout: JSON.stringify([{ name: 'app', pm2_env: { status: 'online', restart_time: 0 } }]),
227+
exitCode: 0,
228+
});
229+
230+
const { DeployLock } = await import('../../src/domain/release/manager.js');
231+
const { HealthCheckService } = await import('../../src/services/health.service.js');
232+
const { CaddyService } = await import('../../src/services/caddy.service.js');
233+
234+
const orchestrator = new DeployOrchestrator(
235+
config,
236+
executor,
237+
new DeployLock(executor, config.remotePath),
238+
new HealthCheckService(executor, config),
239+
new CaddyService(executor, config),
240+
);
241+
242+
const errors: string[] = [];
243+
try {
244+
await orchestrator.deploy({
245+
cwd: '/test',
246+
skipBuild: false,
247+
fleetRole: { first: true, last: true, primary },
248+
});
249+
} catch (error) {
250+
errors.push(error instanceof Error ? error.message : String(error));
251+
}
252+
return errors;
253+
}
254+
255+
it('does not require a primary-pinned process on a secondary replica', async () => {
256+
// The status check requires every declared pm2 app to be online. A pinned
257+
// cron is deliberately absent here, so without the placement filter the
258+
// health check fails precisely when placement is working.
259+
expect(await deployOn(false)).toEqual([]);
260+
});
261+
262+
it('still requires it on the primary, where it is supposed to be running', async () => {
263+
const errors = await deployOn(true);
264+
265+
expect(errors).toHaveLength(1);
266+
expect(errors[0]).toContain('cron');
267+
});
268+
});
269+
201270
describe('run-once fleet hooks', () => {
202271
async function deployWith(fleetRole?: { first: boolean; last: boolean }): Promise<string[]> {
203272
const ran: string[] = [];

0 commit comments

Comments
 (0)