@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.
@@ -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 imageOps = createDockerImageDeployContext(config, effectiveEnv, log);
18
- const { fullImage, getDockerEnv, ensureImageReadyForDeploy } = imageOps;
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
- effectiveEnv,
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, 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') {
@@ -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 };