@akash-chowdhury-24/deployhub 2.0.15 → 2.0.17
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 +47 -16
- package/package.json +1 -1
- package/src/artifact/engine.js +11 -4
- package/src/cli/index.js +2 -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 +111 -64
- package/src/commands/rollback.js +84 -24
- package/src/commands/sync-workflows.js +4 -1
- 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 +202 -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/github-actions.js +414 -33
- package/src/utils/health-check.js +158 -0
- package/src/utils/kubernetes-manifests.js +3 -1
- package/src/utils/nginx.js +66 -15
- package/src/utils/rollback/engine.js +147 -40
- package/src/utils/scaffold.js +2 -1
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { resolveContainerPort } from './dockerfile-expose.js';
|
|
4
|
+
import { getEnvMethod, getEnvSettings } from '../core/environments.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @param {string} name
|
|
@@ -143,7 +144,8 @@ export function patchKubernetesManifestPorts(yaml, port) {
|
|
|
143
144
|
*/
|
|
144
145
|
export function resolveKubernetesManifestOptions(config, environments = {}, options = {}) {
|
|
145
146
|
const envList = Object.values(environments);
|
|
146
|
-
const
|
|
147
|
+
const k8sRaw = envList.find((env) => getEnvMethod(env) === 'kubernetes') || {};
|
|
148
|
+
const k8sEnv = getEnvSettings(k8sRaw);
|
|
147
149
|
|
|
148
150
|
const appName = config.project || 'app';
|
|
149
151
|
const imageName =
|
package/src/utils/nginx.js
CHANGED
|
@@ -7,6 +7,54 @@ export function sanitizeNginxProjectName(projectName) {
|
|
|
7
7
|
return projectName.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Site filename for Nginx activation. Grandfathered envs keep legacy `{project}` only;
|
|
12
|
+
* additional environments use `{project}-{env}` so same-host multi-env deploys don't clobber.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} projectName
|
|
15
|
+
* @param {string} envName
|
|
16
|
+
* @param {boolean} isGrandfathered
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeNginxSiteName(projectName, envName, isGrandfathered) {
|
|
20
|
+
const base = sanitizeNginxProjectName(projectName);
|
|
21
|
+
if (isGrandfathered) {
|
|
22
|
+
// Avoid breaking existing single-env servers that already have sites-available/{project}.
|
|
23
|
+
return base;
|
|
24
|
+
}
|
|
25
|
+
return `${base}-${sanitizeNginxProjectName(envName)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
30
|
+
* @param {string} envName
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isGrandfatheredNginxEnv(config, envName) {
|
|
34
|
+
const envs = config.environments || {};
|
|
35
|
+
if (Object.keys(envs).length <= 1) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const grandfather =
|
|
39
|
+
config.unprefixedSecretEnvironment || config.defaultEnvironment || null;
|
|
40
|
+
return Boolean(grandfather && envName === grandfather);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the Nginx site filename for an environment deploy.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
47
|
+
* @param {string} envName
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function resolveNginxSiteName(config, envName) {
|
|
51
|
+
return sanitizeNginxSiteName(
|
|
52
|
+
config.project || 'app',
|
|
53
|
+
envName,
|
|
54
|
+
isGrandfatheredNginxEnv(config, envName)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
10
58
|
/**
|
|
11
59
|
* Generate nginx server block config for SPA frontend deployments.
|
|
12
60
|
*
|
|
@@ -32,38 +80,38 @@ export function generateNginxConfig(projectName, deployPath, buildOutput = 'dist
|
|
|
32
80
|
}
|
|
33
81
|
|
|
34
82
|
/**
|
|
35
|
-
* Debian/Ubuntu: sites-available path for this
|
|
36
|
-
* @param {string}
|
|
83
|
+
* Debian/Ubuntu: sites-available path for this site name.
|
|
84
|
+
* @param {string} siteName — output of resolveNginxSiteName / sanitizeNginxSiteName
|
|
37
85
|
* @returns {string}
|
|
38
86
|
*/
|
|
39
|
-
export function getNginxSitesAvailablePath(
|
|
40
|
-
return `/etc/nginx/sites-available/${
|
|
87
|
+
export function getNginxSitesAvailablePath(siteName) {
|
|
88
|
+
return `/etc/nginx/sites-available/${siteName}`;
|
|
41
89
|
}
|
|
42
90
|
|
|
43
91
|
/**
|
|
44
|
-
* Debian/Ubuntu: sites-enabled symlink path for this
|
|
45
|
-
* @param {string}
|
|
92
|
+
* Debian/Ubuntu: sites-enabled symlink path for this site name.
|
|
93
|
+
* @param {string} siteName
|
|
46
94
|
* @returns {string}
|
|
47
95
|
*/
|
|
48
|
-
export function getNginxSitesEnabledPath(
|
|
49
|
-
return `/etc/nginx/sites-enabled/${
|
|
96
|
+
export function getNginxSitesEnabledPath(siteName) {
|
|
97
|
+
return `/etc/nginx/sites-enabled/${siteName}`;
|
|
50
98
|
}
|
|
51
99
|
|
|
52
100
|
/**
|
|
53
|
-
* RHEL/Amazon Linux: conf.d drop-in path for this
|
|
54
|
-
* @param {string}
|
|
101
|
+
* RHEL/Amazon Linux: conf.d drop-in path for this site name.
|
|
102
|
+
* @param {string} siteName
|
|
55
103
|
* @returns {string}
|
|
56
104
|
*/
|
|
57
|
-
export function getNginxConfDPath(
|
|
58
|
-
return `/etc/nginx/conf.d/${
|
|
105
|
+
export function getNginxConfDPath(siteName) {
|
|
106
|
+
return `/etc/nginx/conf.d/${siteName}.conf`;
|
|
59
107
|
}
|
|
60
108
|
|
|
61
109
|
/**
|
|
62
|
-
* @param {string}
|
|
110
|
+
* @param {string} siteName
|
|
63
111
|
* @returns {string}
|
|
64
112
|
*/
|
|
65
|
-
export function getNginxSitePath(
|
|
66
|
-
return getNginxSitesAvailablePath(
|
|
113
|
+
export function getNginxSitePath(siteName) {
|
|
114
|
+
return getNginxSitesAvailablePath(siteName);
|
|
67
115
|
}
|
|
68
116
|
|
|
69
117
|
/**
|
|
@@ -83,6 +131,9 @@ export function formatPasswordlessSudoGuidance(sshUser) {
|
|
|
83
131
|
export default {
|
|
84
132
|
generateNginxConfig,
|
|
85
133
|
sanitizeNginxProjectName,
|
|
134
|
+
sanitizeNginxSiteName,
|
|
135
|
+
isGrandfatheredNginxEnv,
|
|
136
|
+
resolveNginxSiteName,
|
|
86
137
|
getNginxSitesAvailablePath,
|
|
87
138
|
getNginxSitesEnabledPath,
|
|
88
139
|
getNginxConfDPath,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { downloadArtifactEntry,
|
|
1
|
+
import { downloadArtifactEntry, ensureLegacyHistoryCopiedToDefaultEnv, loadEnvArtifactHistory } from '../../storage/index.js';
|
|
2
2
|
import { getDeploymentProvider } from '../../deployment/index.js';
|
|
3
3
|
import { extractArtifact } from '../../artifact/engine.js';
|
|
4
4
|
import { createLogger } from '../../logger/index.js';
|
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
formatAmbiguousRollbackMatches,
|
|
7
7
|
resolveRollbackTarget,
|
|
8
8
|
} from '../artifact-history.js';
|
|
9
|
+
import {
|
|
10
|
+
getEnabledEnvironmentNames,
|
|
11
|
+
getEnvMethod,
|
|
12
|
+
resolveDefaultEnvironmentName,
|
|
13
|
+
} from '../../core/environments.js';
|
|
9
14
|
import fs from 'fs-extra';
|
|
10
15
|
import path from 'path';
|
|
11
16
|
|
|
@@ -21,72 +26,174 @@ async function rollbackTarget(config, artifactDir, envName, meta) {
|
|
|
21
26
|
throw new Error(`Environment "${envName}" not found in config`);
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
const method = getEnvMethod(envConfig) || 'server';
|
|
24
30
|
const log = createLogger('rollback');
|
|
25
|
-
log.info(`Rolling back ${envName} (${
|
|
31
|
+
log.info(`Rolling back ${envName} (${method})...`);
|
|
26
32
|
|
|
27
|
-
const provider = getDeploymentProvider(
|
|
33
|
+
const provider = getDeploymentProvider(method, config, envName);
|
|
28
34
|
await provider.rollback(artifactDir, meta);
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
38
|
+
* Map resolveRollbackTarget failures to env-scoped messages.
|
|
39
|
+
* @param {string} envName
|
|
40
|
+
* @param {{ reason: string, message: string, matches?: unknown[] }} resolved
|
|
41
|
+
*/
|
|
42
|
+
function formatEnvRollbackError(envName, resolved) {
|
|
43
|
+
if (resolved.reason === 'no-previous') {
|
|
44
|
+
return `No previous build found for environment "${envName}" (only one deploy in that environment's history). Deploy again before rolling back.`;
|
|
45
|
+
}
|
|
46
|
+
if (resolved.reason === 'empty') {
|
|
47
|
+
return `No previous build found for environment "${envName}" — that environment has no deploy history yet. Deploy at least once first.`;
|
|
48
|
+
}
|
|
49
|
+
if (resolved.reason === 'ambiguous' && resolved.matches) {
|
|
50
|
+
return `[${envName}] ${resolved.message}\n${formatAmbiguousRollbackMatches(
|
|
51
|
+
/** @type {import('../artifact-history.js').ArtifactHistoryEntry[]} */ (resolved.matches)
|
|
52
|
+
)}`;
|
|
53
|
+
}
|
|
54
|
+
return `[${envName}] ${resolved.message}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rollback one or more environments. Each target resolves against THAT
|
|
59
|
+
* environment's `envs/{env}/history.json` ONLY — never the project-wide
|
|
60
|
+
* build catalog (`{project}/history.json`) and never another env's history.
|
|
61
|
+
*
|
|
32
62
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
33
63
|
* @param {string} [versionOrBuildId]
|
|
34
64
|
* @param {string} [cwd]
|
|
65
|
+
* @param {{ envNames?: string[], continueOnError?: boolean }} [options]
|
|
66
|
+
* @returns {Promise<{
|
|
67
|
+
* artifactDir: string|null,
|
|
68
|
+
* entry: import('../artifact-history.js').ArtifactHistoryEntry|null,
|
|
69
|
+
* results: { envName: string, entry: import('../artifact-history.js').ArtifactHistoryEntry, artifactDir: string }[],
|
|
70
|
+
* failures: { envName: string, error: string }[],
|
|
71
|
+
* }>}
|
|
35
72
|
*/
|
|
36
|
-
export async function rollbackToVersion(
|
|
73
|
+
export async function rollbackToVersion(
|
|
74
|
+
config,
|
|
75
|
+
versionOrBuildId,
|
|
76
|
+
cwd = process.cwd(),
|
|
77
|
+
options = {}
|
|
78
|
+
) {
|
|
37
79
|
const log = createLogger('rollback');
|
|
38
80
|
const providers = config.storage || [];
|
|
39
81
|
if (providers.length === 0) {
|
|
40
82
|
throw new Error('No storage providers configured — cannot download artifact for rollback');
|
|
41
83
|
}
|
|
42
84
|
|
|
43
|
-
const
|
|
44
|
-
|
|
85
|
+
const targets = options.envNames || getEnabledEnvironmentNames(config);
|
|
86
|
+
if (targets.length === 0) {
|
|
87
|
+
log.warn('No deployment targets configured');
|
|
88
|
+
return { artifactDir: null, entry: null, results: [], failures: [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const continueOnError = options.continueOnError === true;
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
/** @type {{ envName: string, entry: import('../artifact-history.js').ArtifactHistoryEntry, artifactDir: string }[]} */
|
|
94
|
+
const results = [];
|
|
95
|
+
/** @type {{ envName: string, error: string }[]} */
|
|
96
|
+
const failures = [];
|
|
97
|
+
|
|
98
|
+
for (const envName of targets) {
|
|
99
|
+
try {
|
|
100
|
+
const defaultEnv = resolveDefaultEnvironmentName(config);
|
|
101
|
+
if (defaultEnv && envName === defaultEnv) {
|
|
102
|
+
await ensureLegacyHistoryCopiedToDefaultEnv(
|
|
103
|
+
providers,
|
|
104
|
+
config.project,
|
|
105
|
+
defaultEnv,
|
|
106
|
+
{ legacyHistoryMigrated: config.legacyHistoryMigrated === true }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// CRITICAL: allowLegacyFallback: false — never read project-wide history.json
|
|
111
|
+
// (build catalog) during rollback. Isolation is absolute per environment.
|
|
112
|
+
const { entries: history } = await loadEnvArtifactHistory(
|
|
113
|
+
providers,
|
|
114
|
+
config.project,
|
|
115
|
+
envName,
|
|
116
|
+
{ allowLegacyFallback: false }
|
|
50
117
|
);
|
|
51
|
-
}
|
|
52
|
-
throw new Error(resolved.message);
|
|
53
|
-
}
|
|
54
118
|
|
|
55
|
-
|
|
56
|
-
const restoreDir = path.join(cwd, '.deployhub-restore', `v${entry.buildId}`);
|
|
57
|
-
const artifactDir = path.join(restoreDir, 'artifact');
|
|
119
|
+
const resolved = resolveRollbackTarget(history, versionOrBuildId);
|
|
58
120
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
121
|
+
if (!resolved.ok) {
|
|
122
|
+
throw new Error(formatEnvRollbackError(envName, resolved));
|
|
123
|
+
}
|
|
62
124
|
|
|
63
|
-
|
|
64
|
-
|
|
125
|
+
const entry = resolved.entry;
|
|
126
|
+
const restoreDir = path.join(cwd, '.deployhub-restore', envName, `v${entry.buildId}`);
|
|
127
|
+
const artifactDir = path.join(restoreDir, 'artifact');
|
|
65
128
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
129
|
+
log.info(
|
|
130
|
+
`Downloading artifact buildId=${entry.buildId} for env "${envName}" (semver=${entry.semver})...`
|
|
131
|
+
);
|
|
132
|
+
await fs.emptyDir(restoreDir);
|
|
133
|
+
await fs.ensureDir(artifactDir);
|
|
70
134
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
135
|
+
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
136
|
+
await downloadArtifactEntry(providers, config, entry, zipPath);
|
|
137
|
+
|
|
138
|
+
log.info(`Extracting artifact for rollback (${envName})...`);
|
|
139
|
+
await extractArtifact(artifactDir, artifactDir);
|
|
140
|
+
|
|
141
|
+
const meta = {
|
|
142
|
+
buildId: entry.buildId,
|
|
143
|
+
semver: entry.semver,
|
|
144
|
+
remoteKey: entry.remoteKey,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
await rollbackTarget(config, artifactDir, envName, meta);
|
|
148
|
+
results.push({ envName, entry, artifactDir });
|
|
149
|
+
log.success(`Rollback of ${envName} to buildId=${entry.buildId} complete`);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
152
|
+
if (!continueOnError) {
|
|
153
|
+
throw err instanceof Error ? err : new Error(error);
|
|
154
|
+
}
|
|
155
|
+
failures.push({ envName, error });
|
|
156
|
+
log.error(`Rollback of ${envName} failed: ${error}`);
|
|
157
|
+
}
|
|
75
158
|
}
|
|
76
159
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
160
|
+
const last = results[results.length - 1];
|
|
161
|
+
return {
|
|
162
|
+
artifactDir: last?.artifactDir || null,
|
|
163
|
+
entry: last?.entry || null,
|
|
164
|
+
results,
|
|
165
|
+
failures,
|
|
81
166
|
};
|
|
167
|
+
}
|
|
82
168
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Format the continue-on-error summary for `--env all`.
|
|
171
|
+
* @param {{ envName: string, entry: { buildId: string } }[]} results
|
|
172
|
+
* @param {{ envName: string, error: string }[]} failures
|
|
173
|
+
* @param {string[]} [skippedDisabled]
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function formatRollbackAllSummary(results, failures, skippedDisabled = []) {
|
|
177
|
+
const lines = ['Rollback summary (--env all):'];
|
|
178
|
+
for (const r of results) {
|
|
179
|
+
lines.push(` ✓ ${r.envName.padEnd(14)} → rolled back to ${r.entry.buildId}`);
|
|
86
180
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
181
|
+
for (const f of failures) {
|
|
182
|
+
lines.push(` ✗ ${f.envName.padEnd(14)} → FAILED: ${f.error}`);
|
|
183
|
+
}
|
|
184
|
+
for (const name of skippedDisabled) {
|
|
185
|
+
lines.push(` – ${name.padEnd(14)} → skipped (disabled)`);
|
|
186
|
+
}
|
|
187
|
+
lines.push('');
|
|
188
|
+
if (failures.length === 0) {
|
|
189
|
+
lines.push(`All ${results.length} environment(s) rolled back successfully.`);
|
|
190
|
+
} else {
|
|
191
|
+
const total = results.length + failures.length;
|
|
192
|
+
lines.push(
|
|
193
|
+
`${failures.length} of ${total} environments failed to roll back. See above for details.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return lines.join('\n');
|
|
90
197
|
}
|
|
91
198
|
|
|
92
|
-
export default { rollbackToVersion };
|
|
199
|
+
export default { rollbackToVersion, formatRollbackAllSummary };
|
package/src/utils/scaffold.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
hasKubernetesManifests,
|
|
13
13
|
resolveKubernetesManifestOptionsFromCwd,
|
|
14
14
|
} from './kubernetes-manifests.js';
|
|
15
|
+
import { getEnvMethod } from '../core/environments.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @param {Record<string, Record<string, unknown>>} environments
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
export function getDeployTypes(environments) {
|
|
21
22
|
return new Set(
|
|
22
23
|
Object.values(environments)
|
|
23
|
-
.map((env) =>
|
|
24
|
+
.map((env) => getEnvMethod(env))
|
|
24
25
|
.filter(Boolean)
|
|
25
26
|
);
|
|
26
27
|
}
|