@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
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import * as plugins from './mod.plugins.js';
|
|
2
2
|
import * as helpers from './helpers.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
ServiceConfiguration,
|
|
5
|
+
getS3BucketNameValidationError,
|
|
6
|
+
} from './classes.serviceconfiguration.js';
|
|
7
|
+
import {
|
|
8
|
+
DockerContainer,
|
|
9
|
+
matchDockerUlimits,
|
|
10
|
+
type IDockerExecArgvResult,
|
|
11
|
+
type TDockerUlimits,
|
|
12
|
+
} from './classes.dockercontainer.js';
|
|
5
13
|
import { GlobalRegistry } from './classes.globalregistry.js';
|
|
6
14
|
import {
|
|
7
15
|
getServiceContainerLabels,
|
|
@@ -44,11 +52,261 @@ export interface IServicesStatus {
|
|
|
44
52
|
totalDataBytes: number;
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
export const mongoServiceUlimits = {
|
|
56
|
+
nofile: {
|
|
57
|
+
soft: 65_536,
|
|
58
|
+
hard: 65_536,
|
|
59
|
+
},
|
|
60
|
+
} as const satisfies TDockerUlimits;
|
|
61
|
+
|
|
62
|
+
export const minioReconciliationTimeoutMs = 30_000;
|
|
63
|
+
const minioProbeTimeoutCapMs = 2_000;
|
|
64
|
+
const minioProbeIntervalMs = 250;
|
|
65
|
+
const minioRollbackTimeoutMs = 5_000;
|
|
66
|
+
const minioAlias = 'gitzone-local';
|
|
67
|
+
const minioAliasSetupScript =
|
|
68
|
+
'mc alias set gitzone-local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null';
|
|
69
|
+
|
|
70
|
+
export interface IMinioReconciliationClock {
|
|
71
|
+
now(): number;
|
|
72
|
+
delay(millisecondsArg: number): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface IMinioReconciliationOptions {
|
|
76
|
+
docker: Pick<DockerContainer, 'execArgv'>;
|
|
77
|
+
/** Full immutable Docker container id; mutable names are never accepted here. */
|
|
78
|
+
containerId: string;
|
|
79
|
+
bucket: string;
|
|
80
|
+
/** Shared absolute deadline. Defaults to 30 seconds from this call. */
|
|
81
|
+
deadlineAt?: number;
|
|
82
|
+
clock?: IMinioReconciliationClock;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const defaultMinioClock: IMinioReconciliationClock = {
|
|
86
|
+
now: () => Date.now(),
|
|
87
|
+
delay: async (millisecondsArg) => plugins.smartdelay.delayFor(millisecondsArg),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
class MinioStageError extends Error {
|
|
91
|
+
constructor(stageArg: string, timedOutArg: boolean, causeArg?: unknown) {
|
|
92
|
+
const message = timedOutArg
|
|
93
|
+
? `MinIO ${stageArg} did not complete before the shared reconciliation deadline.`
|
|
94
|
+
: `MinIO ${stageArg} failed.`;
|
|
95
|
+
super(message, causeArg === undefined ? undefined : { cause: causeArg });
|
|
96
|
+
this.name = 'MinioStageError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const minioStageError = (
|
|
101
|
+
stageArg: string,
|
|
102
|
+
timedOutArg: boolean,
|
|
103
|
+
causeArg?: unknown,
|
|
104
|
+
): MinioStageError => {
|
|
105
|
+
return new MinioStageError(stageArg, timedOutArg, causeArg);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const minioStageCause = (errorArg: unknown): unknown => {
|
|
109
|
+
if (errorArg instanceof MinioStageError && errorArg.cause !== undefined) {
|
|
110
|
+
return errorArg.cause;
|
|
111
|
+
}
|
|
112
|
+
return errorArg;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const normalizeMinioLifecycleError = (errorArg: unknown): Error => {
|
|
116
|
+
return errorArg instanceof Error
|
|
117
|
+
? errorArg
|
|
118
|
+
: new Error('MinIO lifecycle failed with a non-Error rejection.');
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
interface IMinioContainerInspection {
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
running: boolean;
|
|
125
|
+
portBindings: Record<string, unknown>;
|
|
126
|
+
environmentEntries: string[];
|
|
127
|
+
labels: Record<string, string>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isUnknownRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
131
|
+
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const parseMinioInspection = (
|
|
135
|
+
stdoutArg: string,
|
|
136
|
+
expectedContainerNameArg: string,
|
|
137
|
+
): IMinioContainerInspection => {
|
|
138
|
+
let parsed: unknown;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(stdoutArg);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(parsed) || parsed.length !== 1 || !isUnknownRecord(parsed[0])) {
|
|
145
|
+
throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const inspected = parsed[0];
|
|
149
|
+
const state = inspected.State;
|
|
150
|
+
const config = inspected.Config;
|
|
151
|
+
const hostConfig = inspected.HostConfig;
|
|
152
|
+
const normalizedName =
|
|
153
|
+
typeof inspected.Name === 'string' ? inspected.Name.replace(/^\//u, '') : undefined;
|
|
154
|
+
if (
|
|
155
|
+
typeof inspected.Id !== 'string' ||
|
|
156
|
+
!/^[a-f0-9]{64}$/u.test(inspected.Id) ||
|
|
157
|
+
normalizedName !== expectedContainerNameArg ||
|
|
158
|
+
!isUnknownRecord(state) ||
|
|
159
|
+
typeof state.Running !== 'boolean' ||
|
|
160
|
+
!isUnknownRecord(config) ||
|
|
161
|
+
!isUnknownRecord(hostConfig) ||
|
|
162
|
+
!isUnknownRecord(hostConfig.PortBindings) ||
|
|
163
|
+
!Array.isArray(config.Env) ||
|
|
164
|
+
!config.Env.every((entryArg) => typeof entryArg === 'string')
|
|
165
|
+
) {
|
|
166
|
+
throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const labels: Record<string, string> = {};
|
|
170
|
+
if (config.Labels !== null && config.Labels !== undefined) {
|
|
171
|
+
if (!isUnknownRecord(config.Labels)) {
|
|
172
|
+
throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
|
|
173
|
+
}
|
|
174
|
+
for (const [key, value] of Object.entries(config.Labels)) {
|
|
175
|
+
if (typeof value !== 'string') {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
labels[key] = value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
id: inspected.Id,
|
|
186
|
+
name: normalizedName,
|
|
187
|
+
running: state.Running,
|
|
188
|
+
portBindings: hostConfig.PortBindings,
|
|
189
|
+
environmentEntries: config.Env,
|
|
190
|
+
labels,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const minioPortMatches = (
|
|
195
|
+
portBindingsArg: Record<string, unknown>,
|
|
196
|
+
containerPortArg: string,
|
|
197
|
+
expectedHostPortArg: string,
|
|
198
|
+
): boolean => {
|
|
199
|
+
const bindings = portBindingsArg[containerPortArg];
|
|
200
|
+
return (
|
|
201
|
+
Array.isArray(bindings) &&
|
|
202
|
+
bindings.some(
|
|
203
|
+
(bindingArg) =>
|
|
204
|
+
isUnknownRecord(bindingArg) && bindingArg.HostPort === expectedHostPortArg,
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Converge a running MinIO container to an authenticated, verified bucket. */
|
|
210
|
+
export const reconcileMinioBucket = async (
|
|
211
|
+
optionsArg: IMinioReconciliationOptions,
|
|
212
|
+
): Promise<void> => {
|
|
213
|
+
if (!/^[a-f0-9]{64}$/u.test(optionsArg.containerId)) {
|
|
214
|
+
throw new Error('MinIO reconciliation requires a full immutable Docker container id.');
|
|
215
|
+
}
|
|
216
|
+
const clock = optionsArg.clock || defaultMinioClock;
|
|
217
|
+
const deadlineAt = optionsArg.deadlineAt ?? clock.now() + minioReconciliationTimeoutMs;
|
|
218
|
+
if (!Number.isFinite(deadlineAt)) {
|
|
219
|
+
throw new Error('MinIO reconciliation deadline must be finite');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const remainingMs = (): number => Math.floor(deadlineAt - clock.now());
|
|
223
|
+
const execOnce = async (
|
|
224
|
+
stageArg: string,
|
|
225
|
+
argvArg: readonly string[],
|
|
226
|
+
timeoutCapMsArg?: number,
|
|
227
|
+
): Promise<IDockerExecArgvResult> => {
|
|
228
|
+
const remaining = remainingMs();
|
|
229
|
+
if (remaining <= 0) {
|
|
230
|
+
throw minioStageError(stageArg, true);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return await optionsArg.docker.execArgv(argvArg, {
|
|
234
|
+
timeoutMs: Math.max(
|
|
235
|
+
1,
|
|
236
|
+
timeoutCapMsArg === undefined ? remaining : Math.min(timeoutCapMsArg, remaining),
|
|
237
|
+
),
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw minioStageError(stageArg, remainingMs() <= 0, error);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const execUntilReady = async (
|
|
244
|
+
stageArg: string,
|
|
245
|
+
argvArg: readonly string[],
|
|
246
|
+
): Promise<IDockerExecArgvResult> => {
|
|
247
|
+
let lastCause: unknown;
|
|
248
|
+
while (true) {
|
|
249
|
+
try {
|
|
250
|
+
return await execOnce(stageArg, argvArg, minioProbeTimeoutCapMs);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const currentCause = minioStageCause(error);
|
|
253
|
+
if (currentCause !== undefined) {
|
|
254
|
+
lastCause = currentCause;
|
|
255
|
+
}
|
|
256
|
+
const remaining = remainingMs();
|
|
257
|
+
if (remaining <= 0) {
|
|
258
|
+
throw minioStageError(stageArg, true, lastCause);
|
|
259
|
+
}
|
|
260
|
+
await clock.delay(Math.min(minioProbeIntervalMs, remaining));
|
|
261
|
+
if (remainingMs() <= 0) {
|
|
262
|
+
throw minioStageError(stageArg, true, lastCause);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
await execUntilReady('container readiness', ['exec', optionsArg.containerId, 'true']);
|
|
269
|
+
// The fixed script reads credentials only from the container environment.
|
|
270
|
+
await execUntilReady('alias configuration', [
|
|
271
|
+
'exec',
|
|
272
|
+
optionsArg.containerId,
|
|
273
|
+
'sh',
|
|
274
|
+
'-c',
|
|
275
|
+
minioAliasSetupScript,
|
|
276
|
+
]);
|
|
277
|
+
await execUntilReady('authenticated readiness', [
|
|
278
|
+
'exec',
|
|
279
|
+
optionsArg.containerId,
|
|
280
|
+
'mc',
|
|
281
|
+
'ready',
|
|
282
|
+
'--json',
|
|
283
|
+
minioAlias,
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const bucketTarget = `${minioAlias}/${optionsArg.bucket}`;
|
|
287
|
+
await execOnce('bucket creation', [
|
|
288
|
+
'exec',
|
|
289
|
+
optionsArg.containerId,
|
|
290
|
+
'mc',
|
|
291
|
+
'mb',
|
|
292
|
+
'--ignore-existing',
|
|
293
|
+
bucketTarget,
|
|
294
|
+
]);
|
|
295
|
+
await execOnce('bucket verification', [
|
|
296
|
+
'exec',
|
|
297
|
+
optionsArg.containerId,
|
|
298
|
+
'mc',
|
|
299
|
+
'stat',
|
|
300
|
+
bucketTarget,
|
|
301
|
+
]);
|
|
302
|
+
};
|
|
303
|
+
|
|
47
304
|
export class ServiceManager {
|
|
48
305
|
private config: ServiceConfiguration;
|
|
49
306
|
private docker: DockerContainer;
|
|
50
307
|
private enabledServices: string[] | null = null;
|
|
51
308
|
private globalRegistry: GlobalRegistry;
|
|
309
|
+
private minioClock: IMinioReconciliationClock = defaultMinioClock;
|
|
52
310
|
|
|
53
311
|
constructor() {
|
|
54
312
|
this.config = new ServiceConfiguration();
|
|
@@ -297,24 +555,60 @@ export class ServiceManager {
|
|
|
297
555
|
|
|
298
556
|
const status = await this.docker.getStatus(containers.mongo);
|
|
299
557
|
|
|
300
|
-
// Containers created before replica-set support, with a
|
|
301
|
-
// running the other auth mode must be recreated; the data
|
|
558
|
+
// Containers created before replica-set or managed-limit support, with a
|
|
559
|
+
// changed port, or running the other auth mode must be recreated; the data
|
|
560
|
+
// is preserved.
|
|
302
561
|
let needsRecreate = false;
|
|
303
562
|
if (status !== 'not_exists') {
|
|
304
|
-
const
|
|
563
|
+
const inspectInfo = await this.docker.inspect(containers.mongo);
|
|
564
|
+
if (!Array.isArray(inspectInfo) || inspectInfo.length !== 1) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`Unable to inspect MongoDB container ${containers.mongo}; refusing to recreate it.`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
const inspectedContainer = inspectInfo[0];
|
|
570
|
+
if (
|
|
571
|
+
!inspectedContainer?.Config ||
|
|
572
|
+
!Array.isArray(inspectedContainer.Config.Cmd) ||
|
|
573
|
+
!inspectedContainer.HostConfig ||
|
|
574
|
+
!Object.hasOwn(inspectedContainer.HostConfig, 'PortBindings')
|
|
575
|
+
) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Incomplete inspection for MongoDB container ${containers.mongo}; refusing to recreate it.`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const containerCmd = inspectedContainer.Config.Cmd;
|
|
305
581
|
const hasReplSet = containerCmd.includes('--replSet');
|
|
306
582
|
const hasKeyFile = containerCmd.includes('--keyFile');
|
|
307
|
-
const
|
|
583
|
+
const mongoPortBindings =
|
|
584
|
+
inspectedContainer.HostConfig.PortBindings?.[`${config.MONGODB_PORT}/tcp`];
|
|
308
585
|
const portMatches =
|
|
309
|
-
|
|
586
|
+
Array.isArray(mongoPortBindings) &&
|
|
587
|
+
mongoPortBindings.length > 0 &&
|
|
588
|
+
mongoPortBindings.some(
|
|
589
|
+
(bindingArg: { HostPort?: string }) => bindingArg?.HostPort === config.MONGODB_PORT,
|
|
590
|
+
);
|
|
310
591
|
const authMatches = hasKeyFile === this.config.isMongoAuthEnabled();
|
|
311
|
-
|
|
592
|
+
const ulimitMatch = matchDockerUlimits(inspectInfo, mongoServiceUlimits);
|
|
593
|
+
if (ulimitMatch === 'unavailable') {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Unable to inspect MongoDB container limits for ${containers.mongo}; refusing to recreate it.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
needsRecreate =
|
|
599
|
+
!hasReplSet ||
|
|
600
|
+
!portMatches ||
|
|
601
|
+
!authMatches ||
|
|
602
|
+
ulimitMatch === 'mismatch';
|
|
312
603
|
if (!authMatches) {
|
|
313
604
|
logger.log(
|
|
314
605
|
'note',
|
|
315
606
|
` Auth mode changed to ${this.config.isMongoAuthEnabled() ? 'enabled' : 'disabled'}, recreating container...`,
|
|
316
607
|
);
|
|
317
608
|
}
|
|
609
|
+
if (ulimitMatch === 'mismatch') {
|
|
610
|
+
logger.log('note', ' MongoDB file-descriptor limit changed, recreating container...');
|
|
611
|
+
}
|
|
318
612
|
}
|
|
319
613
|
|
|
320
614
|
switch (status) {
|
|
@@ -323,7 +617,7 @@ export class ServiceManager {
|
|
|
323
617
|
logger.log('ok', ' Already running ✓');
|
|
324
618
|
break;
|
|
325
619
|
}
|
|
326
|
-
logger.log('note', '
|
|
620
|
+
logger.log('note', ' MongoDB service configuration changed, recreating container...');
|
|
327
621
|
await this.docker.remove(containers.mongo, true);
|
|
328
622
|
await this.createMongoContainer();
|
|
329
623
|
break;
|
|
@@ -420,6 +714,7 @@ export class ServiceManager {
|
|
|
420
714
|
},
|
|
421
715
|
environment,
|
|
422
716
|
labels,
|
|
717
|
+
ulimits: mongoServiceUlimits,
|
|
423
718
|
restart: 'unless-stopped',
|
|
424
719
|
command
|
|
425
720
|
});
|
|
@@ -428,6 +723,7 @@ export class ServiceManager {
|
|
|
428
723
|
logger.log('ok', ' Created and started ✓');
|
|
429
724
|
} else {
|
|
430
725
|
logger.log('error', ' Failed to create container');
|
|
726
|
+
throw new Error(`Failed to create MongoDB container ${containers.mongo}.`);
|
|
431
727
|
}
|
|
432
728
|
}
|
|
433
729
|
|
|
@@ -451,14 +747,6 @@ export class ServiceManager {
|
|
|
451
747
|
});
|
|
452
748
|
}
|
|
453
749
|
|
|
454
|
-
/**
|
|
455
|
-
* The mongod argv the container was created with
|
|
456
|
-
*/
|
|
457
|
-
private async getMongoContainerCmd(containerName: string): Promise<string[]> {
|
|
458
|
-
const info = await this.docker.inspect(containerName);
|
|
459
|
-
return info?.[0]?.Config?.Cmd ?? [];
|
|
460
|
-
}
|
|
461
|
-
|
|
462
750
|
/**
|
|
463
751
|
* Make sure the configured root user exists when auth is enabled.
|
|
464
752
|
*
|
|
@@ -557,137 +845,328 @@ export class ServiceManager {
|
|
|
557
845
|
logger.log('error', ' Replica set not ready after 30s — transactions unavailable');
|
|
558
846
|
return false;
|
|
559
847
|
}
|
|
848
|
+
|
|
849
|
+
private async inspectMinioContainer(
|
|
850
|
+
containerReferenceArg: string,
|
|
851
|
+
expectedContainerNameArg: string,
|
|
852
|
+
dockerTimeoutArg: () => { timeoutMs: number },
|
|
853
|
+
): Promise<IMinioContainerInspection> {
|
|
854
|
+
const result = await this.docker.execArgv(
|
|
855
|
+
['inspect', containerReferenceArg],
|
|
856
|
+
dockerTimeoutArg(),
|
|
857
|
+
);
|
|
858
|
+
return parseMinioInspection(result.stdout, expectedContainerNameArg);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private async assertMinioOwnership(
|
|
862
|
+
inspectionArg: IMinioContainerInspection,
|
|
863
|
+
expectedLabelsArg: Record<string, string>,
|
|
864
|
+
allowLegacyRegistryArg: boolean,
|
|
865
|
+
): Promise<void> {
|
|
866
|
+
const actualGitZoneLabels = Object.entries(inspectionArg.labels).filter(([key]) =>
|
|
867
|
+
key.startsWith('git.zone.'),
|
|
868
|
+
);
|
|
869
|
+
const expectedEntries = Object.entries(expectedLabelsArg);
|
|
870
|
+
const hasExactCurrentLabels =
|
|
871
|
+
actualGitZoneLabels.length === expectedEntries.length &&
|
|
872
|
+
expectedEntries.every(([key, value]) => inspectionArg.labels[key] === value);
|
|
873
|
+
if (hasExactCurrentLabels) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const refusal = (): Error =>
|
|
878
|
+
new Error(
|
|
879
|
+
`Refusing to use MinIO container ${inspectionArg.name}: ` +
|
|
880
|
+
'its ownership is not proven for the current project.',
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
if (!allowLegacyRegistryArg) {
|
|
884
|
+
throw refusal();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Any ownership label is authoritative. A partial, stale, or foreign set
|
|
888
|
+
// must never be reinterpreted through the legacy registry fallback.
|
|
889
|
+
if (actualGitZoneLabels.length > 0 || Object.keys(inspectionArg.labels).length > 0) {
|
|
890
|
+
throw refusal();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Containers created before labels were introduced may be reclaimed only
|
|
894
|
+
// when the old global registry has one unambiguous claim for this exact
|
|
895
|
+
// name and the current resolved project path.
|
|
896
|
+
const currentProjectPath = plugins.path.resolve(process.cwd());
|
|
897
|
+
const registryProjects = await this.globalRegistry.getAllProjects();
|
|
898
|
+
const claims = Object.entries(registryProjects).filter(
|
|
899
|
+
([, project]) => project.containers.minio === inspectionArg.name,
|
|
900
|
+
);
|
|
901
|
+
if (claims.length !== 1) {
|
|
902
|
+
throw refusal();
|
|
903
|
+
}
|
|
904
|
+
const [registryPath, project] = claims[0];
|
|
905
|
+
if (
|
|
906
|
+
plugins.path.resolve(registryPath) !== currentProjectPath ||
|
|
907
|
+
plugins.path.resolve(project.projectPath) !== currentProjectPath
|
|
908
|
+
) {
|
|
909
|
+
throw refusal();
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private async revalidateMinioContainer(
|
|
914
|
+
containerIdArg: string,
|
|
915
|
+
expectedContainerNameArg: string,
|
|
916
|
+
expectedLabelsArg: Record<string, string>,
|
|
917
|
+
allowLegacyRegistryArg: boolean,
|
|
918
|
+
dockerTimeoutArg: () => { timeoutMs: number },
|
|
919
|
+
): Promise<IMinioContainerInspection> {
|
|
920
|
+
const inspection = await this.inspectMinioContainer(
|
|
921
|
+
containerIdArg,
|
|
922
|
+
expectedContainerNameArg,
|
|
923
|
+
dockerTimeoutArg,
|
|
924
|
+
);
|
|
925
|
+
if (inspection.id !== containerIdArg) {
|
|
926
|
+
throw new Error(
|
|
927
|
+
`Refusing to use MinIO container ${expectedContainerNameArg}: ` +
|
|
928
|
+
'its immutable id changed during reconciliation.',
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
await this.assertMinioOwnership(
|
|
932
|
+
inspection,
|
|
933
|
+
expectedLabelsArg,
|
|
934
|
+
allowLegacyRegistryArg,
|
|
935
|
+
);
|
|
936
|
+
return inspection;
|
|
937
|
+
}
|
|
560
938
|
|
|
561
939
|
/**
|
|
562
940
|
* Start MinIO service
|
|
563
941
|
*/
|
|
564
942
|
public async startMinIO(): Promise<void> {
|
|
565
943
|
logger.log('note', '📦 S3/MinIO:');
|
|
566
|
-
|
|
944
|
+
|
|
567
945
|
const config = this.config.getConfig();
|
|
568
946
|
const containers = this.config.getContainerNames();
|
|
569
947
|
const directories = this.config.getDataDirectories();
|
|
570
|
-
|
|
948
|
+
|
|
949
|
+
// Validation lives on the mutating path so recovery commands remain usable.
|
|
950
|
+
const bucketValidationError = getS3BucketNameValidationError(config.S3_BUCKET);
|
|
951
|
+
if (bucketValidationError) {
|
|
952
|
+
throw new Error(
|
|
953
|
+
`Invalid S3_BUCKET in .nogit/env.json: ${bucketValidationError}. ` +
|
|
954
|
+
'Choose a valid custom bucket name or remove the field to regenerate the project default.',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
571
958
|
// Ensure data directory exists and is marked as tool-owned
|
|
572
959
|
const minioLabels = await this.prepareDataDirectory('minio', directories.minio);
|
|
960
|
+
const deadlineAt = this.minioClock.now() + minioReconciliationTimeoutMs;
|
|
961
|
+
const dockerTimeout = (): { timeoutMs: number } => {
|
|
962
|
+
const remaining = Math.floor(deadlineAt - this.minioClock.now());
|
|
963
|
+
if (remaining <= 0) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
'MinIO container setup did not complete before the shared reconciliation deadline.',
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return { timeoutMs: Math.max(1, remaining) };
|
|
969
|
+
};
|
|
573
970
|
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
// Check if port mapping matches config
|
|
583
|
-
const minioPortMappings = await this.docker.getPortMappings(containers.minio);
|
|
584
|
-
if (minioPortMappings &&
|
|
585
|
-
(minioPortMappings['9000'] !== config.S3_PORT ||
|
|
586
|
-
minioPortMappings['9001'] !== config.S3_CONSOLE_PORT)) {
|
|
587
|
-
logger.log('note', ' Port configuration changed, recreating container...');
|
|
588
|
-
await this.docker.remove(containers.minio, true);
|
|
589
|
-
// Fall through to create new container
|
|
590
|
-
const success = await this.docker.run({
|
|
591
|
-
name: containers.minio,
|
|
592
|
-
image: 'minio/minio',
|
|
593
|
-
ports: {
|
|
594
|
-
[config.S3_PORT]: '9000',
|
|
595
|
-
[config.S3_CONSOLE_PORT]: '9001'
|
|
596
|
-
},
|
|
597
|
-
volumes: {
|
|
598
|
-
[directories.minio]: '/data'
|
|
599
|
-
},
|
|
600
|
-
environment: {
|
|
601
|
-
MINIO_ROOT_USER: config.S3_ACCESSKEY,
|
|
602
|
-
MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
|
|
603
|
-
},
|
|
604
|
-
labels: minioLabels,
|
|
605
|
-
restart: 'unless-stopped',
|
|
606
|
-
command: 'server /data --console-address ":9001"'
|
|
607
|
-
});
|
|
971
|
+
const startedContainerIds: string[] = [];
|
|
972
|
+
const startById = async (containerIdArg: string): Promise<void> => {
|
|
973
|
+
const executionOptions = dockerTimeout();
|
|
974
|
+
// Record ownership of the lifecycle before awaiting Docker: a timed-out
|
|
975
|
+
// start may still have reached the daemon and must be rolled back.
|
|
976
|
+
startedContainerIds.push(containerIdArg);
|
|
977
|
+
await this.docker.execArgv(['start', containerIdArg], executionOptions);
|
|
978
|
+
};
|
|
608
979
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
case 'not_exists':
|
|
641
|
-
logger.log('note', ' Creating container...');
|
|
642
|
-
|
|
643
|
-
const success = await this.docker.run({
|
|
644
|
-
name: containers.minio,
|
|
645
|
-
image: 'minio/minio',
|
|
646
|
-
ports: {
|
|
647
|
-
[config.S3_PORT]: '9000',
|
|
648
|
-
[config.S3_CONSOLE_PORT]: '9001'
|
|
649
|
-
},
|
|
650
|
-
volumes: {
|
|
651
|
-
[directories.minio]: '/data'
|
|
652
|
-
},
|
|
653
|
-
environment: {
|
|
654
|
-
MINIO_ROOT_USER: config.S3_ACCESSKEY,
|
|
655
|
-
MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
|
|
656
|
-
},
|
|
657
|
-
labels: minioLabels,
|
|
658
|
-
restart: 'unless-stopped',
|
|
659
|
-
command: 'server /data --console-address ":9001"'
|
|
660
|
-
});
|
|
980
|
+
let outcome: string;
|
|
981
|
+
try {
|
|
982
|
+
const listResult = await this.docker.execArgv(
|
|
983
|
+
[
|
|
984
|
+
'container',
|
|
985
|
+
'ls',
|
|
986
|
+
'-a',
|
|
987
|
+
'--filter',
|
|
988
|
+
`name=${containers.minio}`,
|
|
989
|
+
'--format',
|
|
990
|
+
'{{.Names}}',
|
|
991
|
+
],
|
|
992
|
+
dockerTimeout(),
|
|
993
|
+
);
|
|
994
|
+
const containerExists = listResult.stdout
|
|
995
|
+
.split(/\r?\n/u)
|
|
996
|
+
.map((lineArg) => lineArg.trim())
|
|
997
|
+
.includes(containers.minio);
|
|
998
|
+
|
|
999
|
+
let initialInspection: IMinioContainerInspection | undefined;
|
|
1000
|
+
if (containerExists) {
|
|
1001
|
+
initialInspection = await this.inspectMinioContainer(
|
|
1002
|
+
containers.minio,
|
|
1003
|
+
containers.minio,
|
|
1004
|
+
dockerTimeout,
|
|
1005
|
+
);
|
|
1006
|
+
// No existing container is started or receives an in-container command
|
|
1007
|
+
// until it proves current or unambiguous legacy ownership.
|
|
1008
|
+
await this.assertMinioOwnership(initialInspection, minioLabels, true);
|
|
1009
|
+
}
|
|
661
1010
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1011
|
+
let needsRecreate = false;
|
|
1012
|
+
let credentialsChanged = false;
|
|
1013
|
+
if (initialInspection) {
|
|
1014
|
+
const apiMatches = minioPortMatches(
|
|
1015
|
+
initialInspection.portBindings,
|
|
1016
|
+
'9000/tcp',
|
|
1017
|
+
config.S3_PORT,
|
|
1018
|
+
);
|
|
1019
|
+
const consoleMatches = minioPortMatches(
|
|
1020
|
+
initialInspection.portBindings,
|
|
1021
|
+
'9001/tcp',
|
|
1022
|
+
config.S3_CONSOLE_PORT,
|
|
1023
|
+
);
|
|
1024
|
+
const readExactEnvironmentValue = (nameArg: string): string | undefined => {
|
|
1025
|
+
const prefix = `${nameArg}=`;
|
|
1026
|
+
const matches = initialInspection.environmentEntries.filter((entryArg) =>
|
|
1027
|
+
entryArg.startsWith(prefix),
|
|
677
1028
|
);
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1029
|
+
return matches.length === 1 ? matches[0].slice(prefix.length) : undefined;
|
|
1030
|
+
};
|
|
1031
|
+
credentialsChanged =
|
|
1032
|
+
readExactEnvironmentValue('MINIO_ROOT_USER') !== config.S3_ACCESSKEY ||
|
|
1033
|
+
readExactEnvironmentValue('MINIO_ROOT_PASSWORD') !== config.S3_SECRETKEY;
|
|
1034
|
+
needsRecreate = !apiMatches || !consoleMatches || credentialsChanged;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
let activeInspection: IMinioContainerInspection;
|
|
1038
|
+
let activeAllowsLegacyRegistry: boolean;
|
|
1039
|
+
if (initialInspection && needsRecreate) {
|
|
1040
|
+
logger.log(
|
|
1041
|
+
'note',
|
|
1042
|
+
credentialsChanged
|
|
1043
|
+
? ' Credential configuration changed, recreating container...'
|
|
1044
|
+
: ' Port configuration changed, recreating container...',
|
|
1045
|
+
);
|
|
1046
|
+
await this.revalidateMinioContainer(
|
|
1047
|
+
initialInspection.id,
|
|
1048
|
+
containers.minio,
|
|
1049
|
+
minioLabels,
|
|
1050
|
+
true,
|
|
1051
|
+
dockerTimeout,
|
|
1052
|
+
);
|
|
1053
|
+
await this.docker.execArgv(['rm', '-f', initialInspection.id], dockerTimeout());
|
|
1054
|
+
activeInspection = await this.createMinioContainer(minioLabels, dockerTimeout);
|
|
1055
|
+
activeAllowsLegacyRegistry = false;
|
|
1056
|
+
await startById(activeInspection.id);
|
|
1057
|
+
outcome = 'Recreated with configured ports and reconciled';
|
|
1058
|
+
} else if (initialInspection && !initialInspection.running) {
|
|
1059
|
+
logger.log('note', ' Starting existing container...');
|
|
1060
|
+
activeInspection = initialInspection;
|
|
1061
|
+
activeAllowsLegacyRegistry = true;
|
|
1062
|
+
await startById(activeInspection.id);
|
|
1063
|
+
outcome = 'Started and reconciled';
|
|
1064
|
+
} else if (!initialInspection) {
|
|
1065
|
+
logger.log('note', ' Creating container...');
|
|
1066
|
+
activeInspection = await this.createMinioContainer(minioLabels, dockerTimeout);
|
|
1067
|
+
activeAllowsLegacyRegistry = false;
|
|
1068
|
+
await startById(activeInspection.id);
|
|
1069
|
+
outcome = 'Created, started, and reconciled';
|
|
1070
|
+
} else {
|
|
1071
|
+
logger.log('note', ' Already running; reconciling bucket...');
|
|
1072
|
+
activeInspection = initialInspection;
|
|
1073
|
+
activeAllowsLegacyRegistry = true;
|
|
1074
|
+
outcome = 'Already running and reconciled';
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const reconciliationInspection = await this.revalidateMinioContainer(
|
|
1078
|
+
activeInspection.id,
|
|
1079
|
+
containers.minio,
|
|
1080
|
+
minioLabels,
|
|
1081
|
+
activeAllowsLegacyRegistry,
|
|
1082
|
+
dockerTimeout,
|
|
1083
|
+
);
|
|
1084
|
+
await reconcileMinioBucket({
|
|
1085
|
+
docker: this.docker,
|
|
1086
|
+
containerId: reconciliationInspection.id,
|
|
1087
|
+
bucket: config.S3_BUCKET,
|
|
1088
|
+
deadlineAt,
|
|
1089
|
+
clock: this.minioClock,
|
|
1090
|
+
});
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
const originalError = normalizeMinioLifecycleError(error);
|
|
1093
|
+
const rollbackErrors: Error[] = [];
|
|
1094
|
+
for (let index = startedContainerIds.length - 1; index >= 0; index--) {
|
|
1095
|
+
try {
|
|
1096
|
+
await this.docker.execArgv(['stop', startedContainerIds[index]], {
|
|
1097
|
+
timeoutMs: minioRollbackTimeoutMs,
|
|
1098
|
+
});
|
|
1099
|
+
} catch (rollbackError) {
|
|
1100
|
+
rollbackErrors.push(normalizeMinioLifecycleError(rollbackError));
|
|
682
1101
|
}
|
|
683
|
-
|
|
1102
|
+
}
|
|
1103
|
+
if (rollbackErrors.length > 0) {
|
|
1104
|
+
throw new AggregateError(
|
|
1105
|
+
[originalError, ...rollbackErrors],
|
|
1106
|
+
'MinIO startup failed and rollback could not stop every container started by this invocation.',
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
throw originalError;
|
|
684
1110
|
}
|
|
685
|
-
|
|
1111
|
+
|
|
1112
|
+
logger.log('ok', ` ${outcome} ✓`);
|
|
1113
|
+
logger.log('ok', ` Bucket '${config.S3_BUCKET}' exists and is authenticated ✓`);
|
|
686
1114
|
logger.log('info', ` Container: ${containers.minio}`);
|
|
687
1115
|
logger.log('info', ` Port: ${config.S3_PORT}`);
|
|
688
1116
|
logger.log('info', ` Bucket: ${config.S3_BUCKET}`);
|
|
689
1117
|
logger.log('info', ` API: http://${config.S3_HOST}:${config.S3_PORT}`);
|
|
690
|
-
logger.log(
|
|
1118
|
+
logger.log(
|
|
1119
|
+
'info',
|
|
1120
|
+
` Console: http://${config.S3_HOST}:${config.S3_CONSOLE_PORT} (credentials: ***)`,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Create stopped MinIO, inspect its id, and prove exact current labels. */
|
|
1125
|
+
private async createMinioContainer(
|
|
1126
|
+
labelsArg: { [key: string]: string },
|
|
1127
|
+
dockerTimeoutArg: () => { timeoutMs: number },
|
|
1128
|
+
): Promise<IMinioContainerInspection> {
|
|
1129
|
+
const config = this.config.getConfig();
|
|
1130
|
+
const containers = this.config.getContainerNames();
|
|
1131
|
+
const directories = this.config.getDataDirectories();
|
|
1132
|
+
const creationResult = await this.docker.createArgv(
|
|
1133
|
+
{
|
|
1134
|
+
name: containers.minio,
|
|
1135
|
+
image: 'minio/minio',
|
|
1136
|
+
ports: {
|
|
1137
|
+
[config.S3_PORT]: '9000',
|
|
1138
|
+
[config.S3_CONSOLE_PORT]: '9001',
|
|
1139
|
+
},
|
|
1140
|
+
volumes: {
|
|
1141
|
+
[directories.minio]: '/data',
|
|
1142
|
+
},
|
|
1143
|
+
environment: {
|
|
1144
|
+
MINIO_ROOT_USER: config.S3_ACCESSKEY,
|
|
1145
|
+
MINIO_ROOT_PASSWORD: config.S3_SECRETKEY,
|
|
1146
|
+
},
|
|
1147
|
+
labels: labelsArg,
|
|
1148
|
+
restart: 'unless-stopped',
|
|
1149
|
+
command: ['server', '/data', '--console-address', ':9001'],
|
|
1150
|
+
},
|
|
1151
|
+
dockerTimeoutArg(),
|
|
1152
|
+
);
|
|
1153
|
+
const containerId = creationResult.stdout.trim();
|
|
1154
|
+
if (!/^[a-f0-9]{64}$/u.test(containerId)) {
|
|
1155
|
+
throw new Error(`Docker returned an invalid id while creating ${containers.minio}.`);
|
|
1156
|
+
}
|
|
1157
|
+
const inspection = await this.inspectMinioContainer(
|
|
1158
|
+
containerId,
|
|
1159
|
+
containers.minio,
|
|
1160
|
+
dockerTimeoutArg,
|
|
1161
|
+
);
|
|
1162
|
+
if (inspection.id !== containerId) {
|
|
1163
|
+
throw new Error(
|
|
1164
|
+
`Refusing to start MinIO container ${containers.minio}: ` +
|
|
1165
|
+
'Docker inspection did not return the created immutable id.',
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
await this.assertMinioOwnership(inspection, labelsArg, false);
|
|
1169
|
+
return inspection;
|
|
691
1170
|
}
|
|
692
1171
|
|
|
693
1172
|
/**
|
|
@@ -1103,7 +1582,7 @@ export class ServiceManager {
|
|
|
1103
1582
|
logger.log('info', ` Host: ${config.S3_HOST}`);
|
|
1104
1583
|
logger.log('info', ` API Port: ${config.S3_PORT}`);
|
|
1105
1584
|
logger.log('info', ` Console Port: ${config.S3_CONSOLE_PORT}`);
|
|
1106
|
-
logger.log('info',
|
|
1585
|
+
logger.log('info', ' Access Key: ***');
|
|
1107
1586
|
logger.log('info', ' Secret Key: ***');
|
|
1108
1587
|
logger.log('info', ` Bucket: ${config.S3_BUCKET}`);
|
|
1109
1588
|
logger.log('info', ` Use SSL: ${config.S3_USESSL}`);
|