@git.zone/tsdocker 1.15.1 → 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.
@@ -3,6 +3,7 @@ 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
5
  import { RegistryCopy } from './classes.registrycopy.js';
6
+ import { TsDockerSession } from './classes.tsdockersession.js';
6
7
  import type { IDockerfileOptions, ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
7
8
  import type { TsDockerManager } from './classes.tsdockermanager.js';
8
9
  import * as fs from 'fs';
@@ -11,9 +12,14 @@ const smartshellInstance = new plugins.smartshell.Smartshell({
11
12
  executor: 'bash',
12
13
  });
13
14
 
14
- const LOCAL_REGISTRY_PORT = 5234;
15
- const LOCAL_REGISTRY_HOST = `localhost:${LOCAL_REGISTRY_PORT}`;
16
- 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
+ }
17
23
 
18
24
  /**
19
25
  * Class Dockerfile represents a Dockerfile on disk
@@ -148,40 +154,55 @@ export class Dockerfile {
148
154
  return true;
149
155
  }
150
156
 
151
- /** Starts a persistent registry:2 container on port 5234 with volume storage. */
152
- public static async startLocalRegistry(isRootless?: boolean): Promise<void> {
153
- // Ensure persistent storage directory exists
154
- const registryDataDir = plugins.path.join(paths.cwd, '.nogit', 'docker-registry');
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');
155
165
  fs.mkdirSync(registryDataDir, { recursive: true });
156
166
 
157
167
  await smartshellInstance.execSilent(
158
- `docker rm -f ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
159
- );
160
- const result = await smartshellInstance.execSilent(
161
- `docker run -d --name ${LOCAL_REGISTRY_CONTAINER} -p ${LOCAL_REGISTRY_PORT}:5000 -v "${registryDataDir}:/var/lib/registry" registry:2`
168
+ `docker rm -f ${registryContainerName} 2>/dev/null || true`
162
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
+
163
184
  if (result.exitCode !== 0) {
164
185
  throw new Error(`Failed to start local registry: ${result.stderr || result.stdout}`);
165
186
  }
166
187
  // registry:2 starts near-instantly; brief wait for readiness
167
188
  await new Promise(resolve => setTimeout(resolve, 1000));
168
- logger.log('info', `Started local registry at ${LOCAL_REGISTRY_HOST} (persistent storage at .nogit/docker-registry/)`);
189
+ logger.log('info', `Started local registry at ${session.config.registryHost} (container: ${registryContainerName})`);
169
190
  if (isRootless) {
170
- 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`);
171
192
  }
172
193
  }
173
194
 
