@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.
@@ -266,6 +266,22 @@ export function resolveEnvTargets(config, envFlag) {
266
266
  return { targets: [envFlag], skippedDisabled: [] };
267
267
  }
268
268
 
269
+ /**
270
+ * Whitelist of method-config fields copied onto process env for Docker and
271
+ * Kubernetes providers. Structural (not a per-method if): only these keys are
272
+ * ever copied. `remote` / `host` / `user` / `keyPath` are intentionally absent
273
+ * — docker SSH identity is read by docker.js from settings + SSH_* env vars.
274
+ * Kubernetes uses this same helper and therefore cannot observe remote.mode.
275
+ */
276
+ export const METHOD_SETTINGS_ENV_OVERLAY = Object.freeze({
277
+ dockerImageName: 'DOCKER_IMAGE_NAME',
278
+ dockerRegistryUrl: 'DOCKER_REGISTRY_URL',
279
+ dockerHost: 'DOCKER_HOST',
280
+ kubeNamespace: 'KUBE_NAMESPACE',
281
+ kubeconfig: 'KUBECONFIG',
282
+ kubeContext: 'KUBE_CONTEXT',
283
+ });
284
+
269
285
  /**
270
286
  * Overlay method-specific config onto process env for Docker/K8s providers.
271
287
  * Secrets still come from real env vars; non-secret names/paths come from config.
@@ -277,12 +293,10 @@ export function resolveEnvTargets(config, envFlag) {
277
293
  export function mergeMethodSettingsIntoEnv(env, settings) {
278
294
  /** @type {Record<string, string|undefined>} */
279
295
  const out = { ...env };
280
- if (settings.dockerImageName) out.DOCKER_IMAGE_NAME = String(settings.dockerImageName);
281
- if (settings.dockerRegistryUrl) out.DOCKER_REGISTRY_URL = String(settings.dockerRegistryUrl);
282
- if (settings.dockerHost) out.DOCKER_HOST = String(settings.dockerHost);
283
- if (settings.kubeNamespace) out.KUBE_NAMESPACE = String(settings.kubeNamespace);
284
- if (settings.kubeconfig) out.KUBECONFIG = String(settings.kubeconfig);
285
- if (settings.kubeContext) out.KUBE_CONTEXT = String(settings.kubeContext);
296
+ for (const [settingKey, envKey] of Object.entries(METHOD_SETTINGS_ENV_OVERLAY)) {
297
+ const value = settings[settingKey];
298
+ if (value) out[envKey] = String(value);
299
+ }
286
300
  return out;
287
301
  }
288
302
 
@@ -302,4 +316,5 @@ export default {
302
316
  buildEnvironmentEntry,
303
317
  resolveEnvTargets,
304
318
  mergeMethodSettingsIntoEnv,
319
+ METHOD_SETTINGS_ENV_OVERLAY,
305
320
  };
@@ -3,6 +3,9 @@
3
3
  * and post-init next steps.
4
4
  */
5
5
 
6
+ import { getEnvSettings } from '../core/environments.js';
7
+ import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
8
+
6
9
  /** @typedef {{ key: string, comment: string[], example?: string, default?: string, optionalReason?: string, when?: 'backend'|'optional'|'ci' }} EnvVarDef */
7
10
 
8
11
  /** @type {Record<string, EnvVarDef[]>} */
@@ -55,7 +58,7 @@ export const SSH_BACKEND_ENV_VARS = [
55
58
  {
56
59
  key: 'SSH_APP_NAME',
57
60
  comment: [
58
- 'Process name used by PM2 when restarting your Node.js backend.',
61
+ 'Env-scoped process identity: PM2 app name (Node), DEPLOYHUB_APP / PID markers (Python, Java, Go, .NET, Rails), and related resource names. Not the php-fpm systemd unit.',
59
62
  ],
60
63
  example: 'my-api',
61
64
  when: 'backend',
@@ -131,10 +134,12 @@ export const DEPLOYMENT_ENV_DEFS = {
131
134
  },
132
135
  {
133
136
  key: 'DOCKER_HOST',
134
- optionalReason: 'only required when deploying to a remote Docker daemon instead of local Docker',
137
+ optionalReason:
138
+ 'only for advanced raw Docker CLI transport (tcp:// or custom ssh://). Prefer remote.mode "ssh" (SSH_HOST / SSH_USER / SSH_KEY_PATH) for a remote Linux box',
135
139
  comment: [
136
- 'Remote Docker daemon address.',
137
- 'Examples: ssh://ubuntu@203.0.113.10 | tcp://203.0.113.10:2376',
140
+ 'Advanced/escape-hatch: raw Docker daemon URI. DeployHub cannot validate ssh:// via doctor.',
141
+ 'Prefer "Remote Linux server via SSH" at init (remote.mode: ssh) unless you manage TLS/ssh:// yourself.',
142
+ 'Examples: tcp://203.0.113.10:2376 | ssh://ubuntu@203.0.113.10',
138
143
  ],
139
144
  when: 'optional',
140
145
  },
@@ -394,6 +399,56 @@ export const DEPLOYMENT_ENV_KEYS = Object.fromEntries(
394
399
  ])
395
400
  );
