@git.zone/cli 3.0.1 → 3.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/helpers.tsdocker.d.ts +3 -0
- package/dist_ts/helpers.tsdocker.js +16 -0
- package/dist_ts/helpers.workflow.d.ts +6 -0
- package/dist_ts/helpers.workflow.js +38 -1
- package/dist_ts/mod_config/index.js +77 -7
- package/dist_ts/mod_docker/classes.dockerpruner.js +6 -7
- package/dist_ts/mod_release/index.d.ts +4 -0
- package/dist_ts/mod_release/index.js +52 -6
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +37 -0
- package/dist_ts/mod_services/classes.dockercontainer.js +108 -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 +23 -0
- package/dist_ts/mod_services/classes.servicemanager.js +374 -89
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +5 -5
- package/readme.md +33 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.tsdocker.ts +30 -0
- package/ts/helpers.workflow.ts +46 -0
- package/ts/mod_config/index.ts +95 -7
- package/ts/mod_docker/classes.dockerpruner.ts +6 -7
- package/ts/mod_release/index.ts +67 -4
- package/ts/mod_services/classes.dockercontainer.ts +149 -5
- package/ts/mod_services/classes.serviceconfiguration.ts +110 -2
- package/ts/mod_services/classes.servicemanager.ts +550 -112
- package/ts/plugins.ts +2 -0
|
@@ -25,6 +25,48 @@ export interface IDockerRunOptions {
|
|
|
25
25
|
command?: string;
|
|
26
26
|
}
|
|
27
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
|
+
|
|
28
70
|
/** Container facts needed to decide, safely, whether a container may be removed. */
|
|
29
71
|
export interface IContainerInspectInfo {
|
|
30
72
|
id: string;
|
|
@@ -124,6 +166,108 @@ export class DockerContainer {
|
|
|
124
166
|
return `timeout ${seconds}s docker`;
|
|
125
167
|
}
|
|
126
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
|
+
|
|
127
271
|
/**
|
|
128
272
|
* Check if Docker is installed and available
|
|
129
273
|
*/
|
|
@@ -406,9 +550,9 @@ export class DockerContainer {
|
|
|
406
550
|
): Promise<{ exitCode: number; output: string }> {
|
|
407
551
|
try {
|
|
408
552
|
const result = await this.smartshell.execSilent(
|
|
409
|
-
`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}
|
|
553
|
+
`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`,
|
|
410
554
|
);
|
|
411
|
-
return { exitCode: result.exitCode, output: result.
|
|
555
|
+
return { exitCode: result.exitCode, output: result.combinedOutput };
|
|
412
556
|
} catch (error) {
|
|
413
557
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
414
558
|
return { exitCode: -1, output: errorMessage };
|
|
@@ -422,7 +566,7 @@ export class DockerContainer {
|
|
|
422
566
|
try {
|
|
423
567
|
const tailFlag = lines ? `--tail ${lines}` : '';
|
|
424
568
|
const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`);
|
|
425
|
-
return result.
|
|
569
|
+
return result.combinedOutput;
|
|
426
570
|
} catch (error) {
|
|
427
571
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
428
572
|
return `Error getting logs: ${errorMessage}`;
|
|
@@ -520,7 +664,7 @@ export class DockerContainer {
|
|
|
520
664
|
`${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
|
|
521
665
|
);
|
|
522
666
|
if (result.exitCode !== 0) {
|
|
523
|
-
throw new Error(result.
|
|
667
|
+
throw new Error(result.combinedOutput);
|
|
524
668
|
}
|
|
525
669
|
if (!result.stdout.trim()) {
|
|
526
670
|
return [];
|
|
@@ -544,7 +688,7 @@ export class DockerContainer {
|
|
|
544
688
|
`${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
|
|
545
689
|
);
|
|
546
690
|
if (result.exitCode !== 0) {
|
|
547
|
-
throw new Error(result.
|
|
691
|
+
throw new Error(result.combinedOutput);
|
|
548
692
|
}
|
|
549
693
|
let parsed: any;
|
|
550
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
|