@git.zone/tsdocker 2.0.2 → 2.1.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.
@@ -0,0 +1,76 @@
1
+ import * as fs from 'fs';
2
+ import * as plugins from './tsdocker.plugins.js';
3
+ import { logger } from './tsdocker.logging.js';
4
+ import type { IGlobalConfig, IRemoteBuilder } from './interfaces/index.js';
5
+
6
+ const CONFIG_DIR = plugins.path.join(
7
+ process.env.HOME || process.env.USERPROFILE || '~',
8
+ '.git.zone',
9
+ 'tsdocker',
10
+ );
11
+ const CONFIG_PATH = plugins.path.join(CONFIG_DIR, 'config.json');
12
+
13
+ const DEFAULT_CONFIG: IGlobalConfig = {
14
+ remoteBuilders: [],
15
+ };
16
+
17
+ export class GlobalConfig {
18
+ static getConfigPath(): string {
19
+ return CONFIG_PATH;
20
+ }
21
+
22
+ static load(): IGlobalConfig {
23
+ try {
24
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
25
+ const parsed = JSON.parse(raw);
26
+ return {
27
+ ...DEFAULT_CONFIG,
28
+ ...parsed,
29
+ };
30
+ } catch {
31
+ return { ...DEFAULT_CONFIG };
32
+ }
33
+ }
34
+
35
+ static save(config: IGlobalConfig): void {
36
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
37
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf-8');
38
+ }
39
+
40
+ static addBuilder(builder: IRemoteBuilder): void {
41
+ const config = GlobalConfig.load();
42
+ const existing = config.remoteBuilders.findIndex((b) => b.name === builder.name);
43
+ if (existing >= 0) {
44
+ config.remoteBuilders[existing] = builder;
45
+ logger.log('info', `Updated remote builder: ${builder.name}`);
46
+ } else {
47
+ config.remoteBuilders.push(builder);
48
+ logger.log('info', `Added remote builder: ${builder.name}`);
49
+ }
50
+ GlobalConfig.save(config);
51
+ }
52
+
53
+ static removeBuilder(name: string): void {
54
+ const config = GlobalConfig.load();
55
+ const before = config.remoteBuilders.length;
56
+ config.remoteBuilders = config.remoteBuilders.filter((b) => b.name !== name);
57
+ if (config.remoteBuilders.length < before) {
58
+ logger.log('info', `Removed remote builder: ${name}`);
59
+ } else {
60
+ logger.log('warn', `Remote builder not found: ${name}`);
61
+ }
62
+ GlobalConfig.save(config);
63
+ }
64
+
65
+ static getBuilders(): IRemoteBuilder[] {
66
+ return GlobalConfig.load().remoteBuilders;
67
+ }
68
+
69
+ /**
70
+ * Returns remote builders that match any of the requested platforms
71
+ */
72
+ static getBuildersForPlatforms(platforms: string[]): IRemoteBuilder[] {
73
+ const builders = GlobalConfig.getBuilders();
74
+ return builders.filter((b) => platforms.includes(b.platform));
75
+ }
76
+ }
@@ -0,0 +1,77 @@
1
+ import * as plugins from './tsdocker.plugins.js';
2
+ import { logger } from './tsdocker.logging.js';
3
+ import type { IRemoteBuilder } from './interfaces/index.js';
4
+
5
+ const smartshellInstance = new plugins.smartshell.Smartshell({
6
+ executor: 'bash',
7
+ });
8
+
9
+ /**
10
+ * Manages SSH reverse tunnels for remote builder nodes.
11
+ * Opens tunnels so that the local staging registry (localhost:<port>)
12
+ * is accessible as localhost:<port> on each remote machine.
13
+ */
14
+ export class SshTunnelManager {
15
+ private tunnelPids: number[] = [];
16
+
17
+ /**
18
+ * Opens a reverse SSH tunnel to make localPort accessible on the remote machine.
19
+ * ssh -f -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes
20
+ * -R <localPort>:localhost:<localPort> [-i keyPath] user@host
21
+ */
22
+ async openTunnel(builder: IRemoteBuilder, localPort: number): Promise<void> {
23
+ const keyOpt = builder.sshKeyPath ? `-i ${builder.sshKeyPath} ` : '';
24
+ const cmd = [
25
+ 'ssh -f -N',
26
+ '-o StrictHostKeyChecking=no',
27
+ '-o ExitOnForwardFailure=yes',
28
+ `-R ${localPort}:localhost:${localPort}`,
29
+ `${keyOpt}${builder.host}`,
30
+ ].join(' ');
31
+
32
+ logger.log('info', `Opening SSH tunnel to ${builder.host} for port ${localPort}...`);
33
+ const result = await smartshellInstance.exec(cmd);
34
+
35
+ if (result.exitCode !== 0) {
36
+ throw new Error(
37
+ `Failed to open SSH tunnel to ${builder.host}: ${result.stderr || 'unknown error'}`
38
+ );
39
+ }
40
+
41
+ // Find the PID of the tunnel process we just started
42
+ const pidResult = await smartshellInstance.exec(
43
+ `pgrep -f "ssh.*-R ${localPort}:localhost:${localPort}.*${builder.host}" | tail -1`
44
+ );
45
+ if (pidResult.exitCode === 0 && pidResult.stdout.trim()) {
46
+ const pid = parseInt(pidResult.stdout.trim(), 10);
47
+ if (!isNaN(pid)) {
48
+ this.tunnelPids.push(pid);
49
+ logger.log('ok', `SSH tunnel to ${builder.host} established (PID ${pid})`);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Opens tunnels for all provided remote builders
56
+ */
57
+ async openTunnels(builders: IRemoteBuilder[], localPort: number): Promise<void> {
58
+ for (const builder of builders) {
59
+ await this.openTunnel(builder, localPort);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Closes all tunnel processes
65
+ */
66
+ async closeAll(): Promise<void> {
67
+ for (const pid of this.tunnelPids) {
68
+ try {
69
+ process.kill(pid, 'SIGTERM');
70
+ logger.log('info', `Closed SSH tunnel (PID ${pid})`);
71
+ } catch {
72
+ // Process may have already exited
73
+ }
74
+ }
75
+ this.tunnelPids = [];
76
+ }
77
+ }
@@ -8,7 +8,9 @@ import { TsDockerCache } from './classes.tsdockercache.js';
8
8
  import { DockerContext } from './classes.dockercontext.js';
9
9
  import { TsDockerSession } from './classes.tsdockersession.js';
10
10
  import { RegistryCopy } from './classes.registrycopy.js';
11
- import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
11
+ import { GlobalConfig } from './classes.globalconfig.js';
12
+ import { SshTunnelManager } from './classes.sshtunnel.js';
13
+ import type { ITsDockerConfig, IBuildCommandOptions, IRemoteBuilder } from './interfaces/index.js';
12
14
 
13
15
  const smartshellInstance = new plugins.smartshell.Smartshell({
14
16
  executor: 'bash',
@@ -24,6 +26,8 @@ export class TsDockerManager {
24
26
  public dockerContext: DockerContext;
25
27
  public session!: TsDockerSession;
26
28
  private dockerfiles: Dockerfile[] = [];
29
+ private activeRemoteBuilders: IRemoteBuilder[] = [];
30
+ private sshTunnelManager?: SshTunnelManager;
27
31
 
28
32
  constructor(config: ITsDockerConfig) {
29
33
  this.config = config;
@@ -235,6 +239,7 @@ export class TsDockerManager {
235
239
  const total = toBuild.length;
236
240
  const overallStart = Date.now();
237
241
  await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
242
+ await this.openRemoteTunnels();
238
243
 
239
244
  try {
240
245
  if (options?.parallel) {
@@ -332,6 +337,7 @@ export class TsDockerManager {
332
337
  }
333
338
  }
334
339
  } finally {
340
+ await this.closeRemoteTunnels();
335
341
  await Dockerfile.stopLocalRegistry(this.session);
336
342
  }
337
343
 
@@ -347,6 +353,8 @@ export class TsDockerManager {
347
353
  isRootless: this.dockerContext.contextInfo?.isRootless,
348
354
  parallel: options?.parallel,
349
355
  parallelConcurrency: options?.parallelConcurrency,
356
+ onRegistryStarted: () => this.openRemoteTunnels(),
357
+ onBeforeRegistryStop: () => this.closeRemoteTunnels(),
350
358
  });
351
359
  }
352
360
 
@@ -373,13 +381,76 @@ export class TsDockerManager {
373
381
  }
374
382
 
375
383
  /**
376
- * Ensures Docker buildx is set up for multi-architecture builds
384
+ * Ensures Docker buildx is set up for multi-architecture builds.
385
+ * When remote builders are configured in the global config, creates a multi-node
386
+ * builder with native nodes instead of relying on QEMU emulation.
377
387
  */
378
388
  private async ensureBuildx(): Promise<void> {
379
389
  const builderName = this.dockerContext.getBuilderName() + (this.session?.config.builderSuffix || '');
380
390
  const platforms = this.config.platforms?.join(', ') || 'default';
381
391
  logger.log('info', `Setting up Docker buildx [${platforms}]...`);
382
392
  logger.log('info', `Builder: ${builderName}`);
393
+
394
+ // Check for remote builders matching our target platforms
395
+ const requestedPlatforms = this.config.platforms || ['linux/amd64'];
396
+ const remoteBuilders = GlobalConfig.getBuildersForPlatforms(requestedPlatforms);
397
+
398
+ if (remoteBuilders.length > 0) {
399
+ await this.ensureBuildxWithRemoteNodes(builderName, requestedPlatforms, remoteBuilders);
400
+ } else {
401
+ await this.ensureBuildxLocal(builderName);
402
+ }
403
+
404
+ logger.log('ok', `Docker buildx ready (builder: ${builderName}, platforms: ${platforms})`);
405
+ }
406
+
407
+ /**
408
+ * Creates a multi-node buildx builder with local + remote SSH nodes.
409
+ */
410
+ private async ensureBuildxWithRemoteNodes(
411
+ builderName: string,
412
+ requestedPlatforms: string[],
413
+ remoteBuilders: IRemoteBuilder[],
414
+ ): Promise<void> {
415
+ const remotePlatforms = new Set(remoteBuilders.map((b) => b.platform));
416
+ const localPlatforms = requestedPlatforms.filter((p) => !remotePlatforms.has(p));
417
+
418
+ logger.log('info', `Remote builders: ${remoteBuilders.map((b) => `${b.name} (${b.platform} @ ${b.host})`).join(', ')}`);
419
+ if (localPlatforms.length > 0) {
420
+ logger.log('info', `Local platforms: ${localPlatforms.join(', ')}`);
421
+ }
422
+
423
+ // Always recreate the builder to ensure correct node topology
424
+ await smartshellInstance.execSilent(`docker buildx rm ${builderName} 2>/dev/null || true`);
425
+
426
+ // Create the local node
427
+ const localPlatformFlag = localPlatforms.length > 0 ? ` --platform ${localPlatforms.join(',')}` : '';
428
+ await smartshellInstance.exec(
429
+ `docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host${localPlatformFlag} --use`
430
+ );
431
+
432
+ // Append remote nodes
433
+ for (const builder of remoteBuilders) {
434
+ logger.log('info', `Appending remote node: ${builder.name} (${builder.platform}) via ssh://${builder.host}`);
435
+ const appendResult = await smartshellInstance.exec(
436
+ `docker buildx create --append --name ${builderName} --driver docker-container --driver-opt network=host --platform ${builder.platform} --node ${builder.name} ssh://${builder.host}`
437
+ );
438
+ if (appendResult.exitCode !== 0) {
439
+ throw new Error(`Failed to append remote builder ${builder.name}: ${appendResult.stderr}`);
440
+ }
441
+ }
442
+
443
+ // Bootstrap all nodes
444
+ await smartshellInstance.exec('docker buildx inspect --bootstrap');
445
+
446
+ // Store active remote builders for SSH tunnel setup during build
447
+ this.activeRemoteBuilders = remoteBuilders;
448
+ }
449
+
450
+ /**
451
+ * Creates a single-node local buildx builder (original behavior, uses QEMU for cross-platform).
452
+ */
453
+ private async ensureBuildxLocal(builderName: string): Promise<void> {
383
454
  const inspectResult = await smartshellInstance.exec(`docker buildx inspect ${builderName} 2>/dev/null`);
384
455
 
385
456
  if (inspectResult.exitCode !== 0) {
@@ -401,7 +472,30 @@ export class TsDockerManager {
401
472
  await smartshellInstance.exec(`docker buildx use ${builderName}`);
402
473
  }
403
474
  }
404
- logger.log('ok', `Docker buildx ready (builder: ${builderName}, platforms: ${platforms})`);
475
+ this.activeRemoteBuilders = [];
476
+ }
477
+
478
+ /**
479
+ * Opens SSH reverse tunnels for remote builders so they can reach the local registry.
480
+ */
481
+ private async openRemoteTunnels(): Promise<void> {
482
+ if (this.activeRemoteBuilders.length === 0) return;
483
+
484
+ this.sshTunnelManager = new SshTunnelManager();
485
+ await this.sshTunnelManager.openTunnels(
486
+ this.activeRemoteBuilders,
487
+ this.session.config.registryPort,
488
+ );
489
+ }
490
+
491
+ /**
492
+ * Closes any active SSH tunnels.
493
+ */
494
+ private async closeRemoteTunnels(): Promise<void> {
495
+ if (this.sshTunnelManager) {
496
+ await this.sshTunnelManager.closeAll();
497
+ this.sshTunnelManager = undefined;
498
+ }
405
499
  }
406
500
 
407
501
  /**
@@ -95,3 +95,20 @@ export interface IDockerContextInfo {
95
95
  dockerHost?: string; // value of DOCKER_HOST env var, if set
96
96
  topology?: 'socket-mount' | 'dind' | 'local';
97
97
  }
98
+
99
+ /**
100
+ * A remote builder node for native cross-platform builds
101
+ */
102
+ export interface IRemoteBuilder {
103
+ name: string; // e.g., "arm64-builder"
104
+ host: string; // e.g., "armbuilder@192.168.190.216"
105
+ platform: string; // e.g., "linux/arm64"
106
+ sshKeyPath?: string; // e.g., "~/.ssh/id_ed25519"
107
+ }
108
+
109
+ /**
110
+ * Global tsdocker configuration stored at ~/.git.zone/tsdocker/config.json
111
+ */
112
+ export interface IGlobalConfig {
113
+ remoteBuilders: IRemoteBuilder[];
114
+ }
@@ -7,6 +7,7 @@ import * as ConfigModule from './tsdocker.config.js';
7
7
  import { logger, ora } from './tsdocker.logging.js';
8
8
  import { TsDockerManager } from './classes.tsdockermanager.js';
9
9
  import { DockerContext } from './classes.dockercontext.js';
10
+ import { GlobalConfig } from './classes.globalconfig.js';
10
11
  import type { IBuildCommandOptions } from './interfaces/index.js';
11
12
  import { commitinfo } from './00_commitinfo_data.js';
12
13
 
@@ -33,6 +34,7 @@ COMMANDS
33
34
  test [flags] Build and run container test scripts
34
35
  login Authenticate with configured registries
35
36
  list List discovered Dockerfiles
37
+ config <subcommand> [flags] Manage global tsdocker configuration
36
38
  clean [-y] [--all] Interactive Docker resource cleanup
37
39
 
38
40
  BUILD / PUSH OPTIONS
@@ -52,6 +54,17 @@ CLEAN OPTIONS
52
54
  -y Auto-confirm all prompts
53
55
  --all Include all images and volumes (not just dangling)
54
56
 
57
+ CONFIG SUBCOMMANDS
58
+ add-builder Add a remote builder node
59
+ --name=<n> Builder name (e.g. arm64-builder)
60
+ --host=<h> SSH host (e.g. user@192.168.1.100)
61
+ --platform=<p> Platform (e.g. linux/arm64)
62
+ --ssh-key=<path> SSH key path (optional)
63
+ remove-builder Remove a remote builder by name
64
+ --name=<n> Builder name to remove
65
+ list-builders List all configured remote builders
66
+ show Show full global config
67
+
55
68
  CONFIGURATION
56
69
  Configure via npmextra.json under the "@git.zone/tsdocker" key:
57
70
 
@@ -62,12 +75,17 @@ CONFIGURATION
62
75
  push Boolean, auto-push after build
63
76
  testDir Directory containing test_*.sh scripts
64
77
 
78
+ Global config is stored at ~/.git.zone/tsdocker/config.json
79
+ and managed via the "config" command.
80
+
65
81
  EXAMPLES
66
82
  tsdocker build
67
83
  tsdocker build Dockerfile_app --platform=linux/arm64
68
84
  tsdocker push --registry=ghcr.io
69
85
  tsdocker test --verbose
70
86
  tsdocker clean -y --all
87
+ tsdocker config add-builder --name=arm64 --host=user@host --platform=linux/arm64
88
+ tsdocker config list-builders
71
89
  `;
72
90
  console.log(manPage);
73
91
  };
@@ -280,6 +298,76 @@ export let run = () => {
280
298
  }
281
299
  });
282
300
 
301
+ /**
302
+ * Manage global tsdocker configuration (remote builders, etc.)
303
+ * Usage: tsdocker config <subcommand> [--name=...] [--host=...] [--platform=...] [--ssh-key=...]
304
+ */
305
+ tsdockerCli.addCommand('config').subscribe(async argvArg => {
306
+ try {
307
+ const subcommand = argvArg._[1] as string;
308
+
309
+ switch (subcommand) {
310
+ case 'add-builder': {
311
+ const name = argvArg.name as string;
312
+ const host = argvArg.host as string;
313
+ const platform = argvArg.platform as string;
314
+ const sshKeyPath = argvArg['ssh-key'] as string | undefined;
315
+
316
+ if (!name || !host || !platform) {
317
+ logger.log('error', 'Required: --name, --host, --platform');
318
+ logger.log('info', 'Usage: tsdocker config add-builder --name=arm64-builder --host=user@host --platform=linux/arm64 [--ssh-key=~/.ssh/id_ed25519]');
319
+ process.exit(1);
320
+ }
321
+
322
+ GlobalConfig.addBuilder({ name, host, platform, sshKeyPath });
323
+ logger.log('success', `Remote builder "${name}" configured: ${platform} via ssh://${host}`);
324
+ break;
325
+ }
326
+
327
+ case 'remove-builder': {
328
+ const name = argvArg.name as string;
329
+ if (!name) {
330
+ logger.log('error', 'Required: --name');
331
+ logger.log('info', 'Usage: tsdocker config remove-builder --name=arm64-builder');
332
+ process.exit(1);
333
+ }
334
+ GlobalConfig.removeBuilder(name);
335
+ logger.log('success', `Remote builder "${name}" removed`);
336
+ break;
337
+ }
338
+
339
+ case 'list-builders': {
340
+ const builders = GlobalConfig.getBuilders();
341
+ if (builders.length === 0) {
342
+ logger.log('info', 'No remote builders configured');
343
+ } else {
344
+ logger.log('info', `${builders.length} remote builder(s):`);
345
+ for (const b of builders) {
346
+ const keyInfo = b.sshKeyPath ? ` (key: ${b.sshKeyPath})` : '';
347
+ logger.log('info', ` ${b.name}: ${b.platform} via ssh://${b.host}${keyInfo}`);
348
+ }
349
+ }
350
+ break;
351
+ }
352
+
353
+ case 'show': {
354
+ const config = GlobalConfig.load();
355
+ logger.log('info', `Config file: ${GlobalConfig.getConfigPath()}`);
356
+ console.log(JSON.stringify(config, null, 2));
357
+ break;
358
+ }
359
+
360
+ default:
361
+ logger.log('error', `Unknown config subcommand: ${subcommand || '(none)'}`);
362
+ logger.log('info', 'Available: add-builder, remove-builder, list-builders, show');
363
+ process.exit(1);
364
+ }
365
+ } catch (err) {
366
+ logger.log('error', `Config failed: ${(err as Error).message}`);
367
+ process.exit(1);
368
+ }
369
+ });
370
+
283
371
  tsdockerCli.addCommand('clean').subscribe(async argvArg => {
284
372
  try {
285
373
  const autoYes = !!argvArg.y;