@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
|
@@ -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 };
|
|
@@ -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
|
+
};
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
generateSpringRuntimeDockerfile,
|
|
11
11
|
isFrontendStaticFramework,
|
|
12
12
|
isInterpretedBackendFramework,
|
|
13
|
+
replaceDockerImageTag,
|
|
13
14
|
resolveDockerImageRef,
|
|
14
15
|
EXPLICIT_IMAGE_TAG_WARNING,
|
|
15
16
|
} from './docker-image.js';
|
|
@@ -67,8 +68,9 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
67
68
|
/**
|
|
68
69
|
* Push only when registry credentials are configured. Avoids a noisy failed
|
|
69
70
|
* push to Docker Hub for local-only image names.
|
|
71
|
+
* @param {string} [imageRef]
|
|
70
72
|
*/
|
|
71
|
-
async function maybePushImage() {
|
|
73
|
+
async function maybePushImage(imageRef = fullImage) {
|
|
72
74
|
if (!hasRegistryCredentials()) {
|
|
73
75
|
log.info(
|
|
74
76
|
'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
|
|
@@ -76,12 +78,12 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
log.info(`Pushing ${
|
|
80
|
-
await execa('docker', ['push',
|
|
81
|
+
log.info(`Pushing ${imageRef} to registry...`);
|
|
82
|
+
await execa('docker', ['push', imageRef], {
|
|
81
83
|
stdio: 'inherit',
|
|
82
84
|
env: getDockerEnv(),
|
|
83
85
|
});
|
|
84
|
-
log.success(`Pushed ${
|
|
86
|
+
log.success(`Pushed ${imageRef}`);
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/**
|
|
@@ -102,21 +104,22 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
102
104
|
/**
|
|
103
105
|
* Prefer the image already built during the pipeline `docker` stage.
|
|
104
106
|
* Retag when the pipeline used `:latest` and deploy needs a version tag.
|
|
107
|
+
* @param {string} [imageRef]
|
|
105
108
|
*/
|
|
106
|
-
async function ensureImageFromPipeline() {
|
|
107
|
-
if (await imageExistsLocally(
|
|
108
|
-
log.info(`Reusing existing image ${
|
|
109
|
+
async function ensureImageFromPipeline(imageRef = fullImage) {
|
|
110
|
+
if (await imageExistsLocally(imageRef)) {
|
|
111
|
+
log.info(`Reusing existing image ${imageRef}`);
|
|
109
112
|
return true;
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
|
|
113
|
-
(ref) => ref !==
|
|
116
|
+
(ref) => ref !== imageRef
|
|
114
117
|
);
|
|
115
118
|
|
|
116
119
|
for (const candidate of candidates) {
|
|
117
120
|
if (!(await imageExistsLocally(candidate))) continue;
|
|
118
|
-
log.info(`Re-tagging pipeline image ${candidate} → ${
|
|
119
|
-
await execa('docker', ['tag', candidate,
|
|
121
|
+
log.info(`Re-tagging pipeline image ${candidate} → ${imageRef}`);
|
|
122
|
+
await execa('docker', ['tag', candidate, imageRef], {
|
|
120
123
|
stdio: 'inherit',
|
|
121
124
|
env: getDockerEnv(),
|
|
122
125
|
});
|
|
@@ -211,12 +214,13 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
211
214
|
|
|
212
215
|
/**
|
|
213
216
|
* @param {string} artifactDir
|
|
217
|
+
* @param {string} [imageRef]
|
|
214
218
|
*/
|
|
215
|
-
async function buildFromArtifactContents(artifactDir) {
|
|
219
|
+
async function buildFromArtifactContents(artifactDir, imageRef = fullImage) {
|
|
216
220
|
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
217
221
|
if (!(await fs.pathExists(zipPath))) {
|
|
218
222
|
throw new Error(
|
|
219
|
-
`No local image found for ${
|
|
223
|
+
`No local image found for ${imageRef} and no artifact.zip to build from. ` +
|
|
220
224
|
'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
|
|
221
225
|
);
|
|
222
226
|
}
|
|
@@ -281,7 +285,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
281
285
|
await prepareBackendBuildContext(buildContext, metadata, framework, port);
|
|
282
286
|
}
|
|
283
287
|
|
|
284
|
-
await execa('docker', ['build', '-t',
|
|
288
|
+
await execa('docker', ['build', '-t', imageRef, '.'], {
|
|
285
289
|
cwd: buildContext,
|
|
286
290
|
stdio: 'inherit',
|
|
287
291
|
env: getDockerEnv(),
|
|
@@ -295,36 +299,55 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
295
299
|
/**
|
|
296
300
|
* Ensure a deployable image exists locally, then push when credentials are set.
|
|
297
301
|
* @param {string} artifactDir
|
|
298
|
-
* @param {{
|
|
302
|
+
* @param {{
|
|
303
|
+
* skipPush?: boolean,
|
|
304
|
+
* fullImage?: string,
|
|
305
|
+
* skipImageReuse?: boolean,
|
|
306
|
+
* }} [options]
|
|
299
307
|
*/
|
|
300
308
|
async function ensureImageReadyForDeploy(artifactDir, options = {}) {
|
|
309
|
+
const imageRef = options.fullImage || fullImage;
|
|
310
|
+
const latestRef = options.fullImage
|
|
311
|
+
? replaceDockerImageTag(options.fullImage, 'latest')
|
|
312
|
+
: latestImage;
|
|
313
|
+
const lastSlash = imageRef.lastIndexOf('/');
|
|
314
|
+
const lastColon = imageRef.lastIndexOf(':');
|
|
315
|
+
const effectiveTag =
|
|
316
|
+
lastColon > lastSlash ? imageRef.slice(lastColon + 1) : 'latest';
|
|
317
|
+
|
|
301
318
|
await dockerLogin();
|
|
302
319
|
|
|
303
|
-
|
|
320
|
+
let reused = false;
|
|
321
|
+
if (!options.skipImageReuse) {
|
|
322
|
+
reused = await ensureImageFromPipeline(imageRef);
|
|
323
|
+
} else {
|
|
324
|
+
log.info(`Skipping local image reuse — rebuilding ${imageRef} from artifact`);
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
let ranCompose = false;
|
|
305
328
|
|
|
306
329
|
if (!reused) {
|
|
307
|
-
const result = await buildFromArtifactContents(artifactDir);
|
|
330
|
+
const result = await buildFromArtifactContents(artifactDir, imageRef);
|
|
308
331
|
ranCompose = Boolean(result?.ranCompose);
|
|
309
332
|
}
|
|
310
333
|
|
|
311
334
|
if (ranCompose) {
|
|
312
|
-
return { ranCompose: true };
|
|
335
|
+
return { ranCompose: true, fullImage: imageRef };
|
|
313
336
|
}
|
|
314
337
|
|
|
315
338
|
if (!options.skipPush) {
|
|
316
|
-
await maybePushImage();
|
|
339
|
+
await maybePushImage(imageRef);
|
|
317
340
|
}
|
|
318
341
|
|
|
319
342
|
const dockerEnv = getDockerEnv();
|
|
320
|
-
if (
|
|
321
|
-
await execa('docker', ['tag',
|
|
343
|
+
if (effectiveTag !== 'latest' && imageRef !== latestRef) {
|
|
344
|
+
await execa('docker', ['tag', imageRef, latestRef], {
|
|
322
345
|
stdio: 'pipe',
|
|
323
346
|
env: dockerEnv,
|
|
324
347
|
}).catch(() => {});
|
|
325
348
|
}
|
|
326
349
|
|
|
327
|
-
return { ranCompose: false };
|
|
350
|
+
return { ranCompose: false, fullImage: imageRef };
|
|
328
351
|
}
|
|
329
352
|
|
|
330
353
|
return {
|