@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.
@@ -1,5 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import { resolveContainerPort } from './dockerfile-expose.js';
4
+ import { getEnvMethod, getEnvSettings } from '../core/environments.js';
3
5
 
4
6
  /**
5
7
  * @param {string} name
@@ -65,6 +67,11 @@ export function generateKubernetesManifests({
65
67
  ? ` imagePullSecrets:\n - name: ${imagePullSecret}\n`
66
68
  : '';
67
69
 
70
+ // Service port stays 80 (Ingress-friendly cluster-facing port).
71
+ // containerPort / targetPort must match the container's listening port (EXPOSE).
72
+ const servicePort = 80;
73
+ const targetPort = port;
74
+
68
75
  const deploymentYaml = `apiVersion: apps/v1
69
76
  kind: Deployment
70
77
  metadata:
@@ -96,9 +103,6 @@ ${pullSecretBlock} containers:
96
103
  cpu: "500m"
97
104
  `;
98
105
 
99
- const servicePort = port === 80 ? 80 : 80;
100
- const targetPort = port;
101
-
102
106
  const serviceYaml = `apiVersion: v1
103
107
  kind: Service
104
108
  metadata:
@@ -116,14 +120,32 @@ spec:
116
120
  return { deploymentYaml, serviceYaml };
117
121
  }
118
122
 
123
+ /**
124
+ * Surgically update only containerPort / targetPort in manifest YAML.
125
+ * Does not change Service `port:`, replicas, resources, env, probes, etc.
126
+ *
127
+ * @param {string} yaml
128
+ * @param {number} port
129
+ * @returns {{ content: string, changed: boolean }}
130
+ */
131
+ export function patchKubernetesManifestPorts(yaml, port) {
132
+ const next = String(yaml)
133
+ .replace(/^([ \t]*-?[ \t]*containerPort:[ \t]*)\d+[ \t]*$/gm, `$1${port}`)
134
+ .replace(/^([ \t]*targetPort:[ \t]*)\d+[ \t]*$/gm, `$1${port}`);
135
+
136
+ return { content: next, changed: next !== yaml };
137
+ }
138
+
119
139
  /**
120
140
  * @param {import('../core/config.js').DeployHubConfig} config
121
141
  * @param {Record<string, Record<string, unknown>>} [environments]
142
+ * @param {{ port?: number }} [options]
122
143
  * @returns {{ appName: string, imageName: string, imageTag: string, port: number, namespace: string, imagePullSecret: string }}
123
144
  */
