@akash-chowdhury-24/deployhub 2.0.32 → 2.0.34
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-image-deploy.js +126 -8
- package/src/utils/docker-port-publish.js +328 -0
- package/src/utils/docker-remote.js +6 -2
- package/src/utils/rollback/engine.js +16 -0
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
|
}
|
|
@@ -15,6 +15,57 @@ import {
|
|
|
15
15
|
EXPLICIT_IMAGE_TAG_WARNING,
|
|
16
16
|
} from './docker-image.js';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Classify `docker pull` failure for rollback logs / interpreted-backend errors.
|
|
20
|
+
* @param {unknown} err
|
|
21
|
+
* @returns {'not found'|'auth failed'|'network error'|string}
|
|
22
|
+
*/
|
|
23
|
+
export function classifyDockerPullFailure(err) {
|
|
24
|
+
const execErr = /** @type {{ message?: string, stderr?: string, stdout?: string }} */ (err);
|
|
25
|
+
const combined = `${execErr?.message || ''} ${execErr?.stderr || ''} ${execErr?.stdout || ''}`.toLowerCase();
|
|
26
|
+
// Docker Hub reports missing repos as "denied" — check existence first.
|
|
27
|
+
if (
|
|
28
|
+
combined.includes('manifest unknown') ||
|
|
29
|
+
combined.includes('not found') ||
|
|
30
|
+
combined.includes('repository does not exist') ||
|
|
31
|
+
combined.includes('no such image')
|
|
32
|
+
) {
|
|
33
|
+
return 'not found';
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
combined.includes('unauthorized') ||
|
|
37
|
+
combined.includes('authentication required') ||
|
|
38
|
+
combined.includes('access denied') ||
|
|
39
|
+
combined.includes('denied: requested access')
|
|
40
|
+
) {
|
|
41
|
+
return 'auth failed';
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
combined.includes('network') ||
|
|
45
|
+
combined.includes('timeout') ||
|
|
46
|
+
combined.includes('timed out') ||
|
|
47
|
+
combined.includes('econnrefused') ||
|
|
48
|
+
combined.includes('connection refused') ||
|
|
49
|
+
combined.includes('no such host') ||
|
|
50
|
+
combined.includes('dial tcp')
|
|
51
|
+
) {
|
|
52
|
+
return 'network error';
|
|
53
|
+
}
|
|
54
|
+
const stderr = String(execErr?.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
55
|
+
return stderr || (err instanceof Error ? err.message : 'pull failed');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} imageRef
|
|
60
|
+
* @param {string} reason
|
|
61
|
+
*/
|
|
62
|
+
export function formatImageNotLocalAndPullFailed(imageRef, reason) {
|
|
63
|
+
return (
|
|
64
|
+
`Target image ${imageRef} not found locally and could not be pulled ` +
|
|
65
|
+
`from the registry (${reason}).`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
18
69
|
/**
|
|
19
70
|
* Shared Docker image build, reuse, push, and pullability logic used by
|
|
20
71
|
* docker and kubernetes deploy providers.
|
|
@@ -105,6 +156,41 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
105
156
|
}
|
|
106
157
|
}
|
|
107
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Rollback (and any skipImageReuse path): after a local-cache miss, pull the
|
|
161
|
+
* exact tag from the registry before falling through to artifact rebuild.
|
|
162
|
+
* @param {string} ref
|
|
163
|
+
* @returns {Promise<{ ok: true, output: string }|{ ok: false, reason: string, output: string }>}
|
|
164
|
+
*/
|
|
165
|
+
async function tryPullImage(ref) {
|
|
166
|
+
log.info(`Pulling ${ref} from registry...`);
|
|
167
|
+
try {
|
|
168
|
+
const pulled = await execa('docker', ['pull', ref], {
|
|
169
|
+
stdio: 'pipe',
|
|
170
|
+
env: getDockerEnv(),
|
|
171
|
+
});
|
|
172
|
+
const output = [pulled.stdout, pulled.stderr].filter(Boolean).join('\n').trim();
|
|
173
|
+
if (output) log.info(output);
|
|
174
|
+
if (await imageExistsLocally(ref)) {
|
|
175
|
+
log.success(`Pulled ${ref}`);
|
|
176
|
+
return { ok: true, output };
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: 'pull reported success but image is still missing locally',
|
|
181
|
+
output,
|
|
182
|
+
};
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const execErr = /** @type {{ stdout?: string, stderr?: string }} */ (err);
|
|
185
|
+
const output = [execErr.stdout, execErr.stderr]
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.join('\n')
|
|
188
|
+
.trim();
|
|
189
|
+
if (output) log.info(output);
|
|
190
|
+
return { ok: false, reason: classifyDockerPullFailure(err), output };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
108
194
|
/**
|
|
109
195
|
* Prefer the image already built during the pipeline `docker` stage.
|
|
110
196
|
* Retag when the pipeline used `:latest` and deploy needs a version tag.
|
|
@@ -138,8 +224,15 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
138
224
|
* @param {Record<string, unknown>} metadata
|
|
139
225
|
* @param {string} framework
|
|
140
226
|
* @param {number} port
|
|
227
|
+
* @param {string} [imageRef]
|
|
141
228
|
*/
|
|
142
|
-
async function prepareBackendBuildContext(
|
|
229
|
+
async function prepareBackendBuildContext(
|
|
230
|
+
buildContext,
|
|
231
|
+
metadata,
|
|
232
|
+
framework,
|
|
233
|
+
port,
|
|
234
|
+
imageRef = fullImage
|
|
235
|
+
) {
|
|
143
236
|
if (framework === 'spring') {
|
|
144
237
|
const targetDir = path.join(buildContext, 'target');
|
|
145
238
|
if (await fs.pathExists(targetDir)) {
|
|
@@ -199,7 +292,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
199
292
|
if (isInterpretedBackendFramework(framework)) {
|
|
200
293
|
const gap = describeInterpretedBackendGap(framework);
|
|
201
294
|
throw new Error(
|
|
202
|
-
`Cannot rebuild ${gap.ecosystem} backend image "${
|
|
295
|
+
`Cannot rebuild ${gap.ecosystem} backend image "${imageRef}" from the packaged artifact.\n` +
|
|
203
296
|
`Backend artifacts include source/manifests but not ${gap.missing}, ` +
|
|
204
297
|
`so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
|
|
205
298
|
'What to do instead:\n' +
|
|
@@ -286,7 +379,13 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
286
379
|
generateFrontendRuntimeDockerfile(buildOutput)
|
|
287
380
|
);
|
|
288
381
|
} else {
|
|
289
|
-
await prepareBackendBuildContext(
|
|
382
|
+
await prepareBackendBuildContext(
|
|
383
|
+
buildContext,
|
|
384
|
+
metadata,
|
|
385
|
+
framework,
|
|
386
|
+
port,
|
|
387
|
+
imageRef
|
|
388
|
+
);
|
|
290
389
|
}
|
|
291
390
|
|
|
292
391
|
await execa('docker', ['build', '-t', imageRef, '.'], {
|
|
@@ -322,6 +421,8 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
322
421
|
await dockerLogin();
|
|
323
422
|
|
|
324
423
|
let reused = false;
|
|
424
|
+
/** @type {string|null} */
|
|
425
|
+
let registryPullFailure = null;
|
|
325
426
|
if (!options.skipImageReuse) {
|
|
326
427
|
// Normal deploy: prefer pipeline image (exact tag, then :latest retag).
|
|
327
428
|
reused = await ensureImageFromPipeline(imageRef);
|
|
@@ -331,16 +432,32 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
331
432
|
log.info(`Using restored image ${imageRef} (skipImageReuse — no :latest retag)`);
|
|
332
433
|
reused = true;
|
|
333
434
|
} else {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
435
|
+
const pulled = await tryPullImage(imageRef);
|
|
436
|
+
if (pulled.ok) {
|
|
437
|
+
reused = true;
|
|
438
|
+
} else {
|
|
439
|
+
registryPullFailure = pulled.reason;
|
|
440
|
+
log.info(
|
|
441
|
+
`${formatImageNotLocalAndPullFailed(imageRef, pulled.reason)} — attempting rebuild from artifact`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
337
444
|
}
|
|
338
445
|
|
|
339
446
|
let ranCompose = false;
|
|
340
447
|
|
|
341
448
|
if (!reused) {
|
|
342
|
-
|
|
343
|
-
|
|
449
|
+
try {
|
|
450
|
+
const result = await buildFromArtifactContents(artifactDir, imageRef);
|
|
451
|
+
ranCompose = Boolean(result?.ranCompose);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
if (registryPullFailure) {
|
|
454
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
455
|
+
throw new Error(
|
|
456
|
+
`${formatImageNotLocalAndPullFailed(imageRef, registryPullFailure)}\n${detail}`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
344
461
|
}
|
|
345
462
|
|
|
346
463
|
if (ranCompose) {
|
|
@@ -372,6 +489,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
372
489
|
dockerLogin,
|
|
373
490
|
maybePushImage,
|
|
374
491
|
ensureImageFromPipeline,
|
|
492
|
+
tryPullImage,
|
|
375
493
|
buildFromArtifactContents,
|
|
376
494
|
ensureImageReadyForDeploy,
|
|
377
495
|
};
|
|
@@ -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
|
/**
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
getEnvMethod,
|
|
12
12
|
resolveDefaultEnvironmentName,
|
|
13
13
|
} from '../../core/environments.js';
|
|
14
|
+
import {
|
|
15
|
+
runDockerPortPublishChecksForEnvs,
|
|
16
|
+
} from '../docker-port-publish.js';
|
|
14
17
|
import fs from 'fs-extra';
|
|
15
18
|
import path from 'path';
|
|
16
19
|
|
|
@@ -32,6 +35,19 @@ async function rollbackTarget(config, artifactDir, envName, meta) {
|
|
|
32
35
|
|
|
33
36
|
const provider = getDeploymentProvider(method, config, envName);
|
|
34
37
|
await provider.rollback(artifactDir, meta);
|
|
38
|
+
|
|
39
|
+
// Same post-deploy port-publish check as the deploy pipeline verify stage
|
|
40
|
+
// (`runDockerPortPublishChecksForEnvs`). SSH/EC2/VM skip; docker envs fail
|
|
41
|
+
// if the restored container is up without 0.0.0.0:<port>->.
|
|
42
|
+
const portOutcome = await runDockerPortPublishChecksForEnvs(config, [envName], {
|
|
43
|
+
requireRunning: true,
|
|
44
|
+
});
|
|
45
|
+
if (portOutcome.failures.length > 0) {
|
|
46
|
+
throw new Error(portOutcome.failures[0].error);
|
|
47
|
+
}
|
|
48
|
+
for (const r of portOutcome.results) {
|
|
49
|
+
log.success(r.message);
|
|
50
|
+
}
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
/**
|