@git.zone/tsdocker 1.15.0 → 1.16.0
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.dockercontext.js +13 -3
- package/dist_ts/classes.dockerfile.d.ts +24 -10
- package/dist_ts/classes.dockerfile.js +163 -77
- package/dist_ts/classes.registrycopy.d.ts +70 -0
- package/dist_ts/classes.registrycopy.js +359 -0
- package/dist_ts/classes.tsdockermanager.d.ts +9 -1
- package/dist_ts/classes.tsdockermanager.js +51 -22
- package/dist_ts/classes.tsdockersession.d.ts +35 -0
- package/dist_ts/classes.tsdockersession.js +92 -0
- package/dist_ts/interfaces/index.d.ts +1 -0
- package/dist_ts/tsdocker.cli.js +4 -1
- package/package.json +1 -1
- package/readme.hints.md +12 -0
- package/readme.md +186 -111
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dockercontext.ts +12 -2
- package/ts/classes.dockerfile.ts +188 -86
- package/ts/classes.registrycopy.ts +511 -0
- package/ts/classes.tsdockermanager.ts +52 -22
- package/ts/classes.tsdockersession.ts +107 -0
- package/ts/interfaces/index.ts +1 -0
- package/ts/tsdocker.cli.ts +3 -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
|
+
}
|
|
@@ -6,6 +6,7 @@ import { DockerRegistry } from './classes.dockerregistry.js';
|
|
|
6
6
|
import { RegistryStorage } from './classes.registrystorage.js';
|
|
7
7
|
import { TsDockerCache } from './classes.tsdockercache.js';
|
|
8
8
|
import { DockerContext } from './classes.dockercontext.js';
|
|
9
|
+
import { TsDockerSession } from './classes.tsdockersession.js';
|
|
9
10
|
import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
|
|
10
11
|
|
|
11
12
|
const smartshellInstance = new plugins.smartshell.Smartshell({
|
|
@@ -20,6 +21,7 @@ export class TsDockerManager {
|
|
|
20
21
|
public config: ITsDockerConfig;
|
|
21
22
|
public projectInfo: any;
|
|
22
23
|
public dockerContext: DockerContext;
|
|
24
|
+
public session!: TsDockerSession;
|
|
23
25
|
private dockerfiles: Dockerfile[] = [];
|
|
24
26
|
|
|
25
27
|
constructor(config: ITsDockerConfig) {
|
|
@@ -77,6 +79,9 @@ export class TsDockerManager {
|
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
// Create session identity (unique ports, names for CI concurrency)
|
|
83
|
+
this.session = await TsDockerSession.create();
|
|
84
|
+
|
|
80
85
|
logger.log('info', `Prepared TsDockerManager with ${this.registryStorage.getAllRegistries().length} registries`);
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -98,6 +103,10 @@ export class TsDockerManager {
|
|
|
98
103
|
this.dockerfiles = await Dockerfile.readDockerfiles(this);
|
|
99
104
|
this.dockerfiles = await Dockerfile.sortDockerfiles(this.dockerfiles);
|
|
100
105
|
this.dockerfiles = await Dockerfile.mapDockerfiles(this.dockerfiles);
|
|
106
|
+
// Inject session into each Dockerfile
|
|
107
|
+
for (const df of this.dockerfiles) {
|
|
108
|
+
df.session = this.session;
|
|
109
|
+
}
|
|
101
110
|
return this.dockerfiles;
|
|
102
111
|
}
|
|
103
112
|
|
|
@@ -187,11 +196,7 @@ export class TsDockerManager {
|
|
|
187
196
|
|
|
188
197
|
const total = toBuild.length;
|
|
189
198
|
const overallStart = Date.now();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (useRegistry) {
|
|
193
|
-
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
|
194
|
-
}
|
|
199
|
+
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
|
195
200
|
|
|
196
201
|
try {
|
|
197
202
|
if (options?.parallel) {
|
|
@@ -230,7 +235,7 @@ export class TsDockerManager {
|
|
|
230
235
|
|
|
231
236
|
await Dockerfile.runWithConcurrency(tasks, concurrency);
|
|
232
237
|
|
|
233
|
-
// After the entire level completes,
|
|
238
|
+
// After the entire level completes, push all to local registry + tag for deps
|
|
234
239
|
for (const df of level) {
|
|
235
240
|
const dependentBaseImages = new Set<string>();
|
|
236
241
|
for (const other of toBuild) {
|
|
@@ -242,8 +247,9 @@ export class TsDockerManager {
|
|
|
242
247
|
logger.log('info', `Tagging ${df.buildTag} as ${fullTag} for local dependency resolution`);
|
|
243
248
|
await smartshellInstance.exec(`docker tag ${df.buildTag} ${fullTag}`);
|
|
244
249
|
}
|
|
245
|
-
|
|
246
|
-
|
|
250
|
+
// Push ALL images to local registry (skip if already pushed via buildx)
|
|
251
|
+
if (!df.localRegistryTag) {
|
|
252
|
+
await Dockerfile.pushToLocalRegistry(this.session, df);
|
|
247
253
|
}
|
|
248
254
|
}
|
|
249
255
|
}
|
|
@@ -281,23 +287,21 @@ export class TsDockerManager {
|
|
|
281
287
|
await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`);
|
|
282
288
|
}
|
|
283
289
|
|
|
284
|
-
// Push to local registry
|
|
285
|
-
if (
|
|
286
|
-
await Dockerfile.pushToLocalRegistry(dockerfileArg);
|
|
290
|
+
// Push ALL images to local registry (skip if already pushed via buildx)
|
|
291
|
+
if (!dockerfileArg.localRegistryTag) {
|
|
292
|
+
await Dockerfile.pushToLocalRegistry(this.session, dockerfileArg);
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
}
|
|
290
296
|
} finally {
|
|
291
|
-
|
|
292
|
-
await Dockerfile.stopLocalRegistry();
|
|
293
|
-
}
|
|
297
|
+
await Dockerfile.stopLocalRegistry(this.session);
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
|
|
297
301
|
cache.save();
|
|
298
302
|
} else {
|
|
299
303
|
// === STANDARD MODE: build all via static helper ===
|
|
300
|
-
await Dockerfile.buildDockerfiles(toBuild, {
|
|
304
|
+
await Dockerfile.buildDockerfiles(toBuild, this.session, {
|
|
301
305
|
platform: options?.platform,
|
|
302
306
|
timeout: options?.timeout,
|
|
303
307
|
noCache: options?.noCache,
|
|
@@ -334,7 +338,7 @@ export class TsDockerManager {
|
|
|
334
338
|
* Ensures Docker buildx is set up for multi-architecture builds
|
|
335
339
|
*/
|
|
336
340
|
private async ensureBuildx(): Promise<void> {
|
|
337
|
-
const builderName = this.dockerContext.getBuilderName();
|
|
341
|
+
const builderName = this.dockerContext.getBuilderName() + (this.session?.config.builderSuffix || '');
|
|
338
342
|
const platforms = this.config.platforms?.join(', ') || 'default';
|
|
339
343
|
logger.log('info', `Setting up Docker buildx [${platforms}]...`);
|
|
340
344
|
logger.log('info', `Builder: ${builderName}`);
|
|
@@ -398,11 +402,17 @@ export class TsDockerManager {
|
|
|
398
402
|
return;
|
|
399
403
|
}
|
|
400
404
|
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
+
// Start local registry (reads from persistent .nogit/docker-registry/)
|
|
406
|
+
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
|
407
|
+
try {
|
|
408
|
+
// Push each Dockerfile to each registry via OCI copy
|
|
409
|
+
for (const dockerfile of this.dockerfiles) {
|
|
410
|
+
for (const registry of registriesToPush) {
|
|
411
|
+
await dockerfile.push(registry);
|
|
412
|
+
}
|
|
405
413
|
}
|
|
414
|
+
} finally {
|
|
415
|
+
await Dockerfile.stopLocalRegistry(this.session);
|
|
406
416
|
}
|
|
407
417
|
|
|
408
418
|
logger.log('success', 'All images pushed successfully');
|
|
@@ -429,7 +439,8 @@ export class TsDockerManager {
|
|
|
429
439
|
}
|
|
430
440
|
|
|
431
441
|
/**
|
|
432
|
-
* Runs tests for all Dockerfiles
|
|
442
|
+
* Runs tests for all Dockerfiles.
|
|
443
|
+
* Starts the local registry so multi-platform images can be auto-pulled.
|
|
433
444
|
*/
|
|
434
445
|
public async test(): Promise<void> {
|
|
435
446
|
if (this.dockerfiles.length === 0) {
|
|
@@ -443,7 +454,14 @@ export class TsDockerManager {
|
|
|
443
454
|
|
|
444
455
|
logger.log('info', '');
|
|
445
456
|
logger.log('info', '=== TEST PHASE ===');
|
|
446
|
-
|
|
457
|
+
|
|
458
|
+
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
|
459
|
+
try {
|
|
460
|
+
await Dockerfile.testDockerfiles(this.dockerfiles);
|
|
461
|
+
} finally {
|
|
462
|
+
await Dockerfile.stopLocalRegistry(this.session);
|
|
463
|
+
}
|
|
464
|
+
|
|
447
465
|
logger.log('success', 'All tests completed');
|
|
448
466
|
}
|
|
449
467
|
|
|
@@ -481,4 +499,16 @@ export class TsDockerManager {
|
|
|
481
499
|
public getDockerfiles(): Dockerfile[] {
|
|
482
500
|
return this.dockerfiles;
|
|
483
501
|
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Cleans up session-specific resources.
|
|
505
|
+
* In CI, removes the session-specific buildx builder to avoid accumulation.
|
|
506
|
+
*/
|
|
507
|
+
public async cleanup(): Promise<void> {
|
|
508
|
+
if (this.session?.config.isCI && this.session.config.builderSuffix) {
|
|
509
|
+
const builderName = this.dockerContext.getBuilderName() + this.session.config.builderSuffix;
|
|
510
|
+
logger.log('info', `CI cleanup: removing buildx builder ${builderName}`);
|
|
511
|
+
await smartshellInstance.execSilent(`docker buildx rm ${builderName} 2>/dev/null || true`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
484
514
|
}
|