@akash-chowdhury-24/deployhub 2.0.13 → 2.0.15

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.
@@ -20,7 +20,8 @@ export function createEc2Provider(config, envName, env = process.env) {
20
20
 
21
21
  if (!instanceId) {
22
22
  throw new Error(
23
- 'EC2 host unknown. Set SSH_HOST to your instance public IP, or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
23
+ 'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set ' +
24
+ 'provide SSH_HOST (instance public IP/DNS) or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
24
25
  );
25
26
  }
26
27
 
@@ -58,34 +59,54 @@ export function createEc2Provider(config, envName, env = process.env) {
58
59
  } catch (err) {
59
60
  const msg = err instanceof Error ? err.message : String(err);
60
61
  throw new Error(
61
- `Could not resolve public IP for ${instanceId} ${msg}. Set SSH_HOST manually or install/configure AWS CLI with ec2:DescribeInstances access.`
62
+ `Could not resolve host via EC2 instance lookup (${instanceId}): ${msg}. ` +
63
+ 'Set SSH_HOST to the instance public IP/DNS, or fix AWS CLI credentials / ec2:DescribeInstances access.'
62
64
  );
63
65
  }
64
66
  }
65
67
 
66
- const sshProvider = createSshProvider(config, envName, env);
67
-
68
- async function connect() {
68
+ /**
69
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
70
+ * then create an SSH provider that closes over the resolved host.
71
+ */
72
+ async function getSshProvider() {
69
73
  const host = await resolveHost();
74
+ if (!host) {
75
+ throw new Error(
76
+ 'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set — provide one or the other.'
77
+ );
78
+ }
70
79
  const environment = config.environments[envName];
71
- if (environment && !environment.host) {
80
+ if (environment) {
72
81
  environment.host = host;
73
82
  }
74
- if (!env.SSH_HOST) {
75
- env.SSH_HOST = host;
76
- }
77
- return sshProvider.connect();
83
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
78
84
  }
79
85
 
80
86
  return {
81
- ...sshProvider,
82
- connect,
83
- deploy: sshProvider.deploy.bind(sshProvider),
84
- rollback: sshProvider.rollback.bind(sshProvider),
85
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
86
- testConnection: async () => {
87
- const ssh = await connect();
88
- ssh.dispose();
87
+ async connect() {
88
+ const ssh = await getSshProvider();
89
+ return ssh.connect();
90
+ },
91
+ async deploy(artifactDir, options) {
92
+ const ssh = await getSshProvider();
93
+ return ssh.deploy(artifactDir, options);
94
+ },
95
+ async rollback(artifactDir, meta) {
96
+ const ssh = await getSshProvider();
97
+ return ssh.rollback(artifactDir, meta);
98
+ },
99
+ async healthCheck() {
100
+ const ssh = await getSshProvider();
101
+ return ssh.healthCheck();
102
+ },
103
+ async testConnection() {
104
+ const ssh = await getSshProvider();
105
+ return ssh.testConnection();
106
+ },
107
+ async runRemoteCheck(command) {
108
+ const ssh = await getSshProvider();
109
+ return ssh.runRemoteCheck(command);
89
110
  },
90
111
  };
91
112
  }
@@ -21,7 +21,8 @@ export function createGcpVmProvider(config, envName, env = process.env) {
21
21
 
22
22
  if (!projectId || !zone || !instanceName) {
23
23
  throw new Error(
24
- 'GCP VM host unknown. Set SSH_HOST to your instance external IP, or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
24
+ 'Could not resolve host via GCP instance lookup, and no SSH_HOST was set ' +
25
+ 'provide SSH_HOST (instance external IP/DNS) or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
25
26
  );
26
27
  }
27
28
 
@@ -59,34 +60,54 @@ export function createGcpVmProvider(config, envName, env = process.env) {
59
60
  } catch (err) {
60
61
  const msg = err instanceof Error ? err.message : String(err);
61
62
  throw new Error(
62
- `Could not resolve external IP for ${instanceName} ${msg}. Set SSH_HOST manually or run gcloud auth login and verify project/zone/instance name.`
63
+ `Could not resolve host via GCP instance lookup (${instanceName}): ${msg}. ` +
64
+ 'Set SSH_HOST to the instance external IP/DNS, or run gcloud auth login and verify project/zone/instance name.'
63
65
  );
64
66
  }
65
67
  }
66
68
 
67
- const sshProvider = createSshProvider(config, envName, env);
68
-
69
- async function connect() {
69
+ /**
70
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
71
+ * then create an SSH provider that closes over the resolved host.
72
+ */
73
+ async function getSshProvider() {
70
74
  const host = await resolveHost();
75
+ if (!host) {
76
+ throw new Error(
77
+ 'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — provide one or the other.'
78
+ );
79
+ }
71
80
  const environment = config.environments[envName];
72
- if (environment && !environment.host) {
81
+ if (environment) {
73
82
  environment.host = host;
74
83
  }
75
- if (!env.SSH_HOST) {
76
- env.SSH_HOST = host;
77
- }
78
- return sshProvider.connect();
84
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
79
85
  }
80
86
 
81
87
  return {
82
- ...sshProvider,
83
- connect,
84
- deploy: sshProvider.deploy.bind(sshProvider),
85
- rollback: sshProvider.rollback.bind(sshProvider),
86
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
87
- testConnection: async () => {
88
- const ssh = await connect();
89
- ssh.dispose();
88
+ async connect() {
89
+ const ssh = await getSshProvider();
90
+ return ssh.connect();
91
+ },
92
+ async deploy(artifactDir, options) {
93
+ const ssh = await getSshProvider();
94
+ return ssh.deploy(artifactDir, options);
95
+ },
96
+ async rollback(artifactDir, meta) {
97
+ const ssh = await getSshProvider();
98
+ return ssh.rollback(artifactDir, meta);
99
+ },
100
+ async healthCheck() {
101
+ const ssh = await getSshProvider();
102
+ return ssh.healthCheck();
103
+ },
104
+ async testConnection() {
105
+ const ssh = await getSshProvider();
106
+ return ssh.testConnection();
107
+ },
108
+ async runRemoteCheck(command) {
109
+ const ssh = await getSshProvider();
110
+ return ssh.runRemoteCheck(command);
90
111
  },
91
112
  };
92
113
  }
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
7
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
8
+ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
8
9
  import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
9
10
  import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
10
11
 
@@ -50,10 +51,24 @@ export function createKubernetesProvider(config, envName, env = process.env) {
50
51
 
51
52
  /**
52
53
  * @param {string} artifactDir
54
+ * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
53
55
  */
54
- async function deploy(artifactDir) {
56
+ async function deploy(artifactDir, options = {}) {
57
+ const imageRef = options.fullImage || imageOps.fullImage;
58
+ const isRollbackRedeploy = Boolean(options.skipImageReuse);
59
+
55
60
  log.info(`Deploying to Kubernetes (namespace: ${namespace}${context ? `, context: ${context}` : ''})...`);
56
61
 
62
+ // Rollback always rebuilds and must push — without registry creds the cluster
63
+ // cannot pull the new tag and would sit in ImagePullBackOff after a false success.
64
+ if (isRollbackRedeploy && !imageOps.hasRegistryCredentials()) {
65
+ throw new Error(
66
+ 'Kubernetes rollback requires DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN ' +
67
+ `so the rebuilt image (${imageRef}) can be pushed for the cluster to pull. ` +
68
+ 'Set those credentials and retry.'
69
+ );
70
+ }
71
+
57
72
  const manifestDir = artifactDir;
58
73
  const hasManifests =
59
74
  (await fs.pathExists(path.join(manifestDir, 'k8s'))) ||
@@ -65,8 +80,11 @@ export function createKubernetesProvider(config, envName, env = process.env) {
65
80
  );
66
81
  }
67
82
 
68
- log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
69
- const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
83
+ log.info(`Ensuring container image ${imageRef} is built and pushed before apply...`);
84
+ const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir, {
85
+ fullImage: options.fullImage,
86
+ skipImageReuse: options.skipImageReuse,
87
+ });
70
88
  if (imageResult.ranCompose) {
71
89
  log.warn(
72
90
  'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
@@ -93,23 +111,60 @@ export function createKubernetesProvider(config, envName, env = process.env) {
93
111
  // If the live ref already equals fullImage, rollout restart so a new digest is pulled.
94
112
  await syncKubernetesDeploymentImage({
95
113
  deploymentName,
96
- fullImage: imageOps.fullImage,
114
+ fullImage: imageRef,
97
115
  kubectlArgs,
98
116
  getKubectlEnv,
99
117
  log,
100
118
  });
101
119
 
120
+ // Rollback-only safety net: wait until pods are actually healthy (catches ImagePullBackOff).
121
+ if (isRollbackRedeploy) {
122
+ const timeout = '120s';
123
+ log.info(
124
+ `Waiting for deployment/${deploymentName} rollout to complete (timeout ${timeout})...`
125
+ );
126
+ try {
127
+ await execa(
128
+ 'kubectl',
129
+ kubectlArgs([
130
+ 'rollout',
131
+ 'status',
132
+ `deployment/${deploymentName}`,
133
+ `--timeout=${timeout}`,
134
+ ]),
135
+ { stdio: 'inherit', env: getKubectlEnv() }
136
+ );
137
+ } catch (err) {
138
+ const detail = err instanceof Error ? err.message : String(err);
139
+ throw new Error(
140
+ `Kubernetes rollback failed: deployment/${deploymentName} did not become healthy within ${timeout}. ` +
141
+ `The cluster may be unable to pull ${imageRef} (ImagePullBackOff) or pods are failing. ${detail}`
142
+ );
143
+ }
144
+ }
145
+
102
146
  log.success('Kubernetes deployment complete');
103
147
  }
104
148
 
105
- async function rollback(artifactDir) {
106
- log.info('Rolling back Kubernetes deployment...');
107
- await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
108
- stdio: 'inherit',
109
- env: getKubectlEnv(),
110
- }).catch(async () => {
111
- log.warn('kubectl rollout undo failed — redeploying previous artifact instead');
112
- await deploy(artifactDir);
149
+ /**
150
+ * Artifact-based rollback: restore buildId X's code and image (not cluster undo history).
151
+ * @param {string} artifactDir
152
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
153
+ */
154
+ async function rollback(artifactDir, meta = {}) {
155
+ if (!meta.buildId) {
156
+ throw new Error(
157
+ 'Kubernetes rollback requires buildId from the restored artifact history entry'
158
+ );
159
+ }
160
+
161
+ const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
162
+ log.info(
163
+ `Rolling back Kubernetes to buildId=${meta.buildId} (image: ${rollbackImage})...`
164
+ );
165
+ await deploy(artifactDir, {
166
+ fullImage: rollbackImage,
167
+ skipImageReuse: true,
113
168
  });
114
169
  }
115
170
 
@@ -405,7 +405,7 @@ export function createSshProvider(config, envName, env = process.env) {
405
405
  return result.code === 0 && result.stdout.trim() === 'yes';
406
406
  }
407
407
 
408
- async function rollback(artifactDir) {
408
+ async function rollback(artifactDir, _meta) {
409
409
  await deploy(artifactDir);
410
410
  }
411
411
 
@@ -20,6 +20,7 @@ import {
20
20
  parseArtifactHistory,
21
21
  prependHistoryEntry,
22
22
  } from '../utils/artifact-history.js';
23
+ import { summarizeStorageError } from './storage-errors.js';
23
24
 
24
25
  /** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
25
26
  const PROVIDER_FACTORIES = {
@@ -59,28 +60,51 @@ function ensureBuildIdentity(config) {
59
60
 
60
61
  /**
61
62
  * Read history.json from the first provider that has it.
63
+ * Missing keys across all providers → { entries: [], source: null }.
64
+ * Auth / network / permission failures → thrown with a concise actionable message
65
+ * (not silently treated as "no history").
66
+ *
62
67
  * @param {string[]} providers
63
68
  * @param {string} project
64
- * @returns {Promise<import('../utils/artifact-history.js').ArtifactHistoryEntry[]>}
69
+ * @returns {Promise<{
70
+ * entries: import('../utils/artifact-history.js').ArtifactHistoryEntry[],
71
+ * source: string|null,
72
+ * }>}
65
73
  */
66
74
  export async function loadArtifactHistory(providers, project) {
75
+ if (!providers || providers.length === 0) {
76
+ return { entries: [], source: null };
77
+ }
78
+
67
79
  const key = historyRemoteKey(project);
68
80
  const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
81
+
69
82
  try {
70
83
  for (const name of providers) {
71
- const provider = getStorageProvider(name);
72
- const exists = await provider.verify(key);
73
- if (!exists) continue;
74
- await provider.download(key, tmp);
75
- const raw = await fs.readFile(tmp, 'utf8');
76
- return parseArtifactHistory(raw);
84
+ try {
85
+ const provider = getStorageProvider(name);
86
+ const exists = await provider.verify(key);
87
+ if (!exists) continue;
88
+
89
+ await provider.download(key, tmp);
90
+ const raw = await fs.readFile(tmp, 'utf8');
91
+ return {
92
+ entries: parseArtifactHistory(raw),
93
+ source: name,
94
+ };
95
+ } catch (err) {
96
+ const reason = summarizeStorageError(err);
97
+ throw new Error(
98
+ `Could not check remote history via ${name}: ${reason} — ` +
99
+ 'verify your storage credentials and configuration are correct.'
100
+ );
101
+ }
77
102
  }
78
- } catch {
79
- return [];
80
103
  } finally {
81
104
  await fs.remove(tmp).catch(() => {});
82
105
  }
83
- return [];
106
+
107
+ return { entries: [], source: null };
84
108
  }
85
109
 
86
110
  /**
@@ -2,6 +2,7 @@ import { S3Client, HeadBucketCommand, DeleteObjectCommand, GetObjectCommand } fr
2
2
  import { Upload } from '@aws-sdk/lib-storage';
3
3
  import fs from 'fs-extra';
4
4
  import path from 'path';
5
+ import { isNotFoundStorageError } from '../storage-errors.js';
5
6
 
6
7
  /**
7
8
  * @param {Record<string, string>} env
@@ -65,6 +66,8 @@ export function createAwsProvider(env = process.env) {
65
66
 
66
67
  /**
67
68
  * @param {string} remoteKey
69
+ * @returns {Promise<boolean>} true if object exists; false if missing.
70
+ * Auth / network / permission errors are rethrown (not treated as missing).
68
71
  */
69
72
  async function verify(remoteKey) {
70
73
  try {
@@ -72,8 +75,9 @@ export function createAwsProvider(env = process.env) {
72
75
  new GetObjectCommand({ Bucket: bucket, Key: remoteKey })
73
76
  );
74
77
  return true;
75
- } catch {
76
- return false;
78
+ } catch (err) {
79
+ if (isNotFoundStorageError(err)) return false;
80
+ throw err;
77
81
  }
78
82
  }
79
83
 
@@ -1,6 +1,7 @@
1
1
  import { Dropbox } from 'dropbox';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
+ import { isNotFoundStorageError } from '../storage-errors.js';
4
5
 
5
6
  export function createDropboxProvider(env = process.env) {
6
7
  const token = env.DROPBOX_ACCESS_TOKEN;
@@ -24,13 +25,18 @@ export function createDropboxProvider(env = process.env) {
24
25
  await fs.writeFile(localPath, fileBlob);
25
26
  }
26
27
 
28
+ /**
29
+ * @param {string} remoteKey
30
+ * @returns {Promise<boolean>}
31
+ */
27
32
  async function verify(remoteKey) {
28
33
  const key = remoteKey.startsWith('/') ? remoteKey : `/${remoteKey}`;
29
34
  try {
30
35
  await dbx.filesGetMetadata({ path: key });
31
36
  return true;
32
- } catch {
33
- return false;
37
+ } catch (err) {
38
+ if (isNotFoundStorageError(err)) return false;
39
+ throw err;
34
40
  }
35
41
  }
36
42
 
@@ -1,6 +1,7 @@
1
1
  import { Client } from 'basic-ftp';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
+ import { isNotFoundStorageError } from '../storage-errors.js';
4
5
 
5
6
  export function createFtpProvider(env = process.env) {
6
7
  const host = env.FTP_HOST;
@@ -38,6 +39,10 @@ export function createFtpProvider(env = process.env) {
38
39
  });
39
40
  }
40
41
 
42
+ /**
43
+ * @param {string} remoteKey
44
+ * @returns {Promise<boolean>}
45
+ */
41
46
  async function verify(remoteKey) {
42
47
  const remotePath = `${basePath}/${remoteKey}`;
43
48
  try {
@@ -45,8 +50,9 @@ export function createFtpProvider(env = process.env) {
45
50
  await client.size(remotePath);
46
51
  });
47
52
  return true;
48
- } catch {
49
- return false;
53
+ } catch (err) {
54
+ if (isNotFoundStorageError(err)) return false;
55
+ throw err;
50
56
  }
51
57
  }
52
58
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared helpers for storage provider errors (history load, verify, etc.).
3
+ */
4
+
5
+ /**
6
+ * Concise, actionable one-line reason — no SDK stack dumps.
7
+ * @param {unknown} err
8
+ * @returns {string}
9
+ */
10
+ export function summarizeStorageError(err) {
11
+ if (!err) return 'unknown error';
12
+
13
+ if (typeof err === 'string') {
14
+ return truncateReason(err);
15
+ }
16
+
17
+ const e = /** @type {Record<string, unknown>} */ (err);
18
+ const name = typeof e.name === 'string' ? e.name : '';
19
+ const code =
20
+ typeof e.Code === 'string'
21
+ ? e.Code
22
+ : typeof e.code === 'string'
23
+ ? e.code
24
+ : '';
25
+ const message =
26
+ err instanceof Error
27
+ ? err.message
28
+ : typeof e.message === 'string'
29
+ ? e.message
30
+ : String(err);
31
+
32
+ const firstLine = message.split(/\r?\n/)[0].trim();
33
+
34
+ // Prefer a short "AccessDenied: ..." style when the SDK exposes a name/code.
35
+ const label = [name, code].find(
36
+ (v) => v && v !== 'Error' && !firstLine.includes(v)
37
+ );
38
+ const combined = label ? `${label}: ${firstLine}` : firstLine;
39
+ return truncateReason(combined || 'unknown error');
40
+ }
41
+
42
+ /**
43
+ * @param {string} text
44
+ */
45
+ function truncateReason(text) {
46
+ const cleaned = text.replace(/\s+/g, ' ').trim();
47
+ if (cleaned.length <= 200) return cleaned;
48
+ return `${cleaned.slice(0, 197)}...`;
49
+ }
50
+
51
+ /**
52
+ * True when an error almost certainly means the object/key is missing
53
+ * (as opposed to auth, network, or permission failures).
54
+ * @param {unknown} err
55
+ * @returns {boolean}
56
+ */
57
+ export function isNotFoundStorageError(err) {
58
+ if (!err || typeof err !== 'object') return false;
59
+ const e = /** @type {Record<string, unknown>} */ (err);
60
+ const status =
61
+ (e.$metadata &&
62
+ typeof e.$metadata === 'object' &&
63
+ /** @type {{ httpStatusCode?: number }} */ (e.$metadata).httpStatusCode) ||
64
+ (typeof e.statusCode === 'number' ? e.statusCode : undefined) ||
65
+ (typeof e.status === 'number' ? e.status : undefined);
66
+
67
+ const name = String(e.name || e.Code || e.code || '');
68
+ const msg = String(e.message || '').toLowerCase();
69
+ const blob = `${name} ${msg}`;
70
+
71
+ // Auth / permission / credential failures are never "not found"
72
+ if (
73
+ /\b(accessdenied|access denied|invalidaccesskey|forbidden|unauthorized|credentials|signaturedoesnotmatch|expiredtoken)\b/i.test(
74
+ blob
75
+ )
76
+ ) {
77
+ return false;
78
+ }
79
+
80
+ if (status === 403 || status === 401) return false;
81
+
82
+ if (status === 404) return true;
83
+
84
+ if (/^(NoSuchKey|NotFound|NotFoundError|ENOENT)$/i.test(name)) return true;
85
+
86
+ if (
87
+ /\b(nosuchkey|not\s*found|no such file|path\/not_found)\b/i.test(msg) ||
88
+ /\bthe specified key does not exist\b/i.test(msg)
89
+ ) {
90
+ return true;
91
+ }
92
+
93
+ // Dropbox often uses 409 with path/not_found
94
+ if (status === 409 && /not_found/i.test(msg + name + JSON.stringify(e.error || ''))) {
95
+ return true;
96
+ }
97
+
98
+ // FTP / classic responses
99
+ if (/\b550\b/.test(msg) && /not found|no such|failed to open/i.test(msg)) {
100
+ return true;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ export default { summarizeStorageError, isNotFoundStorageError };