@akash-chowdhury-24/deployhub 2.0.11 → 2.0.13
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/commands/artifact.js +69 -22
- package/src/commands/rollback.js +31 -28
- package/src/core/config.js +1 -0
- package/src/core/stages.js +8 -1
- package/src/storage/index.js +141 -9
- package/src/utils/artifact-history.js +155 -0
- package/src/utils/build-id.js +149 -0
- package/src/utils/docker-image.js +15 -49
- package/src/utils/rollback/engine.js +31 -10
package/README.md
CHANGED
|
@@ -225,10 +225,12 @@ Or push to `main` / `master` — the generated workflow runs the same command.
|
|
|
225
225
|
**Useful follow-up commands:**
|
|
226
226
|
|
|
227
227
|
```bash
|
|
228
|
-
deployhub artifact list #
|
|
229
|
-
deployhub artifact
|
|
228
|
+
deployhub artifact list # local artifacts
|
|
229
|
+
deployhub artifact list --remote # include storage history.json
|
|
230
|
+
deployhub artifact restore <buildId> # download a past build
|
|
230
231
|
deployhub deploy # deploy latest artifact without rebuilding
|
|
231
|
-
deployhub rollback
|
|
232
|
+
deployhub rollback # previous build from history
|
|
233
|
+
deployhub rollback <buildId> # exact build (required if semver is ambiguous)
|
|
232
234
|
deployhub logs # last deployment logs
|
|
233
235
|
```
|
|
234
236
|
|
|
@@ -269,7 +271,17 @@ deployhub doctor
|
|
|
269
271
|
deployhub build
|
|
270
272
|
```
|
|
271
273
|
|
|
272
|
-
Artifacts appear under `artifact/{projectName}/{date}/v{
|
|
274
|
+
Artifacts appear under `artifact/{projectName}/{date}/v{buildId}/` locally.
|
|
275
|
+
|
|
276
|
+
**Remote storage** (S3 and other providers) uses a different layout:
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
{project}/builds/{buildId}/artifact.zip # immutable per CI/build
|
|
280
|
+
{project}/history.json # newest-first index for rollback / list --remote
|
|
281
|
+
{project}/latest/artifact.zip # mutable pointer overwritten every upload (NOT a backup)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only.
|
|
273
285
|
|
|
274
286
|
### Same steps for other languages
|
|
275
287
|
|
|
@@ -943,12 +955,12 @@ Run `deployhub doctor` after any config change.
|
|
|
943
955
|
| `deployhub init` | Interactive project setup |
|
|
944
956
|
| `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy |
|
|
945
957
|
| `deployhub artifact create` | Create artifact from current build |
|
|
946
|
-
| `deployhub artifact list` | List
|
|
947
|
-
| `deployhub artifact restore <
|
|
958
|
+
| `deployhub artifact list [--remote]` | List local artifacts; `--remote` merges storage `history.json` |
|
|
959
|
+
| `deployhub artifact restore <buildId\|semver>` | Download and extract an artifact |
|
|
948
960
|
| `deployhub storage add <provider>` | Add storage provider credentials |
|
|
949
961
|
| `deployhub storage list` | List storage providers and connection status |
|
|
950
962
|
| `deployhub deploy` | Deploy latest artifact |
|
|
951
|
-
| `deployhub rollback [
|
|
963
|
+
| `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
|
|
952
964
|
| `deployhub logs` | Show logs from last deployment |
|
|
953
965
|
| `deployhub doctor` | Pre-flight checks |
|
|
954
966
|
| `deployhub verify` | Health check on configured endpoint |
|
|
@@ -1029,13 +1041,13 @@ If checks fail:
|
|
|
1029
1041
|
|
|
1030
1042
|
## Artifact Structure
|
|
1031
1043
|
|
|
1032
|
-
Each build creates:
|
|
1044
|
+
Each build creates a **local** directory:
|
|
1033
1045
|
|
|
1034
1046
|
```
|
|
1035
1047
|
artifact/
|
|
1036
1048
|
{projectName}/
|
|
1037
1049
|
{YYYY-MM-DD}/
|
|
1038
|
-
v{
|
|
1050
|
+
v{buildId}/
|
|
1039
1051
|
artifact.zip
|
|
1040
1052
|
metadata.json
|
|
1041
1053
|
logs.txt
|
|
@@ -1045,6 +1057,18 @@ artifact/
|
|
|
1045
1057
|
README.md
|
|
1046
1058
|
```
|
|
1047
1059
|
|
|
1060
|
+
`buildId` looks like `{semver}-{gitSha|ciId|timestamp}` (unique every pipeline run).
|
|
1061
|
+
|
|
1062
|
+
**Remote keys** (all storage providers):
|
|
1063
|
+
|
|
1064
|
+
```
|
|
1065
|
+
{project}/builds/{buildId}/artifact.zip
|
|
1066
|
+
{project}/history.json
|
|
1067
|
+
{project}/latest/artifact.zip # overwritten every build — convenience pointer only, not version history
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
Legacy (read-only fallback, no longer written): `{project}/v{semver}/artifact.zip`
|
|
1071
|
+
|
|
1048
1072
|
`deployment.json` records server deployment metadata per environment:
|
|
1049
1073
|
|
|
1050
1074
|
```json
|
package/package.json
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -5,6 +5,7 @@ import { execa } from 'execa';
|
|
|
5
5
|
import { createLogger } from '../logger/index.js';
|
|
6
6
|
import { generateChecksums, formatChecksums } from '../utils/checksums.js';
|
|
7
7
|
import { getProjectVersion } from '../utils/version.js';
|
|
8
|
+
import { resolveBuildId } from '../utils/build-id.js';
|
|
8
9
|
import { generateNginxConfig } from '../utils/nginx.js';
|
|
9
10
|
import {
|
|
10
11
|
ensureDeployScaffold,
|
|
@@ -238,12 +239,13 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
238
239
|
*/
|
|
239
240
|
export function getArtifactDir(config, cwd = process.cwd()) {
|
|
240
241
|
const date = new Date().toISOString().slice(0, 10);
|
|
242
|
+
const buildId = config.buildId || config.version || '0.0.0';
|
|
241
243
|
return path.join(
|
|
242
244
|
cwd,
|
|
243
245
|
'artifact',
|
|
244
246
|
config.project,
|
|
245
247
|
date,
|
|
246
|
-
`v${
|
|
248
|
+
`v${buildId}`
|
|
247
249
|
);
|
|
248
250
|
}
|
|
249
251
|
|
|
@@ -257,6 +259,10 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
257
259
|
const log = createLogger('artifact');
|
|
258
260
|
const version = config.version || (await getProjectVersion(cwd));
|
|
259
261
|
config.version = version;
|
|
262
|
+
if (!config.buildId) {
|
|
263
|
+
const { buildId } = resolveBuildId({ semver: version });
|
|
264
|
+
config.buildId = buildId;
|
|
265
|
+
}
|
|
260
266
|
|
|
261
267
|
const artifactDir = getArtifactDir(config, cwd);
|
|
262
268
|
const stagingDir = path.join(artifactDir, '_staging');
|
|
@@ -266,7 +272,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
266
272
|
const artifactType = resolveArtifactType(config);
|
|
267
273
|
const settings = resolveBuildSettings(config);
|
|
268
274
|
|
|
269
|
-
log.info(`Staging ${projectType} artifact...`);
|
|
275
|
+
log.info(`Staging ${projectType} artifact (buildId=${config.buildId})...`);
|
|
270
276
|
|
|
271
277
|
await ensureDeployScaffold(cwd, config, config.environments || {}, { silent: false });
|
|
272
278
|
|
|
@@ -288,6 +294,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
288
294
|
const metadata = {
|
|
289
295
|
project: config.project,
|
|
290
296
|
version,
|
|
297
|
+
buildId: config.buildId,
|
|
291
298
|
timestamp,
|
|
292
299
|
gitCommit: git.commit,
|
|
293
300
|
branch: git.branch,
|
package/src/commands/artifact.js
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
listLocalArtifacts,
|
|
6
6
|
extractArtifact,
|
|
7
7
|
} from '../artifact/engine.js';
|
|
8
|
-
import { downloadFromFirst } from '../storage/index.js';
|
|
8
|
+
import { downloadArtifactEntry, loadArtifactHistory, downloadFromFirst } from '../storage/index.js';
|
|
9
|
+
import {
|
|
10
|
+
legacyArtifactRemoteKey,
|
|
11
|
+
} from '../utils/build-id.js';
|
|
9
12
|
import fs from 'fs-extra';
|
|
10
13
|
import path from 'path';
|
|
11
14
|
|
|
@@ -29,53 +32,97 @@ export function registerArtifactCommand(program) {
|
|
|
29
32
|
|
|
30
33
|
artifact
|
|
31
34
|
.command('list')
|
|
32
|
-
.description('List
|
|
33
|
-
.
|
|
35
|
+
.description('List local artifacts (add --remote to include storage history.json)')
|
|
36
|
+
.option('--remote', 'Also list builds from remote history.json')
|
|
37
|
+
.action(async (opts) => {
|
|
34
38
|
loadEnv();
|
|
39
|
+
const config = await loadConfig();
|
|
35
40
|
const artifacts = await listLocalArtifacts();
|
|
36
41
|
|
|
42
|
+
console.log(chalk.bold('\nLocal artifacts:\n'));
|
|
37
43
|
if (artifacts.length === 0) {
|
|
38
|
-
console.log(chalk.yellow('
|
|
39
|
-
|
|
44
|
+
console.log(chalk.yellow(' (none)'));
|
|
45
|
+
} else {
|
|
46
|
+
for (const a of artifacts) {
|
|
47
|
+
const sizeMb = (a.size / 1024 / 1024).toFixed(2);
|
|
48
|
+
console.log(
|
|
49
|
+
` ${chalk.cyan(a.version)} ${a.date} ${a.project} ${sizeMb} MB`
|
|
50
|
+
);
|
|
51
|
+
console.log(chalk.gray(` ${a.path}`));
|
|
52
|
+
}
|
|
40
53
|
}
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
if (opts.remote) {
|
|
56
|
+
console.log(chalk.bold('\nRemote history (storage):\n'));
|
|
57
|
+
try {
|
|
58
|
+
const history = await loadArtifactHistory(config.storage || [], config.project);
|
|
59
|
+
if (history.length === 0) {
|
|
60
|
+
console.log(chalk.yellow(' (no history.json found)'));
|
|
61
|
+
} else {
|
|
62
|
+
for (const e of history) {
|
|
63
|
+
console.log(
|
|
64
|
+
` ${chalk.cyan(e.buildId)} semver=${e.semver} ${e.uploadedAt || ''}`
|
|
65
|
+
);
|
|
66
|
+
console.log(chalk.gray(` ${e.remoteKey}`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.log(
|
|
71
|
+
chalk.yellow(
|
|
72
|
+
` Could not load remote history: ${err instanceof Error ? err.message : String(err)}`
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
49
76
|
}
|
|
77
|
+
|
|
50
78
|
console.log('');
|
|
51
79
|
});
|
|
52
80
|
|
|
53
81
|
artifact
|
|
54
|
-
.command('restore <
|
|
55
|
-
.description('Download and extract an artifact by
|
|
56
|
-
.action(async (
|
|
82
|
+
.command('restore <versionOrBuildId>')
|
|
83
|
+
.description('Download and extract an artifact by buildId or legacy semver')
|
|
84
|
+
.action(async (versionOrBuildId) => {
|
|
57
85
|
loadEnv();
|
|
58
86
|
const config = await loadConfig();
|
|
59
87
|
const cwd = process.cwd();
|
|
88
|
+
const needle = String(versionOrBuildId).replace(/^v/i, '');
|
|
60
89
|
|
|
61
90
|
const local = await listLocalArtifacts(cwd);
|
|
62
|
-
const localMatch = local.find(
|
|
91
|
+
const localMatch = local.find(
|
|
92
|
+
(a) => a.version === needle || a.version === versionOrBuildId
|
|
93
|
+
);
|
|
63
94
|
|
|
64
95
|
if (localMatch) {
|
|
65
|
-
const extractTo = path.join(cwd, '.deployhub-restore', `v${version}`);
|
|
96
|
+
const extractTo = path.join(cwd, '.deployhub-restore', `v${localMatch.version}`);
|
|
66
97
|
await extractArtifact(localMatch.path, extractTo);
|
|
67
98
|
console.log(chalk.green(`Restored to ${extractTo}`));
|
|
68
99
|
return;
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
const
|
|
72
|
-
const
|
|
102
|
+
const history = await loadArtifactHistory(config.storage || [], config.project);
|
|
103
|
+
const histMatch =
|
|
104
|
+
history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
|
|
105
|
+
history.find((e) => e.semver === needle);
|
|
106
|
+
|
|
107
|
+
const restoreDir = path.join(cwd, '.deployhub-restore', `v${needle}`);
|
|
73
108
|
await fs.ensureDir(restoreDir);
|
|
74
109
|
const zipPath = path.join(restoreDir, 'artifact.zip');
|
|
75
110
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
if (histMatch) {
|
|
112
|
+
console.log(`Downloading ${histMatch.buildId} from storage...`);
|
|
113
|
+
const provider = await downloadArtifactEntry(
|
|
114
|
+
config.storage,
|
|
115
|
+
config,
|
|
116
|
+
histMatch,
|
|
117
|
+
zipPath
|
|
118
|
+
);
|
|
119
|
+
console.log(chalk.gray(`Downloaded from ${provider}`));
|
|
120
|
+
} else {
|
|
121
|
+
const remoteKey = legacyArtifactRemoteKey(config.project, needle);
|
|
122
|
+
console.log(`Downloading legacy key ${remoteKey} from storage...`);
|
|
123
|
+
const provider = await downloadFromFirst(config.storage, remoteKey, zipPath);
|
|
124
|
+
console.log(chalk.gray(`Downloaded from ${provider}`));
|
|
125
|
+
}
|
|
79
126
|
|
|
80
127
|
const versionDir = path.join(restoreDir, 'artifact');
|
|
81
128
|
await fs.ensureDir(versionDir);
|
package/src/commands/rollback.js
CHANGED
|
@@ -8,42 +8,45 @@ import axios from 'axios';
|
|
|
8
8
|
*/
|
|
9
9
|
export function registerRollbackCommand(program) {
|
|
10
10
|
program
|
|
11
|
-
.command('rollback [
|
|
12
|
-
.description(
|
|
13
|
-
|
|
11
|
+
.command('rollback [versionOrBuildId]')
|
|
12
|
+
.description(
|
|
13
|
+
'Rollback to a previous artifact build (omit arg = previous build; use exact buildId if semver is ambiguous)'
|
|
14
|
+
)
|
|
15
|
+
.action(async (versionOrBuildId) => {
|
|
14
16
|
loadEnv();
|
|
15
17
|
const config = await loadConfig();
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
const {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
console.error(chalk.red('No previous version available for rollback'));
|
|
22
|
-
process.exit(1);
|
|
19
|
+
try {
|
|
20
|
+
const { entry } = await rollbackToVersion(config, versionOrBuildId);
|
|
21
|
+
if (!versionOrBuildId) {
|
|
22
|
+
console.log(chalk.gray(`Rolled back to previous build: ${entry.buildId}`));
|
|
23
23
|
}
|
|
24
|
-
version = artifacts[1].version;
|
|
25
|
-
console.log(chalk.gray(`Rolling back to previous version: v${version}`));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
await rollbackToVersion(config, version);
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
25
|
+
if (config.healthCheck?.url) {
|
|
26
|
+
try {
|
|
27
|
+
const response = await axios.get(config.healthCheck.url, {
|
|
28
|
+
timeout: (config.healthCheck.timeout || 30) * 1000,
|
|
29
|
+
validateStatus: () => true,
|
|
30
|
+
});
|
|
31
|
+
if (response.status >= 200 && response.status < 400) {
|
|
32
|
+
console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
|
|
33
|
+
} else {
|
|
34
|
+
console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.log(
|
|
38
|
+
chalk.yellow(
|
|
39
|
+
`Health check failed: ${err instanceof Error ? err.message : String(err)}`
|
|
40
|
+
)
|
|
41
|
+
);
|
|
40
42
|
}
|
|
41
|
-
} catch (err) {
|
|
42
|
-
console.log(chalk.yellow(`Health check failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
43
43
|
}
|
|
44
|
-
}
|
|
45
44
|
|
|
46
|
-
|
|
45
|
+
console.log(chalk.green(`✓ Rolled back to ${entry.buildId}`));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
|
package/src/core/config.js
CHANGED
|
@@ -47,6 +47,7 @@ const EnvironmentSchema = z.object({
|
|
|
47
47
|
const ConfigSchema = z.object({
|
|
48
48
|
project: z.string(),
|
|
49
49
|
version: z.string().optional(),
|
|
50
|
+
buildId: z.string().optional(),
|
|
50
51
|
projectType: z.enum(['frontend', 'backend', 'both']).default('frontend'),
|
|
51
52
|
framework: z.string().optional(),
|
|
52
53
|
language: z.string().optional(),
|
package/src/core/stages.js
CHANGED
|
@@ -6,6 +6,7 @@ import { deployToAll } from '../deployment/index.js';
|
|
|
6
6
|
import { sendNotifications } from '../notifications/index.js';
|
|
7
7
|
import axios from 'axios';
|
|
8
8
|
import { getProjectVersion } from '../utils/version.js';
|
|
9
|
+
import { resolveBuildId } from '../utils/build-id.js';
|
|
9
10
|
import { ensureDeployScaffold } from '../utils/scaffold.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -41,8 +42,10 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
41
42
|
ctx.config.port = detected.port;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
//
|
|
45
|
+
// Semver label (package.json) + unique buildId (shared with image tag when DOCKER_IMAGE_TAG unset)
|
|
45
46
|
ctx.config.version = await getProjectVersion(ctx.cwd);
|
|
47
|
+
const { buildId } = resolveBuildId({ semver: ctx.config.version });
|
|
48
|
+
ctx.config.buildId = buildId;
|
|
46
49
|
const scaffold = await ensureDeployScaffold(
|
|
47
50
|
ctx.cwd,
|
|
48
51
|
ctx.config,
|
|
@@ -123,6 +126,10 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
123
126
|
if (!ctx.config.version) {
|
|
124
127
|
ctx.config.version = await getProjectVersion(ctx.cwd);
|
|
125
128
|
}
|
|
129
|
+
if (!ctx.config.buildId) {
|
|
130
|
+
const { buildId } = resolveBuildId({ semver: ctx.config.version });
|
|
131
|
+
ctx.config.buildId = buildId;
|
|
132
|
+
}
|
|
126
133
|
const result = await createArtifact(
|
|
127
134
|
ctx.config,
|
|
128
135
|
/** @type {string[]} */ (ctx.state.deployedTargets || []),
|
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,19 @@ 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';
|
|
10
23
|
|
|
11
|
-
/** @type {Record<string, (env?: Record<string, string>) =>
|
|
24
|
+
/** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
|
|
12
25
|
const PROVIDER_FACTORIES = {
|
|
13
26
|
aws: createAwsProvider,
|
|
14
27
|
local: createLocalProvider,
|
|
@@ -32,21 +45,112 @@ export function getStorageProvider(name, env = process.env) {
|
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
/**
|
|
48
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
49
|
+
*/
|
|
50
|
+
function ensureBuildIdentity(config) {
|
|
51
|
+
if (!config.version) {
|
|
52
|
+
config.version = '0.0.0';
|
|
53
|
+
}
|
|
54
|
+
if (!config.buildId) {
|
|
55
|
+
const { buildId } = resolveBuildId({ semver: config.version });
|
|
56
|
+
config.buildId = buildId;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read history.json from the first provider that has it.
|
|
62
|
+
* @param {string[]} providers
|
|
63
|
+
* @param {string} project
|
|
64
|
+
* @returns {Promise<import('../utils/artifact-history.js').ArtifactHistoryEntry[]>}
|
|
65
|
+
*/
|
|
66
|
+
export async function loadArtifactHistory(providers, project) {
|
|
67
|
+
const key = historyRemoteKey(project);
|
|
68
|
+
const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
|
|
69
|
+
try {
|
|
70
|
+
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);
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
} finally {
|
|
81
|
+
await fs.remove(tmp).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {ReturnType<typeof getStorageProvider>} provider
|
|
88
|
+
* @param {string} project
|
|
89
|
+
* @param {import('../utils/artifact-history.js').ArtifactHistoryEntry[]} history
|
|
90
|
+
*/
|
|
91
|
+
async function writeArtifactHistory(provider, project, history) {
|
|
92
|
+
const key = historyRemoteKey(project);
|
|
93
|
+
const tmp = path.join(os.tmpdir(), `deployhub-history-write-${Date.now()}.json`);
|
|
94
|
+
await fs.writeJson(tmp, history, { spaces: 2 });
|
|
95
|
+
try {
|
|
96
|
+
await provider.upload(tmp, key);
|
|
97
|
+
} finally {
|
|
98
|
+
await fs.remove(tmp).catch(() => {});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Upload artifact zip under a unique build key, update latest/ pointer and history.json.
|
|
104
|
+
* Does NOT write legacy `{project}/v{semver}/artifact.zip` (retired for new uploads;
|
|
105
|
+
* legacy keys remain readable for older artifacts).
|
|
106
|
+
*
|
|
35
107
|
* @param {string[]} providers
|
|
36
108
|
* @param {string} zipPath
|
|
37
109
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
38
110
|
*/
|
|
39
111
|
export async function uploadToAll(providers, zipPath, config) {
|
|
40
112
|
const log = createLogger('storage');
|
|
41
|
-
|
|
113
|
+
ensureBuildIdentity(config);
|
|
114
|
+
|
|
115
|
+
const buildId = /** @type {string} */ (config.buildId);
|
|
116
|
+
const remoteKey = buildArtifactRemoteKey(config.project, buildId);
|
|
117
|
+
const latestKey = latestArtifactRemoteKey(config.project);
|
|
118
|
+
const entry = {
|
|
119
|
+
buildId,
|
|
120
|
+
semver: String(config.version || '0.0.0').replace(/^v/i, ''),
|
|
121
|
+
uploadedAt: new Date().toISOString(),
|
|
122
|
+
remoteKey,
|
|
123
|
+
};
|
|
42
124
|
|
|
43
125
|
const uploads = providers.map(async (name) => {
|
|
44
126
|
try {
|
|
45
127
|
const provider = getStorageProvider(name);
|
|
46
|
-
log.info(`Uploading to ${name}...`);
|
|
128
|
+
log.info(`Uploading build ${buildId} to ${name}...`);
|
|
47
129
|
await provider.upload(zipPath, remoteKey);
|
|
48
|
-
|
|
49
|
-
|
|
130
|
+
// Intentional overwrite: mutable "current" pointer, not a versioned backup.
|
|
131
|
+
await provider.upload(zipPath, latestKey);
|
|
132
|
+
|
|
133
|
+
let history = [];
|
|
134
|
+
try {
|
|
135
|
+
const histKey = historyRemoteKey(config.project);
|
|
136
|
+
if (await provider.verify(histKey)) {
|
|
137
|
+
const tmp = path.join(os.tmpdir(), `deployhub-hist-${name}-${Date.now()}.json`);
|
|
138
|
+
try {
|
|
139
|
+
await provider.download(histKey, tmp);
|
|
140
|
+
history = parseArtifactHistory(await fs.readFile(tmp, 'utf8'));
|
|
141
|
+
} finally {
|
|
142
|
+
await fs.remove(tmp).catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
history = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const next = prependHistoryEntry(history, entry);
|
|
150
|
+
await writeArtifactHistory(provider, config.project, next);
|
|
151
|
+
|
|
152
|
+
log.success(`Uploaded to ${name} (${remoteKey})`);
|
|
153
|
+
return { name, success: true, remoteKey, buildId };
|
|
50
154
|
} catch (err) {
|
|
51
155
|
const message = err instanceof Error ? err.message : String(err);
|
|
52
156
|
throw new Error(`Storage upload to ${name} failed: ${message}`);
|
|
@@ -73,6 +177,27 @@ export async function downloadFromFirst(providers, remoteKey, localPath) {
|
|
|
73
177
|
throw new Error(`Artifact not found in any configured storage provider`);
|
|
74
178
|
}
|
|
75
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Download a build by history entry, with legacy key fallback for pre-buildId uploads.
|
|
182
|
+
*
|
|
183
|
+
* @param {string[]} providers
|
|
184
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
185
|
+
* @param {{ remoteKey: string, buildId: string, semver: string }} entry
|
|
186
|
+
* @param {string} localPath
|
|
187
|
+
*/
|
|
188
|
+
export async function downloadArtifactEntry(providers, config, entry, localPath) {
|
|
189
|
+
try {
|
|
190
|
+
return await downloadFromFirst(providers, entry.remoteKey, localPath);
|
|
191
|
+
} catch {
|
|
192
|
+
const legacyKey = legacyArtifactRemoteKey(config.project, entry.semver);
|
|
193
|
+
const log = createLogger('storage');
|
|
194
|
+
log.warn(
|
|
195
|
+
`Build key not found; trying legacy key ${legacyKey} (may be an overwritten single-slot artifact)`
|
|
196
|
+
);
|
|
197
|
+
return downloadFromFirst(providers, legacyKey, localPath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
76
201
|
/**
|
|
77
202
|
* @param {string} name
|
|
78
203
|
*/
|
|
@@ -99,11 +224,18 @@ export async function testAllProviders(providers) {
|
|
|
99
224
|
}
|
|
100
225
|
return {
|
|
101
226
|
name,
|
|
102
|
-
status: '
|
|
103
|
-
error: result.reason
|
|
227
|
+
status: 'failed',
|
|
228
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
104
229
|
};
|
|
105
230
|
});
|
|
106
231
|
}
|
|
107
232
|
|
|
108
|
-
export {
|
|
109
|
-
|
|
233
|
+
export default {
|
|
234
|
+
getStorageProvider,
|
|
235
|
+
uploadToAll,
|
|
236
|
+
downloadFromFirst,
|
|
237
|
+
downloadArtifactEntry,
|
|
238
|
+
loadArtifactHistory,
|
|
239
|
+
testProvider,
|
|
240
|
+
testAllProviders,
|
|
241
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact upload history (newest-first) and rollback target resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* buildId: string,
|
|
8
|
+
* semver: string,
|
|
9
|
+
* uploadedAt: string,
|
|
10
|
+
* remoteKey: string,
|
|
11
|
+
* }} ArtifactHistoryEntry
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string|unknown} raw
|
|
16
|
+
* @returns {ArtifactHistoryEntry[]}
|
|
17
|
+
*/
|
|
18
|
+
export function parseArtifactHistory(raw) {
|
|
19
|
+
if (!raw) return [];
|
|
20
|
+
try {
|
|
21
|
+
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
22
|
+
if (!Array.isArray(data)) return [];
|
|
23
|
+
return data
|
|
24
|
+
.filter(
|
|
25
|
+
(e) =>
|
|
26
|
+
e &&
|
|
27
|
+
typeof e === 'object' &&
|
|
28
|
+
typeof e.buildId === 'string' &&
|
|
29
|
+
typeof e.remoteKey === 'string'
|
|
30
|
+
)
|
|
31
|
+
.map((e) => ({
|
|
32
|
+
buildId: String(e.buildId),
|
|
33
|
+
semver: String(e.semver || '').replace(/^v/i, '') || '0.0.0',
|
|
34
|
+
uploadedAt: String(e.uploadedAt || ''),
|
|
35
|
+
remoteKey: String(e.remoteKey),
|
|
36
|
+
}));
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {ArtifactHistoryEntry[]} history
|
|
44
|
+
* @param {ArtifactHistoryEntry} entry
|
|
45
|
+
* @returns {ArtifactHistoryEntry[]}
|
|
46
|
+
*/
|
|
47
|
+
export function prependHistoryEntry(history, entry) {
|
|
48
|
+
const filtered = history.filter((e) => e.buildId !== entry.buildId);
|
|
49
|
+
return [entry, ...filtered];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} input
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeVersionArg(input) {
|
|
57
|
+
return String(input || '')
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/^v/i, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve which history entry to roll back to.
|
|
64
|
+
*
|
|
65
|
+
* @param {ArtifactHistoryEntry[]} history newest-first
|
|
66
|
+
* @param {string} [versionOrBuildId] omitted = previous build (history[1])
|
|
67
|
+
* @returns {{
|
|
68
|
+
* ok: true,
|
|
69
|
+
* entry: ArtifactHistoryEntry,
|
|
70
|
+
* } | {
|
|
71
|
+
* ok: false,
|
|
72
|
+
* reason: 'empty'|'no-previous'|'not-found'|'ambiguous',
|
|
73
|
+
* message: string,
|
|
74
|
+
* matches?: ArtifactHistoryEntry[],
|
|
75
|
+
* }}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveRollbackTarget(history, versionOrBuildId) {
|
|
78
|
+
if (!history.length) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: 'empty',
|
|
82
|
+
message: 'No artifact history found in storage. Deploy at least once first.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!versionOrBuildId) {
|
|
87
|
+
if (history.length < 2) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: 'no-previous',
|
|
91
|
+
message: 'No previous build available for rollback (only one build in history).',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, entry: history[1] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const needle = normalizeVersionArg(versionOrBuildId);
|
|
98
|
+
|
|
99
|
+
const exact = history.find((e) => e.buildId === needle || e.buildId === versionOrBuildId);
|
|
100
|
+
if (exact) {
|
|
101
|
+
return { ok: true, entry: exact };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const current = history[0];
|
|
105
|
+
const semverMatches = history.filter(
|
|
106
|
+
(e) => e.semver === needle || `v${e.semver}` === String(versionOrBuildId).trim()
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (semverMatches.length === 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: 'not-found',
|
|
113
|
+
message: `No artifact found for '${versionOrBuildId}'. Use an exact buildId from: deployhub artifact list --remote`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const nonCurrent = semverMatches.filter((e) => e.buildId !== current.buildId);
|
|
118
|
+
|
|
119
|
+
if (nonCurrent.length === 1) {
|
|
120
|
+
return { ok: true, entry: nonCurrent[0] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (nonCurrent.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: 'not-found',
|
|
127
|
+
message: `Only the current build matches semver '${needle}' (${current.buildId}). Nothing to roll back to for that label.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: 'ambiguous',
|
|
134
|
+
message: `Multiple builds match semver '${needle}'. Re-run with an exact buildId:`,
|
|
135
|
+
matches: nonCurrent,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {ArtifactHistoryEntry[]} matches
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function formatAmbiguousRollbackMatches(matches) {
|
|
144
|
+
return matches
|
|
145
|
+
.map((e) => ` ${e.buildId} uploadedAt=${e.uploadedAt || '(unknown)'} key=${e.remoteKey}`)
|
|
146
|
+
.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default {
|
|
150
|
+
parseArtifactHistory,
|
|
151
|
+
prependHistoryEntry,
|
|
152
|
+
normalizeVersionArg,
|
|
153
|
+
resolveRollbackTarget,
|
|
154
|
+
formatAmbiguousRollbackMatches,
|
|
155
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unique per-build identity shared by artifact storage keys and (when unset)
|
|
3
|
+
* Docker image tags.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
/** @typedef {'git'|'ci'|'timestamp'} BuildStampSource */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* High-resolution timestamp stamp (seconds+ms) for uniqueness in fast rebuild loops.
|
|
12
|
+
* @param {Date} [now]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function highResBuildStamp(now = new Date()) {
|
|
16
|
+
const y = now.getFullYear();
|
|
17
|
+
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
18
|
+
const d = String(now.getDate()).padStart(2, '0');
|
|
19
|
+
const h = String(now.getHours()).padStart(2, '0');
|
|
20
|
+
const min = String(now.getMinutes()).padStart(2, '0');
|
|
21
|
+
const sec = String(now.getSeconds()).padStart(2, '0');
|
|
22
|
+
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
23
|
+
return `${y}.${m}.${d}.${h}${min}-${sec}${ms}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
function defaultGetGitShortSha() {
|
|
30
|
+
try {
|
|
31
|
+
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
}).trim();
|
|
35
|
+
return sha || null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Unique build stamp: git SHA → CI id → high-res timestamp.
|
|
43
|
+
* Does not read DOCKER_IMAGE_TAG (explicit image tags stay separate from artifact identity).
|
|
44
|
+
*
|
|
45
|
+
* @param {Record<string, string|undefined>} [env]
|
|
46
|
+
* @param {{
|
|
47
|
+
* getGitShortSha?: () => string|null,
|
|
48
|
+
* now?: () => Date,
|
|
49
|
+
* }} [options]
|
|
50
|
+
* @returns {{ stamp: string, source: BuildStampSource }}
|
|
51
|
+
*/
|
|
52
|
+
export function resolveUniqueBuildStamp(env = process.env, options = {}) {
|
|
53
|
+
const getGitShortSha = options.getGitShortSha || defaultGetGitShortSha;
|
|
54
|
+
const gitSha = getGitShortSha();
|
|
55
|
+
if (gitSha) {
|
|
56
|
+
return { stamp: gitSha, source: 'git' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ciTag =
|
|
60
|
+
(env.GITHUB_SHA && String(env.GITHUB_SHA).slice(0, 7)) ||
|
|
61
|
+
env.GITHUB_RUN_ID ||
|
|
62
|
+
env.CI_COMMIT_SHORT_SHA ||
|
|
63
|
+
env.CI_PIPELINE_ID;
|
|
64
|
+
if (ciTag) {
|
|
65
|
+
return { stamp: String(ciTag), source: 'ci' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const now = options.now ? options.now() : new Date();
|
|
69
|
+
return { stamp: highResBuildStamp(now), source: 'timestamp' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sanitize for use in paths and image tags.
|
|
74
|
+
* @param {string} value
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function sanitizeBuildIdPart(value) {
|
|
78
|
+
return String(value)
|
|
79
|
+
.replace(/^v/i, '')
|
|
80
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
81
|
+
.replace(/^-+|-+$/g, '')
|
|
82
|
+
.slice(0, 64) || 'build';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {{
|
|
87
|
+
* semver?: string,
|
|
88
|
+
* env?: Record<string, string|undefined>,
|
|
89
|
+
* getGitShortSha?: () => string|null,
|
|
90
|
+
* now?: () => Date,
|
|
91
|
+
* }} [options]
|
|
92
|
+
* @returns {{ buildId: string, semver: string, stamp: string, source: BuildStampSource }}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveBuildId(options = {}) {
|
|
95
|
+
const env = options.env || process.env;
|
|
96
|
+
const semver = sanitizeBuildIdPart(options.semver || '0.0.0');
|
|
97
|
+
const { stamp, source } = resolveUniqueBuildStamp(env, options);
|
|
98
|
+
const safeStamp = sanitizeBuildIdPart(stamp);
|
|
99
|
+
return {
|
|
100
|
+
buildId: `${semver}-${safeStamp}`,
|
|
101
|
+
semver,
|
|
102
|
+
stamp: safeStamp,
|
|
103
|
+
source,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remote key for an immutable build artifact.
|
|
109
|
+
* @param {string} project
|
|
110
|
+
* @param {string} buildId
|
|
111
|
+
*/
|
|
112
|
+
export function buildArtifactRemoteKey(project, buildId) {
|
|
113
|
+
return `${project}/builds/${buildId}/artifact.zip`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mutable pointer overwritten every upload (not a backup slot).
|
|
118
|
+
* @param {string} project
|
|
119
|
+
*/
|
|
120
|
+
export function latestArtifactRemoteKey(project) {
|
|
121
|
+
return `${project}/latest/artifact.zip`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} project
|
|
126
|
+
*/
|
|
127
|
+
export function historyRemoteKey(project) {
|
|
128
|
+
return `${project}/history.json`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pre-buildId legacy key (read-only fallback; never written by new uploads).
|
|
133
|
+
* @param {string} project
|
|
134
|
+
* @param {string} semver
|
|
135
|
+
*/
|
|
136
|
+
export function legacyArtifactRemoteKey(project, semver) {
|
|
137
|
+
return `${project}/v${sanitizeBuildIdPart(semver)}/artifact.zip`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default {
|
|
141
|
+
highResBuildStamp,
|
|
142
|
+
resolveUniqueBuildStamp,
|
|
143
|
+
resolveBuildId,
|
|
144
|
+
sanitizeBuildIdPart,
|
|
145
|
+
buildArtifactRemoteKey,
|
|
146
|
+
latestArtifactRemoteKey,
|
|
147
|
+
historyRemoteKey,
|
|
148
|
+
legacyArtifactRemoteKey,
|
|
149
|
+
};
|
|
@@ -2,53 +2,27 @@
|
|
|
2
2
|
* Shared Docker image naming for pipeline builds and deploy.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { resolveUniqueBuildStamp, highResBuildStamp } from './build-id.js';
|
|
6
6
|
|
|
7
|
-
/** @typedef {'explicit'|'git'|'ci'|'timestamp'} ImageTagSource */
|
|
7
|
+
/** @typedef {'explicit'|'git'|'ci'|'timestamp'|'buildId'} ImageTagSource */
|
|
8
8
|
|
|
9
9
|
export const EXPLICIT_IMAGE_TAG_WARNING =
|
|
10
10
|
'DOCKER_IMAGE_TAG is set — reusing the same tag across deploys can leave Kubernetes pods on a stale image (imagePullPolicy defaults to IfNotPresent) unless imagePullPolicy is Always or a rollout restart runs.';
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* High-resolution timestamp for image tags only (artifact versioning keeps getDateVersion()).
|
|
14
|
-
* Minute prefix matches getDateVersion(); seconds+ms avoid collisions in fast rebuild loops.
|
|
15
|
-
* @param {Date} [now]
|
|
16
|
-
* @returns {string}
|
|
17
|
-
*/
|
|
12
|
+
/** @deprecated use highResBuildStamp from build-id.js — kept for existing imports/tests */
|
|
18
13
|
export function highResImageTagFallback(now = new Date()) {
|
|
19
|
-
|
|
20
|
-
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
21
|
-
const d = String(now.getDate()).padStart(2, '0');
|
|
22
|
-
const h = String(now.getHours()).padStart(2, '0');
|
|
23
|
-
const min = String(now.getMinutes()).padStart(2, '0');
|
|
24
|
-
const sec = String(now.getSeconds()).padStart(2, '0');
|
|
25
|
-
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
26
|
-
return `${y}.${m}.${d}.${h}${min}-${sec}${ms}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @returns {string|null}
|
|
31
|
-
*/
|
|
32
|
-
function defaultGetGitShortSha() {
|
|
33
|
-
try {
|
|
34
|
-
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
35
|
-
encoding: 'utf8',
|
|
36
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
-
}).trim();
|
|
38
|
-
return sha || null;
|
|
39
|
-
} catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
14
|
+
return highResBuildStamp(now);
|
|
42
15
|
}
|
|
43
16
|
|
|
44
17
|
/**
|
|
45
|
-
* Resolve image tag when DOCKER_IMAGE_TAG is unset:
|
|
46
|
-
*
|
|
18
|
+
* Resolve image tag when DOCKER_IMAGE_TAG is unset: prefer config.buildId (same
|
|
19
|
+
* pipeline identity as the artifact), else git SHA → CI id → high-res timestamp.
|
|
47
20
|
*
|
|
48
21
|
* @param {Record<string, string|undefined>} [env]
|
|
49
22
|
* @param {{
|
|
50
23
|
* getGitShortSha?: () => string|null,
|
|
51
24
|
* now?: () => Date,
|
|
25
|
+
* buildId?: string,
|
|
52
26
|
* }} [options]
|
|
53
27
|
* @returns {{ imageTag: string, tagSource: ImageTagSource }}
|
|
54
28
|
*/
|
|
@@ -58,23 +32,12 @@ export function resolveImageTag(env = process.env, options = {}) {
|
|
|
58
32
|
return { imageTag: explicit, tagSource: 'explicit' };
|
|
59
33
|
}
|
|
60
34
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (gitSha) {
|
|
64
|
-
return { imageTag: gitSha, tagSource: 'git' };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const ciTag =
|
|
68
|
-
(env.GITHUB_SHA && String(env.GITHUB_SHA).slice(0, 7)) ||
|
|
69
|
-
env.GITHUB_RUN_ID ||
|
|
70
|
-
env.CI_COMMIT_SHORT_SHA ||
|
|
71
|
-
env.CI_PIPELINE_ID;
|
|
72
|
-
if (ciTag) {
|
|
73
|
-
return { imageTag: String(ciTag), tagSource: 'ci' };
|
|
35
|
+
if (options.buildId) {
|
|
36
|
+
return { imageTag: options.buildId, tagSource: 'buildId' };
|
|
74
37
|
}
|
|
75
38
|
|
|
76
|
-
const
|
|
77
|
-
return { imageTag:
|
|
39
|
+
const { stamp, source } = resolveUniqueBuildStamp(env, options);
|
|
40
|
+
return { imageTag: stamp, tagSource: source };
|
|
78
41
|
}
|
|
79
42
|
|
|
80
43
|
/**
|
|
@@ -95,7 +58,10 @@ export function resolveImageTag(env = process.env, options = {}) {
|
|
|
95
58
|
*/
|
|
96
59
|
export function resolveDockerImageRef(config, env = process.env, options = {}) {
|
|
97
60
|
const imageName = env.DOCKER_IMAGE_NAME || config.project;
|
|
98
|
-
const { imageTag, tagSource } = resolveImageTag(env,
|
|
61
|
+
const { imageTag, tagSource } = resolveImageTag(env, {
|
|
62
|
+
...options,
|
|
63
|
+
buildId: /** @type {{ buildId?: string }} */ (config).buildId,
|
|
64
|
+
});
|
|
99
65
|
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
100
66
|
|
|
101
67
|
const repository =
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { downloadArtifactEntry, loadArtifactHistory } 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';
|
|
5
|
+
import {
|
|
6
|
+
formatAmbiguousRollbackMatches,
|
|
7
|
+
resolveRollbackTarget,
|
|
8
|
+
} from '../artifact-history.js';
|
|
5
9
|
import fs from 'fs-extra';
|
|
6
10
|
import path from 'path';
|
|
7
11
|
|
|
@@ -25,21 +29,38 @@ async function rollbackTarget(config, artifactDir, envName) {
|
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
28
|
-
* @param {string}
|
|
32
|
+
* @param {string} [versionOrBuildId]
|
|
29
33
|
* @param {string} [cwd]
|
|
30
34
|
*/
|
|
31
|
-
export async function rollbackToVersion(config,
|
|
35
|
+
export async function rollbackToVersion(config, versionOrBuildId, cwd = process.cwd()) {
|
|
32
36
|
const log = createLogger('rollback');
|
|
33
|
-
const
|
|
34
|
-
|
|
37
|
+
const providers = config.storage || [];
|
|
38
|
+
if (providers.length === 0) {
|
|
39
|
+
throw new Error('No storage providers configured — cannot download artifact for rollback');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const history = await loadArtifactHistory(providers, config.project);
|
|
43
|
+
const resolved = resolveRollbackTarget(history, versionOrBuildId);
|
|
44
|
+
|
|
45
|
+
if (!resolved.ok) {
|
|
46
|
+
if (resolved.reason === 'ambiguous' && resolved.matches) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${resolved.message}\n${formatAmbiguousRollbackMatches(resolved.matches)}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
throw new Error(resolved.message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entry = resolved.entry;
|
|
55
|
+
const restoreDir = path.join(cwd, '.deployhub-restore', `v${entry.buildId}`);
|
|
35
56
|
const artifactDir = path.join(restoreDir, 'artifact');
|
|
36
57
|
|
|
37
|
-
log.info(`Downloading artifact
|
|
58
|
+
log.info(`Downloading artifact buildId=${entry.buildId} (semver=${entry.semver})...`);
|
|
38
59
|
await fs.emptyDir(restoreDir);
|
|
39
60
|
await fs.ensureDir(artifactDir);
|
|
40
61
|
|
|
41
62
|
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
42
|
-
await
|
|
63
|
+
await downloadArtifactEntry(providers, config, entry, zipPath);
|
|
43
64
|
|
|
44
65
|
log.info('Extracting artifact for rollback...');
|
|
45
66
|
const extractedDir = path.join(artifactDir, '_extracted');
|
|
@@ -54,7 +75,7 @@ export async function rollbackToVersion(config, version, cwd = process.cwd()) {
|
|
|
54
75
|
const targets = config.deploy || [];
|
|
55
76
|
if (targets.length === 0) {
|
|
56
77
|
log.warn('No deployment targets configured');
|
|
57
|
-
return artifactDir;
|
|
78
|
+
return { artifactDir, entry };
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
log.info('Redeploying previous artifact to server targets...');
|
|
@@ -62,8 +83,8 @@ export async function rollbackToVersion(config, version, cwd = process.cwd()) {
|
|
|
62
83
|
await rollbackTarget(config, artifactDir, envName);
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
log.success(`Rollback to
|
|
66
|
-
return artifactDir;
|
|
86
|
+
log.success(`Rollback to buildId=${entry.buildId} complete`);
|
|
87
|
+
return { artifactDir, entry };
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
export default { rollbackToVersion };
|