@git.zone/tsdocker 2.0.2 → 2.2.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.dockerfile.d.ts +4 -0
- package/dist_ts/classes.dockerfile.js +12 -4
- package/dist_ts/classes.globalconfig.d.ts +13 -0
- package/dist_ts/classes.globalconfig.js +66 -0
- package/dist_ts/classes.sshtunnel.d.ts +23 -0
- package/dist_ts/classes.sshtunnel.js +66 -0
- package/dist_ts/classes.tsdockermanager.d.ts +22 -1
- package/dist_ts/classes.tsdockermanager.js +83 -10
- package/dist_ts/classes.tsdockersession.d.ts +3 -2
- package/dist_ts/classes.tsdockersession.js +8 -5
- package/dist_ts/interfaces/index.d.ts +16 -0
- package/dist_ts/tsdocker.cli.js +88 -1
- package/package.json +8 -8
- package/readme.md +64 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dockerfile.ts +13 -5
- package/ts/classes.globalconfig.ts +76 -0
- package/ts/classes.sshtunnel.ts +77 -0
- package/ts/classes.tsdockermanager.ts +106 -9
- package/ts/classes.tsdockersession.ts +8 -4
- package/ts/interfaces/index.ts +18 -0
- package/ts/tsdocker.cli.ts +93 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git.zone/tsdocker",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "develop npm modules cross platform with docker",
|
|
6
6
|
"main": "dist_ts/index.js",
|
|
@@ -27,22 +27,22 @@
|
|
|
27
27
|
},
|
|
28
28
|
"homepage": "https://gitlab.com/gitzone/tsdocker#readme",
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@git.zone/tsbuild": "^4.
|
|
30
|
+
"@git.zone/tsbuild": "^4.3.0",
|
|
31
31
|
"@git.zone/tsrun": "^2.0.1",
|
|
32
|
-
"@git.zone/tstest": "^3.
|
|
33
|
-
"@types/node": "^25.0
|
|
32
|
+
"@git.zone/tstest": "^3.3.2",
|
|
33
|
+
"@types/node": "^25.5.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@push.rocks/lik": "^6.
|
|
36
|
+
"@push.rocks/lik": "^6.3.1",
|
|
37
37
|
"@push.rocks/npmextra": "^5.3.3",
|
|
38
38
|
"@push.rocks/projectinfo": "^5.0.2",
|
|
39
39
|
"@push.rocks/smartcli": "^4.0.20",
|
|
40
|
-
"@push.rocks/smartfs": "^1.
|
|
40
|
+
"@push.rocks/smartfs": "^1.5.0",
|
|
41
41
|
"@push.rocks/smartinteract": "^2.0.16",
|
|
42
|
-
"@push.rocks/smartlog": "^3.1
|
|
42
|
+
"@push.rocks/smartlog": "^3.2.1",
|
|
43
43
|
"@push.rocks/smartlog-destination-local": "^9.0.2",
|
|
44
44
|
"@push.rocks/smartlog-source-ora": "^1.0.9",
|
|
45
|
-
"@push.rocks/smartshell": "^3.3.
|
|
45
|
+
"@push.rocks/smartshell": "^3.3.7"
|
|
46
46
|
},
|
|
47
47
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
|
48
48
|
"type": "module",
|
package/readme.md
CHANGED
|
@@ -93,6 +93,7 @@ tsdocker push --no-build Dockerfile_api Dockerfile_web
|
|
|
93
93
|
| `tsdocker test` | Build + run container test scripts (`test_*.sh`) |
|
|
94
94
|
| `tsdocker login` | Authenticate with configured registries |
|
|
95
95
|
| `tsdocker list` | Display discovered Dockerfiles and their dependencies |
|
|
96
|
+
| `tsdocker config` | Manage global tsdocker configuration (remote builders, etc.) |
|
|
96
97
|
| `tsdocker clean` | Interactively clean Docker environment |
|
|
97
98
|
|
|
98
99
|
### Build Flags
|
|
@@ -117,6 +118,24 @@ tsdocker push --no-build Dockerfile_api Dockerfile_web
|
|
|
117
118
|
| `--registry=<url>` | Push to a single specific registry instead of all configured |
|
|
118
119
|
| `--no-build` | Skip the build phase; only push existing images from local registry |
|
|
119
120
|
|
|
121
|
+
### Config Subcommands
|
|
122
|
+
|
|
123
|
+
| Subcommand | Description |
|
|
124
|
+
|------------|-------------|
|
|
125
|
+
| `add-builder` | Add or update a remote builder node |
|
|
126
|
+
| `remove-builder` | Remove a remote builder by name |
|
|
127
|
+
| `list-builders` | List all configured remote builders |
|
|
128
|
+
| `show` | Show the full global configuration |
|
|
129
|
+
|
|
130
|
+
**`add-builder` flags:**
|
|
131
|
+
|
|
132
|
+
| Flag | Description |
|
|
133
|
+
|------|-------------|
|
|
134
|
+
| `--name=<name>` | Builder name (e.g. `arm64-builder`) |
|
|
135
|
+
| `--host=<user@ip>` | SSH host (e.g. `armbuilder@192.168.1.100`) |
|
|
136
|
+
| `--platform=<p>` | Target platform (e.g. `linux/arm64`) |
|
|
137
|
+
| `--ssh-key=<path>` | SSH key path (optional, uses SSH agent/config by default) |
|
|
138
|
+
|
|
120
139
|
### Clean Flags
|
|
121
140
|
|
|
122
141
|
| Flag | Description |
|
|
@@ -294,6 +313,51 @@ tsdocker automatically:
|
|
|
294
313
|
- Pushes multi-platform images to the local registry via `buildx --push`
|
|
295
314
|
- Copies the full manifest list (including all platform variants) to remote registries on `tsdocker push`
|
|
296
315
|
|
|
316
|
+
### 🖥️ Native Remote Builders
|
|
317
|
+
|
|
318
|
+
Instead of relying on slow QEMU emulation for cross-platform builds, tsdocker can use **native remote machines** via SSH as build nodes. For example, use a real arm64 machine for `linux/arm64` builds:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
# Add a remote arm64 builder
|
|
322
|
+
tsdocker config add-builder \
|
|
323
|
+
--name=arm64-builder \
|
|
324
|
+
--host=armbuilder@192.168.1.100 \
|
|
325
|
+
--platform=linux/arm64 \
|
|
326
|
+
--ssh-key=~/.ssh/id_ed25519
|
|
327
|
+
|
|
328
|
+
# List configured builders
|
|
329
|
+
tsdocker config list-builders
|
|
330
|
+
|
|
331
|
+
# Remove a builder
|
|
332
|
+
tsdocker config remove-builder --name=arm64-builder
|
|
333
|
+
|
|
334
|
+
# Show full global config
|
|
335
|
+
tsdocker config show
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Global configuration is stored at `~/.git.zone/tsdocker/config.json`.
|
|
339
|
+
|
|
340
|
+
**How it works:**
|
|
341
|
+
|
|
342
|
+
When remote builders are configured and the project's `platforms` includes a matching platform, tsdocker automatically:
|
|
343
|
+
|
|
344
|
+
1. Creates a **multi-node buildx builder** — local node for `linux/amd64`, remote SSH node for `linux/arm64`
|
|
345
|
+
2. Opens **SSH reverse tunnels** so the remote builder can push to the local staging registry
|
|
346
|
+
3. Builds natively on each platform's hardware — no QEMU overhead
|
|
347
|
+
4. Tears down tunnels after the build completes
|
|
348
|
+
|
|
349
|
+
```
|
|
350
|
+
[Local machine] [Remote arm64 machine]
|
|
351
|
+
registry:2 on localhost:PORT <──── SSH reverse tunnel ──── localhost:PORT
|
|
352
|
+
BuildKit (amd64) ──push──> BuildKit (arm64) ──push──>
|
|
353
|
+
localhost:PORT localhost:PORT (tunneled)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Prerequisites for the remote machine:**
|
|
357
|
+
- Docker installed and running
|
|
358
|
+
- A user with Docker group access (no sudo needed)
|
|
359
|
+
- SSH key access configured
|
|
360
|
+
|
|
297
361
|
### ⚡ Parallel Builds
|
|
298
362
|
|
|
299
363
|
Speed up builds by building independent images concurrently:
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/classes.dockerfile.ts
CHANGED
|
@@ -266,12 +266,15 @@ export class Dockerfile {
|
|
|
266
266
|
public static async buildDockerfiles(
|
|
267
267
|
sortedArrayArg: Dockerfile[],
|
|
268
268
|
session: TsDockerSession,
|
|
269
|
-
options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number },
|
|
269
|
+
options?: { platform?: string; timeout?: number; noCache?: boolean; pull?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number; onRegistryStarted?: () => Promise<void>; onBeforeRegistryStop?: () => Promise<void> },
|
|
270
270
|
): Promise<Dockerfile[]> {
|
|
271
271
|
const total = sortedArrayArg.length;
|
|
272
272
|
const overallStart = Date.now();
|
|
273
273
|
|
|
274
274
|
await Dockerfile.startLocalRegistry(session, options?.isRootless);
|
|
275
|
+
if (options?.onRegistryStarted) {
|
|
276
|
+
await options.onRegistryStarted();
|
|
277
|
+
}
|
|
275
278
|
|
|
276
279
|
try {
|
|
277
280
|
if (options?.parallel) {
|
|
@@ -351,6 +354,9 @@ export class Dockerfile {
|
|
|
351
354
|
}
|
|
352
355
|
}
|
|
353
356
|
} finally {
|
|
357
|
+
if (options?.onBeforeRegistryStop) {
|
|
358
|
+
await options.onBeforeRegistryStop();
|
|
359
|
+
}
|
|
354
360
|
await Dockerfile.stopLocalRegistry(session);
|
|
355
361
|
}
|
|
356
362
|
|
|
@@ -662,13 +668,14 @@ export class Dockerfile {
|
|
|
662
668
|
/**
|
|
663
669
|
* Builds the Dockerfile
|
|
664
670
|
*/
|
|
665
|
-
public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean }): Promise<number> {
|
|
671
|
+
public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; pull?: boolean; verbose?: boolean }): Promise<number> {
|
|
666
672
|
const startTime = Date.now();
|
|
667
673
|
const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef);
|
|
668
674
|
const config = this.managerRef.config;
|
|
669
675
|
const platformOverride = options?.platform;
|
|
670
676
|
const timeout = options?.timeout;
|
|
671
677
|
const noCacheFlag = options?.noCache ? ' --no-cache' : '';
|
|
678
|
+
const pullFlag = options?.pull !== false ? ' --pull' : '';
|
|
672
679
|
const verbose = options?.verbose ?? false;
|
|
673
680
|
|
|
674
681
|
let buildContextFlag = '';
|
|
@@ -683,23 +690,24 @@ export class Dockerfile {
|
|
|
683
690
|
}
|
|
684
691
|
|
|
685
692
|
let buildCommand: string;
|
|
693
|
+
const builderFlag = this.managerRef.currentBuilderName ? ` --builder ${this.managerRef.currentBuilderName}` : '';
|
|
686
694
|
|
|
687
695
|
if (platformOverride) {
|
|
688
696
|
// Single platform override via buildx
|
|
689
|
-
buildCommand = `docker buildx build --progress=plain --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
|
697
|
+
buildCommand = `docker buildx build${builderFlag} --progress=plain --platform ${platformOverride}${noCacheFlag}${pullFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
|
690
698
|
logger.log('info', `Build: buildx --platform ${platformOverride} --load`);
|
|
691
699
|
} else if (config.platforms && config.platforms.length > 1) {
|
|
692
700
|
// Multi-platform build using buildx — always push to local registry
|
|
693
701
|
const platformString = config.platforms.join(',');
|
|
694
702
|
const registryHost = this.session?.config.registryHost || 'localhost:5234';
|
|
695
703
|
const localTag = `${registryHost}/${this.buildTag}`;
|
|
696
|
-
buildCommand = `docker buildx build --progress=plain --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
|
|
704
|
+
buildCommand = `docker buildx build${builderFlag} --progress=plain --platform ${platformString}${noCacheFlag}${pullFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
|
|
697
705
|
this.localRegistryTag = localTag;
|
|
698
706
|
logger.log('info', `Build: buildx --platform ${platformString} --push to local registry`);
|
|
699
707
|
} else {
|
|
700
708
|
// Standard build
|
|
701
709
|
const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown';
|
|
702
|
-
buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
|
710
|
+
buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag}${pullFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
|
703
711
|
logger.log('info', 'Build: docker build (standard)');
|
|
704
712
|
}
|
|
705
713
|
|
|
@@ -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
|
|
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',
|
|
@@ -23,7 +25,10 @@ export class TsDockerManager {
|
|
|
23
25
|
public projectInfo: any;
|
|
24
26
|
public dockerContext: DockerContext;
|
|
25
27
|
public session!: TsDockerSession;
|
|
28
|
+
public currentBuilderName?: string;
|
|
26
29
|
private dockerfiles: Dockerfile[] = [];
|
|
30
|
+
private activeRemoteBuilders: IRemoteBuilder[] = [];
|
|
31
|
+
private sshTunnelManager?: SshTunnelManager;
|
|
27
32
|
|
|
28
33
|
constructor(config: ITsDockerConfig) {
|
|
29
34
|
this.config = config;
|
|
@@ -235,6 +240,7 @@ export class TsDockerManager {
|
|
|
235
240
|
const total = toBuild.length;
|
|
236
241
|
const overallStart = Date.now();
|
|
237
242
|
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
|
243
|
+
await this.openRemoteTunnels();
|
|
238
244
|
|
|
239
245
|
try {
|
|
240
246
|
if (options?.parallel) {
|
|
@@ -261,6 +267,7 @@ export class TsDockerManager {
|
|
|
261
267
|
platform: options?.platform,
|
|
262
268
|
timeout: options?.timeout,
|
|
263
269
|
noCache: options?.noCache,
|
|
270
|
+
pull: options?.pull,
|
|
264
271
|
verbose: options?.verbose,
|
|
265
272
|
});
|
|
266
273
|
logger.log('ok', `${progress} Built ${df.cleanTag} in ${formatDuration(elapsed)}`);
|
|
@@ -306,6 +313,7 @@ export class TsDockerManager {
|
|
|
306
313
|
platform: options?.platform,
|
|
307
314
|
timeout: options?.timeout,
|
|
308
315
|
noCache: options?.noCache,
|
|
316
|
+
pull: options?.pull,
|
|
309
317
|
verbose: options?.verbose,
|
|
310
318
|
});
|
|
311
319
|
logger.log('ok', `${progress} Built ${dockerfileArg.cleanTag} in ${formatDuration(elapsed)}`);
|
|
@@ -332,6 +340,7 @@ export class TsDockerManager {
|
|
|
332
340
|
}
|
|
333
341
|
}
|
|
334
342
|
} finally {
|
|
343
|
+
await this.closeRemoteTunnels();
|
|
335
344
|
await Dockerfile.stopLocalRegistry(this.session);
|
|
336
345
|
}
|
|
337
346
|
|
|
@@ -343,10 +352,13 @@ export class TsDockerManager {
|
|
|
343
352
|
platform: options?.platform,
|
|
344
353
|
timeout: options?.timeout,
|
|
345
354
|
noCache: options?.noCache,
|
|
355
|
+
pull: options?.pull,
|
|
346
356
|
verbose: options?.verbose,
|
|
347
357
|
isRootless: this.dockerContext.contextInfo?.isRootless,
|
|
348
358
|
parallel: options?.parallel,
|
|
349
359
|
parallelConcurrency: options?.parallelConcurrency,
|
|
360
|
+
onRegistryStarted: () => this.openRemoteTunnels(),
|
|
361
|
+
onBeforeRegistryStop: () => this.closeRemoteTunnels(),
|
|
350
362
|
});
|
|
351
363
|
}
|
|
352
364
|
|
|
@@ -373,35 +385,120 @@ export class TsDockerManager {
|
|
|
373
385
|
}
|
|
374
386
|
|
|
375
387
|
/**
|
|
376
|
-
* Ensures Docker buildx is set up for multi-architecture builds
|
|
388
|
+
* Ensures Docker buildx is set up for multi-architecture builds.
|
|
389
|
+
* When remote builders are configured in the global config, creates a multi-node
|
|
390
|
+
* builder with native nodes instead of relying on QEMU emulation.
|
|
377
391
|
*/
|
|
378
392
|
private async ensureBuildx(): Promise<void> {
|
|
379
393
|
const builderName = this.dockerContext.getBuilderName() + (this.session?.config.builderSuffix || '');
|
|
380
394
|
const platforms = this.config.platforms?.join(', ') || 'default';
|
|
381
395
|
logger.log('info', `Setting up Docker buildx [${platforms}]...`);
|
|
382
396
|
logger.log('info', `Builder: ${builderName}`);
|
|
397
|
+
|
|
398
|
+
// Check for remote builders matching our target platforms
|
|
399
|
+
const requestedPlatforms = this.config.platforms || ['linux/amd64'];
|
|
400
|
+
const remoteBuilders = GlobalConfig.getBuildersForPlatforms(requestedPlatforms);
|
|
401
|
+
|
|
402
|
+
if (remoteBuilders.length > 0) {
|
|
403
|
+
await this.ensureBuildxWithRemoteNodes(builderName, requestedPlatforms, remoteBuilders);
|
|
404
|
+
} else {
|
|
405
|
+
await this.ensureBuildxLocal(builderName);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.currentBuilderName = builderName;
|
|
409
|
+
logger.log('ok', `Docker buildx ready (builder: ${builderName}, platforms: ${platforms})`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Creates a multi-node buildx builder with local + remote SSH nodes.
|
|
414
|
+
*/
|
|
415
|
+
private async ensureBuildxWithRemoteNodes(
|
|
416
|
+
builderName: string,
|
|
417
|
+
requestedPlatforms: string[],
|
|
418
|
+
remoteBuilders: IRemoteBuilder[],
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
const remotePlatforms = new Set(remoteBuilders.map((b) => b.platform));
|
|
421
|
+
const localPlatforms = requestedPlatforms.filter((p) => !remotePlatforms.has(p));
|
|
422
|
+
|
|
423
|
+
logger.log('info', `Remote builders: ${remoteBuilders.map((b) => `${b.name} (${b.platform} @ ${b.host})`).join(', ')}`);
|
|
424
|
+
if (localPlatforms.length > 0) {
|
|
425
|
+
logger.log('info', `Local platforms: ${localPlatforms.join(', ')}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Always recreate the builder to ensure correct node topology
|
|
429
|
+
await smartshellInstance.execSilent(`docker buildx rm ${builderName} 2>/dev/null || true`);
|
|
430
|
+
|
|
431
|
+
// Create the local node
|
|
432
|
+
const localPlatformFlag = localPlatforms.length > 0 ? ` --platform ${localPlatforms.join(',')}` : '';
|
|
433
|
+
await smartshellInstance.exec(
|
|
434
|
+
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host${localPlatformFlag}`
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Append remote nodes
|
|
438
|
+
for (const builder of remoteBuilders) {
|
|
439
|
+
logger.log('info', `Appending remote node: ${builder.name} (${builder.platform}) via ssh://${builder.host}`);
|
|
440
|
+
const appendResult = await smartshellInstance.exec(
|
|
441
|
+
`docker buildx create --append --name ${builderName} --driver docker-container --driver-opt network=host --platform ${builder.platform} --node ${builder.name} ssh://${builder.host}`
|
|
442
|
+
);
|
|
443
|
+
if (appendResult.exitCode !== 0) {
|
|
444
|
+
throw new Error(`Failed to append remote builder ${builder.name}: ${appendResult.stderr}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Bootstrap all nodes
|
|
449
|
+
await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
|
|
450
|
+
|
|
451
|
+
// Store active remote builders for SSH tunnel setup during build
|
|
452
|
+
this.activeRemoteBuilders = remoteBuilders;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Creates a single-node local buildx builder (original behavior, uses QEMU for cross-platform).
|
|
457
|
+
*/
|
|
458
|
+
private async ensureBuildxLocal(builderName: string): Promise<void> {
|
|
383
459
|
const inspectResult = await smartshellInstance.exec(`docker buildx inspect ${builderName} 2>/dev/null`);
|
|
384
460
|
|
|
385
461
|
if (inspectResult.exitCode !== 0) {
|
|
386
462
|
logger.log('info', 'Creating new buildx builder with host network...');
|
|
387
463
|
await smartshellInstance.exec(
|
|
388
|
-
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host
|
|
464
|
+
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host`
|
|
389
465
|
);
|
|
390
|
-
await smartshellInstance.exec(
|
|
466
|
+
await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
|
|
391
467
|
} else {
|
|
392
468
|
const inspectOutput = inspectResult.stdout || '';
|
|
393
469
|
if (!inspectOutput.includes('network=host')) {
|
|
394
470
|
logger.log('info', 'Recreating buildx builder with host network (migration)...');
|
|
395
471
|
await smartshellInstance.exec(`docker buildx rm ${builderName} 2>/dev/null`);
|
|
396
472
|
await smartshellInstance.exec(
|
|
397
|
-
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host
|
|
473
|
+
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host`
|
|
398
474
|
);
|
|
399
|
-
await smartshellInstance.exec(
|
|
400
|
-
} else {
|
|
401
|
-
await smartshellInstance.exec(`docker buildx use ${builderName}`);
|
|
475
|
+
await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
|
|
402
476
|
}
|
|
403
477
|
}
|
|
404
|
-
|
|
478
|
+
this.activeRemoteBuilders = [];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Opens SSH reverse tunnels for remote builders so they can reach the local registry.
|
|
483
|
+
*/
|
|
484
|
+
private async openRemoteTunnels(): Promise<void> {
|
|
485
|
+
if (this.activeRemoteBuilders.length === 0) return;
|
|
486
|
+
|
|
487
|
+
this.sshTunnelManager = new SshTunnelManager();
|
|
488
|
+
await this.sshTunnelManager.openTunnels(
|
|
489
|
+
this.activeRemoteBuilders,
|
|
490
|
+
this.session.config.registryPort,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Closes any active SSH tunnels.
|
|
496
|
+
*/
|
|
497
|
+
private async closeRemoteTunnels(): Promise<void> {
|
|
498
|
+
if (this.sshTunnelManager) {
|
|
499
|
+
await this.sshTunnelManager.closeAll();
|
|
500
|
+
this.sshTunnelManager = undefined;
|
|
501
|
+
}
|
|
405
502
|
}
|
|
406
503
|
|
|
407
504
|
/**
|
|
@@ -4,6 +4,7 @@ import { logger } from './tsdocker.logging.js';
|
|
|
4
4
|
|
|
5
5
|
export interface ISessionConfig {
|
|
6
6
|
sessionId: string;
|
|
7
|
+
projectHash: string;
|
|
7
8
|
registryPort: number;
|
|
8
9
|
registryHost: string;
|
|
9
10
|
registryContainerName: string;
|
|
@@ -17,8 +18,8 @@ export interface ISessionConfig {
|
|
|
17
18
|
* Generates unique ports, container names, and builder names so that
|
|
18
19
|
* concurrent CI jobs on the same Docker host don't collide.
|
|
19
20
|
*
|
|
20
|
-
* In local (non-CI) dev the builder suffix
|
|
21
|
-
*
|
|
21
|
+
* In local (non-CI) dev the builder suffix contains a project hash so
|
|
22
|
+
* that concurrent runs in different project directories use separate builders.
|
|
22
23
|
*/
|
|
23
24
|
export class TsDockerSession {
|
|
24
25
|
public config: ISessionConfig;
|
|
@@ -34,16 +35,18 @@ export class TsDockerSession {
|
|
|
34
35
|
public static async create(): Promise<TsDockerSession> {
|
|
35
36
|
const sessionId =
|
|
36
37
|
process.env.TSDOCKER_SESSION_ID || crypto.randomBytes(4).toString('hex');
|
|
38
|
+
const projectHash = crypto.createHash('sha256').update(process.cwd()).digest('hex').substring(0, 8);
|
|
37
39
|
|
|
38
40
|
const registryPort = await TsDockerSession.allocatePort();
|
|
39
41
|
const registryHost = `localhost:${registryPort}`;
|
|
40
42
|
const registryContainerName = `tsdocker-registry-${sessionId}`;
|
|
41
43
|
|
|
42
44
|
const { isCI, ciSystem } = TsDockerSession.detectCI();
|
|
43
|
-
const builderSuffix = isCI ? `-${sessionId}` :
|
|
45
|
+
const builderSuffix = isCI ? `-${projectHash}-${sessionId}` : `-${projectHash}`;
|
|
44
46
|
|
|
45
47
|
const config: ISessionConfig = {
|
|
46
48
|
sessionId,
|
|
49
|
+
projectHash,
|
|
47
50
|
registryPort,
|
|
48
51
|
registryHost,
|
|
49
52
|
registryContainerName,
|
|
@@ -99,9 +102,10 @@ export class TsDockerSession {
|
|
|
99
102
|
logger.log('info', '=== TSDOCKER SESSION ===');
|
|
100
103
|
logger.log('info', `Session ID: ${c.sessionId}`);
|
|
101
104
|
logger.log('info', `Registry: ${c.registryHost} (container: ${c.registryContainerName})`);
|
|
105
|
+
logger.log('info', `Project hash: ${c.projectHash}`);
|
|
106
|
+
logger.log('info', `Builder suffix: ${c.builderSuffix}`);
|
|
102
107
|
if (c.isCI) {
|
|
103
108
|
logger.log('info', `CI detected: ${c.ciSystem}`);
|
|
104
|
-
logger.log('info', `Builder suffix: ${c.builderSuffix}`);
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
}
|
package/ts/interfaces/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export interface IBuildCommandOptions {
|
|
|
69
69
|
platform?: string; // Single platform override (e.g., 'linux/arm64')
|
|
70
70
|
timeout?: number; // Build timeout in seconds
|
|
71
71
|
noCache?: boolean; // Force rebuild without Docker layer cache (--no-cache)
|
|
72
|
+
pull?: boolean; // Pull fresh base images before building (default: true)
|
|
72
73
|
cached?: boolean; // Skip builds when Dockerfile content hasn't changed
|
|
73
74
|
verbose?: boolean; // Stream raw docker build output (default: silent)
|
|
74
75
|
context?: string; // Explicit Docker context name (--context flag)
|
|
@@ -95,3 +96,20 @@ export interface IDockerContextInfo {
|
|
|
95
96
|
dockerHost?: string; // value of DOCKER_HOST env var, if set
|
|
96
97
|
topology?: 'socket-mount' | 'dind' | 'local';
|
|
97
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A remote builder node for native cross-platform builds
|
|
102
|
+
*/
|
|
103
|
+
export interface IRemoteBuilder {
|
|
104
|
+
name: string; // e.g., "arm64-builder"
|
|
105
|
+
host: string; // e.g., "armbuilder@192.168.190.216"
|
|
106
|
+
platform: string; // e.g., "linux/arm64"
|
|
107
|
+
sshKeyPath?: string; // e.g., "~/.ssh/id_ed25519"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Global tsdocker configuration stored at ~/.git.zone/tsdocker/config.json
|
|
112
|
+
*/
|
|
113
|
+
export interface IGlobalConfig {
|
|
114
|
+
remoteBuilders: IRemoteBuilder[];
|
|
115
|
+
}
|