@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.
@@ -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 k8sEnv = envList.find((env) => env.type === 'kubernetes') || {};
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 =
@@ -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 project.
36
- * @param {string} projectName
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(projectName) {
40
- return `/etc/nginx/sites-available/${sanitizeNginxProjectName(projectName)}`;
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 project.
45
- * @param {string} projectName
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(projectName) {
49
- return `/etc/nginx/sites-enabled/${sanitizeNginxProjectName(projectName)}`;
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 project.
54
- * @param {string} projectName
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(projectName) {
58
- return `/etc/nginx/conf.d/${sanitizeNginxProjectName(projectName)}.conf`;
105
+ export function getNginxConfDPath(siteName) {
106
+ return `/etc/nginx/conf.d/${siteName}.conf`;
59
107
  }
60
108
 
61
109
  /**
62
- * @param {string} projectName
110
+ * @param {string} siteName
63
111
  * @returns {string}
64
112
  */
65
- export function getNginxSitePath(projectName) {
66
- return getNginxSitesAvailablePath(projectName);
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, loadArtifactHistory } from '../../storage/index.js';
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} (${envConfig.type || 'server'})...`);
31
+ log.info(`Rolling back ${envName} (${method})...`);
26
32
 
27
- const provider = getDeploymentProvider(envConfig.type, config, envName);
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(config, versionOrBuildId, cwd = process.cwd()) {
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 { entries: history } = await loadArtifactHistory(providers, config.project);
44
- const resolved = resolveRollbackTarget(history, versionOrBuildId);
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
- if (!resolved.ok) {
47
- if (resolved.reason === 'ambiguous' && resolved.matches) {
48
- throw new Error(
49
- `${resolved.message}\n${formatAmbiguousRollbackMatches(resolved.matches)}`
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
- const entry = resolved.entry;
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
- log.info(`Downloading artifact buildId=${entry.buildId} (semver=${entry.semver})...`);
60
- await fs.emptyDir(restoreDir);
61
- await fs.ensureDir(artifactDir);
121
+ if (!resolved.ok) {
122
+ throw new Error(formatEnvRollbackError(envName, resolved));
123
+ }
62
124
 
63
- const zipPath = path.join(artifactDir, 'artifact.zip');
64
- await downloadArtifactEntry(providers, config, entry, zipPath);
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
- // Extract into artifactDir itself so layout matches a normal build artifact:
67
- // top-level contents (k8s/, dist/, metadata.json, …) alongside artifact.zip.
68
- log.info('Extracting artifact for rollback...');
69
- await extractArtifact(artifactDir, artifactDir);
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
- const targets = config.deploy || [];
72
- if (targets.length === 0) {
73
- log.warn('No deployment targets configured');
74
- return { artifactDir, entry };
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 meta = {
78
- buildId: entry.buildId,
79
- semver: entry.semver,
80
- remoteKey: entry.remoteKey,
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
- log.info('Redeploying previous artifact to server targets...');
84
- for (const envName of targets) {
85
- await rollbackTarget(config, artifactDir, envName, meta);
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
- log.success(`Rollback to buildId=${entry.buildId} complete`);
89
- return { artifactDir, entry };
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 };
@@ -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) => /** @type {string} */ (env.type))
24
+ .map((env) => getEnvMethod(env))
24
25
  .filter(Boolean)
25
26
  );
26
27
  }