124
- export function resolveKubernetesManifestOptions(config, environments = {}) {
145
+ export function resolveKubernetesManifestOptions(config, environments = {}, options = {}) {
125
146
  const envList = Object.values(environments);
126
- const k8sEnv = envList.find((env) => env.type === 'kubernetes') || {};
147
+ const k8sRaw = envList.find((env) => getEnvMethod(env) === 'kubernetes') || {};
148
+ const k8sEnv = getEnvSettings(k8sRaw);
127
149
 
128
150
  const appName = config.project || 'app';
129
151
  const imageName =
@@ -142,13 +164,18 @@ export function resolveKubernetesManifestOptions(config, environments = {}) {
142
164
  process.env.KUBE_IMAGE_PULL_SECRET ||
143
165
  '';
144
166
 
145
- let port = 3000;
146
- if (config.projectType === 'both' && config.backend?.port) {
147
- port = config.backend.port;
167
+ /** @type {number} */
168
+ let port;
169
+ if (typeof options.port === 'number' && Number.isFinite(options.port)) {
170
+ port = options.port;
171
+ } else if (config.projectType === 'both' && config.backend?.port) {
172
+ port = Number(config.backend.port);
148
173
  } else if (config.port) {
149
- port = config.port;
174
+ port = Number(config.port);
150
175
  } else if (config.backend?.port) {
151
- port = config.backend.port;
176
+ port = Number(config.backend.port);
177
+ } else {
178
+ port = 3000;
152
179
  }
153
180
 
154
181
  return {
@@ -161,9 +188,77 @@ export function resolveKubernetesManifestOptions(config, environments = {}) {
161
188
  };
162
189
  }
163
190
 
191
+ /**
192
+ * Resolve manifest options with Dockerfile EXPOSE → config → fallback port.
193
+ *
194
+ * @param {string} cwd
195
+ * @param {import('../core/config.js').DeployHubConfig} config
196
+ * @param {Record<string, Record<string, unknown>>} [environments]
197
+ */
198
+ export async function resolveKubernetesManifestOptionsFromCwd(
199
+ cwd,
200
+ config,
201
+ environments = {}
202
+ ) {
203
+ const { port, source } = await resolveContainerPort(cwd, config);
204
+ return {
205
+ ...resolveKubernetesManifestOptions(config, environments, { port }),
206
+ portSource: source,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Paths DeployHub normally writes for Kubernetes starter manifests.
212
+ * @param {string} cwd
213
+ * @returns {{ deploymentPath: string, servicePath: string }}
214
+ */
215
+ export function getDefaultKubernetesManifestPaths(cwd) {
216
+ const k8sDir = path.join(cwd, 'k8s');
217
+ return {
218
+ deploymentPath: path.join(k8sDir, 'deployment.yaml'),
219
+ servicePath: path.join(k8sDir, 'service.yaml'),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Patch containerPort/targetPort in existing k8s/deployment.yaml + service.yaml only.
225
+ *
226
+ * @param {string} cwd
227
+ * @param {number} port
228
+ * @returns {Promise<{ patched: string[], skipped: string[], port: number }>}
229
+ */
230
+ export async function syncKubernetesManifestPorts(cwd, port) {
231
+ const { deploymentPath, servicePath } = getDefaultKubernetesManifestPaths(cwd);
232
+ /** @type {string[]} */
233
+ const patched = [];
234
+ /** @type {string[]} */
235
+ const skipped = [];
236
+
237
+ for (const filePath of [deploymentPath, servicePath]) {
238
+ if (!(await fs.pathExists(filePath))) {
239
+ skipped.push(path.relative(cwd, filePath));
240
+ continue;
241
+ }
242
+ const original = await fs.readFile(filePath, 'utf8');
243
+ const { content, changed } = patchKubernetesManifestPorts(original, port);
244
+ if (changed) {
245
+ await fs.writeFile(filePath, content);
246
+ patched.push(path.relative(cwd, filePath));
247
+ } else {
248
+ skipped.push(path.relative(cwd, filePath));
249
+ }
250
+ }
251
+
252
+ return { patched, skipped, port };
253
+ }
254
+
164
255
  export default {
165
256
  sanitizeK8sName,
166
257
  hasKubernetesManifests,
167
258
  generateKubernetesManifests,
259
+ patchKubernetesManifestPorts,
168
260
  resolveKubernetesManifestOptions,
261
+ resolveKubernetesManifestOptionsFromCwd,
262
+ getDefaultKubernetesManifestPaths,
263
+ syncKubernetesManifestPorts,
169
264
  };
@@ -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 };
@@ -10,8 +10,9 @@ import {
10
10
  import {
11
11
  generateKubernetesManifests,
12
12
  hasKubernetesManifests,
13
- resolveKubernetesManifestOptions,
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
  }
@@ -134,7 +135,11 @@ export async function ensureKubernetesManifests(
134
135
  return { generated: false };
135
136
  }
136
137
 
137
- const manifestOptions = resolveKubernetesManifestOptions(config, environments);
138
+ const manifestOptions = await resolveKubernetesManifestOptionsFromCwd(
139
+ cwd,
140
+ config,
141
+ environments
142
+ );
138
143
  const { deploymentYaml, serviceYaml } = generateKubernetesManifests(manifestOptions);
139
144
 
140
145
  const k8sDir = path.join(cwd, 'k8s');