@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.
@@ -1,9 +1,13 @@
1
1
  import * as plugins from './mod.plugins.js';
2
2
  import * as helpers from './helpers.js';
3
- import { ServiceConfiguration } from './classes.serviceconfiguration.js';
3
+ import {
4
+ ServiceConfiguration,
5
+ getS3BucketNameValidationError,
6
+ } from './classes.serviceconfiguration.js';
4
7
  import {
5
8
  DockerContainer,
6
9
  matchDockerUlimits,
10
+ type IDockerExecArgvResult,
7
11
  type TDockerUlimits,
8
12
  } from './classes.dockercontainer.js';
9
13
  import { GlobalRegistry } from './classes.globalregistry.js';
@@ -55,11 +59,254 @@ export const mongoServiceUlimits = {
55
59
  },
56
60
  } as const satisfies TDockerUlimits;
57
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
+
58
304
  export class ServiceManager {
59
305
  private config: ServiceConfiguration;
60
306
  private docker: DockerContainer;
61
307
  private enabledServices: string[] | null = null;
62
308
  private globalRegistry: GlobalRegistry;
309
+ private minioClock: IMinioReconciliationClock = defaultMinioClock;
63
310
 
64
311
  constructor() {
65
312
  this.config = new ServiceConfiguration();
@@ -598,137 +845,328 @@ export class ServiceManager {
598
845
  logger.log('error', ' Replica set not ready after 30s — transactions unavailable');
599
846
  return false;
600
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
+ }
601
938
 
602
939
  /**
603
940
  * Start MinIO service
604
941
  */
605
942
  public async startMinIO(): Promise<void> {
606
943
  logger.log('note', '📦 S3/MinIO:');
607
-
944
+
608
945
  const config = this.config.getConfig();
609
946
  const containers = this.config.getContainerNames();
610
947
  const directories = this.config.getDataDirectories();
611
-
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
+
612
958
  // Ensure data directory exists and is marked as tool-owned
613
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
+ };
614
970
 
615
- const status = await this.docker.getStatus(containers.minio);
616
-
617
- switch (status) {
618
- case 'running':
619
- logger.log('ok', ' Already running ✓');
620
- break;
621
-
622
- case 'stopped':
623
- // Check if port mapping matches config
624
- const minioPortMappings = await this.docker.getPortMappings(containers.minio);
625
- if (minioPortMappings &&
626
- (minioPortMappings['9000'] !== config.S3_PORT ||
627
- minioPortMappings['9001'] !== config.S3_CONSOLE_PORT)) {
628
- logger.log('note', ' Port configuration changed, recreating container...');
629
- await this.docker.remove(containers.minio, true);
630
- // Fall through to create new container
631
- const success = await this.docker.run({
632
- name: containers.minio,
633
- image: 'minio/minio',
634
- ports: {
635
- [config.S3_PORT]: '9000',
636
- [config.S3_CONSOLE_PORT]: '9001'
637
- },
638
- volumes: {
639
- [directories.minio]: '/data'
640
- },
641
- environment: {
642
- MINIO_ROOT_USER: config.S3_ACCESSKEY,
643
- MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
644
- },
645
- labels: minioLabels,
646
- restart: 'unless-stopped',
647
- command: 'server /data --console-address ":9001"'
648
- });
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
+ };
649
979
 
650
- if (success) {
651
- logger.log('ok', ' Recreated with new ports ✓');
652
-
653
- // Wait for MinIO to be ready
654
- await plugins.smartdelay.delayFor(3000);
655
-
656
- // Create default bucket
657
- await this.docker.exec(
658
- containers.minio,
659
- `mc alias set local http://localhost:9000 ${config.S3_ACCESSKEY} ${config.S3_SECRETKEY}`
660
- );
661
-
662
- await this.docker.exec(
663
- containers.minio,
664
- `mc mb local/${config.S3_BUCKET}`
665
- );
666
-
667
- logger.log('ok', ` Bucket '${config.S3_BUCKET}' created ✓`);
668
- } else {
669
- logger.log('error', ' Failed to recreate container');
670
- }
671
- } else {
672
- // Ports match, just start the container
673
- if (await this.docker.start(containers.minio)) {
674
- logger.log('ok', ' Started ✓');
675
- } else {
676
- logger.log('error', ' Failed to start');
677
- }
678
- }
679
- break;
680
-
681
- case 'not_exists':
682
- logger.log('note', ' Creating container...');
683
-
684
- const success = await this.docker.run({
685
- name: containers.minio,
686
- image: 'minio/minio',
687
- ports: {
688
- [config.S3_PORT]: '9000',
689
- [config.S3_CONSOLE_PORT]: '9001'
690
- },
691
- volumes: {
692
- [directories.minio]: '/data'
693
- },
694
- environment: {
695
- MINIO_ROOT_USER: config.S3_ACCESSKEY,
696
- MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
697
- },
698
- labels: minioLabels,
699
- restart: 'unless-stopped',
700
- command: 'server /data --console-address ":9001"'
701
- });
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
+ }
702
1010
 
703
- if (success) {
704
- logger.log('ok', ' Created and started ✓');
705
-
706
- // Wait for MinIO to be ready
707
- await plugins.smartdelay.delayFor(3000);
708
-
709
- // Create default bucket
710
- await this.docker.exec(
711
- containers.minio,
712
- `mc alias set local http://localhost:9000 ${config.S3_ACCESSKEY} ${config.S3_SECRETKEY}`
713
- );
714
-
715
- await this.docker.exec(
716
- containers.minio,
717
- `mc mb local/${config.S3_BUCKET}`
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),
718
1028
  );
719
-
720
- logger.log('ok', ` Bucket '${config.S3_BUCKET}' created ✓`);
721
- } else {
722
- logger.log('error', ' Failed to create container');
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));
723
1101
  }
724
- break;
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;
725
1110
  }
726
-
1111
+
1112
+ logger.log('ok', ` ${outcome} ✓`);
1113
+ logger.log('ok', ` Bucket '${config.S3_BUCKET}' exists and is authenticated ✓`);
727
1114
  logger.log('info', ` Container: ${containers.minio}`);
728
1115
  logger.log('info', ` Port: ${config.S3_PORT}`);
729
1116
  logger.log('info', ` Bucket: ${config.S3_BUCKET}`);
730
1117
  logger.log('info', ` API: http://${config.S3_HOST}:${config.S3_PORT}`);
731
- logger.log('info', ` Console: http://${config.S3_HOST}:${config.S3_CONSOLE_PORT} (login: ${config.S3_ACCESSKEY}/***)`);
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;
732
1170
  }
733
1171
 
734
1172
  /**
@@ -1144,7 +1582,7 @@ export class ServiceManager {
1144
1582
  logger.log('info', ` Host: ${config.S3_HOST}`);
1145
1583
  logger.log('info', ` API Port: ${config.S3_PORT}`);
1146
1584
  logger.log('info', ` Console Port: ${config.S3_CONSOLE_PORT}`);
1147
- logger.log('info', ` Access Key: ${config.S3_ACCESSKEY}`);
1585
+ logger.log('info', ' Access Key: ***');
1148
1586
  logger.log('info', ' Secret Key: ***');
1149
1587
  logger.log('info', ` Bucket: ${config.S3_BUCKET}`);
1150
1588
  logger.log('info', ` Use SSL: ${config.S3_USESSL}`);