@akash-chowdhury-24/deployhub 2.0.31 → 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/commands/doctor.js +147 -17
- package/src/core/config.js +10 -0
- package/src/core/environments.js +21 -6
- package/src/deployment/deployment-env.js +90 -24
- 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 +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-remote-mode.js +33 -0
- package/src/utils/docker-remote.js +214 -0
- package/src/utils/github-actions.js +6 -1
|
@@ -4,6 +4,12 @@ import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.
|
|
|
4
4
|
import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
|
|
5
5
|
import { resolveDockerContainerName } from '../../utils/docker-container-name.js';
|
|
6
6
|
import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
|
|
7
|
+
import { createSshExecSession } from '../ssh-connection.js';
|
|
8
|
+
import { resolveDockerRemoteMode } from '../../utils/docker-remote-mode.js';
|
|
9
|
+
import {
|
|
10
|
+
resolveDockerSshTarget,
|
|
11
|
+
buildRemoteDockerCommands,
|
|
12
|
+
} from '../../utils/docker-remote.js';
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -14,11 +20,39 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
14
20
|
const log = createLogger('docker');
|
|
15
21
|
const settings = getEnvSettings(config.environments?.[envName]);
|
|
16
22
|
const effectiveEnv = mergeMethodSettingsIntoEnv(env, settings);
|
|
17
|
-
const
|
|
18
|
-
|
|
23
|
+
const remoteMode = resolveDockerRemoteMode(settings, effectiveEnv);
|
|
24
|
+
|
|
25
|
+
// SSH remote runs pull/run/stop/rm over node-ssh. Image build/push still uses
|
|
26
|
+
// the local Docker daemon — never inject DOCKER_HOST into the shared image
|
|
27
|
+
// helpers (kubernetes.js also uses those helpers and has no SSH docker host).
|
|
28
|
+
/** @type {Record<string, string|undefined>} */
|
|
29
|
+
const imageEnv = { ...effectiveEnv };
|
|
30
|
+
if (remoteMode === 'ssh') {
|
|
31
|
+
delete imageEnv.DOCKER_HOST;
|
|
32
|
+
delete imageEnv.DOCKER_TLS_VERIFY;
|
|
33
|
+
delete imageEnv.DOCKER_CERT_PATH;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const imageOps = createDockerImageDeployContext(config, imageEnv, log);
|
|
37
|
+
const { fullImage, getDockerEnv, ensureImageReadyForDeploy, hasRegistryCredentials } =
|
|
38
|
+
imageOps;
|
|
19
39
|
// Env-scoped like PM2/Nginx — same-daemon multi-env must not share one container name.
|
|
20
40
|
const containerName = resolveDockerContainerName(config, envName);
|
|
21
41
|
|
|
42
|
+
function sshTarget() {
|
|
43
|
+
return resolveDockerSshTarget(settings, effectiveEnv);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sshSession() {
|
|
47
|
+
const target = sshTarget();
|
|
48
|
+
return createSshExecSession({
|
|
49
|
+
...target,
|
|
50
|
+
keyPath: target.keyPath ? String(target.keyPath) : undefined,
|
|
51
|
+
env: effectiveEnv,
|
|
52
|
+
log,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
/**
|
|
23
57
|
* @param {string} artifactDir
|
|
24
58
|
* @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
|
|
@@ -26,17 +60,30 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
26
60
|
async function deploy(artifactDir, options = {}) {
|
|
27
61
|
const imageRef = options.fullImage || fullImage;
|
|
28
62
|
log.info(`Deploying via Docker (image: ${imageRef})...`);
|
|
29
|
-
const dockerEnv = getDockerEnv();
|
|
30
63
|
|
|
31
64
|
const result = await ensureImageReadyForDeploy(artifactDir, {
|
|
32
65
|
fullImage: options.fullImage,
|
|
33
66
|
skipImageReuse: options.skipImageReuse,
|
|
34
67
|
});
|
|
35
68
|
if (result.ranCompose) {
|
|
69
|
+
if (remoteMode === 'ssh') {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'docker-compose.yml deploys are not supported with remote.mode "ssh". ' +
|
|
72
|
+
'Use local Docker, advanced raw DOCKER_HOST, or a single-image Dockerfile deploy.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
log.success('Docker deployment complete');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (remoteMode === 'ssh') {
|
|
80
|
+
await deployOverSsh(imageRef);
|
|
36
81
|
log.success('Docker deployment complete');
|
|
37
82
|
return;
|
|
38
83
|
}
|
|
39
84
|
|
|
85
|
+
const dockerEnv = getDockerEnv();
|
|
86
|
+
|
|
40
87
|
await execa(
|
|
41
88
|
'docker',
|
|
42
89
|
['rm', '-f', containerName],
|
|
@@ -51,6 +98,39 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
51
98
|
log.success('Docker deployment complete');
|
|
52
99
|
}
|
|
53
100
|
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} imageRef
|
|
103
|
+
*/
|
|
104
|
+
async function deployOverSsh(imageRef) {
|
|
105
|
+
const cmds = buildRemoteDockerCommands(imageRef, containerName);
|
|
106
|
+
const session = sshSession();
|
|
107
|
+
const ssh = await session.connect();
|
|
108
|
+
try {
|
|
109
|
+
const registryUser = imageEnv.DOCKER_REGISTRY_USERNAME || '';
|
|
110
|
+
const registryToken = imageEnv.DOCKER_REGISTRY_TOKEN || '';
|
|
111
|
+
const registryUrl = imageEnv.DOCKER_REGISTRY_URL || '';
|
|
112
|
+
|
|
113
|
+
if (registryUser && registryToken) {
|
|
114
|
+
const registry = registryUrl || 'https://index.docker.io/v1/';
|
|
115
|
+
log.info('Logging in to container registry on remote host...');
|
|
116
|
+
await session.exec(ssh, cmds.login(registry, registryUser, registryToken));
|
|
117
|
+
} else if (!hasRegistryCredentials()) {
|
|
118
|
+
log.warn(
|
|
119
|
+
'No DOCKER_REGISTRY_USERNAME/TOKEN — remote docker pull requires a public image or one already present on the host.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await session.exec(ssh, cmds.stop);
|
|
124
|
+
await session.exec(ssh, cmds.rm);
|
|
125
|
+
await session.exec(ssh, cmds.pull, {
|
|
126
|
+
timeoutMs: Math.max(session.defaultExecTimeoutMs, 300_000),
|
|
127
|
+
});
|
|
128
|
+
await session.exec(ssh, cmds.run);
|
|
129
|
+
} finally {
|
|
130
|
+
ssh.dispose();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
54
134
|
/**
|
|
55
135
|
* @param {string} artifactDir
|
|
56
136
|
* @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
|
|
@@ -65,7 +145,7 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
65
145
|
// Tag stays buildId-based; env only scopes which history informed this buildId.
|
|
66
146
|
const rollbackImage = resolveDockerImageRefForTag(
|
|
67
147
|
config,
|
|
68
|
-
|
|
148
|
+
imageEnv,
|
|
69
149
|
meta.buildId
|
|
70
150
|
).fullImage;
|
|
71
151
|
log.info(
|
|
@@ -82,6 +162,18 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
82
162
|
if (!url) return true;
|
|
83
163
|
|
|
84
164
|
try {
|
|
165
|
+
if (remoteMode === 'ssh') {
|
|
166
|
+
const cmds = buildRemoteDockerCommands(fullImage, containerName);
|
|
167
|
+
const session = sshSession();
|
|
168
|
+
const ssh = await session.connect();
|
|
169
|
+
try {
|
|
170
|
+
const result = await session.exec(ssh, cmds.ps);
|
|
171
|
+
return String(result.stdout || '').includes('Up');
|
|
172
|
+
} finally {
|
|
173
|
+
ssh.dispose();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
85
177
|
const { stdout } = await execa(
|
|
86
178
|
'docker',
|
|
87
179
|
['ps', '--filter', `name=^/${containerName}$`, '--format', '{{.Status}}'],
|
|
@@ -94,10 +186,21 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
94
186
|
}
|
|
95
187
|
|
|
96
188
|
async function testConnection() {
|
|
189
|
+
if (remoteMode === 'ssh') {
|
|
190
|
+
const cmds = buildRemoteDockerCommands(fullImage, containerName);
|
|
191
|
+
const session = sshSession();
|
|
192
|
+
const ssh = await session.connect();
|
|
193
|
+
try {
|
|
194
|
+
await session.exec(ssh, cmds.info);
|
|
195
|
+
} finally {
|
|
196
|
+
ssh.dispose();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
97
200
|
await execa('docker', ['info'], { stdio: 'pipe', env: getDockerEnv() });
|
|
98
201
|
}
|
|
99
202
|
|
|
100
|
-
return { deploy, rollback, healthCheck, testConnection };
|
|
203
|
+
return { deploy, rollback, healthCheck, testConnection, remoteMode };
|
|
101
204
|
}
|
|
102
205
|
|
|
103
206
|
export default { createDockerProvider };
|
|
@@ -19,6 +19,9 @@ import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environme
|
|
|
19
19
|
export function createKubernetesProvider(config, envName, env = process.env) {
|
|
20
20
|
const log = createLogger('kubernetes');
|
|
21
21
|
const settings = getEnvSettings(config.environments?.[envName]);
|
|
22
|
+
// Overlay is a field whitelist (METHOD_SETTINGS_ENV_OVERLAY). It never copies
|
|
23
|
+
// docker `remote.mode` / SSH host identity. Kubernetes talks to the cluster
|
|
24
|
+
// via kubectl regardless of any docker-ssh fields that might sit on settings.
|
|
22
25
|
const effectiveEnv = mergeMethodSettingsIntoEnv(env, settings);
|
|
23
26
|
const imageOps = createDockerImageDeployContext(config, effectiveEnv, log);
|
|
24
27
|
|
|
@@ -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.
|
|
@@ -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') {
|
|
@@ -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 };
|