Skip to content

Commit 1cc6d5b

Browse files
committed
Harden accessories and registry secrets
1 parent effc68b commit 1cc6d5b

11 files changed

Lines changed: 466 additions & 25 deletions

File tree

src/cli/commands/deploy.ts

Lines changed: 133 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import chalk from 'chalk';
2+
import { Result, type Result as ResultType } from 'better-result';
23
import { loadConfig } from '../../config/loader.js';
34
import { DeployService } from '../../services/deploy.service.js';
45
import { LoggingExecutor } from '../../infrastructure/ssh/logging-executor.js';
56
import { runRemoteCommandForTargets } from '../runner.js';
67
import { ui } from '../ui.js';
7-
import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
8+
import type { AccessoryConfig, ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
89
import { getPm2Name } from '../../domain/pm2/apps.js';
9-
import { configForAppResult, configForServer, getServerTargets, resolveServerName } from '../../domain/servers.js';
10+
import { configForAppResult, configForServer, getServerTargets, resolveServerName, resolveServerNameResult } from '../../domain/servers.js';
1011
import { generateBackendCaddyfile, generateFrontendCaddyfile } from '../../services/caddy.service.js';
12+
import { type ServerTargetError } from '../../shared/result-errors.js';
1113

1214
export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBuild?: boolean; app?: string; config?: string }): Promise<void> {
1315
let config: ShipnodeConfig;
@@ -54,12 +56,14 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
5456
const deployer = new DeployService(new LoggingExecutor(executor), deployConfig);
5557
await deployer.execute(cwd, options.skipBuild ?? false);
5658

57-
const lines = [
59+
const lines: string[] = [
5860
`host ${config.ssh.user}@${config.ssh.host}`,
59-
...deployConfig.apps.filter((a) => a.domain).map((a) => `url https://${a.domain}`),
60-
].filter(Boolean).join('\n');
61+
];
62+
for (const deployedApp of deployConfig.apps) {
63+
if (deployedApp.domain) lines.push(`url https://${deployedApp.domain}`);
64+
}
6165

62-
ui.note(lines, 'Done');
66+
ui.note(lines.join('\n'), 'Done');
6367
ui.outro('Run shipnode status to check your app.');
6468
},
6569
{ configPath: options.config },
@@ -96,6 +100,9 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
96100
}
97101

98102
if (app.domain) serverRows.push(['Domain', app.domain]);
103+
if (app.dependsOn?.length) serverRows.push(['Depends on', app.dependsOn.join(', ')]);
104+
105+
const dependencyWarnings = renderDependencyWarnings(config, app);
99106

100107
const caddyPreview = renderCaddyPreview(config, app);
101108

@@ -109,22 +116,21 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
109116
buildRows.push(['', chalk.dim('runs on remote server')]);
110117
}
111118

112-
const steps = [
119+
const steps: string[] = [
113120
'Acquire deploy lock',
114121
'Create release directory',
115122
'Rsync files',
116123
'Install dependencies',
117-
app.hooks?.preDeploy ? 'Run preDeploy hook' : '',
118-
'Switch symlink (atomic)',
119-
app.appType === 'backend' ? 'Reload PM2' : '',
120-
app.healthCheck.enabled ? `Health check ${app.healthCheck.path}` : '',
121-
'Record release',
122-
'Clean old releases',
123-
app.hooks?.postDeploy ? 'Run postDeploy hook' : '',
124-
'Release lock',
125-
].filter(Boolean);
126-
127-
const flowRows: [string, string][] = steps.map((s, i) => [`${i + 1}.`, s as string]);
124+
];
125+
if (app.hooks?.preDeploy) steps.push('Run preDeploy hook');
126+
steps.push('Switch symlink (atomic)');
127+
if (app.appType === 'backend') steps.push('Reload PM2');
128+
if (app.healthCheck.enabled) steps.push(`Health check ${app.healthCheck.path}`);
129+
steps.push('Record release', 'Clean old releases');
130+
if (app.hooks?.postDeploy) steps.push('Run postDeploy hook');
131+
steps.push('Release lock');
132+
133+
const flowRows: [string, string][] = steps.map((step, i) => [`${i + 1}.`, step]);
128134

129135
return [
130136
chalk.bold(`App: ${app.name}`),
@@ -135,10 +141,28 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
135141
'',
136142
chalk.bold(' Deploy flow'),
137143
...flowRows.map(([k, v]) => ` ${chalk.dim(k.padEnd(4))} ${v}`),
144+
...(dependencyWarnings.length ? ['', chalk.bold(' Dependency hints'), ...dependencyWarnings.map((line) => ` ${line}`)] : []),
138145
...(caddyPreview ? ['', chalk.bold(' Caddy'), caddyPreview.split('\n').map((line) => ` ${line}`).join('\n')] : []),
139146
].join('\n');
140147
}
141148

