@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.
@@ -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
- const useRegistry = Dockerfile.needsLocalRegistry(toBuild, options);
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, tag + push for dependency resolution
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
- if (useRegistry && toBuild.some(other => other.localBaseDockerfile === df)) {
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 for buildx (even for cache hits image exists but registry doesn't)
285
- if (useRegistry && toBuild.some(other => other.localBaseDockerfile === dockerfileArg)) {
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
- if (useRegistry) {
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
- // Push each Dockerfile to each registry
402
- for (const dockerfile of this.dockerfiles) {
403
- for (const registry of registriesToPush) {
404
- await dockerfile.push(registry);
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
- await Dockerfile.testDockerfiles(this.dockerfiles);
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