@akash-chowdhury-24/deployhub 2.0.14 → 2.0.16
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 +98 -25
- package/package.json +1 -1
- package/src/artifact/engine.js +11 -4
- package/src/cli/index.js +4 -0
- package/src/commands/deploy.js +61 -16
- package/src/commands/doctor.js +212 -26
- package/src/commands/env.js +326 -0
- package/src/commands/init.js +97 -64
- package/src/commands/rollback.js +84 -24
- package/src/commands/sync-k8s-ports.js +71 -0
- package/src/commands/verify.js +97 -33
- package/src/core/config.js +371 -33
- package/src/core/environments.js +300 -0
- package/src/core/stages.js +55 -19
- package/src/deployment/deployment-env.js +210 -0
- package/src/deployment/index.js +58 -7
- package/src/deployment/init-prompts.js +151 -46
- package/src/deployment/providers/azure-vm.js +8 -10
- package/src/deployment/providers/docker.js +10 -2
- package/src/deployment/providers/ec2.js +7 -9
- package/src/deployment/providers/gcp-vm.js +8 -10
- package/src/deployment/providers/kubernetes.js +15 -6
- package/src/deployment/providers/ssh.js +18 -13
- package/src/storage/index.js +230 -3
- package/src/utils/build-id.js +23 -0
- package/src/utils/dockerfile-expose.js +117 -0
- package/src/utils/github-actions.js +344 -31
- package/src/utils/health-check.js +158 -0
- package/src/utils/kubernetes-manifests.js +105 -10
- package/src/utils/nginx.js +66 -15
- package/src/utils/rollback/engine.js +147 -40
- package/src/utils/scaffold.js +8 -3
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
3
4
|
import { getWorkflowHeaderComment } from './author.js';
|
|
4
5
|
import {
|
|
5
6
|
generateDeploymentEnvSection,
|
|
6
7
|
getDeploymentWorkflowSecretKeys,
|
|
7
8
|
getDeploymentSecretChecklistItems,
|
|
9
|
+
getDeploymentWorkflowSecretKeysForEnv,
|
|
10
|
+
getDeploymentSecretChecklistItemsForEnv,
|
|
11
|
+
shouldPrefixEnvSecrets,
|
|
12
|
+
prefixSecretKey,
|
|
13
|
+
envUsesPrefixedSecrets,
|
|
8
14
|
DEPLOYMENT_ENV_KEYS,
|
|
9
15
|
} from '../deployment/deployment-env.js';
|
|
16
|
+
import { getEnvMethod, getEnvTrigger, isEnvEnabled } from '../core/environments.js';
|
|
10
17
|
|
|
11
18
|
/** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
|
|
12
19
|
|
|
@@ -217,7 +224,9 @@ const KUBECTL_VERSION = 'v1.30.4';
|
|
|
217
224
|
* @returns {boolean}
|
|
218
225
|
*/
|
|
219
226
|
function hasKubernetesDeploy(deployEnvironments, environments) {
|
|
220
|
-
return deployEnvironments.some(
|
|
227
|
+
return deployEnvironments.some(
|
|
228
|
+
(envName) => getEnvMethod(environments[envName]) === 'kubernetes'
|
|
229
|
+
);
|
|
221
230
|
}
|
|
222
231
|
|
|
223
232
|
/**
|
|
@@ -262,46 +271,97 @@ function applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars) {
|
|
|
262
271
|
export const DEPLOY_WORKFLOW_FILENAME = 'deployhub.yml';
|
|
263
272
|
export const ROLLBACK_WORKFLOW_FILENAME = 'deployhub-rollback.yml';
|
|
264
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Upsert a `KEY: value` line into the workflow env set (LHS must be unique for valid YAML).
|
|
276
|
+
* @param {Set<string>} envVars
|
|
277
|
+
* @param {string} lhs
|
|
278
|
+
* @param {string} valueExpr
|
|
279
|
+
*/
|
|
280
|
+
function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
|
|
281
|
+
const linePrefix = `${lhs}: `;
|
|
282
|
+
for (const entry of [...envVars]) {
|
|
283
|
+
if (entry.startsWith(linePrefix)) {
|
|
284
|
+
envVars.delete(entry);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
envVars.add(`${linePrefix}${valueExpr}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
265
290
|
/**
|
|
266
291
|
* Shared env entries for deploy and rollback workflows (same secret resolution).
|
|
267
|
-
*
|
|
292
|
+
* Multi-env: secrets are prefixed (PRODUCTION_SSH_HOST) but mapped to unprefixed
|
|
293
|
+
* process env names the CLI already understands (SSH_HOST).
|
|
294
|
+
*
|
|
295
|
+
* Prefixed secret names are also exported as their own env vars so a single job can
|
|
296
|
+
* carry credentials for every environment; deploy/rollback overlays them per target.
|
|
268
297
|
*
|
|
269
298
|
* @param {string[]} storageProviders
|
|
270
299
|
* @param {string[]} deployEnvironments
|
|
271
|
-
* @param {Record<string, { type
|
|
300
|
+
* @param {Record<string, { type?: string, method?: string }>} environments
|
|
272
301
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
302
|
+
* @param {{ mapForEnv?: string|null }} [options] — when set, only map that env's secrets to unprefixed keys
|
|
273
303
|
* @returns {Set<string>}
|
|
274
304
|
*/
|
|
275
305
|
export function buildWorkflowEnvEntries(
|
|
276
306
|
storageProviders,
|
|
277
307
|
deployEnvironments,
|
|
278
308
|
environments,
|
|
279
|
-
config = null
|
|
309
|
+
config = null,
|
|
310
|
+
options = {}
|
|
280
311
|
) {
|
|
281
312
|
/** @type {Set<string>} */
|
|
282
|
-
const envVars = new Set([
|
|
313
|
+
const envVars = new Set([
|
|
314
|
+
`DEPLOYHUB_ENV: ${options.mapForEnv || deployEnvironments[0] || 'production'}`,
|
|
315
|
+
]);
|
|
283
316
|
|
|
284
317
|
for (const provider of storageProviders) {
|
|
285
318
|
const keys = PROVIDER_ENV_MAP[provider] || [];
|
|
286
319
|
for (const key of keys) {
|
|
287
|
-
envVars
|
|
320
|
+
upsertWorkflowEnvLine(envVars, key, `\${{ secrets.${key} }}`);
|
|
288
321
|
}
|
|
289
322
|
}
|
|
290
323
|
|
|
291
|
-
|
|
324
|
+
const targets = options.mapForEnv
|
|
325
|
+
? [options.mapForEnv]
|
|
326
|
+
: deployEnvironments;
|
|
327
|
+
|
|
328
|
+
const cfg = { ...(config || {}), environments };
|
|
329
|
+
|
|
330
|
+
for (const envName of targets) {
|
|
292
331
|
const env = environments[envName];
|
|
293
332
|
if (!env) continue;
|
|
294
333
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
334
|
+
const method = getEnvMethod(env);
|
|
335
|
+
if (!method) continue;
|
|
336
|
+
const unprefixedKeys = getDeploymentWorkflowSecretKeys(method, config);
|
|
337
|
+
for (const key of unprefixedKeys) {
|
|
338
|
+
const secretName = envUsesPrefixedSecrets(envName, cfg)
|
|
339
|
+
? prefixSecretKey(envName, key)
|
|
340
|
+
: key;
|
|
341
|
+
// Keep every env's prefixed secret available under its full CI name.
|
|
342
|
+
if (secretName !== key) {
|
|
343
|
+
upsertWorkflowEnvLine(envVars, secretName, `\${{ secrets.${secretName} }}`);
|
|
344
|
+
}
|
|
345
|
+
// Unprefixed mapping for CLI defaults (last target wins in static YAML;
|
|
346
|
+
// applyEnvSecretOverlay restores the correct env's values at deploy time).
|
|
347
|
+
upsertWorkflowEnvLine(envVars, key, `\${{ secrets.${secretName} }}`);
|
|
298
348
|
}
|
|
299
349
|
}
|
|
300
350
|
|
|
301
|
-
applyKubernetesWorkflowEnv(
|
|
351
|
+
applyKubernetesWorkflowEnv(targets, environments, envVars);
|
|
302
352
|
return envVars;
|
|
303
353
|
}
|
|
304
354
|
|
|
355
|
+
/**
|
|
356
|
+
* @param {Record<string, unknown>} environments
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
function formatEnvironmentChoiceOptions(environments) {
|
|
360
|
+
const names = Object.keys(environments || {});
|
|
361
|
+
const options = [...names, 'all'];
|
|
362
|
+
return options.map((n) => ` - ${n}`).join('\n');
|
|
363
|
+
}
|
|
364
|
+
|
|
305
365
|
/**
|
|
306
366
|
* @param {Set<string>} envVars
|
|
307
367
|
* @param {string} [indent]
|
|
@@ -339,7 +399,7 @@ export function extractWorkflowSecretKeys(yaml) {
|
|
|
339
399
|
/**
|
|
340
400
|
* @param {string[]} storageProviders
|
|
341
401
|
* @param {string[]} deployEnvironments
|
|
342
|
-
* @param {Record<string, { type
|
|
402
|
+
* @param {Record<string, { type?: string, method?: string }>} environments
|
|
343
403
|
* @param {string} [cliSource]
|
|
344
404
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
345
405
|
* @returns {string}
|
|
@@ -351,9 +411,22 @@ export function generateWorkflowYaml(
|
|
|
351
411
|
cliSource = DEFAULT_NPM_CLI_SOURCE,
|
|
352
412
|
config = null
|
|
353
413
|
) {
|
|
414
|
+
const envNames = Object.keys(environments || {});
|
|
415
|
+
const allDeployNames =
|
|
416
|
+
deployEnvironments.length > 0 ? deployEnvironments : envNames.filter((n) => isEnvEnabled(environments[n]));
|
|
417
|
+
|
|
418
|
+
// Push-triggered auto-deploy targets (build pipeline filters these in CI).
|
|
419
|
+
const pushEnvs = allDeployNames.filter(
|
|
420
|
+
(n) => isEnvEnabled(environments[n]) && getEnvTrigger(environments[n]) === 'push'
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// Secrets for build job: storage + all push envs (or default if none are push).
|
|
424
|
+
const buildSecretEnvs =
|
|
425
|
+
pushEnvs.length > 0 ? pushEnvs : allDeployNames.slice(0, 1);
|
|
426
|
+
|
|
354
427
|
const envVars = buildWorkflowEnvEntries(
|
|
355
428
|
storageProviders,
|
|
356
|
-
|
|
429
|
+
buildSecretEnvs,
|
|
357
430
|
environments,
|
|
358
431
|
config
|
|
359
432
|
);
|
|
@@ -365,7 +438,7 @@ export function generateWorkflowYaml(
|
|
|
365
438
|
const githubGitConfigStep = isGithubCliSource(cliSource)
|
|
366
439
|
? `${getGithubGitConfigStep()}\n`
|
|
367
440
|
: '';
|
|
368
|
-
const kubernetesSteps = hasKubernetesDeploy(
|
|
441
|
+
const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
|
|
369
442
|
? `${getKubernetesSetupSteps()}\n`
|
|
370
443
|
: '';
|
|
371
444
|
|
|
@@ -375,11 +448,41 @@ export function generateWorkflowYaml(
|
|
|
375
448
|
? getInstallDepsCommand(config)
|
|
376
449
|
: 'npm install';
|
|
377
450
|
|
|
451
|
+
const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
|
|
452
|
+
const hasEnvs = envNames.length > 0;
|
|
453
|
+
const dispatchInputs = hasEnvs
|
|
454
|
+
? ` workflow_dispatch:
|
|
455
|
+
inputs:
|
|
456
|
+
environment:
|
|
457
|
+
description: 'Environment to deploy (leave blank on push; use "all" for every enabled env)'
|
|
458
|
+
required: false
|
|
459
|
+
type: choice
|
|
460
|
+
options:
|
|
461
|
+
${envChoiceOptions}
|
|
462
|
+
`
|
|
463
|
+
: ` workflow_dispatch:
|
|
464
|
+
`;
|
|
465
|
+
|
|
466
|
+
const manualDeployStep = hasEnvs
|
|
467
|
+
? ` - name: Deploy (workflow_dispatch)
|
|
468
|
+
if: github.event_name == 'workflow_dispatch'
|
|
469
|
+
env:
|
|
470
|
+
${envBlock}
|
|
471
|
+
run: |
|
|
472
|
+
ENV_INPUT="\${{ inputs.environment }}"
|
|
473
|
+
if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
|
|
474
|
+
${getCliDeployCommand()} --env all
|
|
475
|
+
else
|
|
476
|
+
${getCliDeployCommand()} --env "$ENV_INPUT"
|
|
477
|
+
fi
|
|
478
|
+
`
|
|
479
|
+
: '';
|
|
480
|
+
|
|
378
481
|
const workflow = `${getWorkflowHeaderComment()}name: DeployHub
|
|
379
482
|
on:
|
|
380
483
|
push:
|
|
381
484
|
branches: [main]
|
|
382
|
-
jobs:
|
|
485
|
+
${dispatchInputs}jobs:
|
|
383
486
|
deploy:
|
|
384
487
|
runs-on: ubuntu-latest
|
|
385
488
|
steps:
|
|
@@ -391,20 +494,29 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependenci
|
|
|
391
494
|
run: ${installDepsCommand}
|
|
392
495
|
- name: Install DeployHub CLI
|
|
393
496
|
run: npm install ${installSpec} --no-save
|
|
394
|
-
-
|
|
497
|
+
- name: Build (and auto-deploy push-triggered envs)
|
|
498
|
+
run: ${getCliBuildCommand()}
|
|
395
499
|
env:
|
|
396
500
|
${envBlock}
|
|
397
|
-
`;
|
|
501
|
+
${manualDeployStep}`;
|
|
398
502
|
|
|
399
503
|
return workflow;
|
|
400
504
|
}
|
|
401
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Shell command to run deployhub deploy from the installed scoped package.
|
|
508
|
+
* @returns {string}
|
|
509
|
+
*/
|
|
510
|
+
export function getCliDeployCommand() {
|
|
511
|
+
return `node ./node_modules/${NPM_PACKAGE}/src/cli/index.js deploy`;
|
|
512
|
+
}
|
|
513
|
+
|
|
402
514
|
/**
|
|
403
515
|
* Manual rollback via Actions tab / gh workflow run (workflow_dispatch only).
|
|
404
516
|
*
|
|
405
517
|
* @param {string[]} storageProviders
|
|
406
518
|
* @param {string[]} deployEnvironments
|
|
407
|
-
* @param {Record<string, { type
|
|
519
|
+
* @param {Record<string, { type?: string, method?: string }>} environments
|
|
408
520
|
* @param {string} [cliSource]
|
|
409
521
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
410
522
|
* @returns {string}
|
|
@@ -416,9 +528,15 @@ export function generateRollbackWorkflowYaml(
|
|
|
416
528
|
cliSource = DEFAULT_NPM_CLI_SOURCE,
|
|
417
529
|
config = null
|
|
418
530
|
) {
|
|
531
|
+
const envNames = Object.keys(environments || {});
|
|
532
|
+
const allDeployNames =
|
|
533
|
+
deployEnvironments.length > 0
|
|
534
|
+
? deployEnvironments
|
|
535
|
+
: envNames.filter((n) => isEnvEnabled(environments[n]));
|
|
536
|
+
|
|
419
537
|
const envVars = buildWorkflowEnvEntries(
|
|
420
538
|
storageProviders,
|
|
421
|
-
|
|
539
|
+
allDeployNames,
|
|
422
540
|
environments,
|
|
423
541
|
config
|
|
424
542
|
);
|
|
@@ -428,11 +546,43 @@ export function generateRollbackWorkflowYaml(
|
|
|
428
546
|
const githubGitConfigStep = isGithubCliSource(cliSource)
|
|
429
547
|
? `${getGithubGitConfigStep()}\n`
|
|
430
548
|
: '';
|
|
431
|
-
const kubernetesSteps = hasKubernetesDeploy(
|
|
549
|
+
const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
|
|
432
550
|
? `${getKubernetesSetupSteps()}\n`
|
|
433
551
|
: '';
|
|
434
552
|
|
|
435
553
|
const rollbackCmd = getCliRollbackCommand();
|
|
554
|
+
const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
|
|
555
|
+
const hasEnvs = envNames.length > 0;
|
|
556
|
+
|
|
557
|
+
const environmentInput = hasEnvs
|
|
558
|
+
? ` environment:
|
|
559
|
+
description: 'Environment to roll back (or "all")'
|
|
560
|
+
required: false
|
|
561
|
+
type: choice
|
|
562
|
+
options:
|
|
563
|
+
${envChoiceOptions}
|
|
564
|
+
`
|
|
565
|
+
: '';
|
|
566
|
+
|
|
567
|
+
const rollbackRun = hasEnvs
|
|
568
|
+
? ` ENV_INPUT="\${{ inputs.environment }}"
|
|
569
|
+
BUILD_INPUT="\${{ inputs.buildId }}"
|
|
570
|
+
ENV_FLAG=""
|
|
571
|
+
if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
|
|
572
|
+
ENV_FLAG="--env all"
|
|
573
|
+
else
|
|
574
|
+
ENV_FLAG="--env $ENV_INPUT"
|
|
575
|
+
fi
|
|
576
|
+
if [ -n "$BUILD_INPUT" ]; then
|
|
577
|
+
${rollbackCmd} "$BUILD_INPUT" $ENV_FLAG
|
|
578
|
+
else
|
|
579
|
+
${rollbackCmd} $ENV_FLAG
|
|
580
|
+
fi`
|
|
581
|
+
: ` if [ -n "\${{ inputs.buildId }}" ]; then
|
|
582
|
+
${rollbackCmd} "\${{ inputs.buildId }}"
|
|
583
|
+
else
|
|
584
|
+
${rollbackCmd}
|
|
585
|
+
fi`;
|
|
436
586
|
|
|
437
587
|
return `${getWorkflowHeaderComment()}name: DeployHub Rollback
|
|
438
588
|
on:
|
|
@@ -442,7 +592,7 @@ on:
|
|
|
442
592
|
description: 'Exact buildId to restore (leave blank = previous build)'
|
|
443
593
|
required: false
|
|
444
594
|
type: string
|
|
445
|
-
jobs:
|
|
595
|
+
${environmentInput}jobs:
|
|
446
596
|
rollback:
|
|
447
597
|
runs-on: ubuntu-latest
|
|
448
598
|
steps:
|
|
@@ -456,11 +606,7 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install DeployHub CLI
|
|
|
456
606
|
env:
|
|
457
607
|
${envBlock}
|
|
458
608
|
run: |
|
|
459
|
-
|
|
460
|
-
${rollbackCmd} "\${{ inputs.buildId }}"
|
|
461
|
-
else
|
|
462
|
-
${rollbackCmd}
|
|
463
|
-
fi
|
|
609
|
+
${rollbackRun}
|
|
464
610
|
`;
|
|
465
611
|
}
|
|
466
612
|
|
|
@@ -567,6 +713,166 @@ export async function getRollbackWorkflowDoctorCheck(cwd, config) {
|
|
|
567
713
|
};
|
|
568
714
|
}
|
|
569
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Expected CI secret names for the current config — same set a fresh
|
|
718
|
+
* `sync-workflows` would reference (via buildWorkflowEnvEntries).
|
|
719
|
+
*
|
|
720
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
721
|
+
* @param {'deploy'|'rollback'} [kind]
|
|
722
|
+
* @returns {Set<string>}
|
|
723
|
+
*/
|
|
724
|
+
export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback') {
|
|
725
|
+
const environments = config.environments || {};
|
|
726
|
+
const allNames = Object.keys(environments);
|
|
727
|
+
/** @type {string[]} */
|
|
728
|
+
let targets;
|
|
729
|
+
if (kind === 'deploy') {
|
|
730
|
+
const enabled = allNames.filter((n) => isEnvEnabled(environments[n]));
|
|
731
|
+
const pushEnvs = enabled.filter((n) => getEnvTrigger(environments[n]) === 'push');
|
|
732
|
+
targets = pushEnvs.length > 0 ? pushEnvs : enabled.slice(0, 1);
|
|
733
|
+
} else {
|
|
734
|
+
targets = allNames;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const entries = buildWorkflowEnvEntries(
|
|
738
|
+
config.storage || [],
|
|
739
|
+
targets,
|
|
740
|
+
environments,
|
|
741
|
+
config
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
/** @type {Set<string>} */
|
|
745
|
+
const keys = new Set();
|
|
746
|
+
const re = /secrets\.([A-Z0-9_]+)/g;
|
|
747
|
+
for (const line of entries) {
|
|
748
|
+
let match;
|
|
749
|
+
re.lastIndex = 0;
|
|
750
|
+
while ((match = re.exec(line)) !== null) {
|
|
751
|
+
keys.add(match[1]);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return keys;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Lightweight drift check: checked-in workflow vs current config.
|
|
759
|
+
* Catches missing dispatch env options and missing required secret references.
|
|
760
|
+
*
|
|
761
|
+
* @param {string} yamlText
|
|
762
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
763
|
+
* @param {string} [filename]
|
|
764
|
+
* @returns {{ drifted: boolean, missingEnvs: string[], missingSecrets: string[], summary: string }}
|
|
765
|
+
*/
|
|
766
|
+
export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WORKFLOW_FILENAME) {
|
|
767
|
+
/** @type {string[]} */
|
|
768
|
+
const missingEnvs = [];
|
|
769
|
+
/** @type {string[]} */
|
|
770
|
+
const missingSecrets = [];
|
|
771
|
+
|
|
772
|
+
let parsed;
|
|
773
|
+
try {
|
|
774
|
+
parsed = yaml.load(yamlText);
|
|
775
|
+
} catch {
|
|
776
|
+
return {
|
|
777
|
+
drifted: false,
|
|
778
|
+
missingEnvs,
|
|
779
|
+
missingSecrets,
|
|
780
|
+
summary: '',
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
785
|
+
return { drifted: false, missingEnvs, missingSecrets, summary: '' };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const envNames = Object.keys(config.environments || {});
|
|
789
|
+
const root = /** @type {Record<string, any>} */ (parsed);
|
|
790
|
+
const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
|
|
791
|
+
|
|
792
|
+
if (Array.isArray(options) && envNames.length > 0) {
|
|
793
|
+
const optionSet = new Set(options.map(String));
|
|
794
|
+
for (const name of envNames) {
|
|
795
|
+
if (!optionSet.has(name)) missingEnvs.push(name);
|
|
796
|
+
}
|
|
797
|
+
} else if (envNames.length > 1) {
|
|
798
|
+
// Multi-env config but no dispatch dropdown — stale single-env workflow.
|
|
799
|
+
missingEnvs.push(...envNames);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const kind = filename.includes('rollback') ? 'rollback' : 'deploy';
|
|
803
|
+
const fileSecrets = new Set(extractWorkflowSecretKeys(yamlText));
|
|
804
|
+
const expected = expectedWorkflowSecretKeysFromConfig(config, kind);
|
|
805
|
+
for (const key of expected) {
|
|
806
|
+
if (!fileSecrets.has(key)) missingSecrets.push(key);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const drifted = missingEnvs.length > 0 || missingSecrets.length > 0;
|
|
810
|
+
/** @type {string[]} */
|
|
811
|
+
const parts = [];
|
|
812
|
+
if (missingEnvs.length > 0) {
|
|
813
|
+
parts.push(
|
|
814
|
+
`missing: ${missingEnvs.map((n) => `"${n}"`).join(', ')} in the dispatch dropdown`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (missingSecrets.length > 0) {
|
|
818
|
+
const shown = missingSecrets.slice(0, 4);
|
|
819
|
+
parts.push(
|
|
820
|
+
`missing secret(s): ${shown.join(', ')}${missingSecrets.length > 4 ? ', …' : ''}`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
drifted,
|
|
826
|
+
missingEnvs,
|
|
827
|
+
missingSecrets,
|
|
828
|
+
summary: parts.join('; '),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Doctor helper: informational checks when checked-in workflows drift from config.
|
|
834
|
+
* Skips missing/unparseable files (other checks cover "file missing").
|
|
835
|
+
* Returns an empty array when everything is in sync or not applicable.
|
|
836
|
+
*
|
|
837
|
+
* @param {string} cwd
|
|
838
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
839
|
+
* @returns {Promise<{ name: string, pass: boolean, message: string }[]>}
|
|
840
|
+
*/
|
|
841
|
+
export async function getWorkflowDriftDoctorChecks(cwd, config) {
|
|
842
|
+
const envCount = Object.keys(config.environments || {}).length;
|
|
843
|
+
if (envCount === 0) return [];
|
|
844
|
+
|
|
845
|
+
/** @type {{ name: string, pass: boolean, message: string }[]} */
|
|
846
|
+
const checks = [];
|
|
847
|
+
const files = [DEPLOY_WORKFLOW_FILENAME, ROLLBACK_WORKFLOW_FILENAME];
|
|
848
|
+
|
|
849
|
+
for (const filename of files) {
|
|
850
|
+
const filePath = path.join(cwd, '.github', 'workflows', filename);
|
|
851
|
+
if (!(await fs.pathExists(filePath))) continue;
|
|
852
|
+
|
|
853
|
+
let text;
|
|
854
|
+
try {
|
|
855
|
+
text = await fs.readFile(filePath, 'utf8');
|
|
856
|
+
} catch {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const drift = detectWorkflowConfigDrift(text, config, filename);
|
|
861
|
+
if (!drift.drifted) continue;
|
|
862
|
+
|
|
863
|
+
checks.push({
|
|
864
|
+
name: `Workflow sync (${filename})`,
|
|
865
|
+
// Informational nudge — same pattern as missing rollback workflow (pass: true).
|
|
866
|
+
pass: true,
|
|
867
|
+
message:
|
|
868
|
+
`.github/workflows/${filename} looks out of date with your current environments config ` +
|
|
869
|
+
`(${drift.summary}). Run: deployhub sync-workflows`,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return checks;
|
|
874
|
+
}
|
|
875
|
+
|
|
570
876
|
/**
|
|
571
877
|
* @param {string} cliSource
|
|
572
878
|
* @param {string} [cwd]
|
|
@@ -669,9 +975,15 @@ export function getGithubSecretsChecklist(
|
|
|
669
975
|
|
|
670
976
|
for (const envName of deployEnvironments) {
|
|
671
977
|
const env = environments[envName];
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
978
|
+
const method = getEnvMethod(env);
|
|
979
|
+
if (!method) continue;
|
|
980
|
+
|
|
981
|
+
for (const item of getDeploymentSecretChecklistItemsForEnv(
|
|
982
|
+
envName,
|
|
983
|
+
method,
|
|
984
|
+
config,
|
|
985
|
+
environments
|
|
986
|
+
)) {
|
|
675
987
|
const existing = byKey.get(item.key);
|
|
676
988
|
if (existing) {
|
|
677
989
|
if (item.required && !existing.required) {
|
|
@@ -741,9 +1053,10 @@ export function generateEnvExampleContent(
|
|
|
741
1053
|
|
|
742
1054
|
for (const envName of deployEnvironments) {
|
|
743
1055
|
const env = environments[envName];
|
|
744
|
-
|
|
1056
|
+
const method = getEnvMethod(env);
|
|
1057
|
+
if (!method) continue;
|
|
745
1058
|
|
|
746
|
-
const deploySection = generateDeploymentEnvSection(
|
|
1059
|
+
const deploySection = generateDeploymentEnvSection(method, config, environments);
|
|
747
1060
|
if (deploySection) {
|
|
748
1061
|
sections.push(`${deploySection}\n`);
|
|
749
1062
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getEnvSettings } from '../core/environments.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-environment health URL: `environments.{env}.config.healthCheckUrl`, else top-level fallback.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
8
|
+
* @param {string} envName
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function resolveEnvHealthCheckUrl(config, envName) {
|
|
12
|
+
const env = config.environments?.[envName];
|
|
13
|
+
const settings = getEnvSettings(env);
|
|
14
|
+
const override = settings.healthCheckUrl;
|
|
15
|
+
if (typeof override === 'string' && override.trim()) {
|
|
16
|
+
return override.trim();
|
|
17
|
+
}
|
|
18
|
+
const fallback = config.healthCheck?.url;
|
|
19
|
+
return typeof fallback === 'string' && fallback.trim() ? fallback.trim() : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
24
|
+
* @param {string[]} envNames
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function anyEnvHasResolvableHealthCheckUrl(config, envNames) {
|
|
28
|
+
return (envNames || []).some((name) => Boolean(resolveEnvHealthCheckUrl(config, name)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {number} status
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isHealthyHttpStatus(status) {
|
|
36
|
+
return status >= 200 && status < 400;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
41
|
+
* @param {string} envName
|
|
42
|
+
* @param {{ httpGet?: (url: string, timeoutMs: number) => Promise<{ status: number }> }} [options]
|
|
43
|
+
* @returns {Promise<{ ok: true, envName: string, url: string, status: number, elapsed: number } | { ok: false, envName: string, url: string, error: string }>}
|
|
44
|
+
*/
|
|
45
|
+
export async function checkEnvHealth(config, envName, options = {}) {
|
|
46
|
+
const url = resolveEnvHealthCheckUrl(config, envName);
|
|
47
|
+
if (!url) {
|
|
48
|
+
return { ok: false, envName, url: '', error: `No health check URL configured for environment "${envName}"` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const timeoutMs = (config.healthCheck?.timeout || 30) * 1000;
|
|
52
|
+
const httpGet =
|
|
53
|
+
options.httpGet ||
|
|
54
|
+
(async (target, ms) => {
|
|
55
|
+
const response = await axios.get(target, {
|
|
56
|
+
timeout: ms,
|
|
57
|
+
validateStatus: () => true,
|
|
58
|
+
});
|
|
59
|
+
return { status: response.status };
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
try {
|
|
64
|
+
const { status } = await httpGet(url, timeoutMs);
|
|
65
|
+
const elapsed = Date.now() - start;
|
|
66
|
+
if (!isHealthyHttpStatus(status)) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
envName,
|
|
70
|
+
url,
|
|
71
|
+
error: `Health check failed for ${envName}: HTTP ${status} (${elapsed}ms) — ${url}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, envName, url, status, elapsed };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const elapsed = Date.now() - start;
|
|
77
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
envName,
|
|
81
|
+
url,
|
|
82
|
+
error: `Health check failed for ${envName}: ${detail} (${elapsed}ms) — ${url}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run health checks for each deployed environment independently (checks all, then summarizes).
|
|
89
|
+
*
|
|
90
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
91
|
+
* @param {string[]} envNames
|
|
92
|
+
* @param {{ httpGet?: (url: string, timeoutMs: number) => Promise<{ status: number }> }} [options]
|
|
93
|
+
*/
|
|
94
|
+
export async function runHealthChecksForEnvs(config, envNames, options = {}) {
|
|
95
|
+
/** @type {{ envName: string, url: string, status: number, elapsed: number }[]} */
|
|
96
|
+
const results = [];
|
|
97
|
+
/** @type {{ envName: string, url: string, error: string }[]} */
|
|
98
|
+
const failures = [];
|
|
99
|
+
|
|
100
|
+
for (const envName of envNames || []) {
|
|
101
|
+
const url = resolveEnvHealthCheckUrl(config, envName);
|
|
102
|
+
if (!url) continue;
|
|
103
|
+
|
|
104
|
+
const outcome = await checkEnvHealth(config, envName, options);
|
|
105
|
+
if (outcome.ok) {
|
|
106
|
+
results.push({
|
|
107
|
+
envName: outcome.envName,
|
|
108
|
+
url: outcome.url,
|
|
109
|
+
status: outcome.status,
|
|
110
|
+
elapsed: outcome.elapsed,
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
failures.push({
|
|
114
|
+
envName: outcome.envName,
|
|
115
|
+
url: outcome.url,
|
|
116
|
+
error: outcome.error,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { results, failures };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {{ envName: string, url: string, status: number, elapsed: number }[]} results
|
|
126
|
+
* @param {{ envName: string, url: string, error: string }[]} failures
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function formatHealthCheckAllSummary(results, failures) {
|
|
130
|
+
const lines = ['Health check summary:'];
|
|
131
|
+
for (const r of results) {
|
|
132
|
+
lines.push(
|
|
133
|
+
` ✓ ${r.envName.padEnd(14)} → HTTP ${r.status} (${r.elapsed}ms) ${r.url}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
for (const f of failures) {
|
|
137
|
+
lines.push(` ✗ ${f.envName.padEnd(14)} → FAILED: ${f.error}`);
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
if (failures.length === 0) {
|
|
141
|
+
lines.push(`All ${results.length} environment(s) passed health checks.`);
|
|
142
|
+
} else {
|
|
143
|
+
const total = results.length + failures.length;
|
|
144
|
+
lines.push(
|
|
145
|
+
`${failures.length} of ${total} environment(s) failed health checks. See above for details.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default {
|
|
152
|
+
resolveEnvHealthCheckUrl,
|
|
153
|
+
anyEnvHasResolvableHealthCheckUrl,
|
|
154
|
+
isHealthyHttpStatus,
|
|
155
|
+
checkEnvHealth,
|
|
156
|
+
runHealthChecksForEnvs,
|
|
157
|
+
formatHealthCheckAllSummary,
|
|
158
|
+
};
|