Skip to content

Commit cedfdef

Browse files
committed
Add fleet config: server groups, plural targets, per-server isolation
Groundwork for running one app on several servers. Nothing rolls yet — this is the config and resolution layer the rolling deploy will sit on. An app's `on` becomes a server name, a group name, or a list of either. Groups are declared workspace-level and expand at resolution time, so `.group('web', ['web-a','web-b'])` + `.on('web')` puts one app on two boxes. Resolving to more than one server is what makes an app a fleet: assembly fills in rolling defaults (batch 1, port 80, 30s drain, /_shipnode/ready) so nothing downstream has to ask "is this a fleet?" twice and get two answers. Declaring `fleet` explicitly opts a single-server app into the same contract, which is how you get behind a load balancer before scaling out. servers.ts goes plural. resolveServerName -> resolveServerNames returning a list, with resolveSingleServerName kept for the things that genuinely cannot be replicated — an accessory's host, a `run` invocation, a monitor session. That one errors naming the candidates instead of silently picking the first, because operating on the wrong replica is worse than being asked which. getAppsForServer stops being a partition: a fleet app appears under each of its replicas, or deploy would skip all but one. Two things the schema now refuses. An accessory, database or redis pointed at a group — shipnode does not replicate managed services, and doing so would start two unrelated databases rather than a cluster. And a fleet whose servers have no privateHost, since the load balancer has to reach each replica directly and the public SSH host is not necessarily that address. The fan-out no longer abandons the remaining servers when one fails. The try/catch moves inside the loop, failures are collected per server and reported together, and the process exits non-zero if any failed — a half-finished fan-out with no account of what happened is worse than a slow one. `--app` now also narrows which servers are visited rather than only validating the name, so an unrelated unreachable server stops sinking a command scoped to one app.
1 parent fb02a59 commit cedfdef

13 files changed

Lines changed: 681 additions & 92 deletions

File tree

src/cli/commands/cloudflare.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { runRemoteCommandForTargets } from '../runner.js';
22
import { ui } from '../ui.js';
33
import { CloudflareOrchestrator } from '../../services/cloudflare.orchestrator.js';
44
import { loadConfig } from '../../config/loader.js';
5-
import { getServerTargets, configForServer, resolveServerName } from '../../domain/servers.js';
5+
import { getServerTargets, configForServer, expandTarget } from '../../domain/servers.js';
66
import type { ShipnodeConfig } from '../../shared/types.js';
77

