@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
package/src/core/environments.js
CHANGED
|
@@ -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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
'
|
|
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:
|
|
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
|
-
'
|
|
137
|
-
'
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
|
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: '
|
|
334
|
-
name: '
|
|
335
|
-
message: '
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|