@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.
@@ -4,6 +4,19 @@ 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';
13
+ import {
14
+ resolveDockerPublishPort,
15
+ formatDockerSshPortRequired,
16
+ evaluateDockerPortPublish,
17
+ buildDockerInspectPortsArgs,
18
+ buildDockerInspectPortsCommand,
19
+ } from '../../utils/docker-port-publish.js';
7
20
 
8
21
  /**
9
22
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -14,10 +27,39 @@ export function createDockerProvider(config, envName, env = process.env) {
14
27
  const log = createLogger('docker');
15
28
  const settings = getEnvSettings(config.environments?.[envName]);
16
29
  const effectiveEnv = mergeMethodSettingsIntoEnv(env, settings);
17
- const imageOps = createDockerImageDeployContext(config, effectiveEnv, log);
18
- const { fullImage, getDockerEnv, ensureImageReadyForDeploy } = imageOps;
30
+ const remoteMode = resolveDockerRemoteMode(settings, effectiveEnv);
31
+
32
+ // SSH remote runs pull/run/stop/rm over node-ssh. Image build/push still uses
33
+ // the local Docker daemon — never inject DOCKER_HOST into the shared image
34
+ // helpers (kubernetes.js also uses those helpers and has no SSH docker host).
35
+ /** @type {Record<string, string|undefined>} */
36
+ const imageEnv = { ...effectiveEnv };
37
+ if (remoteMode === 'ssh') {
38
+ delete imageEnv.DOCKER_HOST;
39
+ delete imageEnv.DOCKER_TLS_VERIFY;
40
+ delete imageEnv.DOCKER_CERT_PATH;
41
+ }
42
+
43
+ const imageOps = createDockerImageDeployContext(config, imageEnv, log);
44
+ const { fullImage, getDockerEnv, ensureImageReadyForDeploy, hasRegistryCredentials } =
45
+ imageOps;
19
46
  // Env-scoped like PM2/Nginx — same-daemon multi-env must not share one container name.
20
47
  const containerName = resolveDockerContainerName(config, envName);
48
+ const publishPort = resolveDockerPublishPort(config, settings, envName);
49
+
50
+ function sshTarget() {
51
+ return resolveDockerSshTarget(settings, effectiveEnv);
52
+ }
53
+
54
+ function sshSession() {
55
+ const target = sshTarget();
56
+ return createSshExecSession({
57
+ ...target,
58
+ keyPath: target.keyPath ? String(target.keyPath) : undefined,
59
+ env: effectiveEnv,
60
+ log,
61
+ });
62
+ }
21
63
 
