@git.zone/cli 3.0.0 → 3.0.2
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/mod_docker/classes.dockerpruner.js +6 -7
- package/dist_ts/mod_release/index.js +3 -3
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +46 -0
- package/dist_ts/mod_services/classes.dockercontainer.js +155 -6
- package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +17 -0
- package/dist_ts/mod_services/classes.serviceconfiguration.js +90 -3
- package/dist_ts/mod_services/classes.servicemanager.d.ts +29 -4
- package/dist_ts/mod_services/classes.servicemanager.js +415 -104
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +5 -5
- package/readme.md +19 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mod_docker/classes.dockerpruner.ts +6 -7
- package/ts/mod_release/index.ts +2 -2
- package/ts/mod_services/classes.dockercontainer.ts +221 -5
- package/ts/mod_services/classes.serviceconfiguration.ts +110 -2
- package/ts/mod_services/classes.servicemanager.ts +607 -128
- package/ts/plugins.ts +2 -0
package/dist_ts/plugins.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-loca
|
|
|
3
3
|
import * as smartconfig from '@push.rocks/smartconfig';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as crypto from 'node:crypto';
|
|
6
7
|
import * as projectinfo from '@push.rocks/projectinfo';
|
|
7
8
|
import * as smartcli from '@push.rocks/smartcli';
|
|
8
9
|
import * as smartpath from '@push.rocks/smartpath';
|
|
@@ -15,4 +16,4 @@ import { SmartFs } from '@push.rocks/smartfs';
|
|
|
15
16
|
import * as smartinteract from '@push.rocks/smartinteract';
|
|
16
17
|
import * as smartdelay from '@push.rocks/smartdelay';
|
|
17
18
|
export declare const smartfs: SmartFs;
|
|
18
|
-
export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
|
|
19
|
+
export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, crypto, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
|
package/dist_ts/plugins.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-loca
|
|
|
3
3
|
import * as smartconfig from '@push.rocks/smartconfig';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as crypto from 'node:crypto';
|
|
6
7
|
import * as projectinfo from '@push.rocks/projectinfo';
|
|
7
8
|
import * as smartcli from '@push.rocks/smartcli';
|
|
8
9
|
import * as smartpath from '@push.rocks/smartpath';
|
|
@@ -16,5 +17,5 @@ import * as smartinteract from '@push.rocks/smartinteract';
|
|
|
16
17
|
import * as smartdelay from '@push.rocks/smartdelay';
|
|
17
18
|
// Create smartfs instance for filesystem operations
|
|
18
19
|
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
|
19
|
-
export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
|
|
20
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
20
|
+
export { smartlog, smartlogDestinationLocal, smartconfig, path, fs, crypto, projectinfo, smartcli, smartpath, smartpromise, smartupdate, smartshell, smartnetwork, smartfile, smartinteract, smartdelay, };
|
|
21
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssd0JBQXdCLE1BQU0sd0NBQXdDLENBQUM7QUFDbkYsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUM3QixPQUFPLEtBQUssRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBQ3ZDLE9BQU8sS0FBSyxNQUFNLE1BQU0sYUFBYSxDQUFDO0FBQ3RDLE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFDdkQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBQ3JELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEVBQUUsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFDbkUsT0FBTyxLQUFLLGFBQWEsTUFBTSwyQkFBMkIsQ0FBQztBQUMzRCxPQUFPLEtBQUssVUFBVSxNQUFNLHdCQUF3QixDQUFDO0FBRXJELG9EQUFvRDtBQUNwRCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUNMLFFBQVEsRUFDUix3QkFBd0IsRUFDeEIsV0FBVyxFQUNYLElBQUksRUFDSixFQUFFLEVBQ0YsTUFBTSxFQUNOLFdBQVcsRUFDWCxRQUFRLEVBQ1IsU0FBUyxFQUNULFlBQVksRUFDWixXQUFXLEVBQ1gsVUFBVSxFQUNWLFlBQVksRUFDWixTQUFTLEVBQ1QsYUFBYSxFQUNiLFVBQVUsR0FDWCxDQUFDIn0=
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git.zone/cli",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.0.
|
|
4
|
+
"version": "3.0.2",
|
|
5
5
|
"description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.",
|
|
6
6
|
"main": "dist_ts/index.js",
|
|
7
7
|
"typings": "dist_ts/index.d.ts",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"homepage": "https://code.foss.global/git.zone/cli#README",
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@git.zone/tsbuild": "^4.4.2",
|
|
43
|
-
"@git.zone/tsrun": "^2.0.
|
|
44
|
-
"@git.zone/tstest": "^3.6.
|
|
45
|
-
"@types/node": "^
|
|
43
|
+
"@git.zone/tsrun": "^2.0.6",
|
|
44
|
+
"@git.zone/tstest": "^3.6.7",
|
|
45
|
+
"@types/node": "^26.1.1"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@git.zone/tsdoc": "^2.1.1",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"@push.rocks/smartpath": "^6.0.0",
|
|
70
70
|
"@push.rocks/smartpromise": "^4.2.3",
|
|
71
71
|
"@push.rocks/smartscaf": "^4.0.22",
|
|
72
|
-
"@push.rocks/smartshell": "^
|
|
72
|
+
"@push.rocks/smartshell": "^4.0.0",
|
|
73
73
|
"@push.rocks/smartunique": "^3.0.9",
|
|
74
74
|
"@push.rocks/smartupdate": "^2.0.7",
|
|
75
75
|
"prettier": "^3.8.1"
|
package/readme.md
CHANGED
|
@@ -458,6 +458,20 @@ gitzone services logs mongo 50
|
|
|
458
458
|
|
|
459
459
|
Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
|
|
460
460
|
|
|
461
|
+
GitZone derives new MinIO bucket names from the project name using S3 naming
|
|
462
|
+
rules. When loading older configuration it repairs only the exact generated
|
|
463
|
+
`<project>-documents` value if that value is invalid; custom bucket names are
|
|
464
|
+
never rewritten. `services start minio` validates custom names, recreates a
|
|
465
|
+
container whose configured credentials or ports drifted, authenticates the
|
|
466
|
+
configured alias, creates the bucket idempotently, and verifies it before
|
|
467
|
+
reporting success. Before GitZone starts, recreates, or executes inside an
|
|
468
|
+
existing MinIO container, current-project ownership must be proven by exact
|
|
469
|
+
GitZone labels or one unambiguous legacy registry claim. Every lifecycle
|
|
470
|
+
operation after discovery uses the container's immutable id. If startup fails,
|
|
471
|
+
GitZone stops only containers that this invocation started; it never stops a
|
|
472
|
+
container that was already running. Invalid custom names do not block read-only
|
|
473
|
+
or recovery commands such as `status`, `logs`, `stop`, `remove`, and `clean`.
|
|
474
|
+
|
|
461
475
|
### Consuming a service programmatically
|
|
462
476
|
|
|
463
477
|
`gitzone services status --json` emits only JSON on stdout, so a test suite or
|
|
@@ -512,7 +526,11 @@ touch containers created by anything else.
|
|
|
512
526
|
### MongoDB authentication
|
|
513
527
|
|
|
514
528
|
MongoDB runs as a single-node replica set with authentication enabled, so
|
|
515
|
-
multi-document transactions work.
|
|
529
|
+
multi-document transactions work. The managed container also uses a 65,536
|
|
530
|
+
soft/hard `nofile` limit so persistent, collection-heavy test suites do not hit
|
|
531
|
+
Docker's low default. `services start` reconciles legacy containers to this limit
|
|
532
|
+
by recreating only the container while preserving the bind-mounted data directory.
|
|
533
|
+
Authentication can be disabled per project for
|
|
516
534
|
runtimes whose `node:crypto` cannot complete a SCRAM handshake (notably Deno):
|
|
517
535
|
|
|
518
536
|
```bash
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@git.zone/cli',
|
|
6
|
-
version: '3.0.
|
|
6
|
+
version: '3.0.2',
|
|
7
7
|
description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
|
|
8
8
|
}
|
|
@@ -71,15 +71,14 @@ const dockerCommand = `timeout ${dockerTimeoutSeconds}s docker`;
|
|
|
71
71
|
export class DockerPruner {
|
|
72
72
|
private smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
|
73
73
|
|
|
74
|
-
private async run(commandArg: string): Promise<
|
|
75
|
-
|
|
76
|
-
return { exitCode: result.exitCode, stdout: result.stdout || '' };
|
|
74
|
+
private async run(commandArg: string): Promise<plugins.smartshell.IExecResult> {
|
|
75
|
+
return this.smartshell.execSilent(commandArg);
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
private async listLines(commandArg: string): Promise<string[]> {
|
|
80
79
|
const result = await this.run(commandArg);
|
|
81
80
|
if (result.exitCode !== 0) {
|
|
82
|
-
throw new Error(result.
|
|
81
|
+
throw new Error(result.combinedOutput);
|
|
83
82
|
}
|
|
84
83
|
if (!result.stdout.trim()) {
|
|
85
84
|
return [];
|
|
@@ -99,7 +98,7 @@ export class DockerPruner {
|
|
|
99
98
|
`${dockerCommand} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
|
|
100
99
|
);
|
|
101
100
|
if (result.exitCode !== 0) {
|
|
102
|
-
throw new Error(result.
|
|
101
|
+
throw new Error(result.combinedOutput);
|
|
103
102
|
}
|
|
104
103
|
try {
|
|
105
104
|
const parsed = JSON.parse(result.stdout);
|
|
@@ -287,7 +286,7 @@ export class DockerPruner {
|
|
|
287
286
|
}
|
|
288
287
|
const result = await this.run(`${dockerCommand} rm ${shellQuote(planned.id)}`);
|
|
289
288
|
if (result.exitCode !== 0) {
|
|
290
|
-
throw new Error(`Failed to remove container ${planned.name}: ${result.
|
|
289
|
+
throw new Error(`Failed to remove container ${planned.name}: ${result.combinedOutput}`);
|
|
291
290
|
}
|
|
292
291
|
removedContainers.push(planned.name);
|
|
293
292
|
logger.log('ok', `Removed container ${planned.name}`);
|
|
@@ -322,7 +321,7 @@ export class DockerPruner {
|
|
|
322
321
|
}
|
|
323
322
|
const result = await this.run(`${dockerCommand} volume rm ${shellQuote(planned.name)}`);
|
|
324
323
|
if (result.exitCode !== 0) {
|
|
325
|
-
throw new Error(`Failed to remove volume ${planned.name}: ${result.
|
|
324
|
+
throw new Error(`Failed to remove volume ${planned.name}: ${result.combinedOutput}`);
|
|
326
325
|
}
|
|
327
326
|
removedVolumes.push(planned.name);
|
|
328
327
|
logger.log('ok', `Removed volume ${planned.name}`);
|
package/ts/mod_release/index.ts
CHANGED
|
@@ -247,7 +247,7 @@ async function runNpmTarget(
|
|
|
247
247
|
for (const registry of workflow.npmRegistries) {
|
|
248
248
|
const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`;
|
|
249
249
|
const result = await smartshellInstance.exec(command);
|
|
250
|
-
const output =
|
|
250
|
+
const output = result.combinedOutput;
|
|
251
251
|
if (result.exitCode === 0) {
|
|
252
252
|
results.push({ target: registry, status: "success" });
|
|
253
253
|
} else if (isAlreadyPublishedOutput(output) && workflow.npmAlreadyPublished === "success") {
|
|
@@ -269,7 +269,7 @@ async function runDockerTarget(
|
|
|
269
269
|
|
|
270
270
|
const command = buildTsdockerPushCommand(workflow);
|
|
271
271
|
const result = await smartshellInstance.exec(command);
|
|
272
|
-
const output =
|
|
272
|
+
const output = result.combinedOutput;
|
|
273
273
|
return [{
|
|
274
274
|
target: workflow.dockerPatterns.length > 0
|
|
275
275
|
? `tsdocker:${workflow.dockerPatterns.join(",")}`
|
|
@@ -4,6 +4,15 @@ import { logger } from '../gitzone.logging.js';
|
|
|
4
4
|
|
|
5
5
|
export type ContainerStatus = 'running' | 'stopped' | 'not_exists';
|
|
6
6
|
|
|
7
|
+
export interface IDockerUlimit {
|
|
8
|
+
soft: number;
|
|
9
|
+
hard: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TDockerUlimits = Record<string, IDockerUlimit>;
|
|
13
|
+
|
|
14
|
+
export type TDockerUlimitMatch = 'match' | 'mismatch' | 'unavailable';
|
|
15
|
+
|
|
7
16
|
export interface IDockerRunOptions {
|
|
8
17
|
name: string;
|
|
9
18
|
image: string;
|
|
@@ -11,10 +20,53 @@ export interface IDockerRunOptions {
|
|
|
11
20
|
volumes?: { [key: string]: string };
|
|
12
21
|
environment?: { [key: string]: string };
|
|
13
22
|
labels?: { [key: string]: string };
|
|
23
|
+
ulimits?: TDockerUlimits;
|
|
14
24
|
restart?: string;
|
|
15
25
|
command?: string;
|
|
16
26
|
}
|
|
17
27
|
|
|
28
|
+
export interface IDockerCreateArgvOptions extends Omit<IDockerRunOptions, 'command'> {
|
|
29
|
+
/** Container command passed as exact argv entries. */
|
|
30
|
+
command?: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IDockerExecArgvOptions {
|
|
34
|
+
/** Hard per-process wall-clock bound. */
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IDockerExecArgvResult {
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface IDockerArgvExecutionErrorOptions {
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
timedOut: boolean;
|
|
46
|
+
exitCode?: number;
|
|
47
|
+
signal?: NodeJS.Signals;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sanitised failure from the shell-free Docker execution path. */
|
|
51
|
+
export class DockerArgvExecutionError extends Error {
|
|
52
|
+
public readonly exitCode?: number;
|
|
53
|
+
public readonly signal?: NodeJS.Signals;
|
|
54
|
+
|
|
55
|
+
constructor(optionsArg: IDockerArgvExecutionErrorOptions) {
|
|
56
|
+
const detail = optionsArg.timedOut
|
|
57
|
+
? `timed out after ${optionsArg.timeoutMs}ms`
|
|
58
|
+
: optionsArg.signal
|
|
59
|
+
? `terminated by signal ${optionsArg.signal}`
|
|
60
|
+
: optionsArg.exitCode === undefined
|
|
61
|
+
? 'could not be started'
|
|
62
|
+
: `exited with code ${optionsArg.exitCode}`;
|
|
63
|
+
super(`Docker command ${detail}`);
|
|
64
|
+
this.name = 'DockerArgvExecutionError';
|
|
65
|
+
this.exitCode = optionsArg.exitCode;
|
|
66
|
+
this.signal = optionsArg.signal;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
/** Container facts needed to decide, safely, whether a container may be removed. */
|
|
19
71
|
export interface IContainerInspectInfo {
|
|
20
72
|
id: string;
|
|
@@ -29,6 +81,66 @@ export interface IContainerInspectInfo {
|
|
|
29
81
|
/** Single-quote a value for safe interpolation into a bash command. */
|
|
30
82
|
export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
|
|
31
83
|
|
|
84
|
+
const assertDockerUlimit = (nameArg: string, limitArg: IDockerUlimit): void => {
|
|
85
|
+
if (!/^[a-z][a-z0-9_-]*$/u.test(nameArg)) {
|
|
86
|
+
throw new Error(`Invalid Docker ulimit name: ${nameArg}`);
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
!Number.isSafeInteger(limitArg.soft) ||
|
|
90
|
+
!Number.isSafeInteger(limitArg.hard) ||
|
|
91
|
+
limitArg.soft <= 0 ||
|
|
92
|
+
limitArg.hard <= 0 ||
|
|
93
|
+
limitArg.hard < limitArg.soft
|
|
94
|
+
) {
|
|
95
|
+
throw new Error(`Invalid Docker ulimit values for ${nameArg}`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const renderDockerUlimits = (ulimitsArg: TDockerUlimits = {}): string => {
|
|
100
|
+
let rendered = '';
|
|
101
|
+
for (const [name, limit] of Object.entries(ulimitsArg)) {
|
|
102
|
+
assertDockerUlimit(name, limit);
|
|
103
|
+
rendered += ` --ulimit ${shellQuote(`${name}=${limit.soft}:${limit.hard}`)}`;
|
|
104
|
+
}
|
|
105
|
+
return rendered;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const matchDockerUlimits = (
|
|
109
|
+
inspectDataArg: unknown,
|
|
110
|
+
expectedArg: TDockerUlimits,
|
|
111
|
+
): TDockerUlimitMatch => {
|
|
112
|
+
if (!Array.isArray(inspectDataArg) || inspectDataArg.length !== 1) {
|
|
113
|
+
return 'unavailable';
|
|
114
|
+
}
|
|
115
|
+
const container = inspectDataArg[0] as {
|
|
116
|
+
HostConfig?: {
|
|
117
|
+
Ulimits?: Array<{ Name?: unknown; Soft?: unknown; Hard?: unknown }> | null;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
if (!container?.HostConfig) {
|
|
121
|
+
return 'unavailable';
|
|
122
|
+
}
|
|
123
|
+
if (!Object.hasOwn(container.HostConfig, 'Ulimits')) {
|
|
124
|
+
return 'unavailable';
|
|
125
|
+
}
|
|
126
|
+
const actual = container.HostConfig.Ulimits ?? [];
|
|
127
|
+
if (!Array.isArray(actual)) {
|
|
128
|
+
return 'unavailable';
|
|
129
|
+
}
|
|
130
|
+
for (const [name, expected] of Object.entries(expectedArg)) {
|
|
131
|
+
assertDockerUlimit(name, expected);
|
|
132
|
+
const matchingName = actual.filter((entry) => entry?.Name === name);
|
|
133
|
+
if (
|
|
134
|
+
matchingName.length !== 1 ||
|
|
135
|
+
matchingName[0].Soft !== expected.soft ||
|
|
136
|
+
matchingName[0].Hard !== expected.hard
|
|
137
|
+
) {
|
|
138
|
+
return 'mismatch';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return 'match';
|
|
142
|
+
};
|
|
143
|
+
|
|
32
144
|
export class DockerContainer {
|
|
33
145
|
private smartshell: plugins.smartshell.Smartshell;
|
|
34
146
|
/**
|
|
@@ -54,6 +166,108 @@ export class DockerContainer {
|
|
|
54
166
|
return `timeout ${seconds}s docker`;
|
|
55
167
|
}
|
|
56
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Execute Docker directly with exact argv boundaries and strict failures.
|
|
171
|
+
* Arguments and output are not retained in errors, keeping this safe for
|
|
172
|
+
* credential-boundary operations.
|
|
173
|
+
*/
|
|
174
|
+
public async execArgv(
|
|
175
|
+
argvArg: readonly string[],
|
|
176
|
+
optionsArg: IDockerExecArgvOptions,
|
|
177
|
+
): Promise<IDockerExecArgvResult> {
|
|
178
|
+
return this.execArgvInternal(argvArg, optionsArg, process.env);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async execArgvInternal(
|
|
182
|
+
argvArg: readonly string[],
|
|
183
|
+
optionsArg: IDockerExecArgvOptions,
|
|
184
|
+
environmentArg: NodeJS.ProcessEnv,
|
|
185
|
+
): Promise<IDockerExecArgvResult> {
|
|
186
|
+
if (!Array.isArray(argvArg) || argvArg.length === 0) {
|
|
187
|
+
throw new Error('Docker argv must contain at least one argument');
|
|
188
|
+
}
|
|
189
|
+
if (argvArg.some((argumentArg) => typeof argumentArg !== 'string' || argumentArg.includes('\0'))) {
|
|
190
|
+
throw new Error('Docker argv must contain only NUL-free strings');
|
|
191
|
+
}
|
|
192
|
+
if (!Number.isSafeInteger(optionsArg?.timeoutMs) || optionsArg.timeoutMs <= 0) {
|
|
193
|
+
throw new Error('Docker argv timeoutMs must be a positive safe integer');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const result = await this.smartshell.execSpawn('docker', [...argvArg], {
|
|
198
|
+
silent: true,
|
|
199
|
+
strict: true,
|
|
200
|
+
timeout: optionsArg.timeoutMs,
|
|
201
|
+
timeoutKillGraceMs: 0,
|
|
202
|
+
maxBuffer: 1024 * 1024,
|
|
203
|
+
env: environmentArg,
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
stdout: result.stdout,
|
|
207
|
+
stderr: result.stderr,
|
|
208
|
+
};
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof plugins.smartshell.SmartshellError) {
|
|
211
|
+
throw new DockerArgvExecutionError({
|
|
212
|
+
timeoutMs: optionsArg.timeoutMs,
|
|
213
|
+
timedOut: error.timedOut,
|
|
214
|
+
exitCode: error.exitCode,
|
|
215
|
+
signal: error.signal,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
throw new DockerArgvExecutionError({
|
|
219
|
+
timeoutMs: optionsArg.timeoutMs,
|
|
220
|
+
timedOut: false,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create, but do not start, a container without a host shell. Environment
|
|
227
|
+
* values are inherited by name so credentials do not enter Docker's argv or
|
|
228
|
+
* command errors. The returned stdout contains Docker's immutable id.
|
|
229
|
+
*/
|
|
230
|
+
public async createArgv(
|
|
231
|
+
optionsArg: IDockerCreateArgvOptions,
|
|
232
|
+
executionOptionsArg: IDockerExecArgvOptions,
|
|
233
|
+
): Promise<IDockerExecArgvResult> {
|
|
234
|
+
const argv: string[] = ['create', '--name', optionsArg.name];
|
|
235
|
+
|
|
236
|
+
for (const [hostPort, containerPort] of Object.entries(optionsArg.ports || {})) {
|
|
237
|
+
argv.push('-p', `${hostPort}:${containerPort}`);
|
|
238
|
+
}
|
|
239
|
+
for (const [hostPath, containerPath] of Object.entries(optionsArg.volumes || {})) {
|
|
240
|
+
argv.push('-v', `${hostPath}:${containerPath}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const childEnvironment: NodeJS.ProcessEnv = { ...process.env };
|
|
244
|
+
for (const [key, value] of Object.entries(optionsArg.environment || {})) {
|
|
245
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
|
|
246
|
+
throw new Error(`Invalid Docker environment variable name: ${key}`);
|
|
247
|
+
}
|
|
248
|
+
childEnvironment[key] = value;
|
|
249
|
+
argv.push('--env', key);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const [key, value] of Object.entries(optionsArg.labels || {})) {
|
|
253
|
+
argv.push('--label', `${key}=${value}`);
|
|
254
|
+
}
|
|
255
|
+
for (const [name, limit] of Object.entries(optionsArg.ulimits || {})) {
|
|
256
|
+
assertDockerUlimit(name, limit);
|
|
257
|
+
argv.push('--ulimit', `${name}=${limit.soft}:${limit.hard}`);
|
|
258
|
+
}
|
|
259
|
+
if (optionsArg.restart) {
|
|
260
|
+
argv.push('--restart', optionsArg.restart);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
argv.push(optionsArg.image);
|
|
264
|
+
if (optionsArg.command) {
|
|
265
|
+
argv.push(...optionsArg.command);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return this.execArgvInternal(argv, executionOptionsArg, childEnvironment);
|
|
269
|
+
}
|
|
270
|
+
|
|
57
271
|
/**
|
|
58
272
|
* Check if Docker is installed and available
|
|
59
273
|
*/
|
|
@@ -171,6 +385,8 @@ export class DockerContainer {
|
|
|
171
385
|
}
|
|
172
386
|
}
|
|
173
387
|
|
|
388
|
+
command += renderDockerUlimits(options.ulimits);
|
|
389
|
+
|
|
174
390
|
// Add restart policy
|
|
175
391
|
if (options.restart) {
|
|
176
392
|
command += ` --restart ${options.restart}`;
|
|
@@ -334,9 +550,9 @@ export class DockerContainer {
|
|
|
334
550
|
): Promise<{ exitCode: number; output: string }> {
|
|
335
551
|
try {
|
|
336
552
|
const result = await this.smartshell.execSilent(
|
|
337
|
-
`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}
|
|
553
|
+
`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`,
|
|
338
554
|
);
|
|
339
|
-
return { exitCode: result.exitCode, output: result.
|
|
555
|
+
return { exitCode: result.exitCode, output: result.combinedOutput };
|
|
340
556
|
} catch (error) {
|
|
341
557
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
342
558
|
return { exitCode: -1, output: errorMessage };
|
|
@@ -350,7 +566,7 @@ export class DockerContainer {
|
|
|
350
566
|
try {
|
|
351
567
|
const tailFlag = lines ? `--tail ${lines}` : '';
|
|
352
568
|
const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`);
|
|
353
|
-
return result.
|
|
569
|
+
return result.combinedOutput;
|
|
354
570
|
} catch (error) {
|
|
355
571
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
356
572
|
return `Error getting logs: ${errorMessage}`;
|
|
@@ -448,7 +664,7 @@ export class DockerContainer {
|
|
|
448
664
|
`${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
|
|
449
665
|
);
|
|
450
666
|
if (result.exitCode !== 0) {
|
|
451
|
-
throw new Error(result.
|
|
667
|
+
throw new Error(result.combinedOutput);
|
|
452
668
|
}
|
|
453
669
|
if (!result.stdout.trim()) {
|
|
454
670
|
return [];
|
|
@@ -472,7 +688,7 @@ export class DockerContainer {
|
|
|
472
688
|
`${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
|
|
473
689
|
);
|
|
474
690
|
if (result.exitCode !== 0) {
|
|
475
|
-
throw new Error(result.
|
|
691
|
+
throw new Error(result.combinedOutput);
|
|
476
692
|
}
|
|
477
693
|
let parsed: any;
|
|
478
694
|
try {
|
|
@@ -17,6 +17,104 @@ export const isLocalMongoHost = (hostArg: string): boolean => {
|
|
|
17
17
|
return localMongoHosts.includes(hostArg.trim().toLowerCase());
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const s3ReservedPrefixes = ['xn--', 'sthree-', 'amzn-s3-demo-'];
|
|
21
|
+
const s3ReservedSuffixes = ['-s3alias', '--ol-s3', '.mrap', '--x-s3', '--table-s3'];
|
|
22
|
+
const s3BucketPattern = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/u;
|
|
23
|
+
const ipv4AddressPattern = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
|
|
24
|
+
|
|
25
|
+
const isIpv4Address = (valueArg: string): boolean => {
|
|
26
|
+
if (!ipv4AddressPattern.test(valueArg)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return valueArg.split('.').every((octetArg) => Number(octetArg) <= 255);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Validate a bucket against the current S3 general-purpose naming rules. */
|
|
33
|
+
export const getS3BucketNameValidationError = (bucketArg: unknown): string | undefined => {
|
|
34
|
+
if (typeof bucketArg !== 'string') {
|
|
35
|
+
return 'must be a string';
|
|
36
|
+
}
|
|
37
|
+
if (bucketArg.length < 3 || bucketArg.length > 63) {
|
|
38
|
+
return 'must contain between 3 and 63 characters';
|
|
39
|
+
}
|
|
40
|
+
if (!s3BucketPattern.test(bucketArg)) {
|
|
41
|
+
return 'must use only lowercase letters, numbers, periods, and hyphens, with an alphanumeric first and last character';
|
|
42
|
+
}
|
|
43
|
+
if (bucketArg.includes('..')) {
|
|
44
|
+
return 'must not contain adjacent periods';
|
|
45
|
+
}
|
|
46
|
+
const reservedPrefix = s3ReservedPrefixes.find((prefixArg) => bucketArg.startsWith(prefixArg));
|
|
47
|
+
if (reservedPrefix) {
|
|
48
|
+
return `must not start with the reserved prefix "${reservedPrefix}"`;
|
|
49
|
+
}
|
|
50
|
+
const reservedSuffix = s3ReservedSuffixes.find((suffixArg) => bucketArg.endsWith(suffixArg));
|
|
51
|
+
if (reservedSuffix) {
|
|
52
|
+
return `must not end with the reserved suffix "${reservedSuffix}"`;
|
|
53
|
+
}
|
|
54
|
+
if (isIpv4Address(bucketArg)) {
|
|
55
|
+
return 'must not be formatted as an IPv4 address';
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const isValidS3BucketName = (bucketArg: unknown): bucketArg is string => {
|
|
61
|
+
return getS3BucketNameValidationError(bucketArg) === undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive GitZone's bucket from a project name using a deliberately smaller
|
|
66
|
+
* alphabet than custom S3 names. Long names retain a stable hash suffix.
|
|
67
|
+
*/
|
|
68
|
+
export const deriveS3BucketName = (projectNameArg: string): string => {
|
|
69
|
+
const source = `${projectNameArg}-documents`;
|
|
70
|
+
let canonical = source
|
|
71
|
+
.trim()
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
74
|
+
.replace(/^-+|-+$/gu, '');
|
|
75
|
+
|
|
76
|
+
if (!canonical) {
|
|
77
|
+
canonical = 'project-documents';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
s3ReservedPrefixes.some((prefixArg) => canonical.startsWith(prefixArg)) ||
|
|
82
|
+
s3ReservedSuffixes.some((suffixArg) => canonical.endsWith(suffixArg)) ||
|
|
83
|
+
isIpv4Address(canonical)
|
|
84
|
+
) {
|
|
85
|
+
canonical = `gitzone-${canonical}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (canonical.length > 63) {
|
|
89
|
+
const stableHash = plugins.crypto.createHash('sha256').update(source).digest('hex').slice(0, 12);
|
|
90
|
+
const prefixLength = 63 - stableHash.length - 1;
|
|
91
|
+
const prefix = canonical.slice(0, prefixLength).replace(/-+$/u, '');
|
|
92
|
+
canonical = `${prefix}-${stableHash}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const validationError = getS3BucketNameValidationError(canonical);
|
|
96
|
+
if (validationError) {
|
|
97
|
+
throw new Error(`Could not derive a valid S3 bucket name: ${validationError}`);
|
|
98
|
+
}
|
|
99
|
+
return canonical;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type TLegacyS3BucketRepair =
|
|
103
|
+
| { bucket: unknown; repaired: false }
|
|
104
|
+
| { bucket: string; repaired: true };
|
|
105
|
+
|
|
106
|
+
/** Repair only the exact invalid value emitted by older GitZone releases. */
|
|
107
|
+
export const repairLegacyGeneratedS3Bucket = (
|
|
108
|
+
projectNameArg: string,
|
|
109
|
+
bucketArg: unknown,
|
|
110
|
+
): TLegacyS3BucketRepair => {
|
|
111
|
+
const legacyGeneratedBucket = `${projectNameArg}-documents`;
|
|
112
|
+
if (bucketArg !== legacyGeneratedBucket || isValidS3BucketName(bucketArg)) {
|
|
113
|
+
return { bucket: bucketArg, repaired: false };
|
|
114
|
+
}
|
|
115
|
+
return { bucket: deriveS3BucketName(projectNameArg), repaired: true };
|
|
116
|
+
};
|
|
117
|
+
|
|
20
118
|
export interface IServiceConfig {
|
|
21
119
|
PROJECT_NAME: string;
|
|
22
120
|
MONGODB_HOST: string;
|
|
@@ -255,7 +353,7 @@ export class ServiceConfiguration {
|
|
|
255
353
|
S3_CONSOLE_PORT: s3ConsolePort.toString(),
|
|
256
354
|
S3_ACCESSKEY: 'defaultadmin',
|
|
257
355
|
S3_SECRETKEY: 'defaultpass',
|
|
258
|
-
S3_BUCKET:
|
|
356
|
+
S3_BUCKET: deriveS3BucketName(projectName),
|
|
259
357
|
S3_ENDPOINT: s3Host,
|
|
260
358
|
S3_USESSL: false,
|
|
261
359
|
ELASTICSEARCH_HOST: esHost,
|
|
@@ -373,9 +471,19 @@ export class ServiceConfiguration {
|
|
|
373
471
|
}
|
|
374
472
|
|
|
375
473
|
if (!this.config.S3_BUCKET) {
|
|
376
|
-
this.config.S3_BUCKET =
|
|
474
|
+
this.config.S3_BUCKET = deriveS3BucketName(this.config.PROJECT_NAME);
|
|
377
475
|
fieldsAdded.push('S3_BUCKET');
|
|
378
476
|
updated = true;
|
|
477
|
+
} else {
|
|
478
|
+
const repair = repairLegacyGeneratedS3Bucket(
|
|
479
|
+
this.config.PROJECT_NAME,
|
|
480
|
+
this.config.S3_BUCKET,
|
|
481
|
+
);
|
|
482
|
+
if (repair.repaired) {
|
|
483
|
+
this.config.S3_BUCKET = repair.bucket;
|
|
484
|
+
fieldsAdded.push('S3_BUCKET(repaired legacy generated name)');
|
|
485
|
+
updated = true;
|
|
486
|
+
}
|
|
379
487
|
}
|
|
380
488
|
|
|
381
489
|
// `undefined`, not falsy: a stored `false` is a valid value, and treating it
|