@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-environment helpers — kept separate from config I/O to avoid ESM cycles
|
|
3
|
+
* (e.g. github-actions ↔ config via doctor/sync-workflows).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** DNS label / Kubernetes namespace max length — tightest downstream constraint. */
|
|
7
|
+
export const ENV_NAME_MAX_LENGTH = 63;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reserved CLI sentinel names. `"all"` collides with `--env all`.
|
|
11
|
+
* `"default"` is intentionally NOT reserved — it is a normal env key used by
|
|
12
|
+
* single-env init and legacy migration, with no special CLI meaning.
|
|
13
|
+
*/
|
|
14
|
+
export const RESERVED_ENVIRONMENT_NAMES = new Set(['all']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lowercase kebab-case: safe for storage paths, CI secret prefixes (uppercased),
|
|
18
|
+
* and Nginx site filename segments.
|
|
19
|
+
*/
|
|
20
|
+
export const ENV_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a candidate environment name before writing config.
|
|
24
|
+
* Shared by `deployhub env add` and interactive init / env-name prompts.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @param {Iterable<string>} [existingNames]
|
|
28
|
+
* @returns {{ ok: true, name: string } | { ok: false, error: string }}
|
|
29
|
+
*/
|
|
30
|
+
export function validateEnvironmentName(name, existingNames = []) {
|
|
31
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
return { ok: false, error: 'Environment name cannot be empty.' };
|
|
34
|
+
}
|
|
35
|
+
if (trimmed.length > ENV_NAME_MAX_LENGTH) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: `Environment name must be at most ${ENV_NAME_MAX_LENGTH} characters.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (RESERVED_ENVIRONMENT_NAMES.has(trimmed.toLowerCase())) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `"${trimmed}" is a reserved name and cannot be used as an environment name.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (!ENV_NAME_PATTERN.test(trimmed)) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `Invalid environment name "${trimmed}". Use lowercase letters, numbers, and hyphens only (must start with a letter), e.g. production, staging, my-app.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const existing = [...existingNames];
|
|
54
|
+
const collision = existing.find((n) => String(n).toLowerCase() === trimmed.toLowerCase());
|
|
55
|
+
if (collision) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: `Environment "${collision}" already exists (names are case-insensitive). Use a different name or remove it first.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, name: trimmed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Inquirer `validate` callback for environment name prompts.
|
|
66
|
+
*
|
|
67
|
+
* @param {Iterable<string>} [existingNames]
|
|
68
|
+
* @returns {(input: string) => true|string}
|
|
69
|
+
*/
|
|
70
|
+
export function createEnvNamePromptValidate(existingNames = []) {
|
|
71
|
+
return (input) => {
|
|
72
|
+
const result = validateEnvironmentName(input, existingNames);
|
|
73
|
+
return result.ok ? true : result.error;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {unknown} entry
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isNewEnvironmentShape(entry) {
|
|
82
|
+
return Boolean(
|
|
83
|
+
entry &&
|
|
84
|
+
typeof entry === 'object' &&
|
|
85
|
+
typeof /** @type {Record<string, unknown>} */ (entry).method === 'string' &&
|
|
86
|
+
/** @type {Record<string, unknown>} */ (entry).config &&
|
|
87
|
+
typeof /** @type {Record<string, unknown>} */ (entry).config === 'object' &&
|
|
88
|
+
!Array.isArray(/** @type {Record<string, unknown>} */ (entry).config)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {unknown} envEntry
|
|
94
|
+
* @returns {string|undefined}
|
|
95
|
+
*/
|
|
96
|
+
export function getEnvMethod(envEntry) {
|
|
97
|
+
if (!envEntry || typeof envEntry !== 'object') return undefined;
|
|
98
|
+
const e = /** @type {Record<string, unknown>} */ (envEntry);
|
|
99
|
+
if (typeof e.method === 'string') return e.method;
|
|
100
|
+
if (typeof e.type === 'string') return e.type;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Method-specific settings object (mutable reference for new-shape `config`).
|
|
106
|
+
* Supports legacy flat env entries used in tests.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} envEntry
|
|
109
|
+
* @returns {Record<string, unknown>}
|
|
110
|
+
*/
|
|
111
|
+
export function getEnvSettings(envEntry) {
|
|
112
|
+
if (!envEntry || typeof envEntry !== 'object') return {};
|
|
113
|
+
if (isNewEnvironmentShape(envEntry)) {
|
|
114
|
+
return /** @type {Record<string, unknown>} */ (
|
|
115
|
+
/** @type {Record<string, unknown>} */ (envEntry).config
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return /** @type {Record<string, unknown>} */ (envEntry);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {unknown} envEntry
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export function isEnvEnabled(envEntry) {
|
|
126
|
+
if (!envEntry || typeof envEntry !== 'object') return false;
|
|
127
|
+
const e = /** @type {Record<string, unknown>} */ (envEntry);
|
|
128
|
+
if (typeof e.enabled === 'boolean') return e.enabled;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {unknown} envEntry
|
|
134
|
+
* @returns {'push'|'manual'}
|
|
135
|
+
*/
|
|
136
|
+
export function getEnvTrigger(envEntry) {
|
|
137
|
+
if (!envEntry || typeof envEntry !== 'object') return 'manual';
|
|
138
|
+
const e = /** @type {Record<string, unknown>} */ (envEntry);
|
|
139
|
+
return e.trigger === 'push' ? 'push' : 'manual';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {Record<string, unknown>} config
|
|
144
|
+
* @returns {string|null}
|
|
145
|
+
*/
|
|
146
|
+
export function resolveDefaultEnvironmentName(config) {
|
|
147
|
+
const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
|
|
148
|
+
const named = config.defaultEnvironment;
|
|
149
|
+
if (typeof named === 'string' && envs[named]) return named;
|
|
150
|
+
|
|
151
|
+
const deploy = /** @type {string[]|undefined} */ (config.deploy);
|
|
152
|
+
if (Array.isArray(deploy) && deploy[0] && envs[deploy[0]]) return deploy[0];
|
|
153
|
+
|
|
154
|
+
const enabled = Object.entries(envs)
|
|
155
|
+
.filter(([, e]) => isEnvEnabled(e))
|
|
156
|
+
.map(([n]) => n);
|
|
157
|
+
if (enabled.length === 1) return enabled[0];
|
|
158
|
+
if (envs.default) return 'default';
|
|
159
|
+
if (envs.production) return 'production';
|
|
160
|
+
return enabled[0] || Object.keys(envs)[0] || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Enabled environment names (for `--env all` and pipeline defaults).
|
|
165
|
+
* Falls back to legacy `deploy[]` when present and environments lack `enabled`.
|
|
166
|
+
*
|
|
167
|
+
* @param {Record<string, unknown>} config
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
export function getEnabledEnvironmentNames(config) {
|
|
171
|
+
const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
|
|
172
|
+
const names = Object.keys(envs);
|
|
173
|
+
if (names.length === 0) return [];
|
|
174
|
+
|
|
175
|
+
const hasEnabledFlag = names.some(
|
|
176
|
+
(n) => typeof /** @type {Record<string, unknown>} */ (envs[n])?.enabled === 'boolean'
|
|
177
|
+
);
|
|
178
|
+
if (hasEnabledFlag || names.some((n) => isNewEnvironmentShape(envs[n]))) {
|
|
179
|
+
return names.filter((n) => isEnvEnabled(envs[n]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const deploy = /** @type {string[]|undefined} */ (config.deploy);
|
|
183
|
+
if (Array.isArray(deploy) && deploy.length > 0) {
|
|
184
|
+
return deploy.filter((n) => Boolean(envs[n]));
|
|
185
|
+
}
|
|
186
|
+
return names;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build a new-shape environment entry from method + settings.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} method
|
|
193
|
+
* @param {Record<string, unknown>} [settings]
|
|
194
|
+
* @param {{ enabled?: boolean, trigger?: 'push'|'manual' }} [meta]
|
|
195
|
+
*/
|
|
196
|
+
export function buildEnvironmentEntry(method, settings = {}, meta = {}) {
|
|
197
|
+
const cleaned = { ...settings };
|
|
198
|
+
delete cleaned.type;
|
|
199
|
+
delete cleaned.method;
|
|
200
|
+
delete cleaned.enabled;
|
|
201
|
+
delete cleaned.trigger;
|
|
202
|
+
delete cleaned.config;
|
|
203
|
+
return {
|
|
204
|
+
enabled: meta.enabled !== false,
|
|
205
|
+
method,
|
|
206
|
+
trigger: meta.trigger === 'push' ? 'push' : 'manual',
|
|
207
|
+
config: cleaned,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve which environments a deploy/rollback should target.
|
|
213
|
+
*
|
|
214
|
+
* @param {Record<string, unknown>} config
|
|
215
|
+
* @param {string|undefined} envFlag — omitted = default only; `"all"` = every enabled; else one name
|
|
216
|
+
* @returns {{ targets: string[], skippedDisabled: string[] }}
|
|
217
|
+
*/
|
|
218
|
+
export function resolveEnvTargets(config, envFlag) {
|
|
219
|
+
const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
|
|
220
|
+
|
|
221
|
+
if (!envFlag) {
|
|
222
|
+
const def = resolveDefaultEnvironmentName(config);
|
|
223
|
+
if (!def) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
'No environments configured. Run "deployhub init" or "deployhub env add <name>".'
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!isEnvEnabled(envs[def])) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Environment "${def}" is disabled. Enable it with: deployhub env enable ${def}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return { targets: [def], skippedDisabled: [] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (envFlag === 'all') {
|
|
237
|
+
/** @type {string[]} */
|
|
238
|
+
const skippedDisabled = [];
|
|
239
|
+
/** @type {string[]} */
|
|
240
|
+
const targets = [];
|
|
241
|
+
for (const name of Object.keys(envs)) {
|
|
242
|
+
if (isEnvEnabled(envs[name])) {
|
|
243
|
+
targets.push(name);
|
|
244
|
+
} else {
|
|
245
|
+
skippedDisabled.push(name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { targets, skippedDisabled };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!envs[envFlag]) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Environment "${envFlag}" not found in config. Use: deployhub env list`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (!isEnvEnabled(envs[envFlag])) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Environment "${envFlag}" is disabled. Enable it with: deployhub env enable ${envFlag}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return { targets: [envFlag], skippedDisabled: [] };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Overlay method-specific config onto process env for Docker/K8s providers.
|
|
266
|
+
* Secrets still come from real env vars; non-secret names/paths come from config.
|
|
267
|
+
*
|
|
268
|
+
* @param {Record<string, string|undefined>} env
|
|
269
|
+
* @param {Record<string, unknown>} settings
|
|
270
|
+
* @returns {Record<string, string|undefined>}
|
|
271
|
+
*/
|
|
272
|
+
export function mergeMethodSettingsIntoEnv(env, settings) {
|
|
273
|
+
/** @type {Record<string, string|undefined>} */
|
|
274
|
+
const out = { ...env };
|
|
275
|
+
if (settings.dockerImageName) out.DOCKER_IMAGE_NAME = String(settings.dockerImageName);
|
|
276
|
+
if (settings.dockerRegistryUrl) out.DOCKER_REGISTRY_URL = String(settings.dockerRegistryUrl);
|
|
277
|
+
if (settings.dockerHost) out.DOCKER_HOST = String(settings.dockerHost);
|
|
278
|
+
if (settings.kubeNamespace) out.KUBE_NAMESPACE = String(settings.kubeNamespace);
|
|
279
|
+
if (settings.kubeconfig) out.KUBECONFIG = String(settings.kubeconfig);
|
|
280
|
+
if (settings.kubeContext) out.KUBE_CONTEXT = String(settings.kubeContext);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default {
|
|
285
|
+
ENV_NAME_MAX_LENGTH,
|
|
286
|
+
RESERVED_ENVIRONMENT_NAMES,
|
|
287
|
+
ENV_NAME_PATTERN,
|
|
288
|
+
validateEnvironmentName,
|
|
289
|
+
createEnvNamePromptValidate,
|
|
290
|
+
isNewEnvironmentShape,
|
|
291
|
+
getEnvMethod,
|
|
292
|
+
getEnvSettings,
|
|
293
|
+
isEnvEnabled,
|
|
294
|
+
getEnvTrigger,
|
|
295
|
+
resolveDefaultEnvironmentName,
|
|
296
|
+
getEnabledEnvironmentNames,
|
|
297
|
+
buildEnvironmentEntry,
|
|
298
|
+
resolveEnvTargets,
|
|
299
|
+
mergeMethodSettingsIntoEnv,
|
|
300
|
+
};
|
package/src/core/stages.js
CHANGED
|
@@ -4,10 +4,45 @@ import { createArtifact, repackArtifactZip } from '../artifact/engine.js';
|
|
|
4
4
|
import { uploadToAll } from '../storage/index.js';
|
|
5
5
|
import { deployToAll } from '../deployment/index.js';
|
|
6
6
|
import { sendNotifications } from '../notifications/index.js';
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
anyEnvHasResolvableHealthCheckUrl,
|
|
9
|
+
runHealthChecksForEnvs,
|
|
10
|
+
} from '../utils/health-check.js';
|
|
8
11
|
import { getProjectVersion } from '../utils/version.js';
|
|
9
12
|
import { resolveBuildId } from '../utils/build-id.js';
|
|
10
13
|
import { ensureDeployScaffold } from '../utils/scaffold.js';
|
|
14
|
+
import {
|
|
15
|
+
getEnabledEnvironmentNames,
|
|
16
|
+
resolveDefaultEnvironmentName,
|
|
17
|
+
isEnvEnabled,
|
|
18
|
+
getEnvTrigger,
|
|
19
|
+
} from './environments.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Environments to deploy during `deployhub build` (pipeline.deploy).
|
|
23
|
+
* - Local: default environment only (promote elsewhere via `deploy --env`).
|
|
24
|
+
* - GitHub Actions push: every enabled env with trigger "push".
|
|
25
|
+
* - workflow_dispatch: none here (explicit deploy step handles --env).
|
|
26
|
+
*
|
|
27
|
+
* @param {import('./config.js').DeployHubConfig} config
|
|
28
|
+
* @param {Record<string, string|undefined>} [env]
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
function pipelineDeployTargets(config, env = process.env) {
|
|
32
|
+
if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
if (env.GITHUB_ACTIONS === 'true' || env.GITHUB_ACTIONS === '1') {
|
|
36
|
+
return Object.entries(config.environments || {})
|
|
37
|
+
.filter(([, entry]) => isEnvEnabled(entry) && getEnvTrigger(entry) === 'push')
|
|
38
|
+
.map(([name]) => name);
|
|
39
|
+
}
|
|
40
|
+
const def = resolveDefaultEnvironmentName(config);
|
|
41
|
+
if (def && getEnabledEnvironmentNames(config).includes(def)) {
|
|
42
|
+
return [def];
|
|
43
|
+
}
|
|
44
|
+
return getEnabledEnvironmentNames(config).slice(0, 1);
|
|
45
|
+
}
|
|
11
46
|
|
|
12
47
|
/**
|
|
13
48
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
@@ -143,7 +178,8 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
143
178
|
name: 'storage',
|
|
144
179
|
enabled: (ctx) => {
|
|
145
180
|
const willDeploy =
|
|
146
|
-
ctx.config.pipeline.deploy === true &&
|
|
181
|
+
ctx.config.pipeline.deploy === true &&
|
|
182
|
+
getEnabledEnvironmentNames(ctx.config).length > 0;
|
|
147
183
|
if (willDeploy && (!ctx.config.storage || ctx.config.storage.length === 0)) {
|
|
148
184
|
ctx.config.storage = ['local'];
|
|
149
185
|
}
|
|
@@ -159,7 +195,8 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
159
195
|
{
|
|
160
196
|
name: 'deploy',
|
|
161
197
|
enabled: (ctx) =>
|
|
162
|
-
ctx.config.pipeline.deploy === true &&
|
|
198
|
+
ctx.config.pipeline.deploy === true &&
|
|
199
|
+
getEnabledEnvironmentNames(ctx.config).length > 0,
|
|
163
200
|
async run(ctx) {
|
|
164
201
|
if (!ctx.state.storageCompleted) {
|
|
165
202
|
throw new Error(
|
|
@@ -168,7 +205,8 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
168
205
|
}
|
|
169
206
|
const artifactDir = /** @type {string} */ (ctx.state.artifactDir);
|
|
170
207
|
if (!artifactDir) throw new Error('No artifact to deploy');
|
|
171
|
-
const
|
|
208
|
+
const targets = pipelineDeployTargets(ctx.config);
|
|
209
|
+
const deployed = await deployToAll(ctx.config, artifactDir, targets);
|
|
172
210
|
ctx.state.deployedTargets = deployed;
|
|
173
211
|
|
|
174
212
|
await repackArtifactZip(artifactDir);
|
|
@@ -180,23 +218,21 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
180
218
|
},
|
|
181
219
|
{
|
|
182
220
|
name: 'verify',
|
|
183
|
-
enabled: (ctx) =>
|
|
184
|
-
ctx.config.pipeline.verify
|
|
221
|
+
enabled: (ctx) => {
|
|
222
|
+
if (ctx.config.pipeline.verify !== true) return false;
|
|
223
|
+
const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
|
|
224
|
+
return anyEnvHasResolvableHealthCheckUrl(ctx.config, deployed);
|
|
225
|
+
},
|
|
185
226
|
async run(ctx) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (response.status < 200 || response.status >= 400) {
|
|
195
|
-
throw new Error(
|
|
196
|
-
`Health check failed: HTTP ${response.status} (${elapsed}ms)`
|
|
197
|
-
);
|
|
227
|
+
const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
|
|
228
|
+
const { results, failures } = await runHealthChecksForEnvs(ctx.config, deployed);
|
|
229
|
+
if (failures.length > 0) {
|
|
230
|
+
throw new Error(failures[0].error);
|
|
231
|
+
}
|
|
232
|
+
const last = results[results.length - 1];
|
|
233
|
+
if (last) {
|
|
234
|
+
ctx.state.healthCheck = { status: last.status, elapsed: last.elapsed };
|
|
198
235
|
}
|
|
199
|
-
ctx.state.healthCheck = { status: response.status, elapsed };
|
|
200
236
|
},
|
|
201
237
|
},
|
|
202
238
|
{
|
|
@@ -504,6 +504,216 @@ export function getDeploymentSecretChecklistItems(deployType, config = null) {
|
|
|
504
504
|
return Array.from(byKey.values());
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Uppercase env name for secret prefixes (e.g. production → PRODUCTION).
|
|
509
|
+
* @param {string} envName
|
|
510
|
+
* @returns {string}
|
|
511
|
+
*/
|
|
512
|
+
export function envSecretPrefix(envName) {
|
|
513
|
+
return (
|
|
514
|
+
String(envName || 'default')
|
|
515
|
+
.toUpperCase()
|
|
516
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
517
|
+
.replace(/^_+|_+$/g, '') || 'DEFAULT'
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Trade-off (superseded for per-env grandfathering): true when 2+ environments exist.
|
|
523
|
+
* Prefer envUsesPrefixedSecrets(envName, config) so the original env stays unprefixed.
|
|
524
|
+
*
|
|
525
|
+
* @param {Record<string, unknown>} [environments]
|
|
526
|
+
* @returns {boolean}
|
|
527
|
+
*/
|
|
528
|
+
export function shouldPrefixEnvSecrets(environments) {
|
|
529
|
+
return Object.keys(environments || {}).length > 1;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Which environment keeps reading unprefixed secrets (SSH_HOST, not ENV_SSH_HOST).
|
|
534
|
+
* Once established, this must not silently change when more environments are added.
|
|
535
|
+
*
|
|
536
|
+
* @param {Record<string, unknown>} config
|
|
537
|
+
* @returns {string|null}
|
|
538
|
+
*/
|
|
539
|
+
export function resolveUnprefixedSecretEnvironment(config) {
|
|
540
|
+
const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
|
|
541
|
+
const named = config.unprefixedSecretEnvironment;
|
|
542
|
+
if (typeof named === 'string' && envs[named]) return named;
|
|
543
|
+
|
|
544
|
+
const names = Object.keys(envs);
|
|
545
|
+
if (names.length === 1) return names[0];
|
|
546
|
+
if (typeof config.defaultEnvironment === 'string' && envs[config.defaultEnvironment]) {
|
|
547
|
+
return config.defaultEnvironment;
|
|
548
|
+
}
|
|
549
|
+
return names[0] || null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Whether this environment's CI secrets use ENVNAME_KEY prefixes.
|
|
554
|
+
* - 0–1 environments: never prefixed (BC).
|
|
555
|
+
* - 2+ environments: every env EXCEPT the grandfathered unprefixedSecretEnvironment is prefixed.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} envName
|
|
558
|
+
* @param {Record<string, unknown>} config
|
|
559
|
+
* @returns {boolean}
|
|
560
|
+
*/
|
|
561
|
+
export function envUsesPrefixedSecrets(envName, config) {
|
|
562
|
+
const envs = config.environments || {};
|
|
563
|
+
if (Object.keys(envs).length <= 1) return false;
|
|
564
|
+
const unprefixed = resolveUnprefixedSecretEnvironment(config);
|
|
565
|
+
return envName !== unprefixed;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* @param {string} envName
|
|
570
|
+
* @param {string} key
|
|
571
|
+
* @returns {string}
|
|
572
|
+
*/
|
|
573
|
+
export function prefixSecretKey(envName, key) {
|
|
574
|
+
return `${envSecretPrefix(envName)}_${key}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Overlay prefixed CI secrets (e.g. STAGING_SSH_HOST) onto the unprefixed names
|
|
579
|
+
* providers already read (SSH_HOST). Used when a workflow job carries secrets for
|
|
580
|
+
* multiple environments — static YAML can only map one value per unprefixed key.
|
|
581
|
+
*
|
|
582
|
+
* For prefixed environments, ambient unprefixed leftovers (e.g. a developer's
|
|
583
|
+
* shell SSH_HOST from another project) are cleared unless a matching prefixed
|
|
584
|
+
* secret is present — config settings (host, dockerImageName, …) still win via
|
|
585
|
+
* provider resolution / mergeMethodSettingsIntoEnv.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} envName
|
|
588
|
+
* @param {Record<string, unknown>} config
|
|
589
|
+
* @param {Record<string, string|undefined>} [env]
|
|
590
|
+
* @returns {Record<string, string|undefined>}
|
|
591
|
+
*/
|
|
592
|
+
export function applyEnvSecretOverlay(envName, config, env = process.env) {
|
|
593
|
+
/** @type {Record<string, string|undefined>} */
|
|
594
|
+
const out = { ...env };
|
|
595
|
+
if (!envUsesPrefixedSecrets(envName, config)) {
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const entry = /** @type {Record<string, unknown>|undefined} */ (
|
|
600
|
+
(config.environments || {})[envName]
|
|
601
|
+
);
|
|
602
|
+
const method =
|
|
603
|
+
(entry && typeof entry.method === 'string' && entry.method) ||
|
|
604
|
+
(entry && typeof entry.type === 'string' && entry.type) ||
|
|
605
|
+
null;
|
|
606
|
+
if (!method) return out;
|
|
607
|
+
|
|
608
|
+
const keys = getDeploymentWorkflowSecretKeys(method, /** @type {any} */ (config));
|
|
609
|
+
for (const key of keys) {
|
|
610
|
+
const prefixed = prefixSecretKey(envName, key);
|
|
611
|
+
if (out[prefixed]) {
|
|
612
|
+
out[key] = out[prefixed];
|
|
613
|
+
} else {
|
|
614
|
+
// Prefixed envs must NOT silently inherit ambient unprefixed leftovers
|
|
615
|
+
// (e.g. shell SSH_HOST from another project). Config settings still win
|
|
616
|
+
// via settings.host || env.SSH_HOST in providers.
|
|
617
|
+
delete out[key];
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Workflow secret names for one environment (prefixed only when that env requires it).
|
|
625
|
+
* @param {string} envName
|
|
626
|
+
* @param {string} deployType
|
|
627
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
628
|
+
* @param {Record<string, unknown>} [environments]
|
|
629
|
+
* @returns {string[]}
|
|
630
|
+
*/
|
|
631
|
+
export function getDeploymentWorkflowSecretKeysForEnv(
|
|
632
|
+
envName,
|
|
633
|
+
deployType,
|
|
634
|
+
config = null,
|
|
635
|
+
environments = null
|
|
636
|
+
) {
|
|
637
|
+
const keys = getDeploymentWorkflowSecretKeys(deployType, config);
|
|
638
|
+
const cfg = {
|
|
639
|
+
...(config || {}),
|
|
640
|
+
environments: environments || config?.environments || {},
|
|
641
|
+
};
|
|
642
|
+
if (!envUsesPrefixedSecrets(envName, cfg)) {
|
|
643
|
+
return keys;
|
|
644
|
+
}
|
|
645
|
+
return keys.map((k) => prefixSecretKey(envName, k));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Checklist items for one environment (prefixed keys when that env requires it).
|
|
650
|
+
* @param {string} envName
|
|
651
|
+
* @param {string} deployType
|
|
652
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
653
|
+
* @param {Record<string, unknown>} [environments]
|
|
654
|
+
* @returns {SecretChecklistItem[]}
|
|
655
|
+
*/
|
|
656
|
+
export function getDeploymentSecretChecklistItemsForEnv(
|
|
657
|
+
envName,
|
|
658
|
+
deployType,
|
|
659
|
+
config = null,
|
|
660
|
+
environments = null
|
|
661
|
+
) {
|
|
662
|
+
const items = getDeploymentSecretChecklistItems(deployType, config);
|
|
663
|
+
const cfg = {
|
|
664
|
+
...(config || {}),
|
|
665
|
+
environments: environments || config?.environments || {},
|
|
666
|
+
};
|
|
667
|
+
if (!envUsesPrefixedSecrets(envName, cfg)) {
|
|
668
|
+
return items;
|
|
669
|
+
}
|
|
670
|
+
return items.map((item) => ({
|
|
671
|
+
...item,
|
|
672
|
+
key: prefixSecretKey(envName, item.key),
|
|
673
|
+
note: item.note
|
|
674
|
+
? `${item.note} (env: ${envName})`
|
|
675
|
+
: `Secret for environment "${envName}"`,
|
|
676
|
+
}));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Required doctor/CI secret keys for one environment.
|
|
681
|
+
* @param {string} envName
|
|
682
|
+
* @param {string} deployType
|
|
683
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
684
|
+
* @param {Record<string, unknown>} [environments]
|
|
685
|
+
* @returns {string[]}
|
|
686
|
+
*/
|
|
687
|
+
export function getDeploymentSecretKeysForEnv(
|
|
688
|
+
envName,
|
|
689
|
+
deployType,
|
|
690
|
+
config = null,
|
|
691
|
+
environments = null
|
|
692
|
+
) {
|
|
693
|
+
const keys = getDeploymentSecretKeys(deployType, config);
|
|
694
|
+
const cfg = {
|
|
695
|
+
...(config || {}),
|
|
696
|
+
environments: environments || config?.environments || {},
|
|
697
|
+
};
|
|
698
|
+
if (!envUsesPrefixedSecrets(envName, cfg)) {
|
|
699
|
+
return keys;
|
|
700
|
+
}
|
|
701
|
+
return keys.map((k) => prefixSecretKey(envName, k));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Unprefixed process-env key ↔ GitHub secret name for one env.
|
|
706
|
+
* @param {string} envName
|
|
707
|
+
* @param {string} unprefixedKey
|
|
708
|
+
* @param {Record<string, unknown>} config
|
|
709
|
+
* @returns {string} secret name to look up in GitHub / doctor
|
|
710
|
+
*/
|
|
711
|
+
export function resolveSecretNameForEnv(envName, unprefixedKey, config) {
|
|
712
|
+
return envUsesPrefixedSecrets(envName, config)
|
|
713
|
+
? prefixSecretKey(envName, unprefixedKey)
|
|
714
|
+
: unprefixedKey;
|
|
715
|
+
}
|
|
716
|
+
|
|
507
717
|
/**
|
|
508
718
|
* Format a checklist item for console output.
|
|
509
719
|
* @param {SecretChecklistItem} item
|