@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.
@@ -2,6 +2,8 @@ import * as plugins from './tsdocker.plugins.js';
2
2
  import * as paths from './tsdocker.paths.js';
3
3
  import { logger, formatDuration } from './tsdocker.logging.js';
4
4
  import { DockerRegistry } from './classes.dockerregistry.js';
5
+ import { RegistryCopy } from './classes.registrycopy.js';
6
+ import { TsDockerSession } from './classes.tsdockersession.js';
5
7
  import type { IDockerfileOptions, ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
6
8
  import type { TsDockerManager } from './classes.tsdockermanager.js';
7
9
  import * as fs from 'fs';
@@ -10,9 +12,14 @@ const smartshellInstance = new plugins.smartshell.Smartshell({
10
12
  executor: 'bash',
11
13
  });
12
14
 
13
- const LOCAL_REGISTRY_PORT = 5234;
14
- const LOCAL_REGISTRY_HOST = `localhost:${LOCAL_REGISTRY_PORT}`;
15
- const LOCAL_REGISTRY_CONTAINER = 'tsdocker-local-registry';
15
+ /**
16
+ * Extracts a platform string (e.g. "linux/amd64") from a buildx bracket prefix.
17
+ * The prefix may be like "linux/amd64 ", "linux/amd64 stage-1 ", "stage-1 ", or "".
18
+ */
19
+ function extractPlatform(prefix: string): string | null {
20
+ const match = prefix.match(/linux\/\w+/);
21
+ return match ? match[0] : null;
22
+ }
16
23
 
17
24
  /**
18
25
  * Class Dockerfile represents a Dockerfile on disk
@@ -139,47 +146,63 @@ export class Dockerfile {
139
146
  return sortedDockerfileArray;
140
147
  }
141
148
 
142
- /** Determines if a local registry is needed for buildx dependency resolution. */
149
+ /** Local registry is always needed — it's the canonical store for all built images. */
143
150
  public static needsLocalRegistry(
144
- dockerfiles: Dockerfile[],
145
- options?: { platform?: string },
151
+ _dockerfiles?: Dockerfile[],
152
+ _options?: { platform?: string },
146
153
  ): boolean {
147
- const hasLocalDeps = dockerfiles.some(df => df.localBaseImageDependent);
148
- if (!hasLocalDeps) return false;
149
- const config = dockerfiles[0]?.managerRef?.config;
150
- return !!options?.platform || !!(config?.platforms && config.platforms.length > 1);
154
+ return true;
151
155
  }
152
156
 
153
- /** Starts a temporary registry:2 container on port 5234. */
154
- public static async startLocalRegistry(isRootless?: boolean): Promise<void> {
157
+ /** Starts a persistent registry:2 container with session-unique port and name. */
158
+ public static async startLocalRegistry(session: TsDockerSession, isRootless?: boolean): Promise<void> {
159
+ const { registryPort, registryHost, registryContainerName, isCI, sessionId } = session.config;
160
+
161
+ // Ensure persistent storage directory exists — isolate per session in CI
162
+ const registryDataDir = isCI
163
+ ? plugins.path.join(paths.cwd, '.nogit', 'docker-registry', sessionId)
164
+ : plugins.path.join(paths.cwd, '.nogit', 'docker-registry');
165
+ fs.mkdirSync(registryDataDir, { recursive: true });
166
+
155
167
  await smartshellInstance.execSilent(
156
- `docker rm -f ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
157
- );
158
- const result = await smartshellInstance.execSilent(
159
- `docker run -d --name ${LOCAL_REGISTRY_CONTAINER} -p ${LOCAL_REGISTRY_PORT}:5000 registry:2`
168
+ `docker rm -f ${registryContainerName} 2>/dev/null || true`
160
169
  );
170
+
171
+ const runCmd = `docker run -d --name ${registryContainerName} -p ${registryPort}:5000 -v "${registryDataDir}:/var/lib/registry" registry:2`;
172
+ let result = await smartshellInstance.execSilent(runCmd);
173
+
174
+ // Port retry: if port was stolen between allocation and docker run, reallocate once
175
+ if (result.exitCode !== 0 && (result.stderr || result.stdout || '').includes('port is already allocated')) {
176
+ const newPort = await TsDockerSession.allocatePort();
177
+ logger.log('warn', `Port ${registryPort} taken, retrying with ${newPort}`);
178
+ session.config.registryPort = newPort;
179
+ session.config.registryHost = `localhost:${newPort}`;
180
+ const retryCmd = `docker run -d --name ${registryContainerName} -p ${newPort}:5000 -v "${registryDataDir}:/var/lib/registry" registry:2`;
181
+ result = await smartshellInstance.execSilent(retryCmd);
182
+ }
183
+
161
184
  if (result.exitCode !== 0) {
162
185
  throw new Error(`Failed to start local registry: ${result.stderr || result.stdout}`);
163
186
  }
164
187
  // registry:2 starts near-instantly; brief wait for readiness
165
188
  await new Promise(resolve => setTimeout(resolve, 1000));
166
- logger.log('info', `Started local registry at ${LOCAL_REGISTRY_HOST} (buildx dependency bridge)`);
189
+ logger.log('info', `Started local registry at ${session.config.registryHost} (container: ${registryContainerName})`);
167
190
  if (isRootless) {
168
- logger.log('warn', `[rootless] Registry on port ${LOCAL_REGISTRY_PORT} — if buildx cannot reach localhost:${LOCAL_REGISTRY_PORT}, try 127.0.0.1:${LOCAL_REGISTRY_PORT}`);
191
+ logger.log('warn', `[rootless] Registry on port ${session.config.registryPort} — if buildx cannot reach localhost, try 127.0.0.1`);
169
192
  }
170
193
  }
171
194
 
172
- /** Stops and removes the temporary local registry container. */
173
- public static async stopLocalRegistry(): Promise<void> {
195
+ /** Stops and removes the session-specific local registry container. */
196
+ public static async stopLocalRegistry(session: TsDockerSession): Promise<void> {
174
197
  await smartshellInstance.execSilent(
175
- `docker rm -f ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
198
+ `docker rm -f ${session.config.registryContainerName} 2>/dev/null || true`
176
199
  );
177
- logger.log('info', 'Stopped local registry');
200
+ logger.log('info', `Stopped local registry (${session.config.registryContainerName})`);
178
201
  }
179
202
 
180
203
  /** Pushes a built image to the local registry for buildx consumption. */
181
- public static async pushToLocalRegistry(dockerfile: Dockerfile): Promise<void> {
182
- const registryTag = `${LOCAL_REGISTRY_HOST}/${dockerfile.buildTag}`;
204
+ public static async pushToLocalRegistry(session: TsDockerSession, dockerfile: Dockerfile): Promise<void> {
205
+ const registryTag = `${session.config.registryHost}/${dockerfile.buildTag}`;
183
206
  await smartshellInstance.execSilent(`docker tag ${dockerfile.buildTag} ${registryTag}`);
184
207
  const result = await smartshellInstance.execSilent(`docker push ${registryTag}`);
185
208
  if (result.exitCode !== 0) {
@@ -242,15 +265,13 @@ export class Dockerfile {
242
265
  */
243
266
  public static async buildDockerfiles(
244
267
  sortedArrayArg: Dockerfile[],
268
+ session: TsDockerSession,
245
269
  options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number },
246
270
  ): Promise<Dockerfile[]> {
247
271
  const total = sortedArrayArg.length;
248
272
  const overallStart = Date.now();
249
- const useRegistry = Dockerfile.needsLocalRegistry(sortedArrayArg, options);
250
273
 
251
- if (useRegistry) {
252
- await Dockerfile.startLocalRegistry(options?.isRootless);
253
- }
274
+ await Dockerfile.startLocalRegistry(session, options?.isRootless);
254
275
 
255
276
  try {
256
277
  if (options?.parallel) {
@@ -282,8 +303,9 @@ export class Dockerfile {
282
303
 
283
304
  await Dockerfile.runWithConcurrency(tasks, concurrency);
284
305
 
285
- // After the entire level completes, tag + push for dependency resolution
306
+ // After the entire level completes, push all to local registry + tag for deps
286
307
  for (const df of level) {
308
+ // Tag in host daemon for dependency resolution
287
309
  const dependentBaseImages = new Set<string>();
288
310
  for (const other of sortedArrayArg) {
289
311
  if (other.localBaseDockerfile === df && other.baseImage !== df.buildTag) {
@@ -294,8 +316,9 @@ export class Dockerfile {
294
316
  logger.log('info', `Tagging ${df.buildTag} as ${fullTag} for local dependency resolution`);
295
317
  await smartshellInstance.exec(`docker tag ${df.buildTag} ${fullTag}`);
296
318
  }
297
- if (useRegistry && sortedArrayArg.some(other => other.localBaseDockerfile === df)) {
298
- await Dockerfile.pushToLocalRegistry(df);
319
+ // Push ALL images to local registry (skip if already pushed via buildx)
320
+ if (!df.localRegistryTag) {
321
+ await Dockerfile.pushToLocalRegistry(session, df);
299
322
  }
300
323
  }
301
324
  }
@@ -321,16 +344,14 @@ export class Dockerfile {
321
344
  await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`);
322
345
  }
323
346
 
324
- // Push to local registry for buildx dependency resolution
325
- if (useRegistry && sortedArrayArg.some(other => other.localBaseDockerfile === dockerfileArg)) {
326
- await Dockerfile.pushToLocalRegistry(dockerfileArg);
347
+ // Push ALL images to local registry (skip if already pushed via buildx)
348
+ if (!dockerfileArg.localRegistryTag) {
349
+ await Dockerfile.pushToLocalRegistry(session, dockerfileArg);
327
350
  }
328
351
  }
329
352
  }
330
353
  } finally {
331
- if (useRegistry) {
332
- await Dockerfile.stopLocalRegistry();
333
- }
354
+ await Dockerfile.stopLocalRegistry(session);
334
355
  }
335
356
 
336
357
  logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
@@ -521,6 +542,7 @@ export class Dockerfile {
521
542
 
522
543
  // INSTANCE PROPERTIES
523
544
  public managerRef: TsDockerManager;
545
+ public session?: TsDockerSession;
524
546
  public filePath!: string;
525
547
  public repo: string;
526
548
  public version: string;
@@ -564,6 +586,79 @@ export class Dockerfile {
564
586
  this.localBaseImageDependent = false;
565
587
  }
566
588
 
589
+ /**
590
+ * Creates a line-by-line handler for Docker build output that logs
591
+ * recognized layer/step lines in an emphasized format.
592
+ */
593
+ private createBuildOutputHandler(verbose: boolean): {
594
+ handleChunk: (chunk: Buffer | string) => void;
595
+ } {
596
+ let buffer = '';
597
+ const tag = this.cleanTag;
598
+
599
+ const handleLine = (line: string) => {
600
+ // In verbose mode, write raw output prefixed with tag for identification
601
+ if (verbose) {
602
+ process.stdout.write(`[${tag}] ${line}\n`);
603
+ }
604
+
605
+ // Buildx step: #N [platform step/total] INSTRUCTION
606
+ const bxStep = line.match(/^#\d+ \[([^\]]+?)(\d+\/\d+)\] (.+)/);
607
+ if (bxStep) {
608
+ const prefix = bxStep[1].trim();
609
+ const step = bxStep[2];
610
+ const instruction = bxStep[3];
611
+ const platform = extractPlatform(prefix);
612
+ const platStr = platform ? `${platform} ▸ ` : '';
613
+ logger.log('note', `[${tag}] ${platStr}[${step}] ${instruction}`);
614
+ return;
615
+ }
616
+
617
+ // Buildx CACHED: #N CACHED
618
+ const bxCached = line.match(/^#(\d+) CACHED/);
619
+ if (bxCached) {
620
+ logger.log('note', `[${tag}] CACHED`);
621
+ return;
622
+ }
623
+
624
+ // Buildx DONE: #N DONE 12.3s
625
+ const bxDone = line.match(/^#\d+ DONE (.+)/);
626
+ if (bxDone) {
627
+ const timing = bxDone[1];
628
+ if (!timing.startsWith('0.0')) {
629
+ logger.log('note', `[${tag}] DONE ${timing}`);
630
+ }
631
+ return;
632
+ }
633
+
634
+ // Buildx export phase: #N exporting ...
635
+ const bxExport = line.match(/^#\d+ exporting (.+)/);
636
+ if (bxExport) {
637
+ logger.log('note', `[${tag}] exporting ${bxExport[1]}`);
638
+ return;
639
+ }
640
+
641
+ // Standard docker build: Step N/M : INSTRUCTION
642
+ const stdStep = line.match(/^Step (\d+\/\d+) : (.+)/);
643
+ if (stdStep) {
644
+ logger.log('note', `[${tag}] Step ${stdStep[1]}: ${stdStep[2]}`);
645
+ return;
646
+ }
647
+ };
648
+
649
+ return {
650
+ handleChunk: (chunk: Buffer | string) => {
651
+ buffer += chunk.toString();
652
+ const lines = buffer.split('\n');
653
+ buffer = lines.pop() || '';
654
+ for (const line of lines) {
655
+ const trimmed = line.replace(/\r$/, '').trim();
656
+ if (trimmed) handleLine(trimmed);
657
+ }
658
+ },
659
+ };
660
+ }
661
+
567
662
  /**
568
663
  * Builds the Dockerfile
569
664
  */
@@ -591,32 +686,32 @@ export class Dockerfile {
591
686
 
592
687
  if (platformOverride) {
593
688
  // Single platform override via buildx
594
- buildCommand = `docker buildx build --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
689
+ buildCommand = `docker buildx build --progress=plain --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
595
690
  logger.log('info', `Build: buildx --platform ${platformOverride} --load`);
596
691
  } else if (config.platforms && config.platforms.length > 1) {
597
- // Multi-platform build using buildx
692
+ // Multi-platform build using buildx — always push to local registry
598
693
  const platformString = config.platforms.join(',');
599
- buildCommand = `docker buildx build --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
600
-
601
- if (config.push) {
602
- buildCommand += ' --push';
603
- logger.log('info', `Build: buildx --platform ${platformString} --push`);
604
- } else {
605
- buildCommand += ' --load';
606
- logger.log('info', `Build: buildx --platform ${platformString} --load`);
607
- }
694
+ const registryHost = this.session?.config.registryHost || 'localhost:5234';
695
+ const localTag = `${registryHost}/${this.buildTag}`;
696
+ buildCommand = `docker buildx build --progress=plain --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
697
+ this.localRegistryTag = localTag;
698
+ logger.log('info', `Build: buildx --platform ${platformString} --push to local registry`);
608
699
  } else {
609
700
  // Standard build
610
701
  const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown';
611
- buildCommand = `docker build --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
702
+ buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
612
703
  logger.log('info', 'Build: docker build (standard)');
613
704
  }
614
705
 
706
+ // Execute build with real-time layer logging
707
+ const handler = this.createBuildOutputHandler(verbose);
708
+ const streaming = await smartshellInstance.execStreamingSilent(buildCommand);
709
+
710
+ // Intercept output for layer logging
711
+ streaming.childProcess.stdout?.on('data', handler.handleChunk);
712
+ streaming.childProcess.stderr?.on('data', handler.handleChunk);
713
+
615
714
  if (timeout) {
616
- // Use streaming execution with timeout
617
- const streaming = verbose
618
- ? await smartshellInstance.execStreaming(buildCommand)
619
- : await smartshellInstance.execStreamingSilent(buildCommand);
620
715
  const timeoutPromise = new Promise<never>((_, reject) => {
621
716
  setTimeout(() => {
622
717
  streaming.childProcess.kill();
@@ -629,9 +724,7 @@ export class Dockerfile {
629
724
  throw new Error(`Build failed for ${this.cleanTag}`);
630
725
  }
631
726
  } else {
632
- const result = verbose
633
- ? await smartshellInstance.exec(buildCommand)
634
- : await smartshellInstance.execSilent(buildCommand);
727
+ const result = await streaming.finalPromise;
635
728
  if (result.exitCode !== 0) {
636
729
  logger.log('error', `Build failed for ${this.cleanTag}`);
637
730
  if (!verbose && result.stdout) {
@@ -645,38 +738,40 @@ export class Dockerfile {
645
738
  }
646
739
 
647
740
  /**
648
- * Pushes the Dockerfile to a registry
741
+ * Pushes the Dockerfile to a registry using OCI Distribution API copy
742
+ * from the local registry to the remote registry.
649
743
  */
650
744
  public async push(dockerRegistryArg: DockerRegistry, versionSuffix?: string): Promise<void> {
651
- this.pushTag = Dockerfile.getDockerTagString(
652
- this.managerRef,
653
- dockerRegistryArg.registryUrl,
654
- this.repo,
655
- this.version,
656
- versionSuffix
657
- );
745
+ const destRepo = this.getDestRepo(dockerRegistryArg.registryUrl);
746
+ const destTag = versionSuffix ? `${this.version}_${versionSuffix}` : this.version;
747
+ const registryCopy = new RegistryCopy();
748
+ const registryHost = this.session?.config.registryHost || 'localhost:5234';
658
749
 
659
- await smartshellInstance.exec(`docker tag ${this.buildTag} ${this.pushTag}`);
660
- const pushResult = await smartshellInstance.exec(`docker push ${this.pushTag}`);
750
+ this.pushTag = `${dockerRegistryArg.registryUrl}/${destRepo}:${destTag}`;
751
+ logger.log('info', `Pushing ${this.pushTag} via OCI copy from local registry...`);
661
752
 
662
- if (pushResult.exitCode !== 0) {
663
- logger.log('error', `Push failed for ${this.pushTag}`);
664
- throw new Error(`Push failed for ${this.pushTag}`);
665
- }
666
-
667
- // Get image digest
668
- const inspectResult = await smartshellInstance.exec(
669
- `docker inspect --format="{{index .RepoDigests 0}}" ${this.pushTag}`
753
+ await registryCopy.copyImage(
754
+ registryHost,
755
+ this.repo,
756
+ this.version,
757
+ dockerRegistryArg.registryUrl,
758
+ destRepo,
759
+ destTag,
760
+ { username: dockerRegistryArg.username, password: dockerRegistryArg.password },
670
761
  );
671
762
 
672
- if (inspectResult.exitCode === 0 && inspectResult.stdout.includes('@')) {
673
- const imageDigest = inspectResult.stdout.split('@')[1]?.trim();
674
- logger.log('info', `The image ${this.pushTag} has digest ${imageDigest}`);
675
- }
676
-
677
763
  logger.log('ok', `Pushed ${this.pushTag}`);
678
764
  }
679
765
 
766
+ /**
767
+ * Returns the destination repository for a given registry URL,
768
+ * using registryRepoMap if configured, otherwise the default repo.
769
+ */
770
+ private getDestRepo(registryUrl: string): string {
771
+ const config = this.managerRef.config;
772
+ return config.registryRepoMap?.[registryUrl] || this.repo;
773
+ }
774
+
680
775
  /**
681
776
  * Pulls the Dockerfile from a registry
682
777
  */
@@ -696,30 +791,37 @@ export class Dockerfile {
696
791
  }
697
792
 
698
793
  /**
699
- * Tests the Dockerfile by running a test script if it exists
794
+ * Tests the Dockerfile by running a test script if it exists.
795
+ * For multi-platform builds, uses the local registry tag so Docker can auto-pull.
700
796
  */
701
797
  public async test(): Promise<number> {
702
798
  const startTime = Date.now();
703
799
  const testDir = this.managerRef.config.testDir || plugins.path.join(paths.cwd, 'test');
704
800
  const testFile = plugins.path.join(testDir, 'test_' + this.version + '.sh');
801
+ // Use local registry tag for multi-platform images (not in daemon), otherwise buildTag
802
+ const imageRef = this.localRegistryTag || this.buildTag;
803
+
804
+ const sessionId = this.session?.config.sessionId || 'default';
805
+ const testContainerName = `tsdocker_test_${sessionId}`;
806
+ const testImageName = `tsdocker_test_image_${sessionId}`;
705
807
 
706
808
  const testFileExists = fs.existsSync(testFile);
707
809
 
708
810
  if (testFileExists) {
709
811
  // Run tests in container
710
812
  await smartshellInstance.exec(
711
- `docker run --name tsdocker_test_container --entrypoint="bash" ${this.buildTag} -c "mkdir /tsdocker_test"`
813
+ `docker run --name ${testContainerName} --entrypoint="bash" ${imageRef} -c "mkdir /tsdocker_test"`
712
814
  );
713
- await smartshellInstance.exec(`docker cp ${testFile} tsdocker_test_container:/tsdocker_test/test.sh`);
714
- await smartshellInstance.exec(`docker commit tsdocker_test_container tsdocker_test_image`);
815
+ await smartshellInstance.exec(`docker cp ${testFile} ${testContainerName}:/tsdocker_test/test.sh`);
816
+ await smartshellInstance.exec(`docker commit ${testContainerName} ${testImageName}`);
715
817
 
716
818
  const testResult = await smartshellInstance.exec(
717
- `docker run --entrypoint="bash" tsdocker_test_image -x /tsdocker_test/test.sh`
819
+ `docker run --entrypoint="bash" ${testImageName} -x /tsdocker_test/test.sh`
718
820
  );
719
821
 
720
822
  // Cleanup
721
- await smartshellInstance.exec(`docker rm tsdocker_test_container`);
722
- await smartshellInstance.exec(`docker rmi --force tsdocker_test_image`);
823
+ await smartshellInstance.exec(`docker rm ${testContainerName}`);
824
+ await smartshellInstance.exec(`docker rmi --force ${testImageName}`);
723
825
 
724
826
  if (testResult.exitCode !== 0) {
725
827
  throw new Error(`Tests failed for ${this.cleanTag}`);