@akash-chowdhury-24/deployhub 2.0.11 → 2.0.14
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 +33 -9
- package/package.json +1 -1
- package/src/artifact/engine.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/commands/artifact.js +77 -22
- package/src/commands/doctor.js +19 -2
- package/src/commands/init.js +1 -0
- package/src/commands/rollback.js +31 -28
- package/src/commands/sync-workflows.js +43 -0
- package/src/core/config.js +1 -0
- package/src/core/stages.js +8 -1
- package/src/deployment/index.js +3 -2
- package/src/deployment/providers/azure-vm.js +39 -18
- package/src/deployment/providers/docker.js +29 -7
- package/src/deployment/providers/ec2.js +39 -18
- package/src/deployment/providers/gcp-vm.js +39 -18
- package/src/deployment/providers/kubernetes.js +67 -12
- package/src/deployment/providers/ssh.js +1 -1
- package/src/storage/index.js +165 -9
- package/src/storage/providers/aws.js +6 -2
- package/src/storage/providers/dropbox.js +8 -2
- package/src/storage/providers/ftp.js +8 -2
- package/src/storage/storage-errors.js +106 -0
- package/src/utils/artifact-history.js +155 -0
- package/src/utils/build-id.js +149 -0
- package/src/utils/docker-image-deploy.js +44 -21
- package/src/utils/docker-image.js +59 -54
- package/src/utils/github-actions.js +175 -8
- package/src/utils/rollback/engine.js +44 -21
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
2
|
import { createLogger } from '../../logger/index.js';
|
|
3
3
|
import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
|
|
4
|
+
import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -14,12 +15,17 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @param {string} artifactDir
|
|
18
|
+
* @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
|
|
17
19
|
*/
|
|
18
|
-
async function deploy(artifactDir) {
|
|
19
|
-
|
|
20
|
+
async function deploy(artifactDir, options = {}) {
|
|
21
|
+
const imageRef = options.fullImage || fullImage;
|
|
22
|
+
log.info(`Deploying via Docker (image: ${imageRef})...`);
|
|
20
23
|
const dockerEnv = getDockerEnv();
|
|
21
24
|
|
|
22
|
-
const result = await ensureImageReadyForDeploy(artifactDir
|
|
25
|
+
const result = await ensureImageReadyForDeploy(artifactDir, {
|
|
26
|
+
fullImage: options.fullImage,
|
|
27
|
+
skipImageReuse: options.skipImageReuse,
|
|
28
|
+
});
|
|
23
29
|
if (result.ranCompose) {
|
|
24
30
|
log.success('Docker deployment complete');
|
|
25
31
|
return;
|
|
@@ -31,7 +37,7 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
31
37
|
{ stdio: 'pipe', env: dockerEnv }
|
|
32
38
|
).catch(() => {});
|
|
33
39
|
|
|
34
|
-
await execa('docker', ['run', '-d', '--rm', '--name', config.project,
|
|
40
|
+
await execa('docker', ['run', '-d', '--rm', '--name', config.project, imageRef], {
|
|
35
41
|
stdio: 'inherit',
|
|
36
42
|
env: dockerEnv,
|
|
37
43
|
});
|
|
@@ -39,9 +45,25 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
39
45
|
log.success('Docker deployment complete');
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} artifactDir
|
|
50
|
+
* @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
|
|
51
|
+
*/
|
|
52
|
+
async function rollback(artifactDir, meta = {}) {
|
|
53
|
+
if (!meta.buildId) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Docker rollback requires buildId from the restored artifact history entry'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
|
|
60
|
+
log.info(
|
|
61
|
+
`Rolling back Docker to buildId=${meta.buildId} (image: ${rollbackImage})...`
|
|
62
|
+
);
|
|
63
|
+
await deploy(artifactDir, {
|
|
64
|
+
fullImage: rollbackImage,
|
|
65
|
+
skipImageReuse: true,
|
|
66
|
+
});
|
|
45
67
|
}
|
|
46
68
|
|
|
47
69
|
async function healthCheck() {
|
|
@@ -20,7 +20,8 @@ export function createEc2Provider(config, envName, env = process.env) {
|
|
|
20
20
|
|
|
21
21
|
if (!instanceId) {
|
|
22
22
|
throw new Error(
|
|
23
|
-
'
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
80
|
+
if (environment) {
|
|
72
81
|
environment.host = host;
|
|
73
82
|
}
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
81
|
+
if (environment) {
|
|
73
82
|
environment.host = host;
|
|
74
83
|
}
|
|
75
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
package/src/storage/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import os from 'os';
|
|
2
4
|
import { createLogger } from '../logger/index.js';
|
|
3
5
|
import { createAwsProvider } from './providers/aws.js';
|
|
4
6
|
import { createLocalProvider } from './providers/local.js';
|
|
@@ -7,8 +9,20 @@ import { createGcpProvider } from './providers/gcp.js';
|
|
|
7
9
|
import { createGdriveProvider } from './providers/gdrive.js';
|
|
8
10
|
import { createDropboxProvider } from './providers/dropbox.js';
|
|
9
11
|
import { createFtpProvider } from './providers/ftp.js';
|
|
12
|
+
import {
|
|
13
|
+
buildArtifactRemoteKey,
|
|
14
|
+
historyRemoteKey,
|
|
15
|
+
latestArtifactRemoteKey,
|
|
16
|
+
legacyArtifactRemoteKey,
|
|
17
|
+
resolveBuildId,
|
|
18
|
+
} from '../utils/build-id.js';
|
|
19
|
+
import {
|
|
20
|
+
parseArtifactHistory,
|
|
21
|
+
prependHistoryEntry,
|
|
22
|
+
} from '../utils/artifact-history.js';
|
|
23
|
+
import { summarizeStorageError } from './storage-errors.js';
|
|
10
24
|
|
|
11
|
-
/** @type {Record<string, (env?: Record<string, string>) =>
|
|
25
|
+
/** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
|
|
12
26
|
const PROVIDER_FACTORIES = {
|
|
13
27
|
aws: createAwsProvider,
|
|
14
28
|
local: createLocalProvider,
|
|
@@ -32,21 +46,135 @@ export function getStorageProvider(name, env = process.env) {
|
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
/**
|
|
49
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
50
|
+
*/
|
|
51
|
+
function ensureBuildIdentity(config) {
|
|
52
|
+
if (!config.version) {
|
|
53
|
+
config.version = '0.0.0';
|
|
54
|
+
}
|
|
55
|
+
if (!config.buildId) {
|
|
56
|
+
const { buildId } = resolveBuildId({ semver: config.version });
|
|
57
|
+
config.buildId = buildId;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
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
|
+
*
|
|
67
|
+
* @param {string[]} providers
|
|
68
|
+
* @param {string} project
|
|
69
|
+
* @returns {Promise<{
|
|
70
|
+
* entries: import('../utils/artifact-history.js').ArtifactHistoryEntry[],
|
|
71
|
+
* source: string|null,
|
|
72
|
+
* }>}
|
|
73
|
+
*/
|
|
74
|
+
export async function loadArtifactHistory(providers, project) {
|
|
75
|
+
if (!providers || providers.length === 0) {
|
|
76
|
+
return { entries: [], source: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const key = historyRemoteKey(project);
|
|
80
|
+
const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
for (const name of providers) {
|
|
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
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
await fs.remove(tmp).catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { entries: [], source: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {ReturnType<typeof getStorageProvider>} provider
|
|
112
|
+
* @param {string} project
|
|
113
|
+
* @param {import('../utils/artifact-history.js').ArtifactHistoryEntry[]} history
|
|
114
|
+
*/
|
|
115
|
+
async function writeArtifactHistory(provider, project, history) {
|
|
116
|
+
const key = historyRemoteKey(project);
|
|
117
|
+
const tmp = path.join(os.tmpdir(), `deployhub-history-write-${Date.now()}.json`);
|
|
118
|
+
await fs.writeJson(tmp, history, { spaces: 2 });
|
|
119
|
+
try {
|
|
120
|
+
await provider.upload(tmp, key);
|
|
121
|
+
} finally {
|
|
122
|
+
await fs.remove(tmp).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Upload artifact zip under a unique build key, update latest/ pointer and history.json.
|
|
128
|
+
* Does NOT write legacy `{project}/v{semver}/artifact.zip` (retired for new uploads;
|
|
129
|
+
* legacy keys remain readable for older artifacts).
|
|
130
|
+
*
|
|
35
131
|
* @param {string[]} providers
|
|
36
132
|
* @param {string} zipPath
|
|
37
133
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
38
134
|
*/
|
|
39
135
|
export async function uploadToAll(providers, zipPath, config) {
|
|
40
136
|
const log = createLogger('storage');
|
|
41
|
-
|
|
137
|
+
ensureBuildIdentity(config);
|
|
138
|
+
|
|
139
|
+
const buildId = /** @type {string} */ (config.buildId);
|
|
140
|
+
const remoteKey = buildArtifactRemoteKey(config.project, buildId);
|
|
141
|
+
const latestKey = latestArtifactRemoteKey(config.project);
|
|
142
|
+
const entry = {
|
|
143
|
+
buildId,
|
|
144
|
+
semver: String(config.version || '0.0.0').replace(/^v/i, ''),
|
|
145
|
+
uploadedAt: new Date().toISOString(),
|
|
146
|
+
remoteKey,
|
|
147
|
+
};
|
|
42
148
|
|
|
43
149
|
const uploads = providers.map(async (name) => {
|
|
44
150
|
try {
|
|
45
151
|
const provider = getStorageProvider(name);
|
|
46
|
-
log.info(`Uploading to ${name}...`);
|
|
152
|
+
log.info(`Uploading build ${buildId} to ${name}...`);
|
|
47
153
|
await provider.upload(zipPath, remoteKey);
|
|
48
|
-
|
|
49
|
-
|
|
154
|
+
// Intentional overwrite: mutable "current" pointer, not a versioned backup.
|
|
155
|
+
await provider.upload(zipPath, latestKey);
|
|
156
|
+
|
|
157
|
+
let history = [];
|
|
158
|
+
try {
|
|
159
|
+
const histKey = historyRemoteKey(config.project);
|
|
160
|
+
if (await provider.verify(histKey)) {
|
|
161
|
+
const tmp = path.join(os.tmpdir(), `deployhub-hist-${name}-${Date.now()}.json`);
|
|
162
|
+
try {
|
|
163
|
+
await provider.download(histKey, tmp);
|
|
164
|
+
history = parseArtifactHistory(await fs.readFile(tmp, 'utf8'));
|
|
165
|
+
} finally {
|
|
166
|
+
await fs.remove(tmp).catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
history = [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const next = prependHistoryEntry(history, entry);
|
|
174
|
+
await writeArtifactHistory(provider, config.project, next);
|
|
175
|
+
|
|
176
|
+
log.success(`Uploaded to ${name} (${remoteKey})`);
|
|
177
|
+
return { name, success: true, remoteKey, buildId };
|
|
50
178
|
} catch (err) {
|
|
51
179
|
const message = err instanceof Error ? err.message : String(err);
|
|
52
180
|
throw new Error(`Storage upload to ${name} failed: ${message}`);
|
|
@@ -73,6 +201,27 @@ export async function downloadFromFirst(providers, remoteKey, localPath) {
|
|
|
73
201
|
throw new Error(`Artifact not found in any configured storage provider`);
|
|
74
202
|
}
|
|
75
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Download a build by history entry, with legacy key fallback for pre-buildId uploads.
|
|
206
|
+
*
|
|
207
|
+
* @param {string[]} providers
|
|
208
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
209
|
+
* @param {{ remoteKey: string, buildId: string, semver: string }} entry
|
|
210
|
+
* @param {string} localPath
|
|
211
|
+
*/
|
|
212
|
+
export async function downloadArtifactEntry(providers, config, entry, localPath) {
|
|
213
|
+
try {
|
|
214
|
+
return await downloadFromFirst(providers, entry.remoteKey, localPath);
|
|
215
|
+
} catch {
|
|
216
|
+
const legacyKey = legacyArtifactRemoteKey(config.project, entry.semver);
|
|
217
|
+
const log = createLogger('storage');
|
|
218
|
+
log.warn(
|
|
219
|
+
`Build key not found; trying legacy key ${legacyKey} (may be an overwritten single-slot artifact)`
|
|
220
|
+
);
|
|
221
|
+
return downloadFromFirst(providers, legacyKey, localPath);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
76
225
|
/**
|
|
77
226
|
* @param {string} name
|
|
78
227
|
*/
|
|
@@ -99,11 +248,18 @@ export async function testAllProviders(providers) {
|
|
|
99
248
|
}
|
|
100
249
|
return {
|
|
101
250
|
name,
|
|
102
|
-
status: '
|
|
103
|
-
error: result.reason
|
|
251
|
+
status: 'failed',
|
|
252
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
104
253
|
};
|
|
105
254
|
});
|
|
106
255
|
}
|
|
107
256
|
|
|
108
|
-
export {
|
|
109
|
-
|
|
257
|
+
export default {
|
|
258
|
+
getStorageProvider,
|
|
259
|
+
uploadToAll,
|
|
260
|
+
downloadFromFirst,
|
|
261
|
+
downloadArtifactEntry,
|
|
262
|
+
loadArtifactHistory,
|
|
263
|
+
testProvider,
|
|
264
|
+
testAllProviders,
|
|
265
|
+
};
|
|
@@ -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
|
|