@git.zone/tsdocker 1.14.0 → 1.15.1
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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dockerfile.d.ts +12 -5
- package/dist_ts/classes.dockerfile.js +45 -48
- package/dist_ts/classes.registrycopy.d.ts +70 -0
- package/dist_ts/classes.registrycopy.js +359 -0
- package/dist_ts/classes.tsdockermanager.d.ts +2 -1
- package/dist_ts/classes.tsdockermanager.js +28 -18
- package/dist_ts/tsdocker.cli.js +144 -18
- package/dist_ts/tsdocker.plugins.d.ts +2 -1
- package/dist_ts/tsdocker.plugins.js +3 -2
- package/package.json +2 -1
- package/readme.hints.md +12 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dockerfile.ts +54 -56
- package/ts/classes.registrycopy.ts +511 -0
- package/ts/classes.tsdockermanager.ts +27 -18
- package/ts/tsdocker.cli.ts +190 -16
- package/ts/tsdocker.plugins.ts +2 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { logger } from './tsdocker.logging.js';
|
|
5
|
+
|
|
6
|
+
interface IRegistryCredentials {
|
|
7
|
+
username: string;
|
|
8
|
+
password: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ITokenCache {
|
|
12
|
+
[scope: string]: { token: string; expiry: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* OCI Distribution API client for copying images between registries.
|
|
17
|
+
* Supports manifest lists (multi-arch) and single-platform manifests.
|
|
18
|
+
* Uses native fetch (Node 18+).
|
|
19
|
+
*/
|
|
20
|
+
export class RegistryCopy {
|
|
21
|
+
private tokenCache: ITokenCache = {};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads Docker credentials from ~/.docker/config.json for a given registry.
|
|
25
|
+
* Supports base64-encoded "auth" field in the config.
|
|
26
|
+
*/
|
|
27
|
+
public static getDockerConfigCredentials(registryUrl: string): IRegistryCredentials | null {
|
|
28
|
+
try {
|
|
29
|
+
const configPath = path.join(os.homedir(), '.docker', 'config.json');
|
|
30
|
+
if (!fs.existsSync(configPath)) return null;
|
|
31
|
+
|
|
32
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
33
|
+
const auths = config.auths || {};
|
|
34
|
+
|
|
35
|
+
// Try exact match first, then common variations
|
|
36
|
+
const keys = [
|
|
37
|
+
registryUrl,
|
|
38
|
+
`https://${registryUrl}`,
|
|
39
|
+
`http://${registryUrl}`,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Docker Hub special cases
|
|
43
|
+
if (registryUrl === 'docker.io' || registryUrl === 'registry-1.docker.io') {
|
|
44
|
+
keys.push(
|
|
45
|
+
'https://index.docker.io/v1/',
|
|
46
|
+
'https://index.docker.io/v2/',
|
|
47
|
+
'index.docker.io',
|
|
48
|
+
'docker.io',
|
|
49
|
+
'registry-1.docker.io',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
if (auths[key]?.auth) {
|
|
55
|
+
const decoded = Buffer.from(auths[key].auth, 'base64').toString('utf-8');
|
|
56
|
+
const colonIndex = decoded.indexOf(':');
|
|
57
|
+
if (colonIndex > 0) {
|
|
58
|
+
return {
|
|
59
|
+
username: decoded.substring(0, colonIndex),
|
|
60
|
+
password: decoded.substring(colonIndex + 1),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the API base URL for a registry.
|
|
74
|
+
* Docker Hub uses registry-1.docker.io as API endpoint.
|
|
75
|
+
*/
|
|
76
|
+
private getRegistryApiBase(registry: string): string {
|
|
77
|
+
if (registry === 'docker.io' || registry === 'index.docker.io') {
|
|
78
|
+
return 'https://registry-1.docker.io';
|
|
79
|
+
}
|
|
80
|
+
// Local registries (localhost) use HTTP
|
|
81
|
+
if (registry.startsWith('localhost') || registry.startsWith('127.0.0.1')) {
|
|
82
|
+
return `http://${registry}`;
|
|
83
|
+
}
|
|
84
|
+
return `https://${registry}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Obtains a Bearer token for registry operations.
|
|
89
|
+
* Follows the standard Docker auth flow:
|
|
90
|
+
* GET /v2/ → 401 with Www-Authenticate → request token
|
|
91
|
+
*/
|
|
92
|
+
private async getToken(
|
|
93
|
+
registry: string,
|
|
94
|
+
repo: string,
|
|
95
|
+
actions: string,
|
|
96
|
+
credentials?: IRegistryCredentials | null,
|
|
97
|
+
): Promise<string | null> {
|
|
98
|
+
const scope = `repository:${repo}:${actions}`;
|
|
99
|
+
const cached = this.tokenCache[`${registry}/${scope}`];
|
|
100
|
+
if (cached && cached.expiry > Date.now()) {
|
|
101
|
+
return cached.token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const apiBase = this.getRegistryApiBase(registry);
|
|
105
|
+
|
|
106
|
+
// Local registries typically don't need auth
|
|
107
|
+
if (registry.startsWith('localhost') || registry.startsWith('127.0.0.1')) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const checkResp = await fetch(`${apiBase}/v2/`, { method: 'GET' });
|
|
113
|
+
if (checkResp.ok) return null; // No auth needed
|
|
114
|
+
|
|
115
|
+
const wwwAuth = checkResp.headers.get('www-authenticate') || '';
|
|
116
|
+
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
|
|
117
|
+
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
|
|
118
|
+
|
|
119
|
+
if (!realmMatch) return null;
|
|
120
|
+
|
|
121
|
+
const realm = realmMatch[1];
|
|
122
|
+
const service = serviceMatch ? serviceMatch[1] : '';
|
|
123
|
+
|
|
124
|
+
const tokenUrl = new URL(realm);
|
|
125
|
+
tokenUrl.searchParams.set('scope', scope);
|
|
126
|
+
if (service) tokenUrl.searchParams.set('service', service);
|
|
127
|
+
|
|
128
|
+
const headers: Record<string, string> = {};
|
|
129
|
+
const creds = credentials || RegistryCopy.getDockerConfigCredentials(registry);
|
|
130
|
+
if (creds) {
|
|
131
|
+
headers['Authorization'] = 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tokenResp = await fetch(tokenUrl.toString(), { headers });
|
|
135
|
+
if (!tokenResp.ok) {
|
|
136
|
+
const body = await tokenResp.text();
|
|
137
|
+
throw new Error(`Token request failed (${tokenResp.status}): ${body}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tokenData = await tokenResp.json() as any;
|
|
141
|
+
const token = tokenData.token || tokenData.access_token;
|
|
142
|
+
|
|
143
|
+
if (token) {
|
|
144
|
+
// Cache for 5 minutes (conservative)
|
|
145
|
+
this.tokenCache[`${registry}/${scope}`] = {
|
|
146
|
+
token,
|
|
147
|
+
expiry: Date.now() + 5 * 60 * 1000,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return token;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
logger.log('warn', `Auth for ${registry}: ${(err as Error).message}`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Makes an authenticated request to a registry.
|
|
160
|
+
*/
|
|
161
|
+
private async registryFetch(
|
|
162
|
+
registry: string,
|
|
163
|
+
path: string,
|
|
164
|
+
options: {
|
|
165
|
+
method?: string;
|
|
166
|
+
headers?: Record<string, string>;
|
|
167
|
+
body?: Buffer | ReadableStream | null;
|
|
168
|
+
repo?: string;
|
|
169
|
+
actions?: string;
|
|
170
|
+
credentials?: IRegistryCredentials | null;
|
|
171
|
+
} = {},
|
|
172
|
+
): Promise<Response> {
|
|
173
|
+
const apiBase = this.getRegistryApiBase(registry);
|
|
174
|
+
const method = options.method || 'GET';
|
|
175
|
+
const headers: Record<string, string> = { ...(options.headers || {}) };
|
|
176
|
+
|
|
177
|
+
const repo = options.repo || '';
|
|
178
|
+
const actions = options.actions || 'pull';
|
|
179
|
+
const token = await this.getToken(registry, repo, actions, options.credentials);
|
|
180
|
+
|
|
181
|
+
if (token) {
|
|
182
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const url = `${apiBase}${path}`;
|
|
186
|
+
const fetchOptions: any = { method, headers };
|
|
187
|
+
if (options.body) {
|
|
188
|
+
fetchOptions.body = options.body;
|
|
189
|
+
fetchOptions.duplex = 'half'; // Required for streaming body in Node
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return fetch(url, fetchOptions);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Gets a manifest from a registry (supports both manifest lists and single manifests).
|
|
197
|
+
*/
|
|
198
|
+
private async getManifest(
|
|
199
|
+
registry: string,
|
|
200
|
+
repo: string,
|
|
201
|
+
reference: string,
|
|
202
|
+
credentials?: IRegistryCredentials | null,
|
|
203
|
+
): Promise<{ contentType: string; body: any; digest: string; raw: Buffer }> {
|
|
204
|
+
const accept = [
|
|
205
|
+
'application/vnd.oci.image.index.v1+json',
|
|
206
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
207
|
+
'application/vnd.oci.image.manifest.v1+json',
|
|
208
|
+
'application/vnd.docker.distribution.manifest.v2+json',
|
|
209
|
+
].join(', ');
|
|
210
|
+
|
|
211
|
+
const resp = await this.registryFetch(registry, `/v2/${repo}/manifests/${reference}`, {
|
|
212
|
+
headers: { 'Accept': accept },
|
|
213
|
+
repo,
|
|
214
|
+
actions: 'pull',
|
|
215
|
+
credentials,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (!resp.ok) {
|
|
219
|
+
const body = await resp.text();
|
|
220
|
+
throw new Error(`Failed to get manifest ${registry}/${repo}:${reference} (${resp.status}): ${body}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const raw = Buffer.from(await resp.arrayBuffer());
|
|
224
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
225
|
+
const digest = resp.headers.get('docker-content-digest') || this.computeDigest(raw);
|
|
226
|
+
const body = JSON.parse(raw.toString('utf-8'));
|
|
227
|
+
|
|
228
|
+
return { contentType, body, digest, raw };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Checks if a blob exists in the destination registry.
|
|
233
|
+
*/
|
|
234
|
+
private async blobExists(
|
|
235
|
+
registry: string,
|
|
236
|
+
repo: string,
|
|
237
|
+
digest: string,
|
|
238
|
+
credentials?: IRegistryCredentials | null,
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
const resp = await this.registryFetch(registry, `/v2/${repo}/blobs/${digest}`, {
|
|
241
|
+
method: 'HEAD',
|
|
242
|
+
repo,
|
|
243
|
+
actions: 'pull,push',
|
|
244
|
+
credentials,
|
|
245
|
+
});
|
|
246
|
+
return resp.ok;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Copies a single blob from source to destination registry.
|
|
251
|
+
* Uses monolithic upload (POST initiate + PUT complete).
|
|
252
|
+
*/
|
|
253
|
+
private async copyBlob(
|
|
254
|
+
srcRegistry: string,
|
|
255
|
+
srcRepo: string,
|
|
256
|
+
destRegistry: string,
|
|
257
|
+
destRepo: string,
|
|
258
|
+
digest: string,
|
|
259
|
+
srcCredentials?: IRegistryCredentials | null,
|
|
260
|
+
destCredentials?: IRegistryCredentials | null,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
// Check if blob already exists at destination
|
|
263
|
+
const exists = await this.blobExists(destRegistry, destRepo, digest, destCredentials);
|
|
264
|
+
if (exists) {
|
|
265
|
+
logger.log('info', ` Blob ${digest.substring(0, 19)}... already exists, skipping`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Download blob from source
|
|
270
|
+
const getResp = await this.registryFetch(srcRegistry, `/v2/${srcRepo}/blobs/${digest}`, {
|
|
271
|
+
repo: srcRepo,
|
|
272
|
+
actions: 'pull',
|
|
273
|
+
credentials: srcCredentials,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!getResp.ok) {
|
|
277
|
+
throw new Error(`Failed to get blob ${digest} from ${srcRegistry}/${srcRepo}: ${getResp.status}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const blobData = Buffer.from(await getResp.arrayBuffer());
|
|
281
|
+
const blobSize = blobData.length;
|
|
282
|
+
|
|
283
|
+
// Initiate upload at destination
|
|
284
|
+
const postResp = await this.registryFetch(destRegistry, `/v2/${destRepo}/blobs/uploads/`, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Length': '0' },
|
|
287
|
+
repo: destRepo,
|
|
288
|
+
actions: 'pull,push',
|
|
289
|
+
credentials: destCredentials,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!postResp.ok && postResp.status !== 202) {
|
|
293
|
+
const body = await postResp.text();
|
|
294
|
+
throw new Error(`Failed to initiate upload at ${destRegistry}/${destRepo}: ${postResp.status} ${body}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Get upload URL from Location header
|
|
298
|
+
let uploadUrl = postResp.headers.get('location') || '';
|
|
299
|
+
if (!uploadUrl) {
|
|
300
|
+
throw new Error(`No upload location returned from ${destRegistry}/${destRepo}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Make upload URL absolute if relative
|
|
304
|
+
if (uploadUrl.startsWith('/')) {
|
|
305
|
+
const apiBase = this.getRegistryApiBase(destRegistry);
|
|
306
|
+
uploadUrl = `${apiBase}${uploadUrl}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Complete upload with PUT (monolithic)
|
|
310
|
+
const separator = uploadUrl.includes('?') ? '&' : '?';
|
|
311
|
+
const putUrl = `${uploadUrl}${separator}digest=${encodeURIComponent(digest)}`;
|
|
312
|
+
|
|
313
|
+
// For PUT to the upload URL, we need auth
|
|
314
|
+
const token = await this.getToken(destRegistry, destRepo, 'pull,push', destCredentials);
|
|
315
|
+
const putHeaders: Record<string, string> = {
|
|
316
|
+
'Content-Type': 'application/octet-stream',
|
|
317
|
+
'Content-Length': String(blobSize),
|
|
318
|
+
};
|
|
319
|
+
if (token) {
|
|
320
|
+
putHeaders['Authorization'] = `Bearer ${token}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const putResp = await fetch(putUrl, {
|
|
324
|
+
method: 'PUT',
|
|
325
|
+
headers: putHeaders,
|
|
326
|
+
body: blobData,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (!putResp.ok) {
|
|
330
|
+
const body = await putResp.text();
|
|
331
|
+
throw new Error(`Failed to upload blob ${digest} to ${destRegistry}/${destRepo}: ${putResp.status} ${body}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const sizeStr = blobSize > 1048576
|
|
335
|
+
? `${(blobSize / 1048576).toFixed(1)} MB`
|
|
336
|
+
: `${(blobSize / 1024).toFixed(1)} KB`;
|
|
337
|
+
logger.log('info', ` Copied blob ${digest.substring(0, 19)}... (${sizeStr})`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Pushes a manifest to a registry.
|
|
342
|
+
*/
|
|
343
|
+
private async putManifest(
|
|
344
|
+
registry: string,
|
|
345
|
+
repo: string,
|
|
346
|
+
reference: string,
|
|
347
|
+
manifest: Buffer,
|
|
348
|
+
contentType: string,
|
|
349
|
+
credentials?: IRegistryCredentials | null,
|
|
350
|
+
): Promise<string> {
|
|
351
|
+
const resp = await this.registryFetch(registry, `/v2/${repo}/manifests/${reference}`, {
|
|
352
|
+
method: 'PUT',
|
|
353
|
+
headers: {
|
|
354
|
+
'Content-Type': contentType,
|
|
355
|
+
'Content-Length': String(manifest.length),
|
|
356
|
+
},
|
|
357
|
+
body: manifest,
|
|
358
|
+
repo,
|
|
359
|
+
actions: 'pull,push',
|
|
360
|
+
credentials,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (!resp.ok) {
|
|
364
|
+
const body = await resp.text();
|
|
365
|
+
throw new Error(`Failed to put manifest ${registry}/${repo}:${reference} (${resp.status}): ${body}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const digest = resp.headers.get('docker-content-digest') || this.computeDigest(manifest);
|
|
369
|
+
return digest;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Copies a single-platform manifest and all its blobs from source to destination.
|
|
374
|
+
*/
|
|
375
|
+
private async copySingleManifest(
|
|
376
|
+
srcRegistry: string,
|
|
377
|
+
srcRepo: string,
|
|
378
|
+
destRegistry: string,
|
|
379
|
+
destRepo: string,
|
|
380
|
+
manifestDigest: string,
|
|
381
|
+
srcCredentials?: IRegistryCredentials | null,
|
|
382
|
+
destCredentials?: IRegistryCredentials | null,
|
|
383
|
+
): Promise<void> {
|
|
384
|
+
// Get the platform manifest
|
|
385
|
+
const { body: manifest, contentType, raw } = await this.getManifest(
|
|
386
|
+
srcRegistry, srcRepo, manifestDigest, srcCredentials,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// Copy config blob
|
|
390
|
+
if (manifest.config?.digest) {
|
|
391
|
+
logger.log('info', ` Copying config blob...`);
|
|
392
|
+
await this.copyBlob(
|
|
393
|
+
srcRegistry, srcRepo, destRegistry, destRepo,
|
|
394
|
+
manifest.config.digest, srcCredentials, destCredentials,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Copy layer blobs
|
|
399
|
+
const layers = manifest.layers || [];
|
|
400
|
+
for (let i = 0; i < layers.length; i++) {
|
|
401
|
+
const layer = layers[i];
|
|
402
|
+
logger.log('info', ` Copying layer ${i + 1}/${layers.length}...`);
|
|
403
|
+
await this.copyBlob(
|
|
404
|
+
srcRegistry, srcRepo, destRegistry, destRepo,
|
|
405
|
+
layer.digest, srcCredentials, destCredentials,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Push the platform manifest by digest
|
|
410
|
+
await this.putManifest(
|
|
411
|
+
destRegistry, destRepo, manifestDigest, raw, contentType, destCredentials,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Copies a complete image (single or multi-arch) from source to destination registry.
|
|
417
|
+
*
|
|
418
|
+
* @param srcRegistry - Source registry host (e.g., "localhost:5234")
|
|
419
|
+
* @param srcRepo - Source repository (e.g., "myapp")
|
|
420
|
+
* @param srcTag - Source tag (e.g., "v1.0.0")
|
|
421
|
+
* @param destRegistry - Destination registry host (e.g., "registry.gitlab.com")
|
|
422
|
+
* @param destRepo - Destination repository (e.g., "org/myapp")
|
|
423
|
+
* @param destTag - Destination tag (e.g., "v1.0.0" or "v1.0.0_arm64")
|
|
424
|
+
* @param credentials - Optional credentials for destination registry
|
|
425
|
+
*/
|
|
426
|
+
public async copyImage(
|
|
427
|
+
srcRegistry: string,
|
|
428
|
+
srcRepo: string,
|
|
429
|
+
srcTag: string,
|
|
430
|
+
destRegistry: string,
|
|
431
|
+
destRepo: string,
|
|
432
|
+
destTag: string,
|
|
433
|
+
credentials?: IRegistryCredentials | null,
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
logger.log('info', `Copying ${srcRegistry}/${srcRepo}:${srcTag} -> ${destRegistry}/${destRepo}:${destTag}`);
|
|
436
|
+
|
|
437
|
+
// Source is always the local registry (no credentials needed)
|
|
438
|
+
const srcCredentials: IRegistryCredentials | null = null;
|
|
439
|
+
const destCredentials = credentials || RegistryCopy.getDockerConfigCredentials(destRegistry);
|
|
440
|
+
|
|
441
|
+
// Get the top-level manifest
|
|
442
|
+
const topManifest = await this.getManifest(srcRegistry, srcRepo, srcTag, srcCredentials);
|
|
443
|
+
const { body, contentType, raw } = topManifest;
|
|
444
|
+
|
|
445
|
+
const isManifestList =
|
|
446
|
+
contentType.includes('manifest.list') ||
|
|
447
|
+
contentType.includes('image.index') ||
|
|
448
|
+
body.manifests !== undefined;
|
|
449
|
+
|
|
450
|
+
if (isManifestList) {
|
|
451
|
+
// Multi-arch: copy each platform manifest + blobs, then push the manifest list
|
|
452
|
+
const platforms = (body.manifests || []) as any[];
|
|
453
|
+
logger.log('info', `Multi-arch manifest with ${platforms.length} platform(s)`);
|
|
454
|
+
|
|
455
|
+
for (const platformEntry of platforms) {
|
|
456
|
+
const platDesc = platformEntry.platform
|
|
457
|
+
? `${platformEntry.platform.os}/${platformEntry.platform.architecture}`
|
|
458
|
+
: platformEntry.digest;
|
|
459
|
+
logger.log('info', `Copying platform: ${platDesc}`);
|
|
460
|
+
|
|
461
|
+
await this.copySingleManifest(
|
|
462
|
+
srcRegistry, srcRepo, destRegistry, destRepo,
|
|
463
|
+
platformEntry.digest, srcCredentials, destCredentials,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Push the manifest list/index with the destination tag
|
|
468
|
+
const digest = await this.putManifest(
|
|
469
|
+
destRegistry, destRepo, destTag, raw, contentType, destCredentials,
|
|
470
|
+
);
|
|
471
|
+
logger.log('ok', `Pushed manifest list to ${destRegistry}/${destRepo}:${destTag} (${digest.substring(0, 19)}...)`);
|
|
472
|
+
} else {
|
|
473
|
+
// Single-platform manifest: copy blobs + push manifest
|
|
474
|
+
logger.log('info', 'Single-platform manifest');
|
|
475
|
+
|
|
476
|
+
// Copy config blob
|
|
477
|
+
if (body.config?.digest) {
|
|
478
|
+
logger.log('info', ' Copying config blob...');
|
|
479
|
+
await this.copyBlob(
|
|
480
|
+
srcRegistry, srcRepo, destRegistry, destRepo,
|
|
481
|
+
body.config.digest, srcCredentials, destCredentials,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Copy layer blobs
|
|
486
|
+
const layers = body.layers || [];
|
|
487
|
+
for (let i = 0; i < layers.length; i++) {
|
|
488
|
+
logger.log('info', ` Copying layer ${i + 1}/${layers.length}...`);
|
|
489
|
+
await this.copyBlob(
|
|
490
|
+
srcRegistry, srcRepo, destRegistry, destRepo,
|
|
491
|
+
layers[i].digest, srcCredentials, destCredentials,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Push the manifest with the destination tag
|
|
496
|
+
const digest = await this.putManifest(
|
|
497
|
+
destRegistry, destRepo, destTag, raw, contentType, destCredentials,
|
|
498
|
+
);
|
|
499
|
+
logger.log('ok', `Pushed manifest to ${destRegistry}/${destRepo}:${destTag} (${digest.substring(0, 19)}...)`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Computes sha256 digest of a buffer.
|
|
505
|
+
*/
|
|
506
|
+
private computeDigest(data: Buffer): string {
|
|
507
|
+
const crypto = require('crypto');
|
|
508
|
+
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
509
|
+
return `sha256:${hash}`;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -187,11 +187,7 @@ export class TsDockerManager {
|
|
|
187
187
|
|
|
188
188
|
const total = toBuild.length;
|
|
189
189
|
const overallStart = Date.now();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (useRegistry) {
|
|
193
|
-
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
|
194
|
-
}
|
|
190
|
+
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
|
195
191
|
|
|
196
192
|
try {
|
|
197
193
|
if (options?.parallel) {
|
|
@@ -230,7 +226,7 @@ export class TsDockerManager {
|
|
|
230
226
|
|
|
231
227
|
await Dockerfile.runWithConcurrency(tasks, concurrency);
|
|
232
228
|
|
|
233
|
-
// After the entire level completes,
|
|
229
|
+
// After the entire level completes, push all to local registry + tag for deps
|
|
234
230
|
for (const df of level) {
|
|
235
231
|
const dependentBaseImages = new Set<string>();
|
|
236
232
|
for (const other of toBuild) {
|
|
@@ -242,7 +238,8 @@ export class TsDockerManager {
|
|
|
242
238
|
logger.log('info', `Tagging ${df.buildTag} as ${fullTag} for local dependency resolution`);
|
|
243
239
|
await smartshellInstance.exec(`docker tag ${df.buildTag} ${fullTag}`);
|
|
244
240
|
}
|
|
245
|
-
|
|
241
|
+
// Push ALL images to local registry (skip if already pushed via buildx)
|
|
242
|
+
if (!df.localRegistryTag) {
|
|
246
243
|
await Dockerfile.pushToLocalRegistry(df);
|
|
247
244
|
}
|
|
248
245
|
}
|
|
@@ -281,16 +278,14 @@ export class TsDockerManager {
|
|
|
281
278
|
await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`);
|
|
282
279
|
}
|
|
283
280
|
|
|
284
|
-
// Push to local registry
|
|
285
|
-
if (
|
|
281
|
+
// Push ALL images to local registry (skip if already pushed via buildx)
|
|
282
|
+
if (!dockerfileArg.localRegistryTag) {
|
|
286
283
|
await Dockerfile.pushToLocalRegistry(dockerfileArg);
|
|
287
284
|
}
|
|
288
285
|
}
|
|
289
286
|
}
|
|
290
287
|
} finally {
|
|
291
|
-
|
|
292
|
-
await Dockerfile.stopLocalRegistry();
|
|
293
|
-
}
|
|
288
|
+
await Dockerfile.stopLocalRegistry();
|
|
294
289
|
}
|
|
295
290
|
|
|
296
291
|
logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
|
|
@@ -398,11 +393,17 @@ export class TsDockerManager {
|
|
|
398
393
|
return;
|
|
399
394
|
}
|
|
400
395
|
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
396
|
+
// Start local registry (reads from persistent .nogit/docker-registry/)
|
|
397
|
+
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
|
398
|
+
try {
|
|
399
|
+
// Push each Dockerfile to each registry via OCI copy
|
|
400
|
+
for (const dockerfile of this.dockerfiles) {
|
|
401
|
+
for (const registry of registriesToPush) {
|
|
402
|
+
await dockerfile.push(registry);
|
|
403
|
+
}
|
|
405
404
|
}
|
|
405
|
+
} finally {
|
|
406
|
+
await Dockerfile.stopLocalRegistry();
|
|
406
407
|
}
|
|
407
408
|
|
|
408
409
|
logger.log('success', 'All images pushed successfully');
|
|
@@ -429,7 +430,8 @@ export class TsDockerManager {
|
|
|
429
430
|
}
|
|
430
431
|
|
|
431
432
|
/**
|
|
432
|
-
* Runs tests for all Dockerfiles
|
|
433
|
+
* Runs tests for all Dockerfiles.
|
|
434
|
+
* Starts the local registry so multi-platform images can be auto-pulled.
|
|
433
435
|
*/
|
|
434
436
|
public async test(): Promise<void> {
|
|
435
437
|
if (this.dockerfiles.length === 0) {
|
|
@@ -443,7 +445,14 @@ export class TsDockerManager {
|
|
|
443
445
|
|
|
444
446
|
logger.log('info', '');
|
|
445
447
|
logger.log('info', '=== TEST PHASE ===');
|
|
446
|
-
|
|
448
|
+
|
|
449
|
+
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
|
450
|
+
try {
|
|
451
|
+
await Dockerfile.testDockerfiles(this.dockerfiles);
|
|
452
|
+
} finally {
|
|
453
|
+
await Dockerfile.stopLocalRegistry();
|
|
454
|
+
}
|
|
455
|
+
|
|
447
456
|
logger.log('success', 'All tests completed');
|
|
448
457
|
}
|
|
449
458
|
|