22
64
  /**
23
65
  * @param {string} artifactDir
@@ -26,31 +68,135 @@ export function createDockerProvider(config, envName, env = process.env) {
26
68
  async function deploy(artifactDir, options = {}) {
27
69
  const imageRef = options.fullImage || fullImage;
28
70
  log.info(`Deploying via Docker (image: ${imageRef})...`);
29
- const dockerEnv = getDockerEnv();
30
71
 
31
72
  const result = await ensureImageReadyForDeploy(artifactDir, {
32
73
  fullImage: options.fullImage,
33
74
  skipImageReuse: options.skipImageReuse,
34
75
  });
35
76
  if (result.ranCompose) {
77
+ if (remoteMode === 'ssh') {
78
+ throw new Error(
79
+ 'docker-compose.yml deploys are not supported with remote.mode "ssh". ' +
80
+ 'Use local Docker, advanced raw DOCKER_HOST, or a single-image Dockerfile deploy.'
81
+ );
82
+ }
36
83
  log.success('Docker deployment complete');
37
84
  return;
38
85
  }
39
86
 
87
+ if (remoteMode === 'ssh') {
88
+ if (publishPort == null) {
89
+ throw new Error(formatDockerSshPortRequired(envName));
90
+ }
91
+ await deployOverSsh(imageRef, publishPort);
92
+ log.success('Docker deployment complete');
93
+ return;
94
+ }
95
+
96
+ const dockerEnv = getDockerEnv();
97
+
40
98
  await execa(
41
99
  'docker',
42
100
  ['rm', '-f', containerName],
43
101
  { stdio: 'pipe', env: dockerEnv }
44
102
  ).catch(() => {});
45
103
 
46
- await execa('docker', ['run', '-d', '--rm', '--name', containerName, imageRef], {
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, {
47
112
  stdio: 'inherit',
48
113
  env: dockerEnv,
49
114
  });
50
115
 
116
+ if (publishPort != null) {
117
+ await assertLocalPortPublished(dockerEnv, publishPort);
118
+ }
119
+
51
120
  log.success('Docker deployment complete');
52
121
  }
53
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
+
151
+ /**
152
+ * @param {string} imageRef
153
+ * @param {number} port
154
+ */
155
+ async function deployOverSsh(imageRef, port) {
156
+ const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, { publishPort: port });
157
+ const session = sshSession();
158
+ const ssh = await session.connect();
159
+ try {
160
+ const registryUser = imageEnv.DOCKER_REGISTRY_USERNAME || '';
161
+ const registryToken = imageEnv.DOCKER_REGISTRY_TOKEN || '';
162
+ const registryUrl = imageEnv.DOCKER_REGISTRY_URL || '';
163
+
164
+ if (registryUser && registryToken) {
165
+ const registry = registryUrl || 'https://index.docker.io/v1/';
166
+ log.info('Logging in to container registry on remote host...');
167
+ await session.exec(ssh, cmds.login(registry, registryUser, registryToken));
168
+ } else if (!hasRegistryCredentials()) {
169
+ log.warn(
170
+ 'No DOCKER_REGISTRY_USERNAME/TOKEN — remote docker pull requires a public image or one already present on the host.'
171
+ );
172
+ }
173
+
174
+ await session.exec(ssh, cmds.stop);
175
+ await session.exec(ssh, cmds.rm);
176
+ await session.exec(ssh, cmds.pull, {
177
+ timeoutMs: Math.max(session.defaultExecTimeoutMs, 300_000),
178
+ });
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
+ }
195
+ } finally {
196
+ ssh.dispose();
197
+ }
198
+ }
199
+
54
200
  /**
55
201
  * @param {string} artifactDir
56
202
  * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
@@ -65,7 +211,7 @@ export function createDockerProvider(config, envName, env = process.env) {
65
211
  // Tag stays buildId-based; env only scopes which history informed this buildId.
66
212
  const rollbackImage = resolveDockerImageRefForTag(
67
213
  config,
68
- effectiveEnv,
214
+ imageEnv,
69
215
  meta.buildId
70
216
  ).fullImage;
71
217
  log.info(
@@ -82,6 +228,18 @@ export function createDockerProvider(config, envName, env = process.env) {
82
228
  if (!url) return true;
83
229
 
84
230
  try {
231
+ if (remoteMode === 'ssh') {
232
+ const cmds = buildRemoteDockerCommands(fullImage, containerName);
233
+ const session = sshSession();
234
+ const ssh = await session.connect();
235
+ try {
236
+ const result = await session.exec(ssh, cmds.ps);
237
+ return String(result.stdout || '').includes('Up');
238
+ } finally {
239
+ ssh.dispose();
240
+ }
241
+ }
242
+
85
243
  const { stdout } = await execa(
86
244
  'docker',
87
245
  ['ps', '--filter', `name=^/${containerName}$`, '--format', '{{.Status}}'],
@@ -94,10 +252,21 @@ export function createDockerProvider(config, envName, env = process.env) {
94
252
  }
95
253
 
96
254
  async function testConnection() {
255
+ if (remoteMode === 'ssh') {
256
+ const cmds = buildRemoteDockerCommands(fullImage, containerName);
257
+ const session = sshSession();
258
+ const ssh = await session.connect();
259
+ try {
260
+ await session.exec(ssh, cmds.info);
261
+ } finally {
262
+ ssh.dispose();
263
+ }
264
+ return;
265
+ }
97
266
  await execa('docker', ['info'], { stdio: 'pipe', env: getDockerEnv() });
98
267
  }
99
268
 
100
- return { deploy, rollback, healthCheck, testConnection };
269
+ return { deploy, rollback, healthCheck, testConnection, remoteMode };
101
270
  }
102
271
 
103
272
  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, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
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
- // Defense-in-depth: never let a stuck SSH channel hang CI indefinitely.
68
- // Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms). Backend start/stop uses a shorter bound.
69
- const defaultExecTimeoutMs = Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) || 120_000;
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') {