Skip to content

Commit bdf7214

Browse files
committed
Report fleet convergence in status, and label logs by server
A rolling deploy that dies partway leaves the fleet serving two releases at once, and nothing about a single replica reveals it — status printed each server's release happily and never compared them. It now collects one observation per replica and reports the cross-replica view: which release each is on, whether it is in rotation, and an explicit warning naming the replicas on each release when they disagree. A replica with no release, or one left drained by a failed roll, counts as not converged too. Both are silently serving nothing, and the drained case will stay that way until someone notices, so the warning says how to put it back. The comparison is skipped under --on: one observation proves nothing about the others, and reporting "converged" from it would be worse than saying nothing. status also stops reporting a placement: 'primary' process as missing on the replicas it is deliberately absent from. logs now prefixes every line with its server, not every block — a fleet interleaves output, and a block prefix leaves the reader guessing which box a stack trace came from.
1 parent f6cf785 commit bdf7214

5 files changed

Lines changed: 403 additions & 5 deletions

File tree

src/cli/commands/logs.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import { runRemoteCommandForTargets } from '../runner.js';
22

3+
/**
4+
* Prefix every line, not every block.
5+
*
6+
* A fleet interleaves output from several servers, and a block-level prefix
7+
* leaves the reader guessing which box a stack trace came from once the second
8+
* server's output starts.
9+
*/
10+
function prefixLines(text: string, label: string): string {
11+
return text
12+
.split('\n')
13+
.filter((line) => line.length > 0)
14+
.map((line) => `${label} ${line}`)
15+
.join('\n');
16+
}
17+
318
export async function cmdLogs(cwd: string, options: { lines?: number; config?: string; process?: string; app?: string; on?: string }): Promise<void> {
419
await runRemoteCommandForTargets(
520
cwd,
6-
async ({ config, executor }) => {
21+
async ({ config, executor, serverName }) => {
722
const apps = options.app
823
? config.apps.filter((app) => app.name === options.app)
924
: config.apps.filter((a) => a.appType === 'backend' && a.pm2);
@@ -31,8 +46,9 @@ export async function cmdLogs(cwd: string, options: { lines?: number; config?: s
3146
const result = await executor.exec(
3247
`${mise}; mise exec "node@${nodeVersion}" -- pm2 logs ${target} --lines ${lines} --nostream`,
3348
);
34-
if (result.stdout) process.stdout.write(`[${app.name}] ${result.stdout}\n`);
35-
if (result.stderr) process.stderr.write(`[${app.name}] ${result.stderr}\n`);
49+
const label = `[${serverName} ${app.name}]`;
50+
if (result.stdout) process.stdout.write(`${prefixLines(result.stdout, label)}\n`);
51+
if (result.stderr) process.stderr.write(`${prefixLines(result.stderr, label)}\n`);
3652
}
3753
},
3854
{ configPath: options.config, appName: options.app, serverName: options.on },

src/cli/commands/status.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import { runRemoteCommandForTargets } from '../runner.js';
22
import { ui } from '../ui.js';
33
import { getDeploymentName, getPm2Name } from '../../domain/pm2/apps.js';
4+
import { isDrained } from '../../domain/deploy/drain.js';
5+
import {
6+
assessConvergence,
7+
describeConvergence,
8+
type ReplicaObservation,
9+
} from '../../domain/deploy/convergence.js';
410

511
export async function cmdStatus(cwd: string, options: { config?: string; app?: string; on?: string }): Promise<void> {
12+
// Per-app, per-replica observations, gathered as the fan-out visits each
13+
// server. Release skew is invisible from inside one server, so the comparison
14+
// happens after every replica has reported.
15+
const observed = new Map<string, ReplicaObservation[]>();
16+
const fleetApps = new Set<string>();
17+
618
await runRemoteCommandForTargets(
719
cwd,
820
async ({ config, executor, serverName }) => {
@@ -36,7 +48,13 @@ export async function cmdStatus(cwd: string, options: { config?: string; app?: s
3648
const pm2Name = getPm2Name(namespace, pm2App.name);
3749
const running = byName.get(pm2Name);
3850
if (!running) {
39-
ui.warn(` App '${pm2App.name}' not found in PM2`);
51+
// A process pinned to the primary is absent everywhere else by
52+
// design; saying "not found" would report the feature as a fault.
53+
if (pm2App.placement === 'primary') {
54+
ui.info(` App '${pm2App.name}' is pinned to the primary replica — not expected here`);
55+
} else {
56+
ui.warn(` App '${pm2App.name}' not found in PM2`);
57+
}
4058
continue;
4159
}
4260
ui.heading(` PM2: ${pm2App.name}`);
@@ -59,12 +77,34 @@ export async function cmdStatus(cwd: string, options: { config?: string; app?: s
5977

6078
const appPath = `${config.remotePath}/${app.name}`;
6179
const currentResult = await executor.exec(`readlink "${appPath}/current" 2>/dev/null || echo "no current symlink"`);
62-
if (currentResult.stdout !== 'no current symlink') {
80+
const hasRelease = currentResult.stdout !== 'no current symlink' && currentResult.stdout !== '';
81+
if (hasRelease) {
6382
ui.success(` Current release: ${currentResult.stdout}`);
6483
} else {
6584
ui.warn(' No active release');
6685
}
6786

87+
let drained: boolean | null = null;
88+
if (app.fleet) {
89+
fleetApps.add(app.name);
90+
drained = await isDrained(executor, config.remotePath, app.name);
91+
if (drained) {
92+
ui.warn(` Rotation: draining (${app.fleet.readyPath} answers 503)`);
93+
} else {
94+
ui.success(' Rotation: in rotation');
95+
}
96+
}
97+
98+
const entries = observed.get(app.name) ?? [];
99+
entries.push({
100+
server: serverName,
101+
// The symlink is absolute; only the release directory name is
102+
// comparable between replicas.
103+
release: hasRelease ? currentResult.stdout.split('/').pop() ?? null : null,
104+
drained,
105+
});
106+
observed.set(app.name, entries);
107+
68108
const releasesResult = await executor.exec(`ls -1t "${appPath}/releases/" 2>/dev/null | head -5`);
69109
if (releasesResult.stdout) {
70110
const releases = releasesResult.stdout.split('\n').filter(Boolean);
@@ -74,4 +114,44 @@ export async function cmdStatus(cwd: string, options: { config?: string; app?: s
74114
},
75115
{ configPath: options.config, serverName: options.on },
76116
);
117+
118+
reportFleetConvergence(observed, fleetApps, options.on !== undefined);
119+
}
120+
121+
/**
122+
* The cross-replica view, which is the only place a half-rolled fleet shows up.
123+
*
124+
* Skipped when `--on` narrowed the run to one server: a single observation
125+
* proves nothing about the others, and reporting "converged" from it would be
126+
* worse than saying nothing.
127+
*/
128+
function reportFleetConvergence(
129+
observed: Map<string, ReplicaObservation[]>,
130+
fleetApps: Set<string>,
131+
narrowed: boolean,
132+
): void {
133+
if (narrowed) return;
134+
135+
for (const [appName, observations] of observed) {
136+
if (!fleetApps.has(appName) || observations.length < 2) continue;
137+
138+
const convergence = assessConvergence(observations);
139+
ui.heading(`Fleet: ${appName}`);
140+
ui.section(
141+
' Replicas',
142+
observations.map((o) => [
143+
o.server,
144+
`${o.release ?? 'no release'}${o.drained ? ' (draining)' : ''}`,
145+
]),
146+
);
147+
148+
if (convergence.converged) {
149+
ui.success(` All ${observations.length} replicas on ${convergence.releases[0]}`);
150+
continue;
151+
}
152+
153+
for (const line of describeConvergence(appName, observations, convergence)) {
154+
ui.warn(` ${line}`);
155+
}
156+
}
77157
}

src/domain/deploy/convergence.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Did the fleet converge?
3+
*
4+
* A rolling deploy touches replicas one batch at a time, so an interrupted roll
5+
* leaves the fleet serving two different releases at once. That state is
6+
* legitimate mid-roll and a bug afterwards, and nothing about a single replica
7+
* reveals it — `status` has to compare them. The same comparison catches a
8+
* replica left drained by a failed roll, which is silently out of rotation and
9+
* will stay that way until someone notices.
10+
*/
11+
12+
export interface ReplicaObservation {
13+
server: string;
14+
/** The release directory `current` points at, or null if there is no release. */
15+
release: string | null;
16+
/** Whether the replica is out of rotation. Null for an app with no drain contract. */
17+
drained: boolean | null;
18+
}
19+
20+
export interface FleetConvergence {
21+
/** Distinct releases seen, newest-sorting first. One entry means converged. */
22+
releases: string[];
23+
/** Replicas out of rotation. */
24+
drained: string[];
25+
/** Replicas with no release at all. */
26+
undeployed: string[];
27+
converged: boolean;
28+
}
29+
30+
export function assessConvergence(observations: ReplicaObservation[]): FleetConvergence {
31+
const releases = [...new Set(
32+
observations.map((o) => o.release).filter((r): r is string => r !== null),
33+
)].sort().reverse();
34+
35+
const drained = observations.filter((o) => o.drained === true).map((o) => o.server);
36+
const undeployed = observations.filter((o) => o.release === null).map((o) => o.server);
37+
38+
return {
39+
releases,
40+
drained,
41+
undeployed,
42+
// A replica with no release at all is as much a failure to converge as two
43+
// different releases — the fleet is not uniformly serving anything.
44+
converged: releases.length <= 1 && undeployed.length === 0 && drained.length === 0,
45+
};
46+
}
47+
48+
/** Human-readable lines describing what is wrong, empty when the fleet is converged. */
49+
export function describeConvergence(
50+
appName: string,
51+
observations: ReplicaObservation[],
52+
convergence: FleetConvergence,
53+
): string[] {
54+
if (convergence.converged) return [];
55+
56+
const lines: string[] = [];
57+
const at = (release: string): string =>
58+
observations.filter((o) => o.release === release).map((o) => o.server).join(', ');
59+
60+
if (convergence.releases.length > 1) {
61+
lines.push(
62+
`${appName} is running ${convergence.releases.length} different releases: ` +
63+
convergence.releases.map((release) => `${release} on ${at(release)}`).join('; ') +
64+
`. A roll stopped partway — redeploy to converge the fleet.`,
65+
);
66+
}
67+
68+
if (convergence.undeployed.length > 0) {
69+
lines.push(`${appName} has no release on ${convergence.undeployed.join(', ')}.`);
70+
}
71+
72+
if (convergence.drained.length > 0) {
73+
lines.push(
74+
`${appName} is out of rotation on ${convergence.drained.join(', ')} — ` +
75+
`the load balancer is sending it no traffic. Run 'shipnode undrain --app ${appName}' ` +
76+
`once it is healthy.`,
77+
);
78+
}
79+
80+
return lines;
81+
}

tests/unit/convergence.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
assessConvergence,
4+
describeConvergence,
5+
type ReplicaObservation,
6+
} from '../../src/domain/deploy/convergence.js';
7+
8+
const replica = (
9+
server: string,
10+
release: string | null,
11+
drained: boolean | null = false,
12+
): ReplicaObservation => ({ server, release, drained });
13+
14+
describe('assessConvergence', () => {
15+
it('is converged when every replica serves the same release and is in rotation', () => {
16+
const result = assessConvergence([
17+
replica('web-a', '2026-01-02'),
18+
replica('web-b', '2026-01-02'),
19+
]);
20+
21+
expect(result.converged).toBe(true);
22+
expect(result.releases).toEqual(['2026-01-02']);
23+
});
24+
25+
it('reports skew newest first, which is the release the roll was heading to', () => {
26+
const result = assessConvergence([
27+
replica('web-a', '2026-01-02'),
28+
replica('web-b', '2026-01-01'),
29+
replica('web-c', '2026-01-01'),
30+
]);
31+
32+
expect(result.converged).toBe(false);
33+
expect(result.releases).toEqual(['2026-01-02', '2026-01-01']);
34+
});
35+
36+
it('counts a replica with no release as not converged', () => {
37+
// Uniform in the sense that one release is present, but a replica serving
38+
// nothing is exactly the failure this is meant to surface.
39+
const result = assessConvergence([replica('web-a', '2026-01-02'), replica('web-b', null)]);
40+
41+
expect(result.converged).toBe(false);
42+
expect(result.undeployed).toEqual(['web-b']);
43+
});
44+
45+
it('counts a drained replica as not converged even on the right release', () => {
46+
// A failed roll leaves the replica drained. It is out of rotation and will
47+
// stay there until someone notices.
48+
const result = assessConvergence([
49+
replica('web-a', '2026-01-02'),
50+
replica('web-b', '2026-01-02', true),
51+
]);
52+
53+
expect(result.converged).toBe(false);
54+
expect(result.drained).toEqual(['web-b']);
55+
});
56+
57+
it('ignores drain state for an app with no drain contract', () => {
58+
const result = assessConvergence([replica('web-a', '2026-01-02', null)]);
59+
60+
expect(result.converged).toBe(true);
61+
expect(result.drained).toEqual([]);
62+
});
63+
});
64+
65+
describe('describeConvergence', () => {
66+
it('says nothing when the fleet is converged', () => {
67+
const observations = [replica('web-a', '2026-01-02'), replica('web-b', '2026-01-02')];
68+
69+
expect(describeConvergence('api', observations, assessConvergence(observations))).toEqual([]);
70+
});
71+
72+
it('names which replicas are on which release', () => {
73+
const observations = [
74+
replica('web-a', '2026-01-02'),
75+
replica('web-b', '2026-01-01'),
76+
replica('web-c', '2026-01-01'),
77+
];
78+
79+
const [line] = describeConvergence('api', observations, assessConvergence(observations));
80+
81+
expect(line).toContain('2026-01-02 on web-a');
82+
expect(line).toContain('2026-01-01 on web-b, web-c');
83+
expect(line).toContain('redeploy to converge');
84+
});
85+
86+
it('tells the reader how to put a drained replica back', () => {
87+
const observations = [replica('web-a', '2026-01-02'), replica('web-b', '2026-01-02', true)];
88+
89+
const lines = describeConvergence('api', observations, assessConvergence(observations));
90+
91+
expect(lines.join(' ')).toContain("shipnode undrain --app api");
92+
});
93+
});

0 commit comments

Comments
 (0)