174
- /** Stops and removes the temporary local registry container. */
175
- 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> {
176
197
  await smartshellInstance.execSilent(
177
- `docker rm -f ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
198
+ `docker rm -f ${session.config.registryContainerName} 2>/dev/null || true`
178
199
  );
179
- logger.log('info', 'Stopped local registry');
200
+ logger.log('info', `Stopped local registry (${session.config.registryContainerName})`);
180
201
  }
181
202
 
182
203
  /** Pushes a built image to the local registry for buildx consumption. */
183
- public static async pushToLocalRegistry(dockerfile: Dockerfile): Promise<void> {
184
- 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}`;
185
206
  await smartshellInstance.execSilent(`docker tag ${dockerfile.buildTag} ${registryTag}`);
186
207
  const result = await smartshellInstance.execSilent(`docker push ${registryTag}`);
187
208
  if (result.exitCode !== 0) {
@@ -244,12 +265,13 @@ export class Dockerfile {
244
265
  */
245
266
  public static async buildDockerfiles(
246
267
  sortedArrayArg: Dockerfile[],
268
+ session: TsDockerSession,
247
269
  options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number },
248
270
  ): Promise<Dockerfile[]> {
249
271
  const total = sortedArrayArg.length;
250
272
  const overallStart = Date.now();
251
273
 
252
- await Dockerfile.startLocalRegistry(options?.isRootless);
274
+ await Dockerfile.startLocalRegistry(session, options?.isRootless);
253
275
 
254
276
  try {
255
277
  if (options?.parallel) {
@@ -296,7 +318,7 @@ export class Dockerfile {
296
318
  }
297
319
  // Push ALL images to local registry (skip if already pushed via buildx)
298
320
  if (!df.localRegistryTag) {
299
- await Dockerfile.pushToLocalRegistry(df);
321
+ await Dockerfile.pushToLocalRegistry(session, df);
300
322
  }
301
323
  }
302
324
  }
@@ -324,12 +346,12 @@ export class Dockerfile {
324
346
 
325
347
  // Push ALL images to local registry (skip if already pushed via buildx)
326
348
  if (!dockerfileArg.localRegistryTag) {
327
- await Dockerfile.pushToLocalRegistry(dockerfileArg);
349
+ await Dockerfile.pushToLocalRegistry(session, dockerfileArg);
328
350
  }
329
351
  }
330
352
  }
331
353
  } finally {
332
- await Dockerfile.stopLocalRegistry();
354
+ await Dockerfile.stopLocalRegistry(session);
333
355
  }
334
356
 
335
357
  logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
@@ -520,6 +542,7 @@ export class Dockerfile {
520
542
 
521
543
  // INSTANCE PROPERTIES
522
544
  public managerRef: TsDockerManager;
545
+ public session?: TsDockerSession;
523
546
  public filePath!: string;
524
547
  public repo: string;
525
548
  public version: string;
@@ -563,6 +586,79 @@ export class Dockerfile {
563
586
  this.localBaseImageDependent = false;
564
587
  }
565
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
+
566
662
  /**
567
663
  * Builds the Dockerfile
568
664
  */
@@ -590,27 +686,32 @@ export class Dockerfile {
590
686
 
591
687
  if (platformOverride) {
592
688
  // Single platform override via buildx
593
- 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} .`;
594
690
  logger.log('info', `Build: buildx --platform ${platformOverride} --load`);
595
691
  } else if (config.platforms && config.platforms.length > 1) {
596
692
  // Multi-platform build using buildx — always push to local registry
597
693
  const platformString = config.platforms.join(',');
598
- const localTag = `${LOCAL_REGISTRY_HOST}/${this.buildTag}`;
599
- buildCommand = `docker buildx build --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
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 .`;
600
697
  this.localRegistryTag = localTag;
601
698
  logger.log('info', `Build: buildx --platform ${platformString} --push to local registry`);
602
699
  } else {
603
700
  // Standard build
604
701
  const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown';
605
- 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} .`;
606
703
  logger.log('info', 'Build: docker build (standard)');
607
704
  }
608
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
+
609
714
  if (timeout) {
610
- // Use streaming execution with timeout
611
- const streaming = verbose
612
- ? await smartshellInstance.execStreaming(buildCommand)
613
- : await smartshellInstance.execStreamingSilent(buildCommand);
614
715
  const timeoutPromise = new Promise<never>((_, reject) => {
615
716
  setTimeout(() => {
616
717
  streaming.childProcess.kill();
@@ -623,9 +724,7 @@ export class Dockerfile {
623
724
  throw new Error(`Build failed for ${this.cleanTag}`);
624
725
  }
625
726
  } else {
626
- const result = verbose
627
- ? await smartshellInstance.exec(buildCommand)
628
- : await smartshellInstance.execSilent(buildCommand);
727
+ const result = await streaming.finalPromise;
629
728
  if (result.exitCode !== 0) {
630
729
  logger.log('error', `Build failed for ${this.cleanTag}`);
631
730
  if (!verbose && result.stdout) {
@@ -646,12 +745,13 @@ export class Dockerfile {
646
745
  const destRepo = this.getDestRepo(dockerRegistryArg.registryUrl);
647
746
  const destTag = versionSuffix ? `${this.version}_${versionSuffix}` : this.version;
648
747
  const registryCopy = new RegistryCopy();
748
+ const registryHost = this.session?.config.registryHost || 'localhost:5234';
649
749
 
650
750
  this.pushTag = `${dockerRegistryArg.registryUrl}/${destRepo}:${destTag}`;
651
751
  logger.log('info', `Pushing ${this.pushTag} via OCI copy from local registry...`);
652
752
 
653
753
  await registryCopy.copyImage(
654
- LOCAL_REGISTRY_HOST,
754
+ registryHost,
655
755
  this.repo,
656
756
  this.version,
657
757
  dockerRegistryArg.registryUrl,
@@ -701,23 +801,27 @@ export class Dockerfile {
701
801
  // Use local registry tag for multi-platform images (not in daemon), otherwise buildTag
702
802
  const imageRef = this.localRegistryTag || this.buildTag;
703
803
 
804
+ const sessionId = this.session?.config.sessionId || 'default';
805
+ const testContainerName = `tsdocker_test_${sessionId}`;
806
+ const testImageName = `tsdocker_test_image_${sessionId}`;
807
+
704
808
  const testFileExists = fs.existsSync(testFile);
705
809
 
706
810
  if (testFileExists) {
707
811
  // Run tests in container
708
812
  await smartshellInstance.exec(
709
- `docker run --name tsdocker_test_container --entrypoint="bash" ${imageRef} -c "mkdir /tsdocker_test"`
813
+ `docker run --name ${testContainerName} --entrypoint="bash" ${imageRef} -c "mkdir /tsdocker_test"`
710
814
  );
711
- await smartshellInstance.exec(`docker cp ${testFile} tsdocker_test_container:/tsdocker_test/test.sh`);
712
- 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}`);
713
817
 
714
818
  const testResult = await smartshellInstance.exec(
715
- `docker run --entrypoint="bash" tsdocker_test_image -x /tsdocker_test/test.sh`
819
+ `docker run --entrypoint="bash" ${testImageName} -x /tsdocker_test/test.sh`
716
820
  );
717
821
 
718
822
  // Cleanup
719
- await smartshellInstance.exec(`docker rm tsdocker_test_container`);
720
- await smartshellInstance.exec(`docker rmi --force tsdocker_test_image`);
823
+ await smartshellInstance.exec(`docker rm ${testContainerName}`);
824
+ await smartshellInstance.exec(`docker rmi --force ${testImageName}`);
721
825
 
722
826
  if (testResult.exitCode !== 0) {
723
827
  throw new Error(`Tests failed for ${this.cleanTag}`);
@@ -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,7 +196,7 @@ export class TsDockerManager {
187
196
 
188
197
  const total = toBuild.length;
189
198
  const overallStart = Date.now();
190
- await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
199
+ await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
191
200
 
192
201
  try {
193
202
  if (options?.parallel) {
@@ -240,7 +249,7 @@ export class TsDockerManager {
240
249
  }
241
250
  // Push ALL images to local registry (skip if already pushed via buildx)
242
251
  if (!df.localRegistryTag) {
243
- await Dockerfile.pushToLocalRegistry(df);
252
+ await Dockerfile.pushToLocalRegistry(this.session, df);
244
253
  }
245
254
  }
246
255
  }
@@ -280,19 +289,19 @@ export class TsDockerManager {
280
289
 
281
290
  // Push ALL images to local registry (skip if already pushed via buildx)
282
291
  if (!dockerfileArg.localRegistryTag) {
283
- await Dockerfile.pushToLocalRegistry(dockerfileArg);
292
+ await Dockerfile.pushToLocalRegistry(this.session, dockerfileArg);
284
293
  }
285
294
  }
286
295
  }
287
296
  } finally {
288
- await Dockerfile.stopLocalRegistry();
297
+ await Dockerfile.stopLocalRegistry(this.session);
289
298
  }
290
299
 
291
300
  logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
292
301
  cache.save();
293
302
  } else {
294
303
  // === STANDARD MODE: build all via static helper ===
295
- await Dockerfile.buildDockerfiles(toBuild, {
304
+ await Dockerfile.buildDockerfiles(toBuild, this.session, {
296
305
  platform: options?.platform,
297
306
  timeout: options?.timeout,
298
307
  noCache: options?.noCache,
@@ -329,7 +338,7 @@ export class TsDockerManager {
329
338
  * Ensures Docker buildx is set up for multi-architecture builds
330
339
  */
331
340
  private async ensureBuildx(): Promise<void> {
332
- const builderName = this.dockerContext.getBuilderName();
341
+ const builderName = this.dockerContext.getBuilderName() + (this.session?.config.builderSuffix || '');
333
342
  const platforms = this.config.platforms?.join(', ') || 'default';
334
343
  logger.log('info', `Setting up Docker buildx [${platforms}]...`);
335
344
  logger.log('info', `Builder: ${builderName}`);
@@ -394,7 +403,7 @@ export class TsDockerManager {
394
403
  }
395
404
 
396
405
  // Start local registry (reads from persistent .nogit/docker-registry/)
397
- await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
406
+ await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
398
407
  try {
399
408
  // Push each Dockerfile to each registry via OCI copy
400
409
  for (const dockerfile of this.dockerfiles) {
@@ -403,7 +412,7 @@ export class TsDockerManager {
403
412
  }
404
413
  }
405
414
  } finally {
406
- await Dockerfile.stopLocalRegistry();
415
+ await Dockerfile.stopLocalRegistry(this.session);
407
416
  }
408
417
 
409
418
  logger.log('success', 'All images pushed successfully');
@@ -446,11 +455,11 @@ export class TsDockerManager {
446
455
  logger.log('info', '');
447
456
  logger.log('info', '=== TEST PHASE ===');
448
457
 
449
- await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
458
+ await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
450
459
  try {
451
460
  await Dockerfile.testDockerfiles(this.dockerfiles);
452
461
  } finally {
453
- await Dockerfile.stopLocalRegistry();
462
+ await Dockerfile.stopLocalRegistry(this.session);
454
463
  }
455
464
 
456
465
  logger.log('success', 'All tests completed');
@@ -490,4 +499,16 @@ export class TsDockerManager {
490
499
  public getDockerfiles(): Dockerfile[] {
491
500
  return this.dockerfiles;
492
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
+ }
493
514
  }
@@ -0,0 +1,107 @@
1
+ import * as crypto from 'crypto';
2
+ import * as net from 'net';
3
+ import { logger } from './tsdocker.logging.js';
4
+
5
+ export interface ISessionConfig {
6
+ sessionId: string;
7
+ registryPort: number;
8
+ registryHost: string;
9
+ registryContainerName: string;
10
+ isCI: boolean;
11
+ ciSystem: string | null;
12
+ builderSuffix: string;
13
+ }
14
+
15
+ /**
16
+ * Per-invocation session identity for tsdocker.
17
+ * Generates unique ports, container names, and builder names so that
18
+ * concurrent CI jobs on the same Docker host don't collide.
19
+ *
20
+ * In local (non-CI) dev the builder suffix is empty, preserving the
21
+ * persistent builder behavior.
22
+ */
23
+ export class TsDockerSession {
24
+ public config: ISessionConfig;
25
+
26
+ private constructor(config: ISessionConfig) {
27
+ this.config = config;
28
+ }
29
+
30
+ /**
31
+ * Creates a new session. Allocates a dynamic port unless overridden
32
+ * via `TSDOCKER_REGISTRY_PORT`.
33
+ */
34
+ public static async create(): Promise<TsDockerSession> {
35
+ const sessionId =
36
+ process.env.TSDOCKER_SESSION_ID || crypto.randomBytes(4).toString('hex');
37
+
38
+ const registryPort = await TsDockerSession.allocatePort();
39
+ const registryHost = `localhost:${registryPort}`;
40
+ const registryContainerName = `tsdocker-registry-${sessionId}`;
41
+
42
+ const { isCI, ciSystem } = TsDockerSession.detectCI();
43
+ const builderSuffix = isCI ? `-${sessionId}` : '';
44
+
45
+ const config: ISessionConfig = {
46
+ sessionId,
47
+ registryPort,
48
+ registryHost,
49
+ registryContainerName,
50
+ isCI,
51
+ ciSystem,
52
+ builderSuffix,
53
+ };
54
+
55
+ const session = new TsDockerSession(config);
56
+ session.logInfo();
57
+ return session;
58
+ }
59
+
60
+ /**
61
+ * Allocates a free TCP port. Respects `TSDOCKER_REGISTRY_PORT` override.
62
+ */
63
+ public static async allocatePort(): Promise<number> {
64
+ const envPort = process.env.TSDOCKER_REGISTRY_PORT;
65
+ if (envPort) {
66
+ const parsed = parseInt(envPort, 10);
67
+ if (!isNaN(parsed) && parsed > 0) {
68
+ return parsed;
69
+ }
70
+ }
71
+
72
+ return new Promise<number>((resolve, reject) => {
73
+ const srv = net.createServer();
74
+ srv.listen(0, '127.0.0.1', () => {
75
+ const addr = srv.address() as net.AddressInfo;
76
+ const port = addr.port;
77
+ srv.close((err) => {
78
+ if (err) reject(err);
79
+ else resolve(port);
80
+ });
81
+ });
82
+ srv.on('error', reject);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Detects whether we're running inside a CI system.
88
+ */
89
+ private static detectCI(): { isCI: boolean; ciSystem: string | null } {
90
+ if (process.env.GITEA_ACTIONS) return { isCI: true, ciSystem: 'gitea-actions' };
91
+ if (process.env.GITHUB_ACTIONS) return { isCI: true, ciSystem: 'github-actions' };
92
+ if (process.env.GITLAB_CI) return { isCI: true, ciSystem: 'gitlab-ci' };
93
+ if (process.env.CI) return { isCI: true, ciSystem: 'generic' };
94
+ return { isCI: false, ciSystem: null };
95
+ }
96
+
97
+ private logInfo(): void {
98
+ const c = this.config;
99
+ logger.log('info', '=== TSDOCKER SESSION ===');
100
+ logger.log('info', `Session ID: ${c.sessionId}`);
101
+ logger.log('info', `Registry: ${c.registryHost} (container: ${c.registryContainerName})`);
102
+ if (c.isCI) {
103
+ logger.log('info', `CI detected: ${c.ciSystem}`);
104
+ logger.log('info', `Builder suffix: ${c.builderSuffix}`);
105
+ }
106
+ }
107
+ }
@@ -101,4 +101,5 @@ export interface IDockerContextInfo {
101
101
  endpoint: string; // 'unix:///var/run/docker.sock'
102
102
  isRootless: boolean;
103
103
  dockerHost?: string; // value of DOCKER_HOST env var, if set
104
+ topology?: 'socket-mount' | 'dind' | 'local';
104
105
  }
@@ -64,6 +64,7 @@ export let run = () => {
64
64
  }
65
65
 
66
66
  await manager.build(buildOptions);
67
+ await manager.cleanup();
67
68
  logger.log('success', 'Build completed successfully');
68
69
  } catch (err) {
69
70
  logger.log('error', `Build failed: ${(err as Error).message}`);
@@ -117,6 +118,7 @@ export let run = () => {
117
118
  const registries = registryArg ? [registryArg] : undefined;
118
119
 
119
120
  await manager.push(registries);
121
+ await manager.cleanup();
120
122
  logger.log('success', 'Push completed successfully');
121
123
  } catch (err) {
122
124
  logger.log('error', `Push failed: ${(err as Error).message}`);
@@ -180,6 +182,7 @@ export let run = () => {
180
182
 
181
183
  // Run tests
182
184
  await manager.test();
185
+ await manager.cleanup();
183
186
  logger.log('success', 'Tests completed successfully');
184
187
  } catch (err) {
185
188
  logger.log('error', `Tests failed: ${(err as Error).message}`);