@akash-chowdhury-24/deployhub 2.0.30 → 2.0.32
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/artifact/engine.js +111 -83
- package/src/commands/doctor.js +230 -36
- package/src/core/config.js +10 -0
- package/src/core/environments.js +21 -6
- package/src/deployment/deployment-env.js +91 -25
- package/src/deployment/init-helpers.js +3 -1
- package/src/deployment/init-prompts.js +100 -6
- package/src/deployment/providers/docker.js +108 -5
- package/src/deployment/providers/kubernetes.js +3 -0
- package/src/deployment/providers/ssh.js +44 -92
- package/src/deployment/ssh-connection.js +149 -0
- package/src/detectors/frontend.detector.js +1 -1
- package/src/utils/credential-inventory.js +10 -0
- package/src/utils/docker-image-deploy.js +4 -0
- package/src/utils/docker-image.js +3 -3
- package/src/utils/docker-remote-mode.js +33 -0
- package/src/utils/docker-remote.js +214 -0
- package/src/utils/dockerfile.js +28 -9
- package/src/utils/github-actions.js +28 -6
- package/src/utils/php-fpm.js +3 -1
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { NodeSSH } from 'node-ssh';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
1
|
import path from 'path';
|
|
4
|
-
import os from 'os';
|
|
5
2
|
import { createLogger } from '../../logger/index.js';
|
|
6
3
|
import { getEnvSettings } from '../../core/config.js';
|
|
4
|
+
import { createSshExecSession } from '../ssh-connection.js';
|
|
7
5
|
import {
|
|
8
6
|
getNginxSitesAvailablePath,
|
|
9
7
|
getNginxSitesEnabledPath,
|
|
@@ -11,7 +9,7 @@ import {
|
|
|
11
9
|
resolveNginxSiteName,
|
|
12
10
|
} from '../../utils/nginx.js';
|
|
13
11
|
import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
|
|
14
|
-
import { shellQuote
|
|
12
|
+
import { shellQuote } from '../../utils/shell-quote.js';
|
|
15
13
|
import { extractGunicornTarget } from '../../utils/python-app-target.js';
|
|
16
14
|
import { resolvePhpVersion } from '../../utils/php-version.js';
|
|
17
15
|
import {
|
|
@@ -64,94 +62,21 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
64
62
|
|
|
65
63
|
const log = createLogger('ssh');
|
|
66
64
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
const session = createSshExecSession({
|
|
66
|
+
host,
|
|
67
|
+
user,
|
|
68
|
+
keyPath,
|
|
69
|
+
sshKey,
|
|
70
|
+
sshPort,
|
|
71
|
+
env,
|
|
72
|
+
log,
|
|
73
|
+
});
|
|
74
|
+
const { connect, exec, defaultExecTimeoutMs } = session;
|
|
70
75
|
const startStopTimeoutMs = Math.min(
|
|
71
76
|
defaultExecTimeoutMs,
|
|
72
77
|
Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS) || 60_000
|
|
73
78
|
);
|
|
74
79
|
|
|
75
|
-
async function connect() {
|
|
76
|
-
if (!host || !user) {
|
|
77
|
-
throw new Error(
|
|
78
|
-
'SSH host and user are required. Set SSH_HOST and SSH_USER in .env (see .env.example comments).'
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!sshKey && !keyPath) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
'SSH authentication required. Set SSH_KEY_PATH (local) or SSH_KEY (CI secret) in .env — see .env.example.'
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const ssh = new NodeSSH();
|
|
89
|
-
/** @type {import('node-ssh').SSHConnectOptions} */
|
|
90
|
-
const connectOpts = { host, username: user, port: sshPort };
|
|
91
|
-
|
|
92
|
-
if (sshKey) {
|
|
93
|
-
const tmpKeyPath = path.join(os.tmpdir(), 'deployhub-ssh-key');
|
|
94
|
-
await fs.writeFile(tmpKeyPath, sshKey, { mode: 0o600 });
|
|
95
|
-
connectOpts.privateKeyPath = tmpKeyPath;
|
|
96
|
-
} else if (keyPath) {
|
|
97
|
-
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
98
|
-
connectOpts.privateKeyPath = path.resolve(expanded);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
await ssh.connect(connectOpts);
|
|
103
|
-
} catch (err) {
|
|
104
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
-
throw new Error(
|
|
106
|
-
`SSH connection failed to ${user}@${host}:${sshPort} — ${msg}. Check SSH_HOST, SSH_USER, SSH_KEY_PATH, and that port ${sshPort} is open in your firewall/security group.`
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
return ssh;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* @param {import('node-ssh').NodeSSH} ssh
|
|
114
|
-
* @param {string} command
|
|
115
|
-
* @param {{ timeoutMs?: number }} [opts]
|
|
116
|
-
*/
|
|
117
|
-
async function exec(ssh, command, opts = {}) {
|
|
118
|
-
const timeoutMs = opts.timeoutMs ?? defaultExecTimeoutMs;
|
|
119
|
-
log.info(`$ ${command}`);
|
|
120
|
-
|
|
121
|
-
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
122
|
-
let timer;
|
|
123
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
124
|
-
timer = setTimeout(() => {
|
|
125
|
-
reject(
|
|
126
|
-
new Error(
|
|
127
|
-
`SSH command timed out after ${timeoutMs}ms on ${user}@${host}. ` +
|
|
128
|
-
`The remote command may still be running — check the server. ` +
|
|
129
|
-
`Command: ${command.length > 240 ? `${command.slice(0, 240)}…` : command}`
|
|
130
|
-
)
|
|
131
|
-
);
|
|
132
|
-
}, timeoutMs);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
let result;
|
|
136
|
-
try {
|
|
137
|
-
result = await Promise.race([ssh.execCommand(command), timeoutPromise]);
|
|
138
|
-
} finally {
|
|
139
|
-
if (timer) clearTimeout(timer);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (result.code !== 0 && result.code !== null) {
|
|
143
|
-
const message = formatRemoteCommandFailure(
|
|
144
|
-
command,
|
|
145
|
-
result.code,
|
|
146
|
-
result.stderr,
|
|
147
|
-
result.stdout
|
|
148
|
-
);
|
|
149
|
-
log.error(message);
|
|
150
|
-
throw new Error(message);
|
|
151
|
-
}
|
|
152
|
-
return result;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
80
|
/**
|
|
156
81
|
* Exact marker match in a null-delimited /proc file (cmdline or environ).
|
|
157
82
|
* Uses grep -xF so DEPLOYHUB_APP=myapi does not match DEPLOYHUB_APP=myapi-staging.
|
|
@@ -441,8 +366,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
441
366
|
|
|
442
367
|
if (framework === 'dotnet') {
|
|
443
368
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
444
|
-
|
|
445
|
-
|
|
369
|
+
// Discover the published DLL at runtime — csproj name is not always App.dll
|
|
370
|
+
// (same class of bug as the Docker CMD ["dotnet","App.dll"] hardcode).
|
|
371
|
+
await startScopedNohup(
|
|
372
|
+
ssh,
|
|
373
|
+
targetPath,
|
|
374
|
+
`sh -c 'dll=$(ls -1 *.dll 2>/dev/null | head -n1); test -n "$dll" || { echo "No .dll found in $(pwd) for .NET start" >&2; exit 1; }; exec dotnet "$dll"'`
|
|
375
|
+
);
|
|
446
376
|
return;
|
|
447
377
|
}
|
|
448
378
|
|
|
@@ -576,13 +506,35 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
576
506
|
log.success('Nginx config tested and reloaded');
|
|
577
507
|
}
|
|
578
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Make an existing remote directory writable by the SSH user so the next
|
|
511
|
+
* unzip/rsync is not blocked by www-data ownership from php-fpm or nginx.
|
|
512
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
513
|
+
* @param {string} targetPath
|
|
514
|
+
*/
|
|
515
|
+
async function ensureWritableDeployDir(ssh, targetPath) {
|
|
516
|
+
const targetQ = sh(targetPath);
|
|
517
|
+
const userQ = sh(user);
|
|
518
|
+
await exec(ssh, `mkdir -p ${targetQ}`);
|
|
519
|
+
await exec(
|
|
520
|
+
ssh,
|
|
521
|
+
`if [ -d ${targetQ} ]; then ` +
|
|
522
|
+
`chmod -R u+w ${targetQ} 2>/dev/null || true; ` +
|
|
523
|
+
`if [ ! -w ${targetQ} ]; then ` +
|
|
524
|
+
`sudo chown -R ${userQ}:${userQ} ${targetQ} 2>/dev/null || true; ` +
|
|
525
|
+
`chmod -R u+w ${targetQ} 2>/dev/null || true; ` +
|
|
526
|
+
`fi; ` +
|
|
527
|
+
`fi`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
579
531
|
/**
|
|
580
532
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
581
533
|
* @param {string} remoteZip
|
|
582
534
|
* @param {string} targetPath
|
|
583
535
|
*/
|
|
584
536
|
async function extractToPath(ssh, remoteZip, targetPath) {
|
|
585
|
-
await
|
|
537
|
+
await ensureWritableDeployDir(ssh, targetPath);
|
|
586
538
|
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
|
|
587
539
|
}
|
|
588
540
|
|
|
@@ -606,13 +558,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
606
558
|
await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
|
|
607
559
|
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
|
|
608
560
|
|
|
609
|
-
await
|
|
561
|
+
await ensureWritableDeployDir(ssh, frontendDeployPath);
|
|
610
562
|
await exec(
|
|
611
563
|
ssh,
|
|
612
564
|
`rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
|
|
613
565
|
);
|
|
614
566
|
|
|
615
|
-
await
|
|
567
|
+
await ensureWritableDeployDir(ssh, backendDeployPath);
|
|
616
568
|
await exec(
|
|
617
569
|
ssh,
|
|
618
570
|
`rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { NodeSSH } from 'node-ssh';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { formatRemoteCommandFailure } from '../utils/shell-quote.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared node-ssh connection + exec wrapper used by ssh.js and docker
|
|
9
|
+
* `remote.mode === "ssh"`. Do not duplicate this layer in providers.
|
|
10
|
+
*
|
|
11
|
+
* @param {{
|
|
12
|
+
* host?: string,
|
|
13
|
+
* user?: string,
|
|
14
|
+
* keyPath?: string,
|
|
15
|
+
* sshKey?: string,
|
|
16
|
+
* sshPort?: number,
|
|
17
|
+
* env?: Record<string, string|undefined>,
|
|
18
|
+
* log?: { info: Function, error: Function },
|
|
19
|
+
* }} opts
|
|
20
|
+
*/
|
|
21
|
+
export function createSshExecSession(opts) {
|
|
22
|
+
const env = opts.env || process.env;
|
|
23
|
+
const host = opts.host;
|
|
24
|
+
const user = opts.user;
|
|
25
|
+
const sshKey = opts.sshKey || env.SSH_KEY;
|
|
26
|
+
const keyPath = opts.keyPath;
|
|
27
|
+
const sshPort = Number(opts.sshPort) || 22;
|
|
28
|
+
const log = opts.log || { info() {}, error() {} };
|
|
29
|
+
|
|
30
|
+
// Defense-in-depth: never let a stuck SSH channel hang CI indefinitely.
|
|
31
|
+
// Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms).
|
|
32
|
+
const defaultExecTimeoutMs = Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) || 120_000;
|
|
33
|
+
|
|
34
|
+
async function connect() {
|
|
35
|
+
if (!host || !user) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'SSH host and user are required. Set SSH_HOST and SSH_USER in .env (see .env.example comments).'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!sshKey && !keyPath) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'SSH authentication required. Set SSH_KEY_PATH (local) or SSH_KEY (CI secret) in .env — see .env.example.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ssh = new NodeSSH();
|
|
48
|
+
/** @type {import('node-ssh').SSHConnectOptions} */
|
|
49
|
+
const connectOpts = { host, username: user, port: sshPort };
|
|
50
|
+
|
|
51
|
+
if (sshKey) {
|
|
52
|
+
const tmpKeyPath = path.join(
|
|
53
|
+
os.tmpdir(),
|
|
54
|
+
`deployhub-ssh-key-${process.pid}-${Date.now()}`
|
|
55
|
+
);
|
|
56
|
+
await fs.writeFile(tmpKeyPath, sshKey, { mode: 0o600 });
|
|
57
|
+
connectOpts.privateKeyPath = tmpKeyPath;
|
|
58
|
+
} else if (keyPath) {
|
|
59
|
+
const expanded = keyPath.replace(/^~/, os.homedir());
|
|
60
|
+
connectOpts.privateKeyPath = path.resolve(expanded);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await ssh.connect(connectOpts);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
67
|
+
throw new Error(
|
|
68
|
+
`SSH connection failed to ${user}@${host}:${sshPort} — ${msg}. Check SSH_HOST, SSH_USER, SSH_KEY_PATH, and that port ${sshPort} is open in your firewall/security group.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return ssh;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
76
|
+
* @param {string} command
|
|
77
|
+
* @param {{ timeoutMs?: number }} [execOpts]
|
|
78
|
+
*/
|
|
79
|
+
async function runCommand(ssh, command, execOpts = {}) {
|
|
80
|
+
const timeoutMs = execOpts.timeoutMs ?? defaultExecTimeoutMs;
|
|
81
|
+
log.info(`$ ${command}`);
|
|
82
|
+
|
|
83
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
84
|
+
let timer;
|
|
85
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
86
|
+
timer = setTimeout(() => {
|
|
87
|
+
reject(
|
|
88
|
+
new Error(
|
|
89
|
+
`SSH command timed out after ${timeoutMs}ms on ${user}@${host}. ` +
|
|
90
|
+
`The remote command may still be running — check the server. ` +
|
|
91
|
+
`Command: ${command.length > 240 ? `${command.slice(0, 240)}…` : command}`
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await Promise.race([ssh.execCommand(command), timeoutPromise]);
|
|
100
|
+
} finally {
|
|
101
|
+
if (timer) clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
108
|
+
* @param {string} command
|
|
109
|
+
* @param {{ timeoutMs?: number }} [execOpts]
|
|
110
|
+
*/
|
|
111
|
+
async function exec(ssh, command, execOpts = {}) {
|
|
112
|
+
const result = await runCommand(ssh, command, execOpts);
|
|
113
|
+
if (result.code !== 0 && result.code !== null) {
|
|
114
|
+
const message = formatRemoteCommandFailure(
|
|
115
|
+
command,
|
|
116
|
+
result.code,
|
|
117
|
+
result.stderr,
|
|
118
|
+
result.stdout
|
|
119
|
+
);
|
|
120
|
+
log.error(message);
|
|
121
|
+
throw new Error(message);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Same timeout wrapper as exec(), but does not throw on non-zero exit.
|
|
128
|
+
* Used by doctor so a failed remote probe is classified, not a crash.
|
|
129
|
+
*
|
|
130
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
131
|
+
* @param {string} command
|
|
132
|
+
* @param {{ timeoutMs?: number }} [execOpts]
|
|
133
|
+
*/
|
|
134
|
+
async function execUnchecked(ssh, command, execOpts = {}) {
|
|
135
|
+
return runCommand(ssh, command, execOpts);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
connect,
|
|
140
|
+
exec,
|
|
141
|
+
execUnchecked,
|
|
142
|
+
host,
|
|
143
|
+
user,
|
|
144
|
+
sshPort,
|
|
145
|
+
defaultExecTimeoutMs,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default { createSshExecSession };
|
|
@@ -197,6 +197,16 @@ export const SHARED_BY_DESIGN_DEPLOY_PAIRS = [
|
|
|
197
197
|
},
|
|
198
198
|
];
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* When docker `remote.mode === "ssh"`, the docker method also reads SSH_HOST,
|
|
202
|
+
* SSH_USER, SSH_KEY_PATH, SSH_SSH_PORT, and SSH_KEY at runtime — the same
|
|
203
|
+
* names as ssh/ec2/azure-vm/gcp-vm. Those keys are NOT in static
|
|
204
|
+
* DEPLOYMENT_ENV_KEYS.docker (local and raw docker do not use them), so they
|
|
205
|
+
* do not appear in the storage×deploy matrix. Per-environment secret prefixing
|
|
206
|
+
* already separates a docker-ssh env from a sibling ssh/ec2 env on the same
|
|
207
|
+
* config. Do not invent DOCKER_SSH_* aliases unless prefixing cannot apply.
|
|
208
|
+
*/
|
|
209
|
+
|
|
200
210
|
/**
|
|
201
211
|
* @param {string[]} a
|
|
202
212
|
* @param {string[]} b
|
|
@@ -29,6 +29,10 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
29
29
|
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
30
30
|
const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
|
|
31
31
|
const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
|
|
32
|
+
// DOCKER_HOST here is only the raw CLI transport (tcp:// / ssh://).
|
|
33
|
+
// docker remote.mode "ssh" is resolved in docker.js and must not be injected
|
|
34
|
+
// into this shared helper — kubernetes.js uses the same context for build/push
|
|
35
|
+
// and has no remote SSH docker host.
|
|
32
36
|
const dockerHost = env.DOCKER_HOST || '';
|
|
33
37
|
|
|
34
38
|
if (tagSource === 'explicit') {
|
|
@@ -174,7 +174,7 @@ WORKDIR /app
|
|
|
174
174
|
COPY ${dir}/ .
|
|
175
175
|
EXPOSE ${port}
|
|
176
176
|
ENV ASPNETCORE_URLS=http://+:${port}
|
|
177
|
-
CMD ["
|
|
177
|
+
CMD ["sh", "-c", "dll=$(ls *.dll 2>/dev/null | head -n1); exec dotnet \\"$dll\\""]
|
|
178
178
|
`;
|
|
179
179
|
}
|
|
180
180
|
|
|
@@ -191,7 +191,7 @@ export function isFrontendStaticFramework(framework) {
|
|
|
191
191
|
* @param {string} framework
|
|
192
192
|
*/
|
|
193
193
|
export function isNodeBackendFramework(framework) {
|
|
194
|
-
return ['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework || '');
|
|
194
|
+
return ['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node'].includes(framework || '');
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
@@ -201,7 +201,7 @@ export function isNodeBackendFramework(framework) {
|
|
|
201
201
|
*/
|
|
202
202
|
export function isInterpretedBackendFramework(framework) {
|
|
203
203
|
return [
|
|
204
|
-
...['express', 'nestjs', 'fastify', 'koa', 'nextjs'],
|
|
204
|
+
...['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node'],
|
|
205
205
|
'fastapi',
|
|
206
206
|
'django',
|
|
207
207
|
'flask',
|
|
@@ -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,214 @@
|
|
|
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
|
+
*/
|
|
182
|
+
export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}) {
|
|
183
|
+
const image = shellQuote(imageRef);
|
|
184
|
+
const name = shellQuote(containerName);
|
|
185
|
+
/** @type {string[]} */
|
|
186
|
+
const envFlags = [];
|
|
187
|
+
for (const [key, value] of Object.entries(runEnv)) {
|
|
188
|
+
envFlags.push(`-e ${shellQuote(`${key}=${value}`)}`);
|
|
189
|
+
}
|
|
190
|
+
const envArg = envFlags.length > 0 ? `${envFlags.join(' ')} ` : '';
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
stop: `docker stop ${name} 2>/dev/null || true`,
|
|
194
|
+
rm: `docker rm -f ${name} 2>/dev/null || true`,
|
|
195
|
+
pull: `docker pull ${image}`,
|
|
196
|
+
run: `docker run -d --rm --name ${name} ${envArg}${image}`,
|
|
197
|
+
ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
|
|
198
|
+
info: 'docker info',
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} registry
|
|
201
|
+
* @param {string} username
|
|
202
|
+
* @param {string} token
|
|
203
|
+
*/
|
|
204
|
+
login: (registry, username, token) =>
|
|
205
|
+
`echo ${shellQuote(token)} | docker login ${shellQuote(registry)} -u ${shellQuote(username)} --password-stdin`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export default {
|
|
210
|
+
resolveDockerRemoteMode,
|
|
211
|
+
resolveDockerSshTarget,
|
|
212
|
+
probeRemoteDockerPs,
|
|
213
|
+
buildRemoteDockerCommands,
|
|
214
|
+
};
|