396
401
 
402
+ const DOCKER_HOST_KEYS = new Set(['DOCKER_HOST', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH']);
403
+
404
+ /**
405
+ * SSH identity vars used when docker `remote.mode === "ssh"`.
406
+ * Same names as ec2; per-env secret prefixing separates a sibling ssh/ec2 env.
407
+ * Not listed in static DEPLOYMENT_ENV_KEYS.docker (local/raw docker do not read them).
408
+ */
409
+ export const DOCKER_SSH_ENV_VARS = [
410
+ ...SSH_BASE_ENV_VARS.filter((d) => d.key !== 'SSH_DEPLOY_PATH'),
411
+ ...SSH_CI_ENV_VARS,
412
+ ];
413
+
414
+ /**
415
+ * @param {Record<string, unknown>|null|undefined} settings
416
+ * @returns {boolean}
417
+ */
418
+ function dockerHasExplicitRemoteMode(settings) {
419
+ const remote = settings && /** @type {Record<string, unknown>} */ (settings).remote;
420
+ const mode =
421
+ remote && typeof remote === 'object'
422
+ ? /** @type {Record<string, unknown>} */ (remote).mode
423
+ : undefined;
424
+ return mode === 'ssh' || mode === 'local' || mode === 'raw';
425
+ }
426
+
427
+ /**
428
+ * Per-env docker defs: ssh mode adds SSH_* and drops raw DOCKER_HOST;
429
+ * explicit local drops DOCKER_HOST; configs with no remote.mode keep legacy defs.
430
+ *
431
+ * @param {string} deployType
432
+ * @param {Record<string, unknown>|null} [settings]
433
+ * @returns {EnvVarDef[]}
434
+ */
435
+ export function getMethodEnvDefs(deployType, settings = null) {
436
+ const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
437
+ if (deployType !== 'docker') return defs;
438
+ const s = settings || {};
439
+ if (!dockerHasExplicitRemoteMode(s)) {
440
+ return defs;
441
+ }
442
+ const mode = resolveDockerRemoteMode(s, {});
443
+ if (mode === 'ssh') {
444
+ return [...defs.filter((d) => !DOCKER_HOST_KEYS.has(d.key)), ...DOCKER_SSH_ENV_VARS];
445
+ }
446
+ if (mode === 'local') {
447
+ return defs.filter((d) => !DOCKER_HOST_KEYS.has(d.key));
448
+ }
449
+ return defs;
450
+ }
451
+
397
452
  /**
398
453
  * Deployment-side cloud-API lookup credentials — distinct from storage-provider
399
454
  * env vars (storage stays project-wide / unprefixed).
@@ -422,8 +477,8 @@ export const DEPLOYMENT_LOOKUP_ENV_KEYS = new Set([
422
477
  * @param {import('../core/config.js').DeployHubConfig} [config]
423
478
  * @returns {string[]}
424
479
  */
425
- export function getDeploymentEnvKeys(deployType, config = null) {
426
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
480
+ export function getDeploymentEnvKeys(deployType, config = null, settings = null) {
481
+ const defs = getMethodEnvDefs(deployType, settings);
427
482
  const projectType = config?.projectType || 'frontend';
428
483
  const isBackend = projectType === 'backend' || projectType === 'both';
429
484
 
@@ -452,8 +507,8 @@ function toGithubSecretKey(key) {
452
507
  * @param {import('../core/config.js').DeployHubConfig} [config]
453
508
  * @returns {string[]}
454
509
  */
455
- export function getDeploymentSecretKeys(deployType, config = null) {
456
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
510
+ export function getDeploymentSecretKeys(deployType, config = null, settings = null) {
511
+ const defs = getMethodEnvDefs(deployType, settings);
457
512
  const projectType = config?.projectType || 'frontend';
458
513
  const isBackend = projectType === 'backend' || projectType === 'both';
459
514
 
@@ -477,8 +532,8 @@ export function getDeploymentSecretKeys(deployType, config = null) {
477
532
  * @param {import('../core/config.js').DeployHubConfig} [config]
478
533
  * @returns {string[]}
479
534
  */
480
- export function getDeploymentWorkflowSecretKeys(deployType, config = null) {
481
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
535
+ export function getDeploymentWorkflowSecretKeys(deployType, config = null, settings = null) {
536
+ const defs = getMethodEnvDefs(deployType, settings);
482
537
  const projectType = config?.projectType || 'frontend';
483
538
  const isBackend = projectType === 'backend' || projectType === 'both';
484
539
 
@@ -503,8 +558,8 @@ export function getDeploymentWorkflowSecretKeys(deployType, config = null) {
503
558
  * @param {import('../core/config.js').DeployHubConfig} [config]
504
559
  * @returns {SecretChecklistItem[]}
505
560
  */
506
- export function getDeploymentSecretChecklistItems(deployType, config = null) {
507
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
561
+ export function getDeploymentSecretChecklistItems(deployType, config = null, settings = null) {
562
+ const defs = getMethodEnvDefs(deployType, settings);
508
563
  const projectType = config?.projectType || 'frontend';
509
564
  const isBackend = projectType === 'backend' || projectType === 'both';
510
565
 
@@ -651,7 +706,11 @@ export function applyEnvSecretOverlay(envName, config, env = process.env) {
651
706
  null;
652
707
  if (!method) return out;
653
708
 
654
- const keys = getDeploymentWorkflowSecretKeys(method, /** @type {any} */ (config));
709
+ const keys = getDeploymentWorkflowSecretKeys(
710
+ method,
711
+ /** @type {any} */ (config),
712
+ getEnvSettings(entry)
713
+ );
655
714
  for (const key of keys) {
656
715
  const prefixed = prefixSecretKey(envName, key);
657
716
  if (out[prefixed]) {
@@ -680,10 +739,12 @@ export function getDeploymentWorkflowSecretKeysForEnv(
680
739
  config = null,
681
740
  environments = null
682
741
  ) {
683
- const keys = getDeploymentWorkflowSecretKeys(deployType, config);
742
+ const envs = environments || config?.environments || {};
743
+ const settings = getEnvSettings(envs[envName]);
744
+ const keys = getDeploymentWorkflowSecretKeys(deployType, config, settings);
684
745
  const cfg = {
685
746
  ...(config || {}),
686
- environments: environments || config?.environments || {},
747
+ environments: envs,
687
748
  };
688
749
  if (!envUsesPrefixedSecrets(envName, cfg)) {
689
750
  return keys;
@@ -705,7 +766,9 @@ export function getDeploymentSecretChecklistItemsForEnv(
705
766
  config = null,
706
767
  environments = null
707
768
  ) {
708
- const items = getDeploymentSecretChecklistItems(deployType, config);
769
+ const envs = environments || config?.environments || {};
770
+ const settings = getEnvSettings(envs[envName]);
771
+ const items = getDeploymentSecretChecklistItems(deployType, config, settings);
709
772
  const cfg = {
710
773
  ...(config || {}),
711
774
  environments: environments || config?.environments || {},
@@ -736,10 +799,12 @@ export function getDeploymentSecretKeysForEnv(
736
799
  config = null,
737
800
  environments = null
738
801
  ) {
739
- const keys = getDeploymentSecretKeys(deployType, config);
802
+ const envs = environments || config?.environments || {};
803
+ const settings = getEnvSettings(envs[envName]);
804
+ const keys = getDeploymentSecretKeys(deployType, config, settings);
740
805
  const cfg = {
741
806
  ...(config || {}),
742
- environments: environments || config?.environments || {},
807
+ environments: envs,
743
808
  };
744
809
  if (!envUsesPrefixedSecrets(envName, cfg)) {
745
810
  return keys;
@@ -786,7 +851,6 @@ export function generateDeploymentEnvSection(
786
851
  environments = {},
787
852
  options = {}
788
853
  ) {
789
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
790
854
  const projectType = config?.projectType || 'frontend';
791
855
  const isBackend = projectType === 'backend' || projectType === 'both';
792
856
  const envName = options.envName;
@@ -819,7 +883,7 @@ export function generateDeploymentEnvSection(
819
883
  ? /** @type {Record<string, unknown>} */ (envEntry.config)
820
884
  : envEntry;
821
885
 
822
- for (const d of defs) {
886
+ for (const d of getMethodEnvDefs(deployType, settings)) {
823
887
  if (d.when === 'backend' && !isBackend) continue;
824
888
 
825
889
  const isOptional = d.when === 'optional' || d.when === 'ci';
@@ -907,23 +971,25 @@ export const DEPLOYMENT_GUIDE = {
907
971
  },
908
972
  docker: {
909
973
  before: [
910
- 'Docker installed locally (docker --version works).',
911
- 'If deploying to a remote host: Docker installed on that host and reachable.',
974
+ 'Docker installed locally (docker --version works) — used to build/push images.',
975
+ 'For remote Linux via SSH: Docker installed on that host and the SSH user in the docker group.',
912
976
  'A Dockerfile or docker-compose.yml in your project (or enable pipeline.docker).',
913
977
  'Registry account if pushing to a private registry (Docker Hub, GHCR, etc.).',
914
978
  ],
915
979
  automates: [
916
980
  'Generates config, workflow, and .env.example for registry and image settings.',
917
981
  'Generates a starter Dockerfile and .dockerignore when missing.',
918
- 'Tests Docker daemon connectivity during init.',
982
+ 'Offers local Docker, first-class remote SSH (node-ssh), or advanced raw DOCKER_HOST.',
983
+ 'Validates SSH key and host when remote.mode is ssh; tests the local daemon otherwise.',
919
984
  'Builds the image once during the pipeline docker stage, then reuses it on deploy.',
920
985
  ],
921
986
  after: [
922
987
  'Copy .env.example to .env and set DOCKER_IMAGE_NAME (required — e.g. myuser/myapp).',
923
- 'Optional in .env: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
988
+ 'Remote Linux via SSH: also set SSH_HOST, SSH_USER, SSH_KEY_PATH (same names as EC2).',
989
+ 'Advanced raw URI only: DOCKER_HOST, and DOCKER_TLS_VERIFY / DOCKER_CERT_PATH for tcp:// TLS.',
924
990
  'If using a private registry: also set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
925
991
  'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions). Local .env is NOT used by GitHub Actions — doctor only checks your machine.',
926
- 'Run deployhub doctor to verify Docker is reachable.',
992
+ 'Run deployhub doctor to verify Docker (and SSH, when remote.mode is ssh).',
927
993
  'git push origin main to trigger your first deployment.',
928
994
  ],
929
995
  },
@@ -143,7 +143,9 @@ export async function validateSshKeyForDoctor(keyPath, sshKey) {
143
143
  }
144
144
 
145
145
  const mode = stat.mode & 0o777;
146
- if (mode !== 0o400 && mode !== 0o600) {
146
+ // Windows NTFS does not carry Unix 400/600; OpenSSH uses ACLs instead.
147
+ // A 666 stat here is not "world-readable" the way it is on Linux.
148
+ if (process.platform !== 'win32' && mode !== 0o400 && mode !== 0o600) {
147
149
  return {
148
150
  ok: false,
149
151
  message: `SSH key permissions are ${mode.toString(8)} (should be 400 or 600) — run: chmod 600 ${resolved}`,
@@ -161,6 +161,7 @@ function buildNonInteractiveDeployAnswers(
161
161
  dockerRegistryUsername: '',
162
162
  dockerRegistryToken: '',
163
163
  dockerHost: '',
164
+ remoteMode: 'local',
164
165
  healthUrl: '',
165
166
  };
166
167
  }
@@ -307,7 +308,7 @@ async function promptKubernetesDeployment(base, projectName, projectType, option
307
308
  * @param {'frontend'|'backend'|'both'} projectType
308
309
  */
309
310
  async function promptDockerDeployment(base, projectName, projectType) {
310
- const dockerAnswers = await inquirer.prompt([
311
+ const imageAnswers = await inquirer.prompt([
311
312
  {
312
313
  type: 'input',
313
314
  name: 'dockerImageName',
@@ -329,11 +330,81 @@ async function promptDockerDeployment(base, projectName, projectType) {
329
330
  name: 'dockerRegistryToken',
330
331
  message: 'Registry token/password (only if using a private registry):',
331
332
  },
333
+ ]);
334
+
335
+ const { remoteMode } = await inquirer.prompt([
332
336
  {
333
- type: 'input',
334
- name: 'dockerHost',
335
- message: 'Remote Docker host (optional, e.g. ssh://ubuntu@203.0.113.10):',
337
+ type: 'list',
338
+ name: 'remoteMode',
339
+ message: 'Where should the container run?',
340
+ choices: [
341
+ { name: 'Locally (this machine or CI runner)', value: 'local' },
342
+ {
343
+ name: 'Remote Linux server via SSH (recommended for production)',
344
+ value: 'ssh',
345
+ },
346
+ {
347
+ name: 'Advanced: raw Docker host URI (tcp://, custom SSH setup)',
348
+ value: 'raw',
349
+ },
350
+ ],
351
+ default: 'local',
336
352
  },
353
+ ]);
354
+
355
+ /** @type {Record<string, string>} */
356
+ let sshAnswers = {};
357
+ /** @type {Record<string, string>} */
358
+ let rawAnswers = {};
359
+
360
+ if (remoteMode === 'ssh') {
361
+ sshAnswers = await inquirer.prompt([
362
+ {
363
+ type: 'input',
364
+ name: 'host',
365
+ message: 'Remote server host/IP:',
366
+ },
367
+ {
368
+ type: 'input',
369
+ name: 'user',
370
+ message: 'SSH username:',
371
+ },
372
+ {
373
+ type: 'input',
374
+ name: 'keyPath',
375
+ message: 'Path to SSH private key:',
376
+ },
377
+ ]);
378
+
379
+ await runSshInitValidation({
380
+ host: sshAnswers.host,
381
+ user: sshAnswers.user,
382
+ keyPath: sshAnswers.keyPath,
383
+ sshPort: 22,
384
+ deployType: getDeployTypeLabel('docker'),
385
+ });
386
+ }
387
+
388
+ if (remoteMode === 'raw') {
389
+ console.log(
390
+ chalk.gray(
391
+ '\n Note: this mode uses Docker\'s native ssh://tcp:// transport and\n' +
392
+ ' depends on your local machine\'s own SSH/TLS configuration —\n' +
393
+ ' DeployHub cannot validate this connection ahead of time. Prefer\n' +
394
+ ' "Remote Linux server via SSH" above unless you have a specific\n' +
395
+ ' reason to use this.\n'
396
+ )
397
+ );
398
+ rawAnswers = await inquirer.prompt([
399
+ {
400
+ type: 'input',
401
+ name: 'dockerHost',
402
+ message: 'Remote Docker host (optional, e.g. ssh://ubuntu@203.0.113.10):',
403
+ },
404
+ ]);
405
+ }
406
+
407
+ const { healthUrl } = await inquirer.prompt([
337
408
  {
338
409
  type: 'input',
339
410
  name: 'healthUrl',
@@ -341,7 +412,14 @@ async function promptDockerDeployment(base, projectName, projectType) {
341
412
  },
342
413
  ]);
343
414
 
344
- return { ...base, ...dockerAnswers };
415
+ return {
416
+ ...base,
417
+ ...imageAnswers,
418
+ remoteMode,
419
+ ...sshAnswers,
420
+ dockerHost: rawAnswers.dockerHost || '',
421
+ healthUrl,
422
+ };
345
423
  }
346
424
 
347
425
  /**
@@ -563,7 +641,17 @@ export function buildServerEnvEntry(
563
641
  if (deployAnswers.deployType === 'docker') {
564
642
  settings.dockerImageName = deployAnswers.dockerImageName || projectName;
565
643
  settings.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
566
- settings.dockerHost = deployAnswers.dockerHost || '';
644
+ const remoteMode =
645
+ deployAnswers.remoteMode || (deployAnswers.dockerHost ? 'raw' : 'local');
646
+ settings.remote = { mode: remoteMode };
647
+ // Raw URI stays in config (existing DOCKER_HOST overlay). SSH key path is
648
+ // env-only. Host/user are non-secret settings (same as ec2) so doctor and
649
+ // .env.example can resolve them without writing the private key path here.
650
+ settings.dockerHost = remoteMode === 'raw' ? deployAnswers.dockerHost || '' : '';
651
+ if (remoteMode === 'ssh') {
652
+ if (deployAnswers.host) settings.host = deployAnswers.host;
653
+ if (deployAnswers.user) settings.user = deployAnswers.user;
654
+ }
567
655
  return {
568
656
  enabled: true,
569
657
  method: 'docker',
@@ -680,6 +768,12 @@ export function getDockerEnvSecrets(deployAnswers) {
680
768
  if (deployAnswers.dockerRegistryToken) {
681
769
  vars.DOCKER_REGISTRY_TOKEN = deployAnswers.dockerRegistryToken;
682
770
  }
771
+ if (deployAnswers.deployType === 'docker' && deployAnswers.remoteMode === 'ssh') {
772
+ if (deployAnswers.keyPath) vars.SSH_KEY_PATH = deployAnswers.keyPath;
773
+ }
774
+ if (deployAnswers.deployType === 'docker' && deployAnswers.remoteMode === 'raw') {
775
+ if (deployAnswers.dockerHost) vars.DOCKER_HOST = deployAnswers.dockerHost;
776
+ }
683
777
  return Object.keys(vars).length > 0 ? vars : null;
684
778
  }
685
779
 
@@ -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