149+
function renderDependencyWarnings(config: ShipnodeConfig, app: ShipnodeApp): string[] {
150+
const dependencies = app.dependsOn ?? [];
151+
if (dependencies.length === 0) return [];
152+
153+
const appServer = resolveServerName(config, app.on);
154+
const warnings: string[] = [];
155+
for (const name of dependencies) {
156+
const accessory = config.accessories?.[name];
157+
if (!accessory) continue;
158+
const accessoryServer = resolveServerName(config, accessory.on);
159+
if (appServer !== accessoryServer) {
160+
warnings.push(`${name} runs on ${accessoryServer}; ${app.name} runs on ${appServer}. Confirm reachable networking.`);
161+
}
162+
}
163+
return warnings;
164+
}
165+
142166
function renderCaddyPreview(config: ShipnodeConfig, app: ShipnodeApp): string | null {
143167
if (!app.domain) return null;
144168
if (app.appType === 'frontend') {
@@ -166,5 +190,95 @@ export function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
166190
.flatMap((targetConfig) => targetConfig.apps.map((app) => renderAppPlan(config, app, skipBuild)))
167191
.join('\n\n');
168192

169-
ui.note([header, '', perApp].join('\n'), 'Dry run — no changes will be made');
193+
const accessories = renderAccessoriesPlan(config);
194+
if (accessories.isErr()) {
195+
ui.error(accessories.error.message);
196+
process.exit(1);
197+
return;
198+
}
199+
200+
const output = [header, ''];
201+
if (perApp) output.push(perApp);
202+
if (accessories.value) output.push(accessories.value);
203+
ui.note(output.join('\n'), 'Dry run — no changes will be made');
204+
}
205+
206+
function renderAccessoriesPlan(config: ShipnodeConfig): ResultType<string, ServerTargetError> {
207+
const accessories = Object.entries(config.accessories ?? {});
208+
if (accessories.length === 0) return Result.ok('');
209+
210+
const rendered: string[] = [chalk.bold('Accessories')];
211+
for (const [name, accessory] of accessories) {
212+
const server = resolveAccessoryServer(config, accessory);
213+
if (server.isErr()) return Result.err(server.error);
214+
215+
rendered.push(renderAccessoryPlan(name, accessory, server.value, config));
216+
}
217+
218+
return Result.ok(rendered.join('\n\n'));
219+
}
220+
221+
function resolveAccessoryServer(
222+
config: ShipnodeConfig,
223+
accessory: AccessoryConfig,
224+
): ResultType<string, ServerTargetError> {
225+
return resolveServerNameResult(config, accessory.on);
226+
}
227+
228+
function renderAccessoryPlan(
229+
name: string,
230+
accessory: AccessoryConfig,
231+
serverName: string,
232+
config: ShipnodeConfig,
233+
): string {
234+
const registry = accessory.registry ?? config.registry;
235+
const ports = Array.isArray(accessory.port) ? accessory.port : accessory.port ? [accessory.port] : [];
236+
const rows: [string, string][] = [
237+
['Server', serverName],
238+
['Image', accessory.image],
239+
];
240+
241+
if (ports.length > 0) rows.push(['Ports', ports.join(', ')]);
242+
if (accessory.directories?.length) rows.push(['Volumes', accessory.directories.join(', ')]);
243+
if (accessory.networks?.length) rows.push(['Networks', accessory.networks.join(', ')]);
244+
if (accessory.command) rows.push(['Command', Array.isArray(accessory.command) ? accessory.command.join(' ') : accessory.command]);
245+
if (accessory.labels && Object.keys(accessory.labels).length > 0) rows.push(['Labels', Object.entries(accessory.labels).map(([key, value]) => `${key}=${value}`).join(', ')]);
246+
if (accessory.restart) rows.push(['Restart', accessory.restart]);
247+
if (accessory.resources) rows.push(['Resources', renderAccessoryResources(accessory.resources)]);
248+
if (accessory.stopTimeout !== undefined) rows.push(['Stop timeout', `${accessory.stopTimeout}s`]);
249+
if (registry) rows.push(['Registry', `${registry.server} (${registry.passwordEnv})`]);
250+
if (accessory.healthCheck) rows.push(['Health check', accessory.healthCheck.command]);
251+
252+
const hasVolumeSetup = (accessory.directories ?? []).some((mount) => {
253+
const [source] = mount.split(':');
254+
return source !== undefined && source !== '' && !source.startsWith('/') && !source.startsWith('.') && !source.startsWith('~');
255+
});
256+
const hasNetworkSetup = (accessory.networks ?? []).length > 0;
257+
const setupSteps = [
258+
...(hasVolumeSetup ? ['Inspect/create named Docker volumes'] : []),
259+
...(hasNetworkSetup ? ['Inspect/create Docker networks'] : []),
260+
];
261+
const dockerSteps = [
262+
registry ? `Login to ${registry.server} using $${registry.passwordEnv}` : 'Skip registry login',
263+
...setupSteps,
264+
`Pull ${accessory.image}`,
265+
`Recreate container shipnode-${name}`,
266+
...(accessory.healthCheck ? ['Run health check'] : []),
267+
];
268+
269+
return [
270+
chalk.bold(`Accessory: ${name}`),
271+
...rows.map(([key, value]) => ` ${chalk.dim(key.padEnd(14))} ${value}`),
272+
'',
273+
chalk.bold(' Docker flow'),
274+
...dockerSteps.map((step, index) => ` ${chalk.dim(`${index + 1}.`.padEnd(4))} ${step}`),
275+
].join('\n');
276+
}
277+
278+
function renderAccessoryResources(resources: NonNullable<AccessoryConfig['resources']>): string {
279+
const parts: string[] = [];
280+
if (resources.memory) parts.push(`memory=${resources.memory}`);
281+
if (resources.memoryReservation) parts.push(`memoryReservation=${resources.memoryReservation}`);
282+
if (resources.cpus) parts.push(`cpus=${resources.cpus}`);
283+
return parts.join(', ');
170284
}

src/cli/commands/secrets.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { loadConfig } from '../../config/loader.js';
2+
import { getServerTargetResult } from '../../domain/servers.js';
3+
import { SshConnection } from '../../infrastructure/ssh/connection.js';
4+
import type { RegistryConfig, ShipnodeConfig } from '../../shared/types.js';
5+
import { ui } from '../ui.js';
6+
7+
function shellQuote(value: string): string {
8+
return `'${value.replace(/'/g, "'\"'\"'")}'`;
9+
}
10+
11+
function assertValidEnvName(name: string): void {
12+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
13+
throw new Error(`Invalid secret name '${name}'. Use a valid shell environment variable name.`);
14+
}
15+
}
16+
17+
function secretSetCommand(name: string, value: string): string {
18+
const quotedAssignment = shellQuote(`export ${name}=${shellQuote(value)}`);
19+
return [
20+
'mkdir -p ~/.shipnode',
21+
'touch ~/.shipnode/secrets.env',
22+
`grep -v '^export ${name}=' ~/.shipnode/secrets.env > ~/.shipnode/secrets.env.tmp || true`,
23+
`printf '%s\\n' ${quotedAssignment} >> ~/.shipnode/secrets.env.tmp`,
24+
'mv ~/.shipnode/secrets.env.tmp ~/.shipnode/secrets.env',
25+
'chmod 600 ~/.shipnode/secrets.env',
26+
'grep -q "shipnode/secrets.env" ~/.profile 2>/dev/null || printf \'\\n# shipnode secrets\\n[ -f ~/.shipnode/secrets.env ] && . ~/.shipnode/secrets.env\\n\' >> ~/.profile',
27+
].join(' && ');
28+
}
29+
30+
function registryLoginCommand(registry: RegistryConfig): string {
31+
return `. ~/.shipnode/secrets.env 2>/dev/null || true; ` +
32+
`if [ -z "$${registry.passwordEnv}" ]; then echo "Registry password env ${registry.passwordEnv} is not set on the remote host" >&2; exit 1; fi; ` +
33+
`printf '%s' "$${registry.passwordEnv}" | sudo docker login ${shellQuote(registry.server)} --username ${shellQuote(registry.username)} --password-stdin`;
34+
}
35+
36+
async function runOnTarget(
37+
config: ShipnodeConfig,
38+
targetName: string | undefined,
39+
command: string,
40+
): Promise<string> {
41+
const target = getServerTargetResult(config, targetName);
42+
if (target.isErr()) return target.error.message;
43+
44+
const ssh = new SshConnection();
45+
try {
46+
await ssh.connect(target.value.ssh);
47+
await ssh.execOrThrow(command);
48+
return '';
49+
} finally {
50+
ssh.disconnect();
51+
}
52+
}
53+
54+
export async function cmdSecretSet(
55+
cwd: string,
56+
name: string,
57+
value: string | undefined,
58+
options: { config?: string; on?: string },
59+
): Promise<void> {
60+
try {
61+
assertValidEnvName(name);
62+
const secretValue = value ?? process.env[name];
63+
if (secretValue === undefined) throw new Error(`Secret value missing. Pass value or set local ${name}.`);
64+
65+
const config = await loadConfig(cwd, options.config);
66+
const error = await runOnTarget(config, options.on, secretSetCommand(name, secretValue));
67+
if (error) {
68+
ui.error(error);
69+
process.exit(1);
70+
return;
71+
}
72+
ui.success(`Secret '${name}' set`);
73+
} catch (error) {
74+
const message = error instanceof Error ? error.message : String(error);
75+
ui.error(message);
76+
process.exit(1);
77+
}
78+
}
79+
80+
export async function cmdRegistryLogin(cwd: string, options: { config?: string; on?: string }): Promise<void> {
81+
try {
82+
const config = await loadConfig(cwd, options.config);
83+
if (!config.registry) throw new Error('No workspace registry configured.');
84+
85+
const value = process.env[config.registry.passwordEnv];
86+
if (value !== undefined) {
87+
const setError = await runOnTarget(config, options.on, secretSetCommand(config.registry.passwordEnv, value));
88+
if (setError) {
89+
ui.error(setError);
90+
process.exit(1);
91+
return;
92+
}
93+
}
94+
95+
const loginError = await runOnTarget(config, options.on, registryLoginCommand(config.registry));
96+
if (loginError) {
97+
ui.error(loginError);
98+
process.exit(1);
99+
return;
100+
}
101+
ui.success(`Logged in to ${config.registry.server}`);
102+
} catch (error) {
103+
const message = error instanceof Error ? error.message : String(error);
104+
ui.error(message);
105+
process.exit(1);
106+
}
107+
}