88
function requireToken(): string {
@@ -49,8 +49,10 @@ export async function cmdCloudflareInit(
4949
const workspace = await loadConfig(cwd, options.config);
5050
assertTunnelNameIsUnambiguous(workspace);
5151

52-
// Only one tunnel can own the workspace's ssh hostname.
53-
const sshHostnameOwner = resolveServerName(workspace);
52+
// Only one tunnel can own the workspace's ssh hostname: the default server,
53+
// or the first declared when there is no server called `default`.
54+
const sshHostnameOwner = expandTarget(workspace, undefined)[0]
55+
?? getServerTargets(workspace)[0]?.name;
5456

5557
await runRemoteCommandForTargets(
5658
cwd,

src/cli/commands/deploy.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ui } from '../ui.js';
88
import type { AccessoryConfig, ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
99
import { accessoryMounts } from '../../services/accessory.service.js';
1010
import { getPm2Name } from '../../domain/pm2/apps.js';
11-
import { configForAppResult, configForServer, getServerTargets, resolveServerName, resolveServerNameResult } from '../../domain/servers.js';
11+
import { configForAppResult, configForServer, getServerTargets, resolveServerNames, resolveSingleServerNameResult } from '../../domain/servers.js';
1212
import { generateBackendCaddyfile, generateFrontendCaddyfile } from '../../services/caddy.service.js';
1313
import { type ServerTargetError } from '../../shared/result-errors.js';
1414
import { runDeployWatch } from './deploy-watch.js';
@@ -167,13 +167,19 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
167167
const namespace = app.pm2?.apps[0]?.name;
168168
const web = app.pm2?.apps.find((a) => a.port !== undefined);
169169

170+
const replicas = resolveServerNames(config, app.on);
170171
const serverRows: [string, string][] = [
171172
['App type', app.appType],
172-
['Server', resolveServerName(config, app.on)],
173+
[replicas.length > 1 ? 'Servers' : 'Server', replicas.join(', ')],
173174
['App root', app.appRoot ?? '(repo root)'],
174175
['Keep releases', String(app.keepReleases)],
175176
];
176177

178+
if (app.fleet) {
179+
serverRows.push(['Rolling', `${app.fleet.batch} at a time, ${app.fleet.drainWait}s drain`]);
180+
serverRows.push(['Ready path', `:${app.fleet.port}${app.fleet.readyPath}`]);
181+
}
182+
177183
if (app.appType === 'backend') {
178184
const pm2Apps = app.pm2?.apps ?? [];
179185
if (pm2Apps.length && namespace) {
@@ -243,14 +249,19 @@ function renderDependencyWarnings(config: ShipnodeConfig, app: ShipnodeApp): str
243249
const dependencies = app.dependsOn ?? [];
244250
if (dependencies.length === 0) return [];
245251

246-
const appServer = resolveServerName(config, app.on);
252+
const appServers = resolveServerNames(config, app.on);
247253
const warnings: string[] = [];
248254
for (const name of dependencies) {
249255
const accessory = config.accessories?.[name];
250256
if (!accessory) continue;
251-
const accessoryServer = resolveServerName(config, accessory.on);
252-
if (appServer !== accessoryServer) {
253-
warnings.push(`${name} runs on ${accessoryServer}; ${app.name} runs on ${appServer}. Confirm reachable networking.`);
257+
const [accessoryServer] = resolveServerNames(config, accessory.on);
258+
if (accessoryServer === undefined) continue;
259+
const strangers = appServers.filter((server) => server !== accessoryServer);
260+
if (strangers.length > 0) {
261+
warnings.push(
262+
`${name} runs on ${accessoryServer}; ${app.name} runs on ${strangers.join(', ')}. ` +
263+
`Confirm reachable networking.`,
264+
);
254265
}
255266
}
256267
return warnings;
@@ -315,7 +326,7 @@ function resolveAccessoryServer(
315326
config: ShipnodeConfig,
316327
accessory: AccessoryConfig,
317328
): ResultType<string, ServerTargetError> {
318-
return resolveServerNameResult(config, accessory.on);
329+
return resolveSingleServerNameResult(config, accessory.on, 'This accessory');
319330
}
320331

321332
function renderAccessoryPlan(

src/cli/monitor/monitor-session.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Result, type Result as ResultType } from 'better-result';
2-
import { getServerTargetResult, resolveServerNameResult, type ServerTarget } from '../../domain/servers.js';
2+
import { getServerTargetResult, resolveServerNamesResult, type ServerTarget } from '../../domain/servers.js';
33
import type { ShipnodeApp, ShipnodeConfig } from '../../shared/types.js';
44
import { UnknownAppError, type AppTargetError, type ServerTargetError } from '../../shared/result-errors.js';
55

@@ -19,7 +19,9 @@ export function resolveMonitorSession(
1919

2020
if (app === undefined) return Result.err(new UnknownAppError({ name: appName ?? '(default)' }));
2121

22-
const target = getServerTargetResult(config, app.on);
22+
// The monitor holds one live connection, so a fleet app must be narrowed to
23+
// one replica first (`monitor --on <server>`).
24+
const target = getServerTargetResult(config, app.on, `App '${app.name}'`);
2325
if (target.isErr()) return Result.err(target.error);
2426

2527
return Result.ok({ config, app, target: target.value });
@@ -31,9 +33,9 @@ export function getAppsForMonitorTarget(
3133
): ResultType<ShipnodeApp[], ServerTargetError> {
3234
const apps: ShipnodeApp[] = [];
3335
for (const app of config.apps) {
34-
const serverName = resolveServerNameResult(config, app.on);
35-
if (serverName.isErr()) return Result.err(serverName.error);
36-
if (serverName.value === targetName) apps.push(app);
36+
const serverNames = resolveServerNamesResult(config, app.on);
37+
if (serverNames.isErr()) return Result.err(serverNames.error);
38+
if (serverNames.value.includes(targetName)) apps.push(app);
3739
}
3840
return Result.ok(apps);
3941
}
@@ -44,9 +46,9 @@ export function getAccessoriesForMonitorTarget(
4446
): ResultType<string[], ServerTargetError> {
4547
const names: string[] = [];
4648
for (const [name, accessory] of Object.entries(config.accessories ?? {})) {
47-
const serverName = resolveServerNameResult(config, accessory.on);
48-
if (serverName.isErr()) return Result.err(serverName.error);
49-
if (serverName.value === targetName) names.push(name);
49+
const serverNames = resolveServerNamesResult(config, accessory.on);
50+
if (serverNames.isErr()) return Result.err(serverNames.error);
51+
if (serverNames.value.includes(targetName)) names.push(name);
5052
}
5153
return Result.ok(names);
5254
}

src/cli/runner.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,39 +42,75 @@ export async function runRemoteCommand(
4242
}
4343
}
4444

45+
/**
46+
* Run a command against every server the workspace touches.
47+
*
48+
* Servers are independent: one failing does not stop the rest, because a
49+
* half-finished fan-out with no account of what happened is worse than a slow
50+
* one. Failures are collected and reported together at the end, and the process
51+
* exits non-zero if any server failed.
52+
*
53+
* `appName` both validates the app exists and narrows the traversal to the
54+
* servers that app actually runs on — otherwise an unrelated server being
55+
* unreachable would sink a command scoped to one app.
56+
*/
4557
export async function runRemoteCommandForTargets(
4658
cwd: string,
4759
command: (ctx: { config: ShipnodeConfig; executor: RemoteExecutor; serverName: string }) => Promise<void>,
48-
options: { configPath?: string; includeEmpty?: boolean; appName?: string } = {},
60+
options: { configPath?: string; includeEmpty?: boolean; appName?: string; serverName?: string } = {},
4961
): Promise<void> {
50-
const config = await loadConfig(cwd, options.configPath);
62+
const workspace = await loadConfig(cwd, options.configPath);
63+
64+
let config = workspace;
5165
if (options.appName) {
52-
const selected = configForAppResult(config, options.appName);
66+
const selected = configForAppResult(workspace, options.appName);
5367
if (selected.isErr()) {
5468
ui.error(selected.error.message);
5569
process.exit(1);
5670
return;
5771
}
72+
config = selected.value;
5873
}
5974

60-
try {
61-
for (const target of getServerTargets(config)) {
62-
const targetConfig = configForServer(config, target.name);
63-
if (!options.includeEmpty && targetConfig.apps.length === 0 && Object.keys(targetConfig.accessories ?? {}).length === 0) {
64-
continue;
65-
}
75+
let targets = getServerTargets(config);
76+
if (options.serverName) {
77+
targets = targets.filter((target) => target.name === options.serverName);
78+
if (targets.length === 0) {
79+
const known = getServerTargets(config).map((target) => target.name).join(', ') || '(none)';
80+
ui.error(`Unknown server target '${options.serverName}'. Known targets: ${known}`);
81+
process.exit(1);
82+
return;
83+
}
84+
}
85+
86+
const failures: { serverName: string; message: string }[] = [];
87+
let visited = 0;
6688

67-
const ssh = new SshConnection();
68-
try {
69-
await ssh.connect(target.ssh);
70-
await command({ config: targetConfig, executor: ssh, serverName: target.name });
71-
} finally {
72-
ssh.disconnect();
73-
}
89+
for (const target of targets) {
90+
const targetConfig = configForServer(config, target.name);
91+
if (!options.includeEmpty && targetConfig.apps.length === 0 && Object.keys(targetConfig.accessories ?? {}).length === 0) {
92+
continue;
7493
}
75-
} catch (error) {
76-
const message = error instanceof Error ? error.message : String(error);
77-
ui.error(message);
94+
95+
visited += 1;
96+
const ssh = new SshConnection();
97+
try {
98+
await ssh.connect(target.ssh);
99+
await command({ config: targetConfig, executor: ssh, serverName: target.name });
100+
} catch (error) {
101+
const message = error instanceof Error ? error.message : String(error);
102+
ui.error(`${target.name}: ${message}`);
103+
failures.push({ serverName: target.name, message });
104+
} finally {
105+
ssh.disconnect();
106+
}
107+
}
108+
109+
if (failures.length > 0) {
110+
ui.error(
111+
`Failed on ${failures.length} of ${visited} servers: ` +
112+
failures.map((failure) => failure.serverName).join(', '),
113+
);
78114
process.exit(1);
79115
}
80116
}

src/config/assembly.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type LegacyPm2Input = {
1313
type AssembleInput = {
1414
ssh?: ShipnodeConfig['ssh'];
1515
servers?: ShipnodeConfig['servers'];
16+
groups?: ShipnodeConfig['groups'];
1617
remotePath?: string;
1718
nodeVersion?: string;
1819
pkgManager?: ShipnodeConfig['pkgManager'];
@@ -26,7 +27,7 @@ type AssembleInput = {
2627
accessories?: ShipnodeConfig['accessories'];
2728
// Legacy input fields — synthesized to apps[0] by z.preprocess
2829
app?: string;
29-
on?: string;
30+
on?: string | string[];
3031
domain?: string;
3132
caddy?: ShipnodeApp['caddy'];
3233
pm2?: LegacyPm2Input | Pm2Config;

src/config/builder.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { assembleConfig } from './assembly.js';
1818
type BuilderState = {
1919
ssh?: SshConfig;
2020
servers?: Record<string, SshConfig>;
21+
groups?: Record<string, string[]>;
2122
remotePath?: string;
2223
nodeVersion?: string;
2324
pkgManager?: PkgManager;
@@ -31,7 +32,7 @@ type BuilderState = {
3132
accessories?: Record<string, AccessoryConfig>;
3233
// Legacy per-app input fields (synthesized to apps[0] by z.preprocess)
3334
app?: string;
34-
on?: string;
35+
on?: string | string[];
3536
pm2?: { apps: Pm2App[] };
3637
domain?: string;
3738
keepReleases?: number;
@@ -49,8 +50,10 @@ type BuilderState = {
4950
apps?: Partial<ShipnodeApp>[];
5051
};
5152

52-
type AppBuilderState = Omit<Partial<ShipnodeApp>, 'pm2'> & {
53+
type AppBuilderState = Omit<Partial<ShipnodeApp>, 'pm2' | 'fleet'> & {
5354
pm2?: { apps: Pm2App[] };
55+
// Partial: the schema fills in batch/port/drainWait/readyPath.
56+
fleet?: Partial<ShipnodeApp['fleet']>;
5457
};
5558

5659
export type WorkerOptions = Omit<Pm2App, 'port'>;
@@ -84,7 +87,21 @@ export class ShipnodeBuilder {
8487
return this;
8588
}
8689

87-
on(target: string): this {
90+
/**
91+
* Name a set of servers, usable anywhere a single server name is.
92+
* `.group('web', ['web-a', 'web-b'])` then `.on('web')` runs an app on both.
93+
*/
94+
group(name: string, servers: string[]): this {
95+
this.config.groups = { ...this.config.groups, [name]: servers };
96+
return this;
97+
}
98+
99+
groups(groups: Record<string, string[]>): this {
100+
this.config.groups = { ...this.config.groups, ...groups };
101+
return this;
102+
}
103+
104+
on(target: string | string[]): this {
88105
this.config.on = target;
89106
return this;
90107
}
@@ -343,11 +360,26 @@ export class ShipnodeAppBuilder {
343360
return this;
344361
}
345362

346-
on(target: string): this {
363+
/** A server name, a group name, or a list of either. More than one is a fleet. */
364+
on(target: string | string[]): this {
347365
this.state.on = target;
348366
return this;
349367
}
350368

369+
/**
370+
* Rolling-deploy settings. Implied for any app that resolves to more than one
371+
* server; declare it explicitly to put a single-server app behind the same
372+
* drain contract, or to tune the roll.
373+
*
374+
* `drainWait` (seconds) must match your load balancer's health check — it is
375+
* how long shipnode waits after flipping the readiness endpoint to 503 before
376+
* it touches the app. Too low and in-flight requests are dropped.
377+
*/
378+
fleet(opts: Partial<ShipnodeApp['fleet']> = {}): this {
379+
this.state.fleet = { ...this.state.fleet, ...opts };
380+
return this;
381+
}
382+
351383
caddy(opts: { append?: string }): this {
352384
this.state.caddy = opts;
353385
return this;

0 commit comments

Comments
 (0)