@akash-chowdhury-24/deployhub 2.0.32 → 2.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/deploy.js +21 -1
- package/src/commands/doctor.js +20 -0
- package/src/commands/env.js +11 -1
- package/src/commands/init.js +1 -0
- package/src/commands/verify.js +32 -9
- package/src/core/stages.js +20 -2
- package/src/deployment/init-prompts.js +47 -10
- package/src/deployment/providers/docker.js +70 -4
- package/src/utils/docker-port-publish.js +328 -0
- package/src/utils/docker-remote.js +6 -2
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
runHealthChecksForEnvs,
|
|
12
12
|
formatHealthCheckAllSummary,
|
|
13
13
|
} from '../utils/health-check.js';
|
|
14
|
+
import {
|
|
15
|
+
runDockerPortPublishChecksForEnvs,
|
|
16
|
+
verifyStageShouldRun,
|
|
17
|
+
} from '../utils/docker-port-publish.js';
|
|
14
18
|
import { createLogger } from '../logger/index.js';
|
|
15
19
|
|
|
16
20
|
/**
|
|
@@ -76,12 +80,28 @@ export function registerDeployCommand(program) {
|
|
|
76
80
|
const deployed = /** @type {string[]} */ (
|
|
77
81
|
ctx.state.deployedTargets || targets
|
|
78
82
|
);
|
|
79
|
-
return
|
|
83
|
+
return verifyStageShouldRun(
|
|
84
|
+
ctx.config,
|
|
85
|
+
deployed,
|
|
86
|
+
anyEnvHasResolvableHealthCheckUrl
|
|
87
|
+
);
|
|
80
88
|
},
|
|
81
89
|
async run(ctx) {
|
|
82
90
|
const deployed = /** @type {string[]} */ (
|
|
83
91
|
ctx.state.deployedTargets || targets
|
|
84
92
|
);
|
|
93
|
+
const portOutcome = await runDockerPortPublishChecksForEnvs(
|
|
94
|
+
ctx.config,
|
|
95
|
+
deployed,
|
|
96
|
+
{ requireRunning: true }
|
|
97
|
+
);
|
|
98
|
+
if (portOutcome.failures.length > 0) {
|
|
99
|
+
throw new Error(portOutcome.failures[0].error);
|
|
100
|
+
}
|
|
101
|
+
for (const r of portOutcome.results) {
|
|
102
|
+
console.log(chalk.green(`Docker port published (${r.envName}): ${r.message}`));
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
const { results, failures } = await runHealthChecksForEnvs(
|
|
86
106
|
ctx.config,
|
|
87
107
|
deployed
|
package/src/commands/doctor.js
CHANGED
|
@@ -40,6 +40,10 @@ import {
|
|
|
40
40
|
formatRemoteDockerDaemonOk,
|
|
41
41
|
} from '../utils/docker-remote.js';
|
|
42
42
|
import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
|
|
43
|
+
import {
|
|
44
|
+
checkEnvDockerPortPublish,
|
|
45
|
+
resolveDockerPublishPort,
|
|
46
|
+
} from '../utils/docker-port-publish.js';
|
|
43
47
|
import {
|
|
44
48
|
buildPhpFpmUnitListCommand,
|
|
45
49
|
formatPhpFpmMissingError,
|
|
@@ -591,6 +595,22 @@ export async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
591
595
|
})
|
|
592
596
|
);
|
|
593
597
|
}
|
|
598
|
+
|
|
599
|
+
const publishPort = resolveDockerPublishPort(config, settings, envName);
|
|
600
|
+
if (dockerRemoteMode === 'ssh' || publishPort != null) {
|
|
601
|
+
checks.push(
|
|
602
|
+
await runCheck('Docker port published', async () => {
|
|
603
|
+
const outcome = await checkEnvDockerPortPublish(config, envName, {
|
|
604
|
+
requireRunning: false,
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
name: 'Docker port published',
|
|
608
|
+
pass: outcome.pass,
|
|
609
|
+
message: outcome.message,
|
|
610
|
+
};
|
|
611
|
+
})
|
|
612
|
+
);
|
|
613
|
+
}
|
|
594
614
|
}
|
|
595
615
|
|
|
596
616
|
if (deployType === 'kubernetes') {
|
package/src/commands/env.js
CHANGED
|
@@ -164,6 +164,7 @@ export function registerEnvCommand(program) {
|
|
|
164
164
|
existingEnvNames: Object.keys(config.environments || {}),
|
|
165
165
|
deployType: opts.method,
|
|
166
166
|
nonInteractive: Boolean(opts.yes),
|
|
167
|
+
portDefault: config.port,
|
|
167
168
|
}
|
|
168
169
|
);
|
|
169
170
|
} catch (err) {
|
|
@@ -171,12 +172,21 @@ export function registerEnvCommand(program) {
|
|
|
171
172
|
process.exit(1);
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// Docker env add must not inherit top-level port via singleConfig — that
|
|
176
|
+
// silently stamps another environment's port onto the new env. Interactive
|
|
177
|
+
// answers carry deployAnswers.port; --yes omits it so SSH deploy/doctor
|
|
178
|
+
// fail with the published-port error instead of a wrong fallback.
|
|
179
|
+
const entrySingleConfig =
|
|
180
|
+
deployAnswers.deployType === 'docker'
|
|
181
|
+
? { framework: singleConfig?.framework, port: deployAnswers.port }
|
|
182
|
+
: singleConfig;
|
|
183
|
+
|
|
174
184
|
config.environments[name] = buildServerEnvEntry(
|
|
175
185
|
deployAnswers,
|
|
176
186
|
projectType,
|
|
177
187
|
config.project,
|
|
178
188
|
backendConfig,
|
|
179
|
-
|
|
189
|
+
entrySingleConfig
|
|
180
190
|
);
|
|
181
191
|
|
|
182
192
|
if (!config.defaultEnvironment) {
|
package/src/commands/init.js
CHANGED
|
@@ -382,6 +382,7 @@ export function registerInitCommand(program) {
|
|
|
382
382
|
{
|
|
383
383
|
...(opts.envName ? { envName: opts.envName } : {}),
|
|
384
384
|
existingEnvNames: Object.keys(environments),
|
|
385
|
+
portDefault: singleConfig?.port ?? backendConfig?.port,
|
|
385
386
|
}
|
|
386
387
|
);
|
|
387
388
|
primaryDeployType = deployAnswers.deployType;
|
package/src/commands/verify.js
CHANGED
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
runHealthChecksForEnvs,
|
|
8
8
|
formatHealthCheckAllSummary,
|
|
9
9
|
} from '../utils/health-check.js';
|
|
10
|
+
import {
|
|
11
|
+
runDockerPortPublishChecksForEnvs,
|
|
12
|
+
verifyStageShouldRun,
|
|
13
|
+
} from '../utils/docker-port-publish.js';
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Standalone verify — same per-env URL resolution and summary as the deploy pipeline stage.
|
|
@@ -31,7 +35,7 @@ export async function runVerify(config, envFlag, options = {}) {
|
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
if (!
|
|
38
|
+
if (!verifyStageShouldRun(config, targets, anyEnvHasResolvableHealthCheckUrl)) {
|
|
35
39
|
const label =
|
|
36
40
|
targets.length === 1
|
|
37
41
|
? `environment "${targets[0]}"`
|
|
@@ -47,28 +51,47 @@ export async function runVerify(config, envFlag, options = {}) {
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
const portOutcome = await runDockerPortPublishChecksForEnvs(config, targets, {
|
|
55
|
+
requireRunning: true,
|
|
56
|
+
...options,
|
|
57
|
+
});
|
|
58
|
+
|
|
50
59
|
const { results, failures } = await runHealthChecksForEnvs(config, targets, options);
|
|
51
|
-
const
|
|
60
|
+
const allFailures = [
|
|
61
|
+
...portOutcome.failures.map((f) => ({ envName: f.envName, url: '', error: f.error })),
|
|
62
|
+
...failures,
|
|
63
|
+
];
|
|
64
|
+
const portResults = portOutcome.results.map((r) => ({
|
|
65
|
+
envName: r.envName,
|
|
66
|
+
url: '',
|
|
67
|
+
status: 0,
|
|
68
|
+
elapsed: 0,
|
|
69
|
+
message: r.message,
|
|
70
|
+
}));
|
|
71
|
+
const mergedResults = [...portResults, ...results];
|
|
72
|
+
const multi = targets.length > 1 || allFailures.length > 0;
|
|
52
73
|
const summary = multi ? formatHealthCheckAllSummary(results, failures) : '';
|
|
53
74
|
|
|
54
75
|
let message = '';
|
|
55
|
-
if (
|
|
76
|
+
if (allFailures.length === 0 && results.length === 1 && portOutcome.results.length === 0 && !multi) {
|
|
56
77
|
const r = results[0];
|
|
57
78
|
message = `Health check passed (${r.envName}): HTTP ${r.status} (${r.elapsed}ms)`;
|
|
58
|
-
} else if (
|
|
79
|
+
} else if (allFailures.length === 0 && results.length === 0 && portOutcome.results.length === 1) {
|
|
80
|
+
message = portOutcome.results[0].message;
|
|
81
|
+
} else if (allFailures.length === 0 && results.length === 0 && portOutcome.results.length === 0) {
|
|
59
82
|
message = `No health check URL configured for the selected environment(s).`;
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
return {
|
|
63
|
-
ok:
|
|
86
|
+
ok: allFailures.length === 0 && (results.length > 0 || portOutcome.results.length > 0),
|
|
64
87
|
targets,
|
|
65
|
-
results,
|
|
66
|
-
failures,
|
|
88
|
+
results: mergedResults,
|
|
89
|
+
failures: allFailures,
|
|
67
90
|
summary,
|
|
68
91
|
message:
|
|
69
92
|
message ||
|
|
70
|
-
(
|
|
71
|
-
?
|
|
93
|
+
(allFailures.length > 0
|
|
94
|
+
? allFailures[0].error
|
|
72
95
|
: `All ${results.length} environment(s) passed health checks.`),
|
|
73
96
|
skippedDisabled,
|
|
74
97
|
};
|
package/src/core/stages.js
CHANGED
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
anyEnvHasResolvableHealthCheckUrl,
|
|
9
9
|
runHealthChecksForEnvs,
|
|
10
10
|
} from '../utils/health-check.js';
|
|
11
|
+
import {
|
|
12
|
+
anyDockerEnvHasPublishPort,
|
|
13
|
+
runDockerPortPublishChecksForEnvs,
|
|
14
|
+
verifyStageShouldRun,
|
|
15
|
+
} from '../utils/docker-port-publish.js';
|
|
11
16
|
import { getProjectVersion } from '../utils/version.js';
|
|
12
17
|
import { resolveBuildId } from '../utils/build-id.js';
|
|
13
18
|
import { ensureDeployScaffold } from '../utils/scaffold.js';
|
|
@@ -224,12 +229,25 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
224
229
|
{
|
|
225
230
|
name: 'verify',
|
|
226
231
|
enabled: (ctx) => {
|
|
227
|
-
if (ctx.config.pipeline.verify !== true)
|
|
232
|
+
if (ctx.config.pipeline.verify !== true) {
|
|
233
|
+
const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
|
|
234
|
+
return anyDockerEnvHasPublishPort(ctx.config, deployed);
|
|
235
|
+
}
|
|
228
236
|
const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
|
|
229
|
-
return
|
|
237
|
+
return verifyStageShouldRun(
|
|
238
|
+
ctx.config,
|
|
239
|
+
deployed,
|
|
240
|
+
anyEnvHasResolvableHealthCheckUrl
|
|
241
|
+
);
|
|
230
242
|
},
|
|
231
243
|
async run(ctx) {
|
|
232
244
|
const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
|
|
245
|
+
const portOutcome = await runDockerPortPublishChecksForEnvs(ctx.config, deployed, {
|
|
246
|
+
requireRunning: true,
|
|
247
|
+
});
|
|
248
|
+
if (portOutcome.failures.length > 0) {
|
|
249
|
+
throw new Error(portOutcome.failures[0].error);
|
|
250
|
+
}
|
|
233
251
|
const { results, failures } = await runHealthChecksForEnvs(ctx.config, deployed);
|
|
234
252
|
if (failures.length > 0) {
|
|
235
253
|
throw new Error(failures[0].error);
|
|
@@ -58,13 +58,15 @@ export function backendProcessNamePromptMessage(framework, projectName, projectT
|
|
|
58
58
|
* @param {'frontend'|'backend'|'both'} projectType
|
|
59
59
|
* @param {Record<string, unknown>|null} backendConfig
|
|
60
60
|
* @param {{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
* envName?: string,
|
|
62
|
+
* existingEnvNames?: string[],
|
|
63
|
+
* deployType?: string,
|
|
64
|
+
* nonInteractive?: boolean,
|
|
65
|
+
* portDefault?: number,
|
|
66
|
+
* }} [options]
|
|
67
|
+
* — when envName is set, skip the name prompt; existingEnvNames blocks in-session / config duplicates
|
|
68
|
+
* — deployType skips the method list; nonInteractive uses defaults (requires deployType)
|
|
69
|
+
* — portDefault seeds the docker "Default port" prompt (init / env add)
|
|
68
70
|
*/
|
|
69
71
|
export async function promptServerDeployment(
|
|
70
72
|
projectName,
|
|
@@ -133,7 +135,10 @@ export async function promptServerDeployment(
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
if (deployType === 'docker') {
|
|
136
|
-
return promptDockerDeployment(base, projectName, projectType
|
|
138
|
+
return promptDockerDeployment(base, projectName, projectType, {
|
|
139
|
+
backendConfig,
|
|
140
|
+
portDefault: options.portDefault,
|
|
141
|
+
});
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
return promptSshBasedDeployment(base, projectName, projectType, backendConfig, deployType);
|
|
@@ -306,8 +311,12 @@ async function promptKubernetesDeployment(base, projectName, projectType, option
|
|
|
306
311
|
* @param {Record<string, string>} base
|
|
307
312
|
* @param {string} projectName
|
|
308
313
|
* @param {'frontend'|'backend'|'both'} projectType
|
|
314
|
+
* @param {{
|
|
315
|
+
* backendConfig?: Record<string, unknown>|null,
|
|
316
|
+
* portDefault?: number,
|
|
317
|
+
* }} [options]
|
|
309
318
|
*/
|
|
310
|
-
async function promptDockerDeployment(base, projectName, projectType) {
|
|
319
|
+
async function promptDockerDeployment(base, projectName, projectType, options = {}) {
|
|
311
320
|
const imageAnswers = await inquirer.prompt([
|
|
312
321
|
{
|
|
313
322
|
type: 'input',
|
|
@@ -404,7 +413,25 @@ async function promptDockerDeployment(base, projectName, projectType) {
|
|
|
404
413
|
]);
|
|
405
414
|
}
|
|
406
415
|
|
|
407
|
-
const
|
|
416
|
+
const portDefaultRaw = options.portDefault ?? options.backendConfig?.port;
|
|
417
|
+
const portDefault = Number.isInteger(Number(portDefaultRaw))
|
|
418
|
+
? Number(portDefaultRaw)
|
|
419
|
+
: 3000;
|
|
420
|
+
|
|
421
|
+
const { port, healthUrl } = await inquirer.prompt([
|
|
422
|
+
{
|
|
423
|
+
type: 'number',
|
|
424
|
+
name: 'port',
|
|
425
|
+
message: 'Default port:',
|
|
426
|
+
default: portDefault,
|
|
427
|
+
validate: (value) => {
|
|
428
|
+
const n = Number(value);
|
|
429
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
430
|
+
return 'Enter a port number between 1 and 65535.';
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
433
|
+
},
|
|
434
|
+
},
|
|
408
435
|
{
|
|
409
436
|
type: 'input',
|
|
410
437
|
name: 'healthUrl',
|
|
@@ -418,6 +445,7 @@ async function promptDockerDeployment(base, projectName, projectType) {
|
|
|
418
445
|
remoteMode,
|
|
419
446
|
...sshAnswers,
|
|
420
447
|
dockerHost: rawAnswers.dockerHost || '',
|
|
448
|
+
port,
|
|
421
449
|
healthUrl,
|
|
422
450
|
};
|
|
423
451
|
}
|
|
@@ -652,6 +680,15 @@ export function buildServerEnvEntry(
|
|
|
652
680
|
if (deployAnswers.host) settings.host = deployAnswers.host;
|
|
653
681
|
if (deployAnswers.user) settings.user = deployAnswers.user;
|
|
654
682
|
}
|
|
683
|
+
// Per-env port (same key resolveDockerPublishPort reads). Prefer the
|
|
684
|
+
// docker "Default port" answer; fall back to init's project-level port.
|
|
685
|
+
// Do not invent || 3000 — missing port must stay missing so SSH deploy
|
|
686
|
+
// fails loudly instead of publishing a sibling environment's port.
|
|
687
|
+
const rawPort = deployAnswers.port ?? singleConfig?.port;
|
|
688
|
+
const n = Number(rawPort);
|
|
689
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
|
|
690
|
+
settings.port = n;
|
|
691
|
+
}
|
|
655
692
|
return {
|
|
656
693
|
enabled: true,
|
|
657
694
|
method: 'docker',
|
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
resolveDockerSshTarget,
|
|
11
11
|
buildRemoteDockerCommands,
|
|
12
12
|
} from '../../utils/docker-remote.js';
|
|
13
|
+
import {
|
|
14
|
+
resolveDockerPublishPort,
|
|
15
|
+
formatDockerSshPortRequired,
|
|
16
|
+
evaluateDockerPortPublish,
|
|
17
|
+
buildDockerInspectPortsArgs,
|
|
18
|
+
buildDockerInspectPortsCommand,
|
|
19
|
+
} from '../../utils/docker-port-publish.js';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -38,6 +45,7 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
38
45
|
imageOps;
|
|
39
46
|
// Env-scoped like PM2/Nginx — same-daemon multi-env must not share one container name.
|
|
40
47
|
const containerName = resolveDockerContainerName(config, envName);
|
|
48
|
+
const publishPort = resolveDockerPublishPort(config, settings, envName);
|
|
41
49
|
|
|
42
50
|
function sshTarget() {
|
|
43
51
|
return resolveDockerSshTarget(settings, effectiveEnv);
|
|
@@ -77,7 +85,10 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
if (remoteMode === 'ssh') {
|
|
80
|
-
|
|
88
|
+
if (publishPort == null) {
|
|
89
|
+
throw new Error(formatDockerSshPortRequired(envName));
|
|
90
|
+
}
|
|
91
|
+
await deployOverSsh(imageRef, publishPort);
|
|
81
92
|
log.success('Docker deployment complete');
|
|
82
93
|
return;
|
|
83
94
|
}
|
|
@@ -90,19 +101,59 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
90
101
|
{ stdio: 'pipe', env: dockerEnv }
|
|
91
102
|
).catch(() => {});
|
|
92
103
|
|
|
93
|
-
|
|
104
|
+
/** @type {string[]} */
|
|
105
|
+
const runArgs = ['run', '-d', '--rm', '--name', containerName];
|
|
106
|
+
if (publishPort != null) {
|
|
107
|
+
runArgs.push('-p', `${publishPort}:${publishPort}`);
|
|
108
|
+
}
|
|
109
|
+
runArgs.push(imageRef);
|
|
110
|
+
|
|
111
|
+
await execa('docker', runArgs, {
|
|
94
112
|
stdio: 'inherit',
|
|
95
113
|
env: dockerEnv,
|
|
96
114
|
});
|
|
97
115
|
|
|
116
|
+
if (publishPort != null) {
|
|
117
|
+
await assertLocalPortPublished(dockerEnv, publishPort);
|
|
118
|
+
}
|
|
119
|
+
|
|
98
120
|
log.success('Docker deployment complete');
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
/**
|
|
124
|
+
* @param {Record<string, string>} dockerEnv
|
|
125
|
+
* @param {number} port
|
|
126
|
+
*/
|
|
127
|
+
async function assertLocalPortPublished(dockerEnv, port) {
|
|
128
|
+
try {
|
|
129
|
+
const inspected = await execa('docker', buildDockerInspectPortsArgs(containerName), {
|
|
130
|
+
stdio: 'pipe',
|
|
131
|
+
env: dockerEnv,
|
|
132
|
+
});
|
|
133
|
+
const verdict = evaluateDockerPortPublish(
|
|
134
|
+
{ code: 0, stdout: inspected.stdout },
|
|
135
|
+
{ containerName, port, requireRunning: false }
|
|
136
|
+
);
|
|
137
|
+
if (!verdict.pass) {
|
|
138
|
+
throw new Error(verdict.message);
|
|
139
|
+
}
|
|
140
|
+
if (verdict.reason === 'published') {
|
|
141
|
+
log.info(verdict.message);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof Error && err.message.startsWith("Container '")) {
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
// One-shot images (hello-world) exit before inspect; verify/doctor catch long-running misses.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
101
151
|
/**
|
|
102
152
|
* @param {string} imageRef
|
|
153
|
+
* @param {number} port
|
|
103
154
|
*/
|
|
104
|
-
async function deployOverSsh(imageRef) {
|
|
105
|
-
const cmds = buildRemoteDockerCommands(imageRef, containerName);
|
|
155
|
+
async function deployOverSsh(imageRef, port) {
|
|
156
|
+
const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, { publishPort: port });
|
|
106
157
|
const session = sshSession();
|
|
107
158
|
const ssh = await session.connect();
|
|
108
159
|
try {
|
|
@@ -126,6 +177,21 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
126
177
|
timeoutMs: Math.max(session.defaultExecTimeoutMs, 300_000),
|
|
127
178
|
});
|
|
128
179
|
await session.exec(ssh, cmds.run);
|
|
180
|
+
const inspected = await session.execUnchecked(
|
|
181
|
+
ssh,
|
|
182
|
+
buildDockerInspectPortsCommand(containerName)
|
|
183
|
+
);
|
|
184
|
+
const verdict = evaluateDockerPortPublish(inspected, {
|
|
185
|
+
containerName,
|
|
186
|
+
port,
|
|
187
|
+
requireRunning: false,
|
|
188
|
+
});
|
|
189
|
+
if (!verdict.pass) {
|
|
190
|
+
throw new Error(verdict.message);
|
|
191
|
+
}
|
|
192
|
+
if (verdict.reason === 'published') {
|
|
193
|
+
log.info(verdict.message);
|
|
194
|
+
}
|
|
129
195
|
} finally {
|
|
130
196
|
ssh.dispose();
|
|
131
197
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { getEnvMethod, getEnvSettings } from '../core/environments.js';
|
|
3
|
+
import { resolveDockerContainerName } from './docker-container-name.js';
|
|
4
|
+
import { resolveDockerRemoteMode } from './docker-remote-mode.js';
|
|
5
|
+
import { resolveDockerSshTarget } from './docker-remote.js';
|
|
6
|
+
import { createSshExecSession } from '../deployment/ssh-connection.js';
|
|
7
|
+
import { shellQuote } from './shell-quote.js';
|
|
8
|
+
|
|
9
|
+
/** Go template: running|bindings. Empty HostIp treated as 0.0.0.0. */
|
|
10
|
+
export const DOCKER_INSPECT_PORT_FORMAT =
|
|
11
|
+
`{{if .State.Running}}running{{else}}stopped{{end}}|` +
|
|
12
|
+
`{{range $p, $conf := .NetworkSettings.Ports}}` +
|
|
13
|
+
`{{range $conf}}` +
|
|
14
|
+
`{{if eq .HostIp ""}}0.0.0.0{{else}}{{.HostIp}}{{end}}:{{.HostPort}}->` +
|
|
15
|
+
`{{end}}{{end}}`;
|
|
16
|
+
|
|
17
|
+
export const DOCKER_PORT_NOT_PUBLISHED_TRAILER =
|
|
18
|
+
'The app is not reachable from outside the container.';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Exact doctor/verify failure copy when a container is running without `-p`.
|
|
22
|
+
* @param {string} containerName
|
|
23
|
+
* @param {number} port
|
|
24
|
+
*/
|
|
25
|
+
export function formatDockerPortNotPublished(containerName, port) {
|
|
26
|
+
return (
|
|
27
|
+
`Container '${containerName}' is running but port ${port} is not published ` +
|
|
28
|
+
`on the host (no 0.0.0.0:${port}-> mapping).\n` +
|
|
29
|
+
DOCKER_PORT_NOT_PUBLISHED_TRAILER
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} containerName
|
|
35
|
+
* @param {number} port
|
|
36
|
+
*/
|
|
37
|
+
export function formatDockerPortPublished(containerName, port) {
|
|
38
|
+
return `Container '${containerName}' publishes 0.0.0.0:${port}->`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} envName
|
|
43
|
+
*/
|
|
44
|
+
export function formatDockerSshPortRequired(envName) {
|
|
45
|
+
return (
|
|
46
|
+
`Docker remote.mode "ssh" requires a published port. ` +
|
|
47
|
+
`Set environments.${envName}.config.port (or top-level port) so the container ` +
|
|
48
|
+
`is started with -p <port>:<port>. Deploying without -p leaves the app unreachable.`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} raw
|
|
54
|
+
* @returns {number|null}
|
|
55
|
+
*/
|
|
56
|
+
function parsePublishPort(raw) {
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Top-level `config.port` is the legacy single-env fallback (scenario 2A).
|
|
64
|
+
* With two or more docker environments it must not be inherited by a later
|
|
65
|
+
* env that never stored its own `config.port` — that would silently publish
|
|
66
|
+
* another environment's port.
|
|
67
|
+
*
|
|
68
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
69
|
+
* @param {string} [envName]
|
|
70
|
+
*/
|
|
71
|
+
function shouldUseTopLevelDockerPortFallback(config = {}, envName) {
|
|
72
|
+
const dockerNames = Object.entries(config.environments || {})
|
|
73
|
+
.filter(([, entry]) => getEnvMethod(entry) === 'docker')
|
|
74
|
+
.map(([name]) => name);
|
|
75
|
+
|
|
76
|
+
if (dockerNames.length <= 1) return true;
|
|
77
|
+
if (!envName) return false;
|
|
78
|
+
|
|
79
|
+
if (config.defaultEnvironment && dockerNames.includes(config.defaultEnvironment)) {
|
|
80
|
+
return envName === config.defaultEnvironment;
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
config.unprefixedSecretEnvironment &&
|
|
84
|
+
dockerNames.includes(config.unprefixedSecretEnvironment)
|
|
85
|
+
) {
|
|
86
|
+
return envName === config.unprefixedSecretEnvironment;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Configured host/container publish port. No silent default — missing means
|
|
93
|
+
* SSH must fail loudly instead of running unpublished.
|
|
94
|
+
*
|
|
95
|
+
* Fallback chain is still `settings.port ?? config.port` (plus `backend.port`)
|
|
96
|
+
* for legacy single-env configs and the original docker env. It is not used
|
|
97
|
+
* for additional docker environments that omitted `config.port`.
|
|
98
|
+
*
|
|
99
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
100
|
+
* @param {Record<string, unknown>} [settings]
|
|
101
|
+
* @param {string} [envName]
|
|
102
|
+
* @returns {number|null}
|
|
103
|
+
*/
|
|
104
|
+
export function resolveDockerPublishPort(config = {}, settings = {}, envName) {
|
|
105
|
+
const own = parsePublishPort(settings.port);
|
|
106
|
+
if (own != null) return own;
|
|
107
|
+
if (!shouldUseTopLevelDockerPortFallback(config, envName)) return null;
|
|
108
|
+
return parsePublishPort(config.port ?? config.backend?.port);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} stdout
|
|
113
|
+
* @param {number} port
|
|
114
|
+
*/
|
|
115
|
+
export function inspectShowsHostPortMapping(stdout, port) {
|
|
116
|
+
return String(stdout || '').includes(`0.0.0.0:${port}->`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} containerName
|
|
121
|
+
*/
|
|
122
|
+
export function buildDockerInspectPortsArgs(containerName) {
|
|
123
|
+
return ['inspect', '--format', DOCKER_INSPECT_PORT_FORMAT, containerName];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} containerName
|
|
128
|
+
*/
|
|
129
|
+
export function buildDockerInspectPortsCommand(containerName) {
|
|
130
|
+
const args = buildDockerInspectPortsArgs(containerName);
|
|
131
|
+
return `docker inspect --format ${shellQuote(args[2])} ${shellQuote(containerName)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {{ code?: number|null, stdout?: string, stderr?: string }} result
|
|
136
|
+
* @param {{ containerName: string, port: number, requireRunning: boolean }} opts
|
|
137
|
+
* @returns {{ pass: boolean, reason: 'published'|'not-running'|'unpublished'|'missing-container', message: string }}
|
|
138
|
+
*/
|
|
139
|
+
export function evaluateDockerPortPublish(result, opts) {
|
|
140
|
+
const { containerName, port, requireRunning } = opts;
|
|
141
|
+
const code = result.code;
|
|
142
|
+
const raw = String(result.stdout || '');
|
|
143
|
+
let running = true;
|
|
144
|
+
let mappings = raw;
|
|
145
|
+
const pipe = raw.indexOf('|');
|
|
146
|
+
if (pipe >= 0 && (raw.startsWith('running|') || raw.startsWith('stopped|'))) {
|
|
147
|
+
running = raw.startsWith('running|');
|
|
148
|
+
mappings = raw.slice(pipe + 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((code !== 0 && code !== null && code !== undefined) || !running) {
|
|
152
|
+
if (!requireRunning) {
|
|
153
|
+
return {
|
|
154
|
+
pass: true,
|
|
155
|
+
reason: 'not-running',
|
|
156
|
+
message: `No running container '${containerName}' — deploy first, then re-run this check.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (!running && (code === 0 || code === null || code === undefined)) {
|
|
160
|
+
return {
|
|
161
|
+
pass: false,
|
|
162
|
+
reason: 'missing-container',
|
|
163
|
+
message:
|
|
164
|
+
`Container '${containerName}' is not running — cannot confirm port ${port} is published.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
pass: false,
|
|
169
|
+
reason: 'missing-container',
|
|
170
|
+
message:
|
|
171
|
+
`Container '${containerName}' is not running — cannot confirm port ${port} is published.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (inspectShowsHostPortMapping(mappings, port)) {
|
|
176
|
+
return {
|
|
177
|
+
pass: true,
|
|
178
|
+
reason: 'published',
|
|
179
|
+
message: formatDockerPortPublished(containerName, port),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
pass: false,
|
|
185
|
+
reason: 'unpublished',
|
|
186
|
+
message: formatDockerPortNotPublished(containerName, port),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
192
|
+
* @param {string[]} envNames
|
|
193
|
+
*/
|
|
194
|
+
export function anyDockerEnvHasPublishPort(config, envNames) {
|
|
195
|
+
return (envNames || []).some((envName) => {
|
|
196
|
+
const entry = config.environments?.[envName];
|
|
197
|
+
if (getEnvMethod(entry) !== 'docker') return false;
|
|
198
|
+
const settings = getEnvSettings(entry);
|
|
199
|
+
return resolveDockerPublishPort(config, settings, envName) != null;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether the post-deploy verify stage should run (HTTP health and/or docker port).
|
|
205
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
206
|
+
* @param {string[]} envNames
|
|
207
|
+
* @param {(config: import('../core/config.js').DeployHubConfig, envNames: string[]) => boolean} hasHealthUrl
|
|
208
|
+
*/
|
|
209
|
+
export function verifyStageShouldRun(config, envNames, hasHealthUrl) {
|
|
210
|
+
return Boolean(hasHealthUrl(config, envNames) || anyDockerEnvHasPublishPort(config, envNames));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Inspect one docker environment's running container for 0.0.0.0:<port>->.
|
|
215
|
+
*
|
|
216
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
217
|
+
* @param {string} envName
|
|
218
|
+
* @param {{ requireRunning?: boolean, env?: Record<string, string|undefined> }} [options]
|
|
219
|
+
*/
|
|
220
|
+
export async function checkEnvDockerPortPublish(config, envName, options = {}) {
|
|
221
|
+
const entry = config.environments?.[envName];
|
|
222
|
+
const method = getEnvMethod(entry);
|
|
223
|
+
if (method !== 'docker') {
|
|
224
|
+
return { skipped: true, envName, pass: true, message: '' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const settings = getEnvSettings(entry);
|
|
228
|
+
const env = options.env || process.env;
|
|
229
|
+
const requireRunning = options.requireRunning !== false;
|
|
230
|
+
const port = resolveDockerPublishPort(config, settings, envName);
|
|
231
|
+
const remoteMode = resolveDockerRemoteMode(settings, env);
|
|
232
|
+
const containerName = resolveDockerContainerName(config, envName);
|
|
233
|
+
|
|
234
|
+
if (port == null) {
|
|
235
|
+
if (remoteMode === 'ssh') {
|
|
236
|
+
return {
|
|
237
|
+
skipped: false,
|
|
238
|
+
envName,
|
|
239
|
+
pass: false,
|
|
240
|
+
message: formatDockerSshPortRequired(envName),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { skipped: true, envName, pass: true, message: '' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @type {{ code?: number|null, stdout?: string, stderr?: string }} */
|
|
247
|
+
let result;
|
|
248
|
+
if (remoteMode === 'ssh') {
|
|
249
|
+
const target = resolveDockerSshTarget(settings, env);
|
|
250
|
+
const session = createSshExecSession({
|
|
251
|
+
...target,
|
|
252
|
+
keyPath: target.keyPath ? String(target.keyPath) : undefined,
|
|
253
|
+
env,
|
|
254
|
+
});
|
|
255
|
+
const ssh = await session.connect();
|
|
256
|
+
try {
|
|
257
|
+
result = await session.execUnchecked(ssh, buildDockerInspectPortsCommand(containerName));
|
|
258
|
+
} finally {
|
|
259
|
+
ssh.dispose();
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
try {
|
|
263
|
+
const inspected = await execa('docker', buildDockerInspectPortsArgs(containerName), {
|
|
264
|
+
stdio: 'pipe',
|
|
265
|
+
env: { ...process.env, ...env },
|
|
266
|
+
});
|
|
267
|
+
result = { code: 0, stdout: inspected.stdout, stderr: inspected.stderr };
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const execErr = /** @type {{ exitCode?: number, stdout?: string, stderr?: string }} */ (err);
|
|
270
|
+
result = {
|
|
271
|
+
code: execErr.exitCode ?? 1,
|
|
272
|
+
stdout: execErr.stdout || '',
|
|
273
|
+
stderr: execErr.stderr || (err instanceof Error ? err.message : String(err)),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const verdict = evaluateDockerPortPublish(result, {
|
|
279
|
+
containerName,
|
|
280
|
+
port,
|
|
281
|
+
requireRunning,
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
skipped: false,
|
|
285
|
+
envName,
|
|
286
|
+
pass: verdict.pass,
|
|
287
|
+
message: verdict.message,
|
|
288
|
+
reason: verdict.reason,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
294
|
+
* @param {string[]} envNames
|
|
295
|
+
* @param {{ requireRunning?: boolean, env?: Record<string, string|undefined> }} [options]
|
|
296
|
+
*/
|
|
297
|
+
export async function runDockerPortPublishChecksForEnvs(config, envNames, options = {}) {
|
|
298
|
+
/** @type {{ envName: string, message: string }[]} */
|
|
299
|
+
const results = [];
|
|
300
|
+
/** @type {{ envName: string, error: string }[]} */
|
|
301
|
+
const failures = [];
|
|
302
|
+
|
|
303
|
+
for (const envName of envNames || []) {
|
|
304
|
+
const outcome = await checkEnvDockerPortPublish(config, envName, options);
|
|
305
|
+
if (outcome.skipped) continue;
|
|
306
|
+
if (outcome.pass) {
|
|
307
|
+
results.push({ envName: outcome.envName, message: outcome.message });
|
|
308
|
+
} else {
|
|
309
|
+
failures.push({ envName: outcome.envName, error: outcome.message });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { results, failures };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default {
|
|
317
|
+
DOCKER_INSPECT_PORT_FORMAT,
|
|
318
|
+
formatDockerPortNotPublished,
|
|
319
|
+
formatDockerPortPublished,
|
|
320
|
+
formatDockerSshPortRequired,
|
|
321
|
+
resolveDockerPublishPort,
|
|
322
|
+
inspectShowsHostPortMapping,
|
|
323
|
+
evaluateDockerPortPublish,
|
|
324
|
+
anyDockerEnvHasPublishPort,
|
|
325
|
+
verifyStageShouldRun,
|
|
326
|
+
checkEnvDockerPortPublish,
|
|
327
|
+
runDockerPortPublishChecksForEnvs,
|
|
328
|
+
};
|
|
@@ -178,8 +178,9 @@ export async function probeRemoteDockerPs(target) {
|
|
|
178
178
|
* @param {string} imageRef
|
|
179
179
|
* @param {string} containerName
|
|
180
180
|
* @param {Record<string, string>} [runEnv]
|
|
181
|
+
* @param {{ publishPort?: number|null }} [options]
|
|
181
182
|
*/
|
|
182
|
-
export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}) {
|
|
183
|
+
export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}, options = {}) {
|
|
183
184
|
const image = shellQuote(imageRef);
|
|
184
185
|
const name = shellQuote(containerName);
|
|
185
186
|
/** @type {string[]} */
|
|
@@ -188,12 +189,15 @@ export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {})
|
|
|
188
189
|
envFlags.push(`-e ${shellQuote(`${key}=${value}`)}`);
|
|
189
190
|
}
|
|
190
191
|
const envArg = envFlags.length > 0 ? `${envFlags.join(' ')} ` : '';
|
|
192
|
+
const publishPort = options.publishPort;
|
|
193
|
+
const pFlag =
|
|
194
|
+
publishPort != null ? `-p ${shellQuote(`${publishPort}:${publishPort}`)} ` : '';
|
|
191
195
|
|
|
192
196
|
return {
|
|
193
197
|
stop: `docker stop ${name} 2>/dev/null || true`,
|
|
194
198
|
rm: `docker rm -f ${name} 2>/dev/null || true`,
|
|
195
199
|
pull: `docker pull ${image}`,
|
|
196
|
-
run: `docker run -d --rm --name ${name} ${envArg}${image}`,
|
|
200
|
+
run: `docker run -d --rm --name ${name} ${pFlag}${envArg}${image}`,
|
|
197
201
|
ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
|
|
198
202
|
info: 'docker info',
|
|
199
203
|
/**
|