src/cli/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
cmdAccessoryStatus,
3636
cmdAccessoryStop,
3737
} from './commands/accessory.js';
38+
import { cmdRegistryLogin, cmdSecretSet } from './commands/secrets.js';
3839

3940
// Read version from package.json so `shipnode --version` stays in sync with the published
4041
// version automatically. The dist layout puts this file at dist/cli/index.js, so the
@@ -300,6 +301,24 @@ accessory.command('health <name>')
300301
.option('--config <path>', 'Use a specific config file')
301302
.action((name: string, opts) => cmdAccessoryHealth(process.cwd(), name, opts));
302303

304+
// ── Secrets / Registry ───────────────────────────────────────────
305+
306+
const secret = program.command('secret').description('Manage remote secrets');
307+
308+
secret.command('set <name> [value]')
309+
.description('Set a remote secret from an explicit value or local environment variable')
310+
.option('--on <target>', 'Server target name')
311+
.option('--config <path>', 'Use a specific config file')
312+
.action((name: string, value: string | undefined, opts) => cmdSecretSet(process.cwd(), name, value, opts));
313+
314+
const registry = program.command('registry').description('Manage Docker registry auth');
315+
316+
registry.command('login')
317+
.description('Store configured registry token on the target and run docker login')
318+
.option('--on <target>', 'Server target name')
319+
.option('--config <path>', 'Use a specific config file')
320+
.action((opts) => cmdRegistryLogin(process.cwd(), opts));
321+
303322
// ── Cloudflare ────────────────────────────────────────────────────
304323

305324
const cloudflare = program.command('cloudflare').description('Cloudflare Tunnel integration');

src/config/builder.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type BuilderState = {
4242
sharedFiles?: string[];
4343
appRoot?: string;
4444
hooks?: { preDeploy?: HookFn; postDeploy?: HookFn };
45+
dependsOn?: string[];
4546
apps?: Partial<ShipnodeApp>[];
4647
};
4748

@@ -222,6 +223,11 @@ export class ShipnodeBuilder {
222223
return this;
223224
}
224225

226+
dependsOn(accessories: string[]): this {
227+
this.config.dependsOn = accessories;
228+
return this;
229+
}
230+
225231
registry(opts: RegistryConfig): this {
226232
this.config.registry = opts;
227233
return this;
@@ -379,6 +385,11 @@ export class ShipnodeAppBuilder {
379385
return this;
380386
}
381387

388+
dependsOn(accessories: string[]): this {
389+
this.state.dependsOn = accessories;
390+
return this;
391+
}
392+
382393
/** Internal: hand off the accumulated state to the workspace builder. */
383394
toApp(): Partial<ShipnodeApp> {
384395
return this.state as Partial<ShipnodeApp>;

0 commit comments

Comments
 (0)