@akash-chowdhury-24/deployhub 2.0.31 → 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/README.md +3 -2
- package/package.json +1 -1
- package/src/commands/deploy.js +21 -1
- package/src/commands/doctor.js +167 -17
- package/src/commands/env.js +11 -1
- package/src/commands/init.js +1 -0
- package/src/commands/verify.js +32 -9
- package/src/core/config.js +10 -0
- package/src/core/environments.js +21 -6
- package/src/core/stages.js +20 -2
- package/src/deployment/deployment-env.js +90 -24
- package/src/deployment/init-prompts.js +146 -15
- package/src/deployment/providers/docker.js +175 -6
- package/src/deployment/providers/kubernetes.js +3 -0
- package/src/deployment/providers/ssh.js +12 -87
- package/src/deployment/ssh-connection.js +149 -0
- package/src/utils/credential-inventory.js +10 -0
- package/src/utils/docker-image-deploy.js +4 -0
- package/src/utils/docker-port-publish.js +328 -0
- package/src/utils/docker-remote-mode.js +33 -0
- package/src/utils/docker-remote.js +218 -0
- package/src/utils/github-actions.js +6 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker remote-host mode only — no node-ssh import.
|
|
3
|
+
* Keep this module free of connection code so deployment-env.js can classify
|
|
4
|
+
* env defs without loading NodeSSH (which would break jest node-ssh mocks).
|
|
5
|
+
*
|
|
6
|
+
* Kubernetes must not honor this: a Kubernetes deploy talks to the cluster via
|
|
7
|
+
* kubectl, not a remote Docker daemon.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @typedef {'ssh'|'local'|'raw'} DockerRemoteMode */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Record<string, unknown>} [settings]
|
|
14
|
+
* @param {Record<string, string|undefined>} [env]
|
|
15
|
+
* @returns {DockerRemoteMode}
|
|
16
|
+
*/
|
|
17
|
+
export function resolveDockerRemoteMode(settings = {}, env = process.env) {
|
|
18
|
+
const remote = settings.remote;
|
|
19
|
+
const explicit =
|
|
20
|
+
remote && typeof remote === 'object'
|
|
21
|
+
? /** @type {Record<string, unknown>} */ (remote).mode
|
|
22
|
+
: undefined;
|
|
23
|
+
if (explicit === 'ssh' || explicit === 'local' || explicit === 'raw') {
|
|
24
|
+
return explicit;
|
|
25
|
+
}
|
|
26
|
+
// Existing configs with a bare DOCKER_HOST keep the unmanaged CLI transport.
|
|
27
|
+
if (settings.dockerHost || env.DOCKER_HOST) {
|
|
28
|
+
return 'raw';
|
|
29
|
+
}
|
|
30
|
+
return 'local';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default { resolveDockerRemoteMode };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker remote-host mode: local daemon, first-class SSH (node-ssh), or raw
|
|
3
|
+
* DOCKER_HOST (Docker CLI ssh:// / tcp:// transport).
|
|
4
|
+
*
|
|
5
|
+
* Kubernetes must not import or honor this module — a Kubernetes deploy talks
|
|
6
|
+
* to the cluster via kubectl, not a remote Docker daemon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { shellQuote } from './shell-quote.js';
|
|
10
|
+
import { createSshExecSession } from '../deployment/ssh-connection.js';
|
|
11
|
+
import { resolveDockerRemoteMode } from './docker-remote-mode.js';
|
|
12
|
+
|
|
13
|
+
export { resolveDockerRemoteMode };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SSH identity for docker remote.mode === "ssh".
|
|
17
|
+
* Same env names as ec2 (SSH_HOST / SSH_USER / SSH_KEY_PATH / SSH_KEY).
|
|
18
|
+
* Per-environment prefixing already separates these from a sibling ssh/ec2 env.
|
|
19
|
+
*
|
|
20
|
+
* @param {Record<string, unknown>} settings
|
|
21
|
+
* @param {Record<string, string|undefined>} env
|
|
22
|
+
*/
|
|
23
|
+
export function resolveDockerSshTarget(settings, env = process.env) {
|
|
24
|
+
const host = String(settings.host || env.SSH_HOST || '');
|
|
25
|
+
const user = String(settings.user || env.SSH_USER || '');
|
|
26
|
+
const keyPath = settings.keyPath || env.SSH_KEY_PATH;
|
|
27
|
+
const sshKey = env.SSH_KEY;
|
|
28
|
+
const sshPort = Number(env.SSH_SSH_PORT || settings.sshPort) || 22;
|
|
29
|
+
return { host, user, keyPath, sshKey, sshPort };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} host
|
|
34
|
+
* @param {string} user
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function formatRemoteDockerSshFailure(host, user) {
|
|
38
|
+
return (
|
|
39
|
+
`Could not reach ${host} via SSH as '${user}'. Check host,\n` +
|
|
40
|
+
`username, and key path.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} host
|
|
46
|
+
* @param {string} user
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function formatRemoteDockerNotInstalled(host, user) {
|
|
50
|
+
return (
|
|
51
|
+
`Docker is not installed on the remote host (${user}@${host}).\n` +
|
|
52
|
+
`Install Docker on the server first: https://docs.docker.com/engine/install/`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} host
|
|
58
|
+
* @param {string} user
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function formatRemoteDockerPermissionDenied(host, user) {
|
|
62
|
+
return (
|
|
63
|
+
`SSH user '${user}' cannot access the Docker daemon on ${host}\n` +
|
|
64
|
+
`(permission denied).\n` +
|
|
65
|
+
`Run this on the remote server, then reconnect your SSH session:\n` +
|
|
66
|
+
`sudo usermod -aG docker ${user}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} host
|
|
72
|
+
* @param {string} user
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function formatRemoteDockerDaemonOk(host, user) {
|
|
76
|
+
return `Remote Docker daemon reachable (${user}@${host})`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{ code?: number|null, stdout?: string, stderr?: string }} result
|
|
81
|
+
* @returns {'ok'|'permission'|'not-installed'|'other'}
|
|
82
|
+
*/
|
|
83
|
+
export function classifyRemoteDockerPs(result) {
|
|
84
|
+
const stdout = String(result?.stdout || '');
|
|
85
|
+
const stderr = String(result?.stderr || '');
|
|
86
|
+
const combined = `${stderr}\n${stdout}`.toLowerCase();
|
|
87
|
+
const code = result?.code;
|
|
88
|
+
|
|
89
|
+
if (code === 0 || code === null) {
|
|
90
|
+
return 'ok';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
combined.includes('permission denied') ||
|
|
95
|
+
combined.includes('got permission denied while trying to connect to the docker daemon')
|
|
96
|
+
) {
|
|
97
|
+
return 'permission';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
code === 127 ||
|
|
102
|
+
combined.includes('command not found') ||
|
|
103
|
+
/docker:\s*not found/.test(combined) ||
|
|
104
|
+
(combined.includes('no such file or directory') && combined.includes('docker'))
|
|
105
|
+
) {
|
|
106
|
+
return 'not-installed';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return 'other';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Probe `docker ps` over the shared node-ssh session. Never throws — doctor
|
|
114
|
+
* wraps each check independently.
|
|
115
|
+
*
|
|
116
|
+
* @param {{
|
|
117
|
+
* host: string,
|
|
118
|
+
* user: string,
|
|
119
|
+
* keyPath?: string,
|
|
120
|
+
* sshKey?: string,
|
|
121
|
+
* sshPort?: number,
|
|
122
|
+
* env?: Record<string, string|undefined>,
|
|
123
|
+
* }} target
|
|
124
|
+
* @returns {Promise<{
|
|
125
|
+
* sshOk: boolean,
|
|
126
|
+
* sshError?: string,
|
|
127
|
+
* kind?: ReturnType<typeof classifyRemoteDockerPs>,
|
|
128
|
+
* detail?: string,
|
|
129
|
+
* host: string,
|
|
130
|
+
* user: string,
|
|
131
|
+
* }>}
|
|
132
|
+
*/
|
|
133
|
+
export async function probeRemoteDockerPs(target) {
|
|
134
|
+
const host = target.host;
|
|
135
|
+
const user = target.user;
|
|
136
|
+
if (!host || !user) {
|
|
137
|
+
return {
|
|
138
|
+
sshOk: false,
|
|
139
|
+
sshError: formatRemoteDockerSshFailure(host || '(missing host)', user || '(missing user)'),
|
|
140
|
+
host: host || '',
|
|
141
|
+
user: user || '',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const session = createSshExecSession({
|
|
146
|
+
host,
|
|
147
|
+
user,
|
|
148
|
+
keyPath: target.keyPath ? String(target.keyPath) : undefined,
|
|
149
|
+
sshKey: target.sshKey,
|
|
150
|
+
sshPort: target.sshPort,
|
|
151
|
+
env: target.env,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** @type {import('node-ssh').NodeSSH | undefined} */
|
|
155
|
+
let ssh;
|
|
156
|
+
try {
|
|
157
|
+
ssh = await session.connect();
|
|
158
|
+
const result = await session.execUnchecked(ssh, 'docker ps');
|
|
159
|
+
const kind = classifyRemoteDockerPs(result);
|
|
160
|
+
const detail = String(result.stderr || result.stdout || '').trim();
|
|
161
|
+
return { sshOk: true, kind, detail, host, user };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
+
return {
|
|
165
|
+
sshOk: false,
|
|
166
|
+
sshError: formatRemoteDockerSshFailure(host, user),
|
|
167
|
+
detail: msg,
|
|
168
|
+
host,
|
|
169
|
+
user,
|
|
170
|
+
};
|
|
171
|
+
} finally {
|
|
172
|
+
if (ssh) ssh.dispose();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Quoted remote docker commands (image/container/env interpolated via shellQuote).
|
|
178
|
+
* @param {string} imageRef
|
|
179
|
+
* @param {string} containerName
|
|
180
|
+
* @param {Record<string, string>} [runEnv]
|
|
181
|
+
* @param {{ publishPort?: number|null }} [options]
|
|
182
|
+
*/
|
|
183
|
+
export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}, options = {}) {
|
|
184
|
+
const image = shellQuote(imageRef);
|
|
185
|
+
const name = shellQuote(containerName);
|
|
186
|
+
/** @type {string[]} */
|
|
187
|
+
const envFlags = [];
|
|
188
|
+
for (const [key, value] of Object.entries(runEnv)) {
|
|
189
|
+
envFlags.push(`-e ${shellQuote(`${key}=${value}`)}`);
|
|
190
|
+
}
|
|
191
|
+
const envArg = envFlags.length > 0 ? `${envFlags.join(' ')} ` : '';
|
|
192
|
+
const publishPort = options.publishPort;
|
|
193
|
+
const pFlag =
|
|
194
|
+
publishPort != null ? `-p ${shellQuote(`${publishPort}:${publishPort}`)} ` : '';
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
stop: `docker stop ${name} 2>/dev/null || true`,
|
|
198
|
+
rm: `docker rm -f ${name} 2>/dev/null || true`,
|
|
199
|
+
pull: `docker pull ${image}`,
|
|
200
|
+
run: `docker run -d --rm --name ${name} ${pFlag}${envArg}${image}`,
|
|
201
|
+
ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
|
|
202
|
+
info: 'docker info',
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} registry
|
|
205
|
+
* @param {string} username
|
|
206
|
+
* @param {string} token
|
|
207
|
+
*/
|
|
208
|
+
login: (registry, username, token) =>
|
|
209
|
+
`echo ${shellQuote(token)} | docker login ${shellQuote(registry)} -u ${shellQuote(username)} --password-stdin`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default {
|
|
214
|
+
resolveDockerRemoteMode,
|
|
215
|
+
resolveDockerSshTarget,
|
|
216
|
+
probeRemoteDockerPs,
|
|
217
|
+
buildRemoteDockerCommands,
|
|
218
|
+
};
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getEnvTrigger,
|
|
22
22
|
getEnabledEnvironmentNames,
|
|
23
23
|
isEnvEnabled,
|
|
24
|
+
getEnvSettings,
|
|
24
25
|
} from '../core/environments.js';
|
|
25
26
|
import { resolvePhpVersion } from './php-version.js';
|
|
26
27
|
|
|
@@ -540,7 +541,11 @@ export function buildWorkflowEnvEntries(
|
|
|
540
541
|
|
|
541
542
|
const method = getEnvMethod(env);
|
|
542
543
|
if (!method) continue;
|
|
543
|
-
const unprefixedKeys = getDeploymentWorkflowSecretKeys(
|
|
544
|
+
const unprefixedKeys = getDeploymentWorkflowSecretKeys(
|
|
545
|
+
method,
|
|
546
|
+
config,
|
|
547
|
+
getEnvSettings(env)
|
|
548
|
+
);
|
|
544
549
|
for (const key of unprefixedKeys) {
|
|
545
550
|
const secretName = envUsesPrefixedSecrets(envName, cfg)
|
|
546
551
|
? prefixSecretKey(envName, key)
|