@git.zone/cli 3.2.1 → 5.0.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.
Files changed (135) hide show
  1. package/.smartconfig.json +0 -1
  2. package/dist_ts/00_commitinfo_data.js +2 -2
  3. package/dist_ts/classes.gitzoneconfig.js +1 -1
  4. package/dist_ts/classes.project.js +1 -1
  5. package/dist_ts/gitzone.cli.js +1 -1
  6. package/dist_ts/gitzone.logging.js +1 -1
  7. package/dist_ts/helpers.changelog.js +1 -1
  8. package/dist_ts/helpers.climode.js +1 -1
  9. package/dist_ts/helpers.smartconfig.js +61 -11
  10. package/dist_ts/helpers.smartconfigmigrations.js +1 -1
  11. package/dist_ts/helpers.tsdocker.js +1 -1
  12. package/dist_ts/helpers.workflow.d.ts +0 -2
  13. package/dist_ts/helpers.workflow.js +23 -12
  14. package/dist_ts/index.js +1 -1
  15. package/dist_ts/mod_audit/index.js +1 -1
  16. package/dist_ts/mod_commit/index.js +1 -1
  17. package/dist_ts/mod_commit/mod.helpers.d.ts +3 -3
  18. package/dist_ts/mod_commit/mod.helpers.js +10 -10
  19. package/dist_ts/mod_commit/mod.plugins.js +1 -1
  20. package/dist_ts/mod_commit/mod.ui.js +1 -1
  21. package/dist_ts/mod_config/classes.commitconfig.js +1 -1
  22. package/dist_ts/mod_config/classes.releaseconfig.js +1 -1
  23. package/dist_ts/mod_config/index.js +23 -24
  24. package/dist_ts/mod_config/mod.plugins.js +1 -1
  25. package/dist_ts/mod_deprecate/index.js +1 -1
  26. package/dist_ts/mod_deprecate/mod.plugins.js +1 -1
  27. package/dist_ts/mod_docker/classes.dockerpruner.js +7 -1
  28. package/dist_ts/mod_docker/index.js +1 -1
  29. package/dist_ts/mod_docker/mod.plugins.js +1 -1
  30. package/dist_ts/mod_format/classes.baseformatter.js +1 -1
  31. package/dist_ts/mod_format/classes.diffreporter.js +1 -1
  32. package/dist_ts/mod_format/classes.formatcontext.js +1 -1
  33. package/dist_ts/mod_format/classes.formatplanner.js +1 -1
  34. package/dist_ts/mod_format/classes.formatstats.js +1 -1
  35. package/dist_ts/mod_format/formatters/assets.formatter.js +1 -1
  36. package/dist_ts/mod_format/formatters/cleanup.formatter.js +1 -1
  37. package/dist_ts/mod_format/formatters/copy.formatter.js +1 -1
  38. package/dist_ts/mod_format/formatters/gitignore.formatter.js +1 -1
  39. package/dist_ts/mod_format/formatters/license.formatter.js +1 -1
  40. package/dist_ts/mod_format/formatters/packagejson.formatter.js +1 -1
  41. package/dist_ts/mod_format/formatters/prettier.formatter.js +1 -1
  42. package/dist_ts/mod_format/formatters/readme.formatter.js +1 -1
  43. package/dist_ts/mod_format/formatters/smartconfig.formatter.js +1 -1
  44. package/dist_ts/mod_format/formatters/templates.formatter.js +1 -1
  45. package/dist_ts/mod_format/formatters/tsconfig.formatter.js +1 -1
  46. package/dist_ts/mod_format/index.js +1 -1
  47. package/dist_ts/mod_format/interfaces.format.js +1 -1
  48. package/dist_ts/mod_format/mod.plugins.js +1 -1
  49. package/dist_ts/mod_helpers/index.js +1 -1
  50. package/dist_ts/mod_helpers/mod.plugins.js +1 -1
  51. package/dist_ts/mod_meta/index.js +1 -1
  52. package/dist_ts/mod_meta/meta.classes.meta.js +1 -1
  53. package/dist_ts/mod_meta/meta.interfaces.js +1 -1
  54. package/dist_ts/mod_meta/meta.plugins.js +1 -1
  55. package/dist_ts/mod_open/index.js +1 -1
  56. package/dist_ts/mod_open/mod.plugins.js +1 -1
  57. package/dist_ts/mod_release/helpers.releasebranch.d.ts +46 -0
  58. package/dist_ts/mod_release/helpers.releasebranch.js +579 -0
  59. package/dist_ts/mod_release/index.d.ts +10 -0
  60. package/dist_ts/mod_release/index.js +300 -92
  61. package/dist_ts/mod_release/mod.plugins.js +1 -1
  62. package/dist_ts/mod_services/classes.dockercontainer.d.ts +50 -3
  63. package/dist_ts/mod_services/classes.dockercontainer.js +646 -84
  64. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -24
  65. package/dist_ts/mod_services/classes.globalregistry.js +210 -62
  66. package/dist_ts/mod_services/classes.globalregistrystore.d.ts +48 -0
  67. package/dist_ts/mod_services/classes.globalregistrystore.js +156 -0
  68. package/dist_ts/mod_services/classes.interprocesslock.d.ts +21 -0
  69. package/dist_ts/mod_services/classes.interprocesslock.js +141 -0
  70. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +15 -12
  71. package/dist_ts/mod_services/classes.serviceconfiguration.js +188 -91
  72. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +32 -7
  73. package/dist_ts/mod_services/classes.servicedatamarker.js +145 -49
  74. package/dist_ts/mod_services/classes.servicemanager.d.ts +70 -28
  75. package/dist_ts/mod_services/classes.servicemanager.js +995 -421
  76. package/dist_ts/mod_services/classes.servicenames.d.ts +8 -0
  77. package/dist_ts/mod_services/classes.servicenames.js +54 -0
  78. package/dist_ts/mod_services/classes.serviceoptions.js +1 -1
  79. package/dist_ts/mod_services/classes.servicepruner.d.ts +3 -0
  80. package/dist_ts/mod_services/classes.servicepruner.js +208 -46
  81. package/dist_ts/mod_services/helpers.js +1 -1
  82. package/dist_ts/mod_services/index.d.ts +2 -0
  83. package/dist_ts/mod_services/index.js +81 -58
  84. package/dist_ts/mod_services/mod.plugins.js +1 -1
  85. package/dist_ts/mod_standard/index.js +2 -2
  86. package/dist_ts/mod_standard/mod.plugins.js +1 -1
  87. package/dist_ts/mod_start/index.js +1 -1
  88. package/dist_ts/mod_start/mod.plugins.js +1 -1
  89. package/dist_ts/mod_template/index.js +1 -1
  90. package/dist_ts/mod_template/mod.plugins.js +1 -1
  91. package/dist_ts/mod_tools/classes.packagemanager.js +1 -1
  92. package/dist_ts/mod_tools/index.js +1 -1
  93. package/dist_ts/mod_tools/mod.plugins.js +1 -1
  94. package/dist_ts/paths.js +1 -1
  95. package/dist_ts/plugins.d.ts +3 -1
  96. package/dist_ts/plugins.js +4 -2
  97. package/dist_ts_migration/001.service-selection.d.ts +9 -0
  98. package/dist_ts_migration/001.service-selection.js +51 -0
  99. package/dist_ts_migration/002.runtime-config.d.ts +19 -0
  100. package/dist_ts_migration/002.runtime-config.js +170 -0
  101. package/dist_ts_migration/003.service-data-marker.d.ts +9 -0
  102. package/dist_ts_migration/003.service-data-marker.js +156 -0
  103. package/dist_ts_migration/004.global-registry.d.ts +13 -0
  104. package/dist_ts_migration/004.global-registry.js +165 -0
  105. package/dist_ts_migration/index.d.ts +11 -0
  106. package/dist_ts_migration/index.js +59 -0
  107. package/package.json +6 -3
  108. package/readme.hints.md +102 -25
  109. package/readme.md +144 -41
  110. package/readme.plan.md +29 -16
  111. package/ts/00_commitinfo_data.ts +1 -1
  112. package/ts/helpers.smartconfig.ts +63 -10
  113. package/ts/helpers.workflow.ts +41 -12
  114. package/ts/mod_commit/mod.helpers.ts +12 -8
  115. package/ts/mod_config/index.ts +22 -23
  116. package/ts/mod_docker/classes.dockerpruner.ts +6 -0
  117. package/ts/mod_release/helpers.releasebranch.ts +1282 -0
  118. package/ts/mod_release/index.ts +608 -129
  119. package/ts/mod_services/classes.dockercontainer.ts +912 -96
  120. package/ts/mod_services/classes.globalregistry.ts +290 -89
  121. package/ts/mod_services/classes.globalregistrystore.ts +279 -0
  122. package/ts/mod_services/classes.interprocesslock.ts +189 -0
  123. package/ts/mod_services/classes.serviceconfiguration.ts +215 -108
  124. package/ts/mod_services/classes.servicedatamarker.ts +210 -52
  125. package/ts/mod_services/classes.servicemanager.ts +1332 -512
  126. package/ts/mod_services/classes.servicenames.ts +76 -0
  127. package/ts/mod_services/classes.servicepruner.ts +310 -52
  128. package/ts/mod_services/index.ts +89 -62
  129. package/ts/mod_standard/index.ts +1 -1
  130. package/ts/plugins.ts +4 -0
  131. package/ts_migration/001.service-selection.ts +77 -0
  132. package/ts_migration/002.runtime-config.ts +218 -0
  133. package/ts_migration/003.service-data-marker.ts +216 -0
  134. package/ts_migration/004.global-registry.ts +272 -0
  135. package/ts_migration/index.ts +83 -0
@@ -3,21 +3,43 @@ import * as helpers from './helpers.js';
3
3
  import {
4
4
  ServiceConfiguration,
5
5
  getS3BucketNameValidationError,
6
+ type IServiceConfig,
6
7
  } from './classes.serviceconfiguration.js';
7
8
  import {
8
9
  DockerContainer,
9
10
  matchDockerUlimits,
10
- type IDockerExecArgvResult,
11
+ type IContainerInspectInfo,
11
12
  type TDockerUlimits,
12
13
  } from './classes.dockercontainer.js';
13
14
  import { GlobalRegistry } from './classes.globalregistry.js';
14
15
  import {
16
+ getLegacyMinioDataDirectory,
15
17
  getServiceContainerLabels,
18
+ getServiceDataDirectory,
19
+ hasExactServiceContainerLabels,
20
+ markerProvesServiceDataDirectory,
21
+ objectStorageImage,
22
+ readServiceDataMarker,
16
23
  recordServiceDataDirectory,
24
+ serviceBindOperation,
17
25
  serviceImages,
26
+ serviceNames,
27
+ serviceOperationLabel,
28
+ serviceToolLabel,
18
29
  type TServiceName,
19
30
  } from './classes.servicedatamarker.js';
31
+ import { readPersistedServiceConfiguration } from './classes.servicenames.js';
32
+ import { withServiceDataOperationLock } from './classes.interprocesslock.js';
33
+ import {
34
+ runServiceMigrations,
35
+ type TServiceMigrationScope,
36
+ } from '../../ts_migration/index.js';
20
37
  import { logger } from '../gitzone.logging.js';
38
+ import {
39
+ readSmartconfigFile,
40
+ setCliConfigValueInData,
41
+ writeSmartconfigFile,
42
+ } from '../helpers.smartconfig.js';
21
43
  import type { ContainerStatus } from './classes.dockercontainer.js';
22
44
  import type { TServiceOptionSource } from './classes.serviceoptions.js';
23
45
 
@@ -36,8 +58,35 @@ export interface IServiceStatus {
36
58
  authEnabled?: boolean;
37
59
  /** MongoDB only: whether the auth mode is declared in committed config. */
38
60
  authSource?: TServiceOptionSource;
61
+ /** ObjectStorage only: an unowned same-name container is a conflict, not managed state. */
62
+ ownership?: 'owned' | 'conflict' | 'absent';
63
+ managementUi?: string;
39
64
  }
40
65
 
66
+ export type TServiceManagerOperation =
67
+ | 'auth'
68
+ | 'clean'
69
+ | 'compass'
70
+ | 'config'
71
+ | 'logs'
72
+ | 'remove'
73
+ | 'reconfigure'
74
+ | 'restart'
75
+ | 'start'
76
+ | 'status'
77
+ | 'stop';
78
+
79
+ export const getServiceManagerMigrationScopes = (
80
+ operationArg: TServiceManagerOperation,
81
+ ): TServiceMigrationScope[] => [
82
+ 'global-registry',
83
+ 'service-selection',
84
+ 'runtime-config',
85
+ ...(['start', 'restart', 'reconfigure', 'clean'].includes(operationArg)
86
+ ? (['service-data-marker'] as const)
87
+ : []),
88
+ ];
89
+
41
90
  export interface IServicesStatus {
42
91
  project: {
43
92
  name: string;
@@ -46,9 +95,14 @@ export interface IServicesStatus {
46
95
  };
47
96
  services: {
48
97
  mongodb: IServiceStatus;
49
- minio: IServiceStatus;
98
+ objectstorage: IServiceStatus;
50
99
  elasticsearch: IServiceStatus;
51
100
  };
101
+ legacy: {
102
+ minioContainer?: string;
103
+ minioDataDirectory: string;
104
+ minioDataExists: boolean;
105
+ };
52
106
  totalDataBytes: number;
53
107
  }
54
108
 
@@ -59,96 +113,191 @@ export const mongoServiceUlimits = {
59
113
  },
60
114
  } as const satisfies TDockerUlimits;
61
115
 
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';
116
+ export const objectStorageReconciliationTimeoutMs = 30_000;
117
+ const objectStorageProbeTimeoutCapMs = 2_000;
118
+ const objectStorageProbeIntervalMs = 250;
119
+ const objectStorageRollbackTimeoutMs = 5_000;
69
120
 
70
- export interface IMinioReconciliationClock {
121
+ export interface IObjectStorageReconciliationClock {
71
122
  now(): number;
72
123
  delay(millisecondsArg: number): Promise<void>;
73
124
  }
74
125
 
75
- export interface IMinioReconciliationOptions {
76
- docker: Pick<DockerContainer, 'execArgv'>;
77
- /** Full immutable Docker container id; mutable names are never accepted here. */
78
- containerId: string;
126
+ export interface IObjectStorageBucketReconciliationOptions {
127
+ smartBucket: Pick<plugins.smartbucket.SmartBucket, 'checkReadiness' | 'close'> & {
128
+ storageClient: {
129
+ send(
130
+ commandArg: plugins.s3.HeadBucketCommand | plugins.s3.CreateBucketCommand,
131
+ optionsArg?: { abortSignal?: AbortSignal },
132
+ ): Promise<unknown>;
133
+ };
134
+ };
79
135
  bucket: string;
80
- /** Shared absolute deadline. Defaults to 30 seconds from this call. */
81
136
  deadlineAt?: number;
82
- clock?: IMinioReconciliationClock;
137
+ clock?: IObjectStorageReconciliationClock;
83
138
  }
84
139
 
85
- const defaultMinioClock: IMinioReconciliationClock = {
140
+ const defaultObjectStorageClock: IObjectStorageReconciliationClock = {
86
141
  now: () => Date.now(),
87
142
  delay: async (millisecondsArg) => plugins.smartdelay.delayFor(millisecondsArg),
88
143
  };
89
144
 
90
- class MinioStageError extends Error {
145
+ class ObjectStorageStageError extends Error {
91
146
  constructor(stageArg: string, timedOutArg: boolean, causeArg?: unknown) {
92
147
  const message = timedOutArg
93
- ? `MinIO ${stageArg} did not complete before the shared reconciliation deadline.`
94
- : `MinIO ${stageArg} failed.`;
148
+ ? `ObjectStorage ${stageArg} did not complete before the shared reconciliation deadline.`
149
+ : `ObjectStorage ${stageArg} failed.`;
95
150
  super(message, causeArg === undefined ? undefined : { cause: causeArg });
96
- this.name = 'MinioStageError';
151
+ this.name = 'ObjectStorageStageError';
97
152
  }
98
153
  }
99
154
 
100
- const minioStageError = (
155
+ const objectStorageStageError = (
101
156
  stageArg: string,
102
157
  timedOutArg: boolean,
103
158
  causeArg?: unknown,
104
- ): MinioStageError => {
105
- return new MinioStageError(stageArg, timedOutArg, causeArg);
159
+ ): ObjectStorageStageError => {
160
+ return new ObjectStorageStageError(stageArg, timedOutArg, causeArg);
106
161
  };
107
162
 
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 => {
163
+ const normalizeObjectStorageLifecycleError = (errorArg: unknown): Error => {
116
164
  return errorArg instanceof Error
117
165
  ? errorArg
118
- : new Error('MinIO lifecycle failed with a non-Error rejection.');
166
+ : new Error('ObjectStorage lifecycle failed with a non-Error rejection.');
119
167
  };
120
168
 
121
- interface IMinioContainerInspection {
169
+ interface IObjectStorageContainerInspection {
122
170
  id: string;
123
171
  name: string;
124
172
  running: boolean;
173
+ status: string;
174
+ image: string;
125
175
  portBindings: Record<string, unknown>;
126
176
  environmentEntries: string[];
127
177
  labels: Record<string, string>;
178
+ mounts: Array<{ source: string; destination: string; readWrite: boolean }>;
179
+ restartPolicy: string;
180
+ command: string[];
181
+ entrypoint: string[];
182
+ user: string;
183
+ healthcheck: {
184
+ test: string[];
185
+ interval: number;
186
+ timeout: number;
187
+ startPeriod: number;
188
+ retries: number;
189
+ };
190
+ }
191
+
192
+ interface IObjectStorageContainerIdentity {
193
+ id: string;
194
+ name: string;
195
+ running: boolean;
196
+ status: string;
197
+ labels: Record<string, string>;
128
198
  }
129
199
 
130
200
  const isUnknownRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
131
201
  return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
132
202
  };
133
203
 
134
- const parseMinioInspection = (
204
+ export const objectStorageExpectedEntrypoint = ['/usr/bin/tini', '--'];
205
+ export const objectStorageExpectedCommand = [
206
+ 'deno',
207
+ 'run',
208
+ '--allow-all',
209
+ 'mod.ts',
210
+ 'server',
211
+ ];
212
+ export const objectStorageExpectedHealthcheck = {
213
+ test: [
214
+ 'CMD-SHELL',
215
+ `deno eval "const response = await fetch('http://127.0.0.1:3000/readyz'); Deno.exit(response.ok ? 0 : 1);"`,
216
+ ],
217
+ interval: 30_000_000_000,
218
+ timeout: 5_000_000_000,
219
+ startPeriod: 10_000_000_000,
220
+ retries: 3,
221
+ };
222
+
223
+ const parseObjectStorageIdentity = (
224
+ stdoutArg: string,
225
+ expectedContainerNameArg: string,
226
+ ): IObjectStorageContainerIdentity => {
227
+ let parsed: unknown;
228
+ try {
229
+ parsed = JSON.parse(stdoutArg);
230
+ } catch {
231
+ throw new Error(
232
+ `Docker returned an invalid ObjectStorage identity for ${expectedContainerNameArg}.`,
233
+ );
234
+ }
235
+ if (!Array.isArray(parsed) || parsed.length !== 1 || !isUnknownRecord(parsed[0])) {
236
+ throw new Error(
237
+ `Docker returned an invalid ObjectStorage identity for ${expectedContainerNameArg}.`,
238
+ );
239
+ }
240
+ const inspected = parsed[0];
241
+ const state = inspected.State;
242
+ const config = inspected.Config;
243
+ const normalizedName =
244
+ typeof inspected.Name === 'string' ? inspected.Name.replace(/^\//u, '') : undefined;
245
+ if (
246
+ typeof inspected.Id !== 'string' ||
247
+ !/^[a-f0-9]{64}$/u.test(inspected.Id) ||
248
+ normalizedName !== expectedContainerNameArg ||
249
+ !isUnknownRecord(state) ||
250
+ typeof state.Running !== 'boolean' ||
251
+ typeof state.Status !== 'string' ||
252
+ !isUnknownRecord(config) ||
253
+ (config.Labels !== null && config.Labels !== undefined && !isUnknownRecord(config.Labels))
254
+ ) {
255
+ throw new Error(
256
+ `Docker returned an invalid ObjectStorage identity for ${expectedContainerNameArg}.`,
257
+ );
258
+ }
259
+ const labels: Record<string, string> = {};
260
+ for (const [keyArg, valueArg] of Object.entries(config.Labels || {})) {
261
+ if (typeof valueArg !== 'string') {
262
+ throw new Error(
263
+ `Docker returned an invalid ObjectStorage identity for ${expectedContainerNameArg}.`,
264
+ );
265
+ }
266
+ labels[keyArg] = valueArg;
267
+ }
268
+ return {
269
+ id: inspected.Id,
270
+ name: normalizedName,
271
+ running: state.Running,
272
+ status: state.Status,
273
+ labels,
274
+ };
275
+ };
276
+
277
+ const parseObjectStorageInspection = (
135
278
  stdoutArg: string,
136
279
  expectedContainerNameArg: string,
137
- ): IMinioContainerInspection => {
280
+ ): IObjectStorageContainerInspection => {
138
281
  let parsed: unknown;
139
282
  try {
140
283
  parsed = JSON.parse(stdoutArg);
141
284
  } catch {
142
- throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
285
+ throw new Error(
286
+ `Docker returned an invalid ObjectStorage inspection for ${expectedContainerNameArg}.`,
287
+ );
143
288
  }
144
289
  if (!Array.isArray(parsed) || parsed.length !== 1 || !isUnknownRecord(parsed[0])) {
145
- throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
290
+ throw new Error(
291
+ `Docker returned an invalid ObjectStorage inspection for ${expectedContainerNameArg}.`,
292
+ );
146
293
  }
147
294
 
148
295
  const inspected = parsed[0];
149
296
  const state = inspected.State;
150
297
  const config = inspected.Config;
151
298
  const hostConfig = inspected.HostConfig;
299
+ const healthcheck = isUnknownRecord(config) ? config.Healthcheck : undefined;
300
+ const restartPolicy = isUnknownRecord(hostConfig) ? hostConfig.RestartPolicy : undefined;
152
301
  const normalizedName =
153
302
  typeof inspected.Name === 'string' ? inspected.Name.replace(/^\//u, '') : undefined;
154
303
  if (
@@ -157,41 +306,88 @@ const parseMinioInspection = (
157
306
  normalizedName !== expectedContainerNameArg ||
158
307
  !isUnknownRecord(state) ||
159
308
  typeof state.Running !== 'boolean' ||
309
+ typeof state.Status !== 'string' ||
160
310
  !isUnknownRecord(config) ||
311
+ typeof config.Image !== 'string' ||
312
+ typeof config.User !== 'string' ||
313
+ !Array.isArray(config.Cmd) ||
314
+ !config.Cmd.every((entryArg) => typeof entryArg === 'string') ||
315
+ !Array.isArray(config.Entrypoint) ||
316
+ !config.Entrypoint.every((entryArg) => typeof entryArg === 'string') ||
161
317
  !isUnknownRecord(hostConfig) ||
162
318
  !isUnknownRecord(hostConfig.PortBindings) ||
319
+ !isUnknownRecord(restartPolicy) ||
320
+ typeof restartPolicy.Name !== 'string' ||
163
321
  !Array.isArray(config.Env) ||
164
- !config.Env.every((entryArg) => typeof entryArg === 'string')
322
+ !config.Env.every((entryArg) => typeof entryArg === 'string') ||
323
+ !isUnknownRecord(config.Labels) ||
324
+ !isUnknownRecord(healthcheck) ||
325
+ !Array.isArray(healthcheck.Test) ||
326
+ !healthcheck.Test.every((entryArg) => typeof entryArg === 'string') ||
327
+ typeof healthcheck.Interval !== 'number' ||
328
+ typeof healthcheck.Timeout !== 'number' ||
329
+ typeof healthcheck.StartPeriod !== 'number' ||
330
+ typeof healthcheck.Retries !== 'number' ||
331
+ !Array.isArray(inspected.Mounts)
165
332
  ) {
166
- throw new Error(`Docker returned an invalid MinIO inspection for ${expectedContainerNameArg}.`);
333
+ throw new Error(
334
+ `Docker returned an invalid ObjectStorage inspection for ${expectedContainerNameArg}.`,
335
+ );
167
336
  }
168
337
 
169
338
  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;
339
+ for (const [key, value] of Object.entries(config.Labels)) {
340
+ if (typeof value !== 'string') {
341
+ throw new Error(
342
+ `Docker returned an invalid ObjectStorage inspection for ${expectedContainerNameArg}.`,
343
+ );
181
344
  }
345
+ labels[key] = value;
182
346
  }
347
+ const mounts = inspected.Mounts.map((mountArg) => {
348
+ if (
349
+ !isUnknownRecord(mountArg) ||
350
+ mountArg.Type !== 'bind' ||
351
+ typeof mountArg.Source !== 'string' ||
352
+ typeof mountArg.Destination !== 'string' ||
353
+ typeof mountArg.RW !== 'boolean'
354
+ ) {
355
+ throw new Error(
356
+ `Docker returned an invalid ObjectStorage inspection for ${expectedContainerNameArg}.`,
357
+ );
358
+ }
359
+ return {
360
+ source: plugins.path.resolve(mountArg.Source),
361
+ destination: mountArg.Destination,
362
+ readWrite: mountArg.RW,
363
+ };
364
+ });
183
365
 
184
366
  return {
185
367
  id: inspected.Id,
186
368
  name: normalizedName,
187
369
  running: state.Running,
370
+ status: state.Status,
371
+ image: config.Image,
188
372
  portBindings: hostConfig.PortBindings,
189
373
  environmentEntries: config.Env,
190
374
  labels,
375
+ mounts,
376
+ restartPolicy: restartPolicy.Name,
377
+ command: [...config.Cmd],
378
+ entrypoint: [...config.Entrypoint],
379
+ user: config.User,
380
+ healthcheck: {
381
+ test: [...healthcheck.Test],
382
+ interval: healthcheck.Interval,
383
+ timeout: healthcheck.Timeout,
384
+ startPeriod: healthcheck.StartPeriod,
385
+ retries: healthcheck.Retries,
386
+ },
191
387
  };
192
388
  };
193
389
 
194
- const minioPortMatches = (
390
+ const objectStoragePortMatches = (
195
391
  portBindingsArg: Record<string, unknown>,
196
392
  containerPortArg: string,
197
393
  expectedHostPortArg: string,
@@ -199,106 +395,143 @@ const minioPortMatches = (
199
395
  const bindings = portBindingsArg[containerPortArg];
200
396
  return (
201
397
  Array.isArray(bindings) &&
202
- bindings.some(
203
- (bindingArg) =>
204
- isUnknownRecord(bindingArg) && bindingArg.HostPort === expectedHostPortArg,
205
- )
398
+ bindings.length === 1 &&
399
+ isUnknownRecord(bindings[0]) &&
400
+ bindings[0].HostIp === '127.0.0.1' &&
401
+ bindings[0].HostPort === expectedHostPortArg
206
402
  );
207
403
  };
208
404
 
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');
405
+ const isS3BucketNotFound = (errorArg: unknown): boolean => {
406
+ if (!isUnknownRecord(errorArg)) {
407
+ return false;
220
408
  }
409
+ const metadata = errorArg.$metadata;
410
+ return (
411
+ errorArg.name === 'NoSuchBucket' ||
412
+ errorArg.name === 'NotFound' ||
413
+ (isUnknownRecord(metadata) && metadata.httpStatusCode === 404)
414
+ );
415
+ };
416
+
417
+ /** Create the configured bucket only after an authenticated 404, then verify by HEAD. */
418
+ export const reconcileObjectStorageBucket = async (
419
+ optionsArg: IObjectStorageBucketReconciliationOptions,
420
+ ): Promise<void> => {
421
+ const clock = optionsArg.clock || defaultObjectStorageClock;
422
+ const deadlineAt =
423
+ optionsArg.deadlineAt ?? clock.now() + objectStorageReconciliationTimeoutMs;
221
424
 
222
425
  const remainingMs = (): number => Math.floor(deadlineAt - clock.now());
223
- const execOnce = async (
426
+ const sendWithDeadline = async (
224
427
  stageArg: string,
225
- argvArg: readonly string[],
226
- timeoutCapMsArg?: number,
227
- ): Promise<IDockerExecArgvResult> => {
428
+ commandArg: plugins.s3.HeadBucketCommand | plugins.s3.CreateBucketCommand,
429
+ ): Promise<void> => {
228
430
  const remaining = remainingMs();
229
431
  if (remaining <= 0) {
230
- throw minioStageError(stageArg, true);
432
+ throw objectStorageStageError(stageArg, true);
231
433
  }
434
+ const controller = new AbortController();
435
+ let timedOut = false;
436
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
437
+ const timer = setTimeout(() => {
438
+ timedOut = true;
439
+ controller.abort();
440
+ reject(objectStorageStageError(stageArg, true));
441
+ }, remaining);
442
+ controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
443
+ });
232
444
  try {
233
- return await optionsArg.docker.execArgv(argvArg, {
234
- timeoutMs: Math.max(
235
- 1,
236
- timeoutCapMsArg === undefined ? remaining : Math.min(timeoutCapMsArg, remaining),
237
- ),
238
- });
445
+ await Promise.race([
446
+ optionsArg.smartBucket.storageClient.send(commandArg, {
447
+ abortSignal: controller.signal,
448
+ }),
449
+ timeoutPromise,
450
+ ]);
239
451
  } catch (error) {
240
- throw minioStageError(stageArg, remainingMs() <= 0, error);
452
+ if (timedOut || error instanceof ObjectStorageStageError) {
453
+ throw objectStorageStageError(stageArg, true);
454
+ }
455
+ throw error;
456
+ } finally {
457
+ controller.abort();
241
458
  }
242
459
  };
243
- const execUntilReady = async (
244
- stageArg: string,
245
- argvArg: readonly string[],
246
- ): Promise<IDockerExecArgvResult> => {
247
- let lastCause: unknown;
248
- while (true) {
460
+
461
+ let primaryError: Error | undefined;
462
+ try {
463
+ if (!Number.isFinite(deadlineAt)) {
464
+ throw new Error('ObjectStorage reconciliation deadline must be finite.');
465
+ }
466
+ let bucketMissing = false;
467
+ try {
468
+ await sendWithDeadline(
469
+ 'authenticated bucket lookup',
470
+ new plugins.s3.HeadBucketCommand({ Bucket: optionsArg.bucket }),
471
+ );
472
+ } catch (error) {
473
+ if (error instanceof ObjectStorageStageError) {
474
+ throw error;
475
+ }
476
+ if (!isS3BucketNotFound(error)) {
477
+ throw objectStorageStageError('authenticated bucket lookup', false);
478
+ }
479
+ bucketMissing = true;
480
+ }
481
+
482
+ let createFailed = false;
483
+ if (bucketMissing) {
249
484
  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
- }
485
+ await sendWithDeadline(
486
+ 'bucket creation',
487
+ new plugins.s3.CreateBucketCommand({ Bucket: optionsArg.bucket }),
488
+ );
489
+ } catch {
490
+ // A timed-out or disconnected create can still have reached the server.
491
+ createFailed = true;
264
492
  }
265
493
  }
266
- };
267
494
 
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
- ]);
495
+ const verificationTimeout = Math.min(30_000, remainingMs());
496
+ if (verificationTimeout <= 0) {
497
+ throw objectStorageStageError('bucket verification', true);
498
+ }
499
+ let verification;
500
+ try {
501
+ verification = await optionsArg.smartBucket.checkReadiness(optionsArg.bucket, {
502
+ timeoutMs: verificationTimeout,
503
+ });
504
+ } catch {
505
+ throw objectStorageStageError('bucket verification', false);
506
+ }
507
+ if (!verification.ready) {
508
+ throw objectStorageStageError(
509
+ createFailed ? 'bucket creation and verification' : 'bucket verification',
510
+ verification.reason === 'timeout',
511
+ );
512
+ }
513
+ } catch (error) {
514
+ primaryError = normalizeObjectStorageLifecycleError(error);
515
+ }
516
+
517
+ let closeError: Error | undefined;
518
+ try {
519
+ await optionsArg.smartBucket.close();
520
+ } catch {
521
+ closeError = objectStorageStageError('S3 client cleanup', false);
522
+ }
523
+ if (primaryError && closeError) {
524
+ throw new AggregateError(
525
+ [primaryError, closeError],
526
+ 'ObjectStorage bucket reconciliation failed and S3 client cleanup could not be confirmed.',
527
+ );
528
+ }
529
+ if (primaryError) {
530
+ throw primaryError;
531
+ }
532
+ if (closeError) {
533
+ throw closeError;
534
+ }
302
535
  };
303
536
 
304
537
  export class ServiceManager {
@@ -306,7 +539,17 @@ export class ServiceManager {
306
539
  private docker: DockerContainer;
307
540
  private enabledServices: string[] | null = null;
308
541
  private globalRegistry: GlobalRegistry;
309
- private minioClock: IMinioReconciliationClock = defaultMinioClock;
542
+ private objectStorageClock: IObjectStorageReconciliationClock = defaultObjectStorageClock;
543
+ private fetchImplementation: typeof fetch = globalThis.fetch;
544
+ private smartBucketFactory = (configArg: IServiceConfig) =>
545
+ new plugins.smartbucket.SmartBucket({
546
+ endpoint: '127.0.0.1',
547
+ port: parseInt(configArg.S3_PORT),
548
+ useSsl: false,
549
+ region: configArg.S3_REGION,
550
+ accessKey: configArg.S3_ACCESSKEY,
551
+ accessSecret: configArg.S3_SECRETKEY,
552
+ });
310
553
 
311
554
  constructor() {
312
555
  this.config = new ServiceConfiguration();
@@ -317,7 +560,12 @@ export class ServiceManager {
317
560
  /**
318
561
  * Initialize the service manager
319
562
  */
320
- public async init(): Promise<void> {
563
+ public async init(operationArg: TServiceManagerOperation): Promise<void> {
564
+ await runServiceMigrations({
565
+ projectPath: process.cwd(),
566
+ scopes: getServiceManagerMigrationScopes(operationArg),
567
+ });
568
+
321
569
  // Check Docker availability
322
570
  if (!(await this.docker.checkDocker())) {
323
571
  logger.log('error', 'Error: Docker is not installed. Please install Docker first.');
@@ -349,7 +597,9 @@ export class ServiceManager {
349
597
  // Any services command run in a project counts as activity. Without this
350
598
  // lastActive only advanced on start, so an actively used project could look
351
599
  // stale to the reaper.
352
- await this.globalRegistry.touchProject(process.cwd());
600
+ await this.withProjectServiceLocks(process.cwd(), () =>
601
+ this.globalRegistry.touchProject(process.cwd()),
602
+ );
353
603
  }
354
604
 
355
605
  /**
@@ -379,11 +629,10 @@ export class ServiceManager {
379
629
  * Load service configuration from .smartconfig.json
380
630
  */
381
631
  private async loadServiceConfiguration(): Promise<void> {
382
- const smartconfigInstance = new plugins.smartconfig.Smartconfig(process.cwd());
383
- const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
632
+ const persistedServices = await readPersistedServiceConfiguration(process.cwd());
384
633
 
385
634
  // Check if services array exists
386
- if (!gitzoneConfig.services || !Array.isArray(gitzoneConfig.services) || gitzoneConfig.services.length === 0) {
635
+ if (persistedServices.length === 0) {
387
636
  // Prompt user to select services
388
637
  const smartinteract = new plugins.smartinteract.SmartInteract();
389
638
  const response = await smartinteract.askQuestion({
@@ -392,21 +641,20 @@ export class ServiceManager {
392
641
  message: 'Which services do you want to enable for this project?',
393
642
  choices: [
394
643
  { name: 'MongoDB', value: 'mongodb' },
395
- { name: 'MinIO (S3)', value: 'minio' },
644
+ { name: 'ObjectStorage (S3)', value: 'objectstorage' },
396
645
  { name: 'Elasticsearch', value: 'elasticsearch' }
397
646
  ],
398
- default: ['mongodb', 'minio', 'elasticsearch']
647
+ default: ['mongodb', 'objectstorage', 'elasticsearch']
399
648
  });
400
649
 
401
- const enabledServices = response.value || ['mongodb', 'minio', 'elasticsearch'];
650
+ const enabledServices = response.value || ['mongodb', 'objectstorage', 'elasticsearch'];
402
651
  this.enabledServices = enabledServices;
403
652
 
404
653
  // Save to .smartconfig.json
405
654
  await this.saveServiceConfiguration(enabledServices);
406
655
  } else {
407
- const enabledServices = gitzoneConfig.services as string[];
408
- this.enabledServices = enabledServices;
409
- logger.log('info', `🔧 Enabled services: ${enabledServices.join(', ')}`);
656
+ this.enabledServices = persistedServices;
657
+ logger.log('info', `🔧 Enabled services: ${persistedServices.join(', ')}`);
410
658
  }
411
659
  }
412
660
 
@@ -414,26 +662,9 @@ export class ServiceManager {
414
662
  * Save service configuration to .smartconfig.json
415
663
  */
416
664
  private async saveServiceConfiguration(services: string[]): Promise<void> {
417
- const smartconfigPath = plugins.path.join(process.cwd(), '.smartconfig.json');
418
- let smartconfigData: any = {};
419
-
420
- // Read existing .smartconfig.json if it exists
421
- if (await plugins.smartfs.file(smartconfigPath).exists()) {
422
- const content = await plugins.smartfs.file(smartconfigPath).encoding('utf8').read();
423
- smartconfigData = JSON.parse(content as string);
424
- }
425
-
426
- // Update @git.zone/cli.services
427
- if (!smartconfigData['@git.zone/cli']) {
428
- smartconfigData['@git.zone/cli'] = {};
429
- }
430
- smartconfigData['@git.zone/cli'].services = services;
431
-
432
- // Write back to .smartconfig.json
433
- await plugins.smartfs
434
- .file(smartconfigPath)
435
- .encoding('utf8')
436
- .write(JSON.stringify(smartconfigData, null, 2));
665
+ const smartconfigData = await readSmartconfigFile(process.cwd());
666
+ setCliConfigValueInData(smartconfigData, 'services', services);
667
+ await writeSmartconfigFile(smartconfigData, process.cwd());
437
668
 
438
669
  logger.log('ok', `✅ Saved service configuration to .smartconfig.json`);
439
670
  logger.log('info', `🔧 Enabled services: ${services.join(', ')}`);
@@ -442,7 +673,7 @@ export class ServiceManager {
442
673
  /**
443
674
  * Check if a service is enabled
444
675
  */
445
- private isServiceEnabled(service: string): boolean {
676
+ public isServiceEnabled(service: TServiceName): boolean {
446
677
  if (!this.enabledServices) {
447
678
  return true; // If no configuration, enable all
448
679
  }
@@ -459,8 +690,14 @@ export class ServiceManager {
459
690
  serviceArg: TServiceName,
460
691
  dataPathArg: string,
461
692
  ): Promise<{ [key: string]: string }> {
462
- await plugins.smartfs.directory(dataPathArg).recursive().create();
463
- await recordServiceDataDirectory(process.cwd(), serviceArg);
693
+ if (await plugins.smartfs.directory(dataPathArg).exists()) {
694
+ await recordServiceDataDirectory(process.cwd(), serviceArg);
695
+ } else {
696
+ // Publish ownership first: interruption can leave an absent directory with
697
+ // a harmless claim, but never an unmarked directory that blocks recovery.
698
+ await recordServiceDataDirectory(process.cwd(), serviceArg);
699
+ await this.createDataDirectory(dataPathArg);
700
+ }
464
701
  return getServiceContainerLabels({
465
702
  projectPath: process.cwd(),
466
703
  service: serviceArg,
@@ -468,6 +705,10 @@ export class ServiceManager {
468
705
  });
469
706
  }
470
707
 
708
+ private async createDataDirectory(dataPathArg: string): Promise<void> {
709
+ await plugins.smartfs.directory(dataPathArg).recursive().create();
710
+ }
711
+
471
712
  /**
472
713
  * Register this project with the global registry.
473
714
  *
@@ -484,16 +725,16 @@ export class ServiceManager {
484
725
  projectName: config.PROJECT_NAME,
485
726
  containers: {
486
727
  mongo: containers.mongo,
487
- minio: containers.minio,
728
+ objectstorage: containers.objectstorage,
488
729
  elasticsearch: containers.elasticsearch,
489
730
  },
490
731
  ports: {
491
732
  mongo: parseInt(config.MONGODB_PORT),
492
733
  s3: parseInt(config.S3_PORT),
493
- s3Console: parseInt(config.S3_CONSOLE_PORT),
734
+ s3Ui: parseInt(config.S3_UI_PORT),
494
735
  elasticsearch: parseInt(config.ELASTICSEARCH_PORT),
495
736
  },
496
- enabledServices: this.enabledServices || ['mongodb', 'minio', 'elasticsearch'],
737
+ enabledServices: this.enabledServices || ['mongodb', 'objectstorage', 'elasticsearch'],
497
738
  });
498
739
  }
499
740
 
@@ -507,9 +748,9 @@ export class ServiceManager {
507
748
  await this.startMongoDB();
508
749
  first = false;
509
750
  }
510
- if (this.isServiceEnabled('minio')) {
751
+ if (this.isServiceEnabled('objectstorage')) {
511
752
  if (!first) console.log();
512
- await this.startMinIO();
753
+ await this.startObjectStorage();
513
754
  first = false;
514
755
  }
515
756
  if (this.isServiceEnabled('elasticsearch')) {
@@ -530,7 +771,7 @@ export class ServiceManager {
530
771
  public async stopAll(): Promise<void> {
531
772
  await this.stopMongoDB();
532
773
  console.log();
533
- await this.stopMinIO();
774
+ await this.stopObjectStorage();
534
775
  console.log();
535
776
  await this.stopElasticsearch();
536
777
  }
@@ -544,6 +785,28 @@ export class ServiceManager {
544
785
  * (localhost:MONGODB_PORT) is reachable from host and container alike.
545
786
  */
546
787
  public async startMongoDB(): Promise<void> {
788
+ await withServiceDataOperationLock(process.cwd(), 'mongodb', () =>
789
+ this.startMongoDBLocked(),
790
+ );
791
+ }
792
+
793
+ private async assertNoPendingDataDeletion(serviceArg: TServiceName): Promise<void> {
794
+ const pendingPath = await this.docker.getPendingDataDeletionPath(
795
+ process.cwd(),
796
+ serviceArg,
797
+ );
798
+ if (pendingPath) {
799
+ throw new Error(
800
+ `Pending ${serviceArg} data deletion must finish before startup: ${pendingPath}. ` +
801
+ 'Run `gitzone services clean --yes` to resume the recorded cleanup.',
802
+ );
803
+ }
804
+ }
805
+
806
+ private async startMongoDBLocked(): Promise<void> {
807
+ await this.config.reloadFromDisk();
808
+ await this.assertNoPendingDataDeletion('mongodb');
809
+ await this.globalRegistry.touchProject(process.cwd());
547
810
  logger.log('note', '📦 MongoDB:');
548
811
 
549
812
  const config = this.config.getConfig();
@@ -846,107 +1109,291 @@ export class ServiceManager {
846
1109
  return false;
847
1110
  }
848
1111
 
849
- private async inspectMinioContainer(
1112
+ private async inspectObjectStorageContainer(
850
1113
  containerReferenceArg: string,
851
1114
  expectedContainerNameArg: string,
852
1115
  dockerTimeoutArg: () => { timeoutMs: number },
853
- ): Promise<IMinioContainerInspection> {
1116
+ ): Promise<IObjectStorageContainerInspection> {
854
1117
  const result = await this.docker.execArgv(
855
1118
  ['inspect', containerReferenceArg],
856
1119
  dockerTimeoutArg(),
857
1120
  );
858
- return parseMinioInspection(result.stdout, expectedContainerNameArg);
1121
+ return parseObjectStorageInspection(result.stdout, expectedContainerNameArg);
859
1122
  }
860
1123
 
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.'),
1124
+ private async inspectObjectStorageIdentity(
1125
+ containerReferenceArg: string,
1126
+ expectedContainerNameArg: string,
1127
+ dockerTimeoutArg: () => { timeoutMs: number },
1128
+ ): Promise<IObjectStorageContainerIdentity> {
1129
+ const result = await this.docker.execArgv(
1130
+ ['inspect', containerReferenceArg],
1131
+ dockerTimeoutArg(),
868
1132
  );
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.',
1133
+ const identity = parseObjectStorageIdentity(result.stdout, expectedContainerNameArg);
1134
+ if (/^[a-f0-9]{64}$/u.test(containerReferenceArg) && identity.id !== containerReferenceArg) {
1135
+ throw new Error(
1136
+ `Refusing ObjectStorage container ${expectedContainerNameArg}: its immutable id changed.`,
881
1137
  );
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
1138
  }
1139
+ return identity;
1140
+ }
892
1141
 
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,
1142
+ private async discoverContainerIdByExactName(
1143
+ containerNameArg: string,
1144
+ dockerTimeoutArg: () => { timeoutMs: number },
1145
+ ): Promise<string | undefined> {
1146
+ const result = await this.docker.execArgv(
1147
+ [
1148
+ 'container',
1149
+ 'ls',
1150
+ '-a',
1151
+ '--no-trunc',
1152
+ '--filter',
1153
+ `name=^/${containerNameArg}$`,
1154
+ '--format',
1155
+ '{{.ID}}',
1156
+ ],
1157
+ dockerTimeoutArg(),
900
1158
  );
901
- if (claims.length !== 1) {
902
- throw refusal();
1159
+ const ids = result.stdout
1160
+ .split(/\r?\n/u)
1161
+ .map((entry) => entry.trim())
1162
+ .filter(Boolean);
1163
+ if (ids.length === 0) {
1164
+ return undefined;
903
1165
  }
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();
1166
+ if (ids.length !== 1 || !/^[a-f0-9]{64}$/u.test(ids[0])) {
1167
+ throw new Error(`Docker returned ambiguous identity for container ${containerNameArg}.`);
910
1168
  }
1169
+ return ids[0];
911
1170
  }
912
1171
 
913
- private async revalidateMinioContainer(
1172
+ private assertObjectStorageOwnership(
1173
+ inspectionArg: IObjectStorageContainerInspection,
1174
+ expectedLabelsArg: Record<string, string>,
1175
+ ): void {
1176
+ if (!hasExactServiceContainerLabels(inspectionArg.labels, expectedLabelsArg)) {
1177
+ throw new Error(
1178
+ `Refusing to use ObjectStorage container ${inspectionArg.name}: ` +
1179
+ 'its ownership is not proven for the current project.',
1180
+ );
1181
+ }
1182
+ }
1183
+
1184
+ private async revalidateObjectStorageContainer(
914
1185
  containerIdArg: string,
915
1186
  expectedContainerNameArg: string,
916
1187
  expectedLabelsArg: Record<string, string>,
917
- allowLegacyRegistryArg: boolean,
918
1188
  dockerTimeoutArg: () => { timeoutMs: number },
919
- ): Promise<IMinioContainerInspection> {
920
- const inspection = await this.inspectMinioContainer(
1189
+ ): Promise<IObjectStorageContainerInspection> {
1190
+ const inspection = await this.inspectObjectStorageContainer(
921
1191
  containerIdArg,
922
1192
  expectedContainerNameArg,
923
1193
  dockerTimeoutArg,
924
1194
  );
925
1195
  if (inspection.id !== containerIdArg) {
926
1196
  throw new Error(
927
- `Refusing to use MinIO container ${expectedContainerNameArg}: ` +
1197
+ `Refusing to use ObjectStorage container ${expectedContainerNameArg}: ` +
928
1198
  'its immutable id changed during reconciliation.',
929
1199
  );
930
1200
  }
931
- await this.assertMinioOwnership(
932
- inspection,
933
- expectedLabelsArg,
934
- allowLegacyRegistryArg,
935
- );
1201
+ this.assertObjectStorageOwnership(inspection, expectedLabelsArg);
936
1202
  return inspection;
937
1203
  }
938
-
939
- /**
940
- * Start MinIO service
941
- */
942
- public async startMinIO(): Promise<void> {
943
- logger.log('note', '📦 S3/MinIO:');
944
1204
 
1205
+ private objectStorageConfigurationMatches(
1206
+ inspectionArg: IObjectStorageContainerInspection,
1207
+ expectedLabelsArg: Record<string, string>,
1208
+ ): boolean {
945
1209
  const config = this.config.getConfig();
946
- const containers = this.config.getContainerNames();
947
1210
  const directories = this.config.getDataDirectories();
1211
+ const expectedEnvironment: Record<string, string> = {
1212
+ OBJST_PORT: '9000',
1213
+ UI_PORT: '3000',
1214
+ OBJST_STORAGE_DIR: '/data',
1215
+ OBJST_ACCESS_KEY: config.S3_ACCESSKEY,
1216
+ OBJST_SECRET_KEY: config.S3_SECRETKEY,
1217
+ OBJST_ADMIN_PASSWORD: config.S3_ADMIN_PASSWORD,
1218
+ OBJST_REGION: config.S3_REGION,
1219
+ };
1220
+ const controlledEnvironmentEntries = inspectionArg.environmentEntries.filter((entryArg) => {
1221
+ const separatorIndex = entryArg.indexOf('=');
1222
+ const name = separatorIndex === -1 ? entryArg : entryArg.slice(0, separatorIndex);
1223
+ return name === 'UI_PORT' || name.startsWith('OBJST_');
1224
+ });
1225
+ const readEnvironment = (nameArg: string): string | undefined => {
1226
+ const prefix = `${nameArg}=`;
1227
+ const matches = inspectionArg.environmentEntries.filter((entry) =>
1228
+ entry.startsWith(prefix),
1229
+ );
1230
+ return matches.length === 1 ? matches[0].slice(prefix.length) : undefined;
1231
+ };
1232
+ const portKeys = Object.keys(inspectionArg.portBindings).sort();
1233
+ const expectedPortKeys = ['3000/tcp', '9000/tcp'];
1234
+ const healthcheckMatches =
1235
+ inspectionArg.healthcheck.test.length === objectStorageExpectedHealthcheck.test.length &&
1236
+ inspectionArg.healthcheck.test.every(
1237
+ (entry, index) => entry === objectStorageExpectedHealthcheck.test[index],
1238
+ ) &&
1239
+ inspectionArg.healthcheck.interval === objectStorageExpectedHealthcheck.interval &&
1240
+ inspectionArg.healthcheck.timeout === objectStorageExpectedHealthcheck.timeout &&
1241
+ inspectionArg.healthcheck.startPeriod === objectStorageExpectedHealthcheck.startPeriod &&
1242
+ inspectionArg.healthcheck.retries === objectStorageExpectedHealthcheck.retries;
1243
+ return (
1244
+ inspectionArg.image === objectStorageImage &&
1245
+ portKeys.length === expectedPortKeys.length &&
1246
+ portKeys.every((entry, index) => entry === expectedPortKeys[index]) &&
1247
+ objectStoragePortMatches(inspectionArg.portBindings, '9000/tcp', config.S3_PORT) &&
1248
+ objectStoragePortMatches(inspectionArg.portBindings, '3000/tcp', config.S3_UI_PORT) &&
1249
+ inspectionArg.mounts.length === 1 &&
1250
+ inspectionArg.mounts[0].source === plugins.path.resolve(directories.objectstorage) &&
1251
+ inspectionArg.mounts[0].destination === '/data' &&
1252
+ inspectionArg.mounts[0].readWrite &&
1253
+ controlledEnvironmentEntries.length === Object.keys(expectedEnvironment).length &&
1254
+ Object.entries(expectedEnvironment).every(
1255
+ ([name, value]) => readEnvironment(name) === value,
1256
+ ) &&
1257
+ hasExactServiceContainerLabels(inspectionArg.labels, expectedLabelsArg) &&
1258
+ inspectionArg.restartPolicy === 'unless-stopped' &&
1259
+ inspectionArg.command.length === objectStorageExpectedCommand.length &&
1260
+ inspectionArg.command.every(
1261
+ (entry, index) => entry === objectStorageExpectedCommand[index],
1262
+ ) &&
1263
+ inspectionArg.entrypoint.length === objectStorageExpectedEntrypoint.length &&
1264
+ inspectionArg.entrypoint.every(
1265
+ (entry, index) => entry === objectStorageExpectedEntrypoint[index],
1266
+ ) &&
1267
+ inspectionArg.user === 'objectstorage' &&
1268
+ healthcheckMatches
1269
+ );
1270
+ }
1271
+
1272
+ private async assertNoSymlinkedObjectStoragePath(dataPathArg: string): Promise<void> {
1273
+ const projectPath = plugins.path.resolve(process.cwd());
1274
+ const expectedPath = getServiceDataDirectory(projectPath, 'objectstorage');
1275
+ if (plugins.path.resolve(dataPathArg) !== expectedPath) {
1276
+ throw new Error(`Refusing non-canonical ObjectStorage data path: ${dataPathArg}`);
1277
+ }
1278
+ for (const candidate of [projectPath, plugins.path.join(projectPath, '.nogit'), expectedPath]) {
1279
+ try {
1280
+ if ((await plugins.fs.lstat(candidate)).isSymbolicLink()) {
1281
+ throw new Error(`Refusing symbolic-link ObjectStorage path component: ${candidate}`);
1282
+ }
1283
+ } catch (error) {
1284
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
1285
+ throw error;
1286
+ }
1287
+ }
1288
+ }
1289
+ }
948
1290
 
949
- // Validation lives on the mutating path so recovery commands remain usable.
1291
+ private async prepareObjectStorageDataDirectory(
1292
+ dockerTimeoutArg: () => { timeoutMs: number },
1293
+ ): Promise<Record<string, string>> {
1294
+ const projectPath = plugins.path.resolve(process.cwd());
1295
+ const dataPath = this.config.getDataDirectories().objectstorage;
1296
+ await this.assertNoSymlinkedObjectStoragePath(dataPath);
1297
+ const exists = await plugins.smartfs.directory(dataPath).exists();
1298
+ const marker = await readServiceDataMarker(projectPath);
1299
+ if (exists && !markerProvesServiceDataDirectory(marker, projectPath, 'objectstorage')) {
1300
+ throw new Error(
1301
+ `Refusing existing ObjectStorage data directory with unproven ownership: ${dataPath}`,
1302
+ );
1303
+ }
1304
+ if (!exists) {
1305
+ await recordServiceDataDirectory(projectPath, 'objectstorage');
1306
+ await this.createDataDirectory(dataPath);
1307
+ }
1308
+
1309
+ const labels = getServiceContainerLabels({
1310
+ projectPath,
1311
+ service: 'objectstorage',
1312
+ dataPath,
1313
+ });
1314
+ await this.docker.runFencedBindOperation({
1315
+ image: objectStorageImage,
1316
+ hostPath: dataPath,
1317
+ containerPath: '/data',
1318
+ entrypoint: '/bin/chown',
1319
+ command: ['-R', 'objectstorage:objectstorage', '/data'],
1320
+ labels: {
1321
+ 'git.zone.tool': serviceToolLabel,
1322
+ 'git.zone.service': 'objectstorage',
1323
+ 'git.zone.project-path': projectPath,
1324
+ 'git.zone.data-path': plugins.path.resolve(dataPath),
1325
+ 'git.zone.safe-to-prune': 'false',
1326
+ [serviceOperationLabel]: serviceBindOperation,
1327
+ },
1328
+ executionOptions: dockerTimeoutArg,
1329
+ rollbackTimeoutMs: objectStorageRollbackTimeoutMs,
1330
+ });
1331
+ return labels;
1332
+ }
1333
+
1334
+ private async repairObjectStorageMarkerFromOwnedContainer(dataPathArg: string): Promise<void> {
1335
+ const projectPath = plugins.path.resolve(process.cwd());
1336
+ await this.assertNoSymlinkedObjectStoragePath(dataPathArg);
1337
+ if (!(await plugins.smartfs.directory(dataPathArg).exists())) {
1338
+ throw new Error(
1339
+ `Refusing owned ObjectStorage container with a missing data directory: ${dataPathArg}`,
1340
+ );
1341
+ }
1342
+ const marker = await readServiceDataMarker(projectPath);
1343
+ if (!markerProvesServiceDataDirectory(marker, projectPath, 'objectstorage')) {
1344
+ // recordServiceDataDirectory rejects an invalid/foreign marker, so only an
1345
+ // absent or valid mergeable claim is repaired from exact container labels.
1346
+ await recordServiceDataDirectory(projectPath, 'objectstorage');
1347
+ }
1348
+ }
1349
+
1350
+ private async assertNoLegacyMinioResources(
1351
+ dockerTimeoutArg: () => { timeoutMs: number },
1352
+ ): Promise<void> {
1353
+ const projectPath = plugins.path.resolve(process.cwd());
1354
+ const containers = this.config.getContainerNames();
1355
+ const projects = await this.globalRegistry.getAllProjects();
1356
+ const registryEntry = Object.entries(projects).find(
1357
+ ([registeredPath]) => plugins.path.resolve(registeredPath) === projectPath,
1358
+ )?.[1];
1359
+ const candidateNames = new Set<string>([containers.legacyMinio]);
1360
+ if (registryEntry?.legacy?.minioContainer) {
1361
+ candidateNames.add(registryEntry.legacy.minioContainer);
1362
+ }
1363
+ const existingContainers: string[] = [];
1364
+ for (const candidateName of candidateNames) {
1365
+ if (await this.discoverContainerIdByExactName(candidateName, dockerTimeoutArg)) {
1366
+ existingContainers.push(candidateName);
1367
+ }
1368
+ }
1369
+ const legacyDataPath = getLegacyMinioDataDirectory(projectPath);
1370
+ const legacyDataExists = await plugins.smartfs.directory(legacyDataPath).exists();
1371
+ if (existingContainers.length === 0 && !legacyDataExists) {
1372
+ return;
1373
+ }
1374
+
1375
+ const evidence = [
1376
+ ...existingContainers.map((name) => `container ${name}`),
1377
+ ...(legacyDataExists ? [`data directory ${legacyDataPath}`] : []),
1378
+ ].join(', ');
1379
+ throw new Error(
1380
+ `Legacy MinIO state blocks ObjectStorage startup: ${evidence}. ` +
1381
+ 'GitZone will not start, stop, remove, or reuse these resources because their disk layout is not compatible with SmartStorage. ' +
1382
+ 'Recovery: (1) export the required bucket data with your existing legacy tooling; ' +
1383
+ '(2) stop and rename the legacy container and move .nogit/miniodata to a preserved backup location; ' +
1384
+ '(3) run `gitzone services start s3`; ' +
1385
+ '(4) import the backup with an external S3 client and verify every required object before disposing of the legacy backup.',
1386
+ );
1387
+ }
1388
+
1389
+ /** Read-only preflight used before restart or reconfigure may stop anything. */
1390
+ public async assertObjectStorageStartupAllowed(): Promise<void> {
1391
+ this.assertObjectStorageConfigurationSafe();
1392
+ await this.assertNoLegacyMinioResources(() => ({ timeoutMs: 60_000 }));
1393
+ }
1394
+
1395
+ private assertObjectStorageConfigurationSafe(): void {
1396
+ const config = this.config.getConfig();
950
1397
  const bucketValidationError = getS3BucketNameValidationError(config.S3_BUCKET);
951
1398
  if (bucketValidationError) {
952
1399
  throw new Error(
@@ -954,156 +1401,277 @@ export class ServiceManager {
954
1401
  'Choose a valid custom bucket name or remove the field to regenerate the project default.',
955
1402
  );
956
1403
  }
1404
+ this.config.assertObjectStorageCredentialsAreSafe();
1405
+ }
957
1406
 
958
- // Ensure data directory exists and is marked as tool-owned
959
- const minioLabels = await this.prepareDataDirectory('minio', directories.minio);
960
- const deadlineAt = this.minioClock.now() + minioReconciliationTimeoutMs;
1407
+ private async waitForObjectStorageReadiness(
1408
+ configArg: IServiceConfig,
1409
+ deadlineAtArg: number,
1410
+ ): Promise<void> {
1411
+ while (true) {
1412
+ const remaining = Math.floor(deadlineAtArg - this.objectStorageClock.now());
1413
+ if (remaining <= 0) {
1414
+ throw objectStorageStageError('management readiness', true);
1415
+ }
1416
+ const controller = new AbortController();
1417
+ const probeTimeoutMs = Math.max(
1418
+ 1,
1419
+ Math.min(objectStorageProbeTimeoutCapMs, remaining),
1420
+ );
1421
+ let probeTimedOut = false;
1422
+ let timer: NodeJS.Timeout | undefined;
1423
+ const fetchPromise = this.fetchImplementation(
1424
+ `http://127.0.0.1:${configArg.S3_UI_PORT}/readyz`,
1425
+ { signal: controller.signal },
1426
+ );
1427
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1428
+ timer = setTimeout(() => {
1429
+ probeTimedOut = true;
1430
+ controller.abort();
1431
+ reject(objectStorageStageError('management readiness', true));
1432
+ }, probeTimeoutMs);
1433
+ });
1434
+ try {
1435
+ const response = await Promise.race([fetchPromise, timeoutPromise]);
1436
+ const ready = response.ok;
1437
+ controller.abort();
1438
+ if (response.body) {
1439
+ void response.body.cancel().catch(() => {});
1440
+ }
1441
+ if (ready) {
1442
+ return;
1443
+ }
1444
+ } catch {
1445
+ // Startup commonly refuses connections before the management listener binds.
1446
+ } finally {
1447
+ if (timer) {
1448
+ clearTimeout(timer);
1449
+ }
1450
+ controller.abort();
1451
+ if (probeTimedOut) {
1452
+ // A non-cooperative fetch may resolve after the timeout race. Consume
1453
+ // that late response as well so its body cannot retain a connection.
1454
+ void fetchPromise.then(
1455
+ (responseArg) => {
1456
+ if (responseArg.body) {
1457
+ void responseArg.body.cancel().catch(() => {});
1458
+ }
1459
+ },
1460
+ () => {},
1461
+ );
1462
+ }
1463
+ }
1464
+ const afterProbe = Math.floor(deadlineAtArg - this.objectStorageClock.now());
1465
+ if (afterProbe <= 0) {
1466
+ throw objectStorageStageError('management readiness', true);
1467
+ }
1468
+ await this.objectStorageClock.delay(
1469
+ Math.min(objectStorageProbeIntervalMs, afterProbe),
1470
+ );
1471
+ }
1472
+ }
1473
+
1474
+ private async createObjectStorageContainer(
1475
+ labelsArg: Record<string, string>,
1476
+ dockerTimeoutArg: () => { timeoutMs: number },
1477
+ ): Promise<IObjectStorageContainerInspection> {
1478
+ const config = this.config.getConfig();
1479
+ const containers = this.config.getContainerNames();
1480
+ const directories = this.config.getDataDirectories();
1481
+ const creationResult = await this.docker.createArgv(
1482
+ {
1483
+ name: containers.objectstorage,
1484
+ image: objectStorageImage,
1485
+ ports: {
1486
+ [`127.0.0.1:${config.S3_PORT}`]: '9000',
1487
+ [`127.0.0.1:${config.S3_UI_PORT}`]: '3000',
1488
+ },
1489
+ volumes: { [directories.objectstorage]: '/data' },
1490
+ environment: {
1491
+ OBJST_PORT: '9000',
1492
+ UI_PORT: '3000',
1493
+ OBJST_STORAGE_DIR: '/data',
1494
+ OBJST_ACCESS_KEY: config.S3_ACCESSKEY,
1495
+ OBJST_SECRET_KEY: config.S3_SECRETKEY,
1496
+ OBJST_ADMIN_PASSWORD: config.S3_ADMIN_PASSWORD,
1497
+ OBJST_REGION: config.S3_REGION,
1498
+ },
1499
+ labels: labelsArg,
1500
+ restart: 'unless-stopped',
1501
+ },
1502
+ dockerTimeoutArg(),
1503
+ );
1504
+ const containerId = creationResult.stdout.trim();
1505
+ if (!/^[a-f0-9]{64}$/u.test(containerId)) {
1506
+ throw new Error(`Docker returned an invalid id while creating ${containers.objectstorage}.`);
1507
+ }
1508
+ const inspection = await this.revalidateObjectStorageContainer(
1509
+ containerId,
1510
+ containers.objectstorage,
1511
+ labelsArg,
1512
+ dockerTimeoutArg,
1513
+ );
1514
+ if (inspection.running || inspection.status !== 'created') {
1515
+ throw new Error(`ObjectStorage container ${containers.objectstorage} was not created stopped.`);
1516
+ }
1517
+ if (!this.objectStorageConfigurationMatches(inspection, labelsArg)) {
1518
+ throw new Error(
1519
+ `ObjectStorage container ${containers.objectstorage} does not match the required runtime configuration.`,
1520
+ );
1521
+ }
1522
+ return inspection;
1523
+ }
1524
+
1525
+ /** Start or reconcile the canonical ObjectStorage service. */
1526
+ public async startObjectStorage(): Promise<void> {
1527
+ await withServiceDataOperationLock(process.cwd(), 'objectstorage', () =>
1528
+ this.startObjectStorageLocked(),
1529
+ );
1530
+ }
1531
+
1532
+ private async startObjectStorageLocked(): Promise<void> {
1533
+ await this.config.reloadFromDisk();
1534
+ await this.assertNoPendingDataDeletion('objectstorage');
1535
+ await this.globalRegistry.touchProject(process.cwd());
1536
+ logger.log('note', '📦 S3/ObjectStorage:');
1537
+
1538
+ const config = this.config.getConfig();
1539
+ const containers = this.config.getContainerNames();
1540
+ const directories = this.config.getDataDirectories();
1541
+ this.assertObjectStorageConfigurationSafe();
1542
+
1543
+ const deadlineAt =
1544
+ this.objectStorageClock.now() + objectStorageReconciliationTimeoutMs;
961
1545
  const dockerTimeout = (): { timeoutMs: number } => {
962
- const remaining = Math.floor(deadlineAt - this.minioClock.now());
1546
+ const remaining = Math.floor(deadlineAt - this.objectStorageClock.now());
963
1547
  if (remaining <= 0) {
964
- throw new Error(
965
- 'MinIO container setup did not complete before the shared reconciliation deadline.',
966
- );
1548
+ throw objectStorageStageError('container setup', true);
967
1549
  }
968
1550
  return { timeoutMs: Math.max(1, remaining) };
969
1551
  };
970
-
1552
+ const expectedLabels = getServiceContainerLabels({
1553
+ projectPath: process.cwd(),
1554
+ service: 'objectstorage',
1555
+ dataPath: directories.objectstorage,
1556
+ });
971
1557
  const startedContainerIds: string[] = [];
972
- const startById = async (containerIdArg: string): Promise<void> => {
1558
+ const startById = async (
1559
+ containerIdArg: string,
1560
+ ownedByInvocationArg: boolean,
1561
+ ): Promise<void> => {
973
1562
  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);
1563
+ if (ownedByInvocationArg) {
1564
+ startedContainerIds.push(containerIdArg);
1565
+ }
977
1566
  await this.docker.execArgv(['start', containerIdArg], executionOptions);
1567
+ if (!ownedByInvocationArg) {
1568
+ startedContainerIds.push(containerIdArg);
1569
+ }
978
1570
  };
979
1571
 
980
1572
  let outcome: string;
981
1573
  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(),
1574
+ await this.assertNoLegacyMinioResources(dockerTimeout);
1575
+
1576
+ const existingId = await this.discoverContainerIdByExactName(
1577
+ containers.objectstorage,
1578
+ dockerTimeout,
993
1579
  );
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,
1580
+ let initialInspection: IObjectStorageContainerInspection | undefined;
1581
+ if (existingId) {
1582
+ initialInspection = await this.inspectObjectStorageContainer(
1583
+ existingId,
1584
+ containers.objectstorage,
1004
1585
  dockerTimeout,
1005
1586
  );
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);
1587
+ this.assertObjectStorageOwnership(initialInspection, expectedLabels);
1588
+ await this.repairObjectStorageMarkerFromOwnedContainer(directories.objectstorage);
1589
+ } else {
1590
+ await this.config.validateObjectStoragePorts();
1009
1591
  }
1010
1592
 
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),
1593
+ const needsRecreate =
1594
+ !!initialInspection &&
1595
+ !this.objectStorageConfigurationMatches(initialInspection, expectedLabels);
1596
+ let activeInspection: IObjectStorageContainerInspection;
1597
+ if (!initialInspection || needsRecreate) {
1598
+ const labels = await this.prepareObjectStorageDataDirectory(dockerTimeout);
1599
+ if (initialInspection) {
1600
+ logger.log('note', ' Runtime configuration changed, recreating container...');
1601
+ await this.revalidateObjectStorageContainer(
1602
+ initialInspection.id,
1603
+ containers.objectstorage,
1604
+ expectedLabels,
1605
+ dockerTimeout,
1028
1606
  );
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) {
1607
+ await this.docker.execArgv(['rm', '-f', initialInspection.id], dockerTimeout());
1608
+ } else {
1609
+ logger.log('note', ' Creating container...');
1610
+ }
1611
+ activeInspection = await this.createObjectStorageContainer(labels, dockerTimeout);
1612
+ await startById(activeInspection.id, true);
1613
+ outcome = initialInspection
1614
+ ? 'Recreated with canonical configuration and reconciled'
1615
+ : 'Created, started, and reconciled';
1616
+ } else if (!initialInspection.running) {
1059
1617
  logger.log('note', ' Starting existing container...');
1060
1618
  activeInspection = initialInspection;
1061
- activeAllowsLegacyRegistry = true;
1062
- await startById(activeInspection.id);
1619
+ await startById(activeInspection.id, false);
1063
1620
  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
1621
  } else {
1071
1622
  logger.log('note', ' Already running; reconciling bucket...');
1072
1623
  activeInspection = initialInspection;
1073
- activeAllowsLegacyRegistry = true;
1074
1624
  outcome = 'Already running and reconciled';
1075
1625
  }
1076
1626
 
1077
- const reconciliationInspection = await this.revalidateMinioContainer(
1627
+ await this.revalidateObjectStorageContainer(
1078
1628
  activeInspection.id,
1079
- containers.minio,
1080
- minioLabels,
1081
- activeAllowsLegacyRegistry,
1629
+ containers.objectstorage,
1630
+ expectedLabels,
1082
1631
  dockerTimeout,
1083
1632
  );
1084
- await reconcileMinioBucket({
1085
- docker: this.docker,
1086
- containerId: reconciliationInspection.id,
1633
+ await this.waitForObjectStorageReadiness(config, deadlineAt);
1634
+ await reconcileObjectStorageBucket({
1635
+ smartBucket: this.smartBucketFactory(config),
1087
1636
  bucket: config.S3_BUCKET,
1088
1637
  deadlineAt,
1089
- clock: this.minioClock,
1638
+ clock: this.objectStorageClock,
1090
1639
  });
1640
+ const finalInspection = await this.revalidateObjectStorageContainer(
1641
+ activeInspection.id,
1642
+ containers.objectstorage,
1643
+ expectedLabels,
1644
+ dockerTimeout,
1645
+ );
1646
+ if (!finalInspection.running) {
1647
+ throw new Error(
1648
+ `ObjectStorage container ${containers.objectstorage} stopped during reconciliation.`,
1649
+ );
1650
+ }
1091
1651
  } catch (error) {
1092
- const originalError = normalizeMinioLifecycleError(error);
1652
+ const originalError = normalizeObjectStorageLifecycleError(error);
1093
1653
  const rollbackErrors: Error[] = [];
1094
1654
  for (let index = startedContainerIds.length - 1; index >= 0; index--) {
1095
1655
  try {
1096
- await this.docker.execArgv(['stop', startedContainerIds[index]], {
1097
- timeoutMs: minioRollbackTimeoutMs,
1098
- });
1656
+ const rollbackInspection = await this.revalidateObjectStorageContainer(
1657
+ startedContainerIds[index],
1658
+ containers.objectstorage,
1659
+ expectedLabels,
1660
+ () => ({ timeoutMs: objectStorageRollbackTimeoutMs }),
1661
+ );
1662
+ if (rollbackInspection.running) {
1663
+ await this.docker.execArgv(['stop', startedContainerIds[index]], {
1664
+ timeoutMs: objectStorageRollbackTimeoutMs,
1665
+ });
1666
+ }
1099
1667
  } catch (rollbackError) {
1100
- rollbackErrors.push(normalizeMinioLifecycleError(rollbackError));
1668
+ rollbackErrors.push(normalizeObjectStorageLifecycleError(rollbackError));
1101
1669
  }
1102
1670
  }
1103
1671
  if (rollbackErrors.length > 0) {
1104
1672
  throw new AggregateError(
1105
1673
  [originalError, ...rollbackErrors],
1106
- 'MinIO startup failed and rollback could not stop every container started by this invocation.',
1674
+ 'ObjectStorage startup failed and rollback could not stop every container started by this invocation.',
1107
1675
  );
1108
1676
  }
1109
1677
  throw originalError;
@@ -1111,68 +1679,25 @@ export class ServiceManager {
1111
1679
 
1112
1680
  logger.log('ok', ` ${outcome} ✓`);
1113
1681
  logger.log('ok', ` Bucket '${config.S3_BUCKET}' exists and is authenticated ✓`);
1114
- logger.log('info', ` Container: ${containers.minio}`);
1115
- logger.log('info', ` Port: ${config.S3_PORT}`);
1682
+ logger.log('info', ` Container: ${containers.objectstorage}`);
1683
+ logger.log('info', ` S3 API: http://127.0.0.1:${config.S3_PORT}`);
1116
1684
  logger.log('info', ` Bucket: ${config.S3_BUCKET}`);
1117
- logger.log('info', ` API: http://${config.S3_HOST}:${config.S3_PORT}`);
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;
1685
+ logger.log('info', ` Management UI: http://127.0.0.1:${config.S3_UI_PORT}`);
1170
1686
  }
1171
1687
 
1172
1688
  /**
1173
1689
  * Start Elasticsearch service
1174
1690
  */
1175
1691
  public async startElasticsearch(): Promise<void> {
1692
+ await withServiceDataOperationLock(process.cwd(), 'elasticsearch', () =>
1693
+ this.startElasticsearchLocked(),
1694
+ );
1695
+ }
1696
+
1697
+ private async startElasticsearchLocked(): Promise<void> {
1698
+ await this.config.reloadFromDisk();
1699
+ await this.assertNoPendingDataDeletion('elasticsearch');
1700
+ await this.globalRegistry.touchProject(process.cwd());
1176
1701
  logger.log('note', '📦 Elasticsearch:');
1177
1702
 
1178
1703
  const config = this.config.getConfig();
@@ -1271,6 +1796,13 @@ export class ServiceManager {
1271
1796
  * Stop MongoDB service
1272
1797
  */
1273
1798
  public async stopMongoDB(): Promise<void> {
1799
+ await withServiceDataOperationLock(process.cwd(), 'mongodb', () =>
1800
+ this.stopMongoDBLocked(),
1801
+ );
1802
+ }
1803
+
1804
+ private async stopMongoDBLocked(): Promise<void> {
1805
+ await this.config.reloadFromDisk();
1274
1806
  logger.log('note', '📦 MongoDB:');
1275
1807
 
1276
1808
  const containers = this.config.getContainerNames();
@@ -1287,30 +1819,57 @@ export class ServiceManager {
1287
1819
  }
1288
1820
  }
1289
1821
 
1290
- /**
1291
- * Stop MinIO service
1292
- */
1293
- public async stopMinIO(): Promise<void> {
1294
- logger.log('note', '📦 S3/MinIO:');
1822
+ /** Stop only an exactly owned canonical ObjectStorage container. */
1823
+ public async stopObjectStorage(): Promise<void> {
1824
+ await withServiceDataOperationLock(process.cwd(), 'objectstorage', () =>
1825
+ this.stopObjectStorageLocked(),
1826
+ );
1827
+ }
1295
1828
 
1829
+ private async stopObjectStorageLocked(): Promise<void> {
1830
+ await this.config.reloadFromDisk();
1831
+ logger.log('note', '📦 S3/ObjectStorage:');
1296
1832
  const containers = this.config.getContainerNames();
1297
- const status = await this.docker.getStatus(containers.minio);
1298
-
1299
- if (status === 'running') {
1300
- if (await this.docker.stop(containers.minio)) {
1301
- logger.log('ok', ' Stopped ✓');
1302
- } else {
1303
- logger.log('error', ' Failed to stop');
1304
- }
1305
- } else {
1833
+ const directories = this.config.getDataDirectories();
1834
+ const labels = getServiceContainerLabels({
1835
+ projectPath: process.cwd(),
1836
+ service: 'objectstorage',
1837
+ dataPath: directories.objectstorage,
1838
+ });
1839
+ const timeout = () => ({ timeoutMs: 60_000 });
1840
+ const id = await this.discoverContainerIdByExactName(
1841
+ containers.objectstorage,
1842
+ timeout,
1843
+ );
1844
+ if (!id) {
1306
1845
  logger.log('note', ' Not running');
1846
+ return;
1307
1847
  }
1848
+ const inspection = await this.revalidateObjectStorageContainer(
1849
+ id,
1850
+ containers.objectstorage,
1851
+ labels,
1852
+ timeout,
1853
+ );
1854
+ if (!inspection.running) {
1855
+ logger.log('note', ' Not running');
1856
+ return;
1857
+ }
1858
+ await this.docker.execArgv(['stop', inspection.id], timeout());
1859
+ logger.log('ok', ' Stopped ✓');
1308
1860
  }
1309
1861
 
1310
1862
  /**
1311
1863
  * Stop Elasticsearch service
1312
1864
  */
1313
1865
  public async stopElasticsearch(): Promise<void> {
1866
+ await withServiceDataOperationLock(process.cwd(), 'elasticsearch', () =>
1867
+ this.stopElasticsearchLocked(),
1868
+ );
1869
+ }
1870
+
1871
+ private async stopElasticsearchLocked(): Promise<void> {
1872
+ await this.config.reloadFromDisk();
1314
1873
  logger.log('note', '📦 Elasticsearch:');
1315
1874
 
1316
1875
  const containers = this.config.getContainerNames();
@@ -1372,13 +1931,52 @@ export class ServiceManager {
1372
1931
  mongo.authEnabled = this.config.isMongoAuthEnabled();
1373
1932
  mongo.authSource = this.config.getMongoAuthSource();
1374
1933
 
1375
- const minio = await describe(
1376
- 'minio',
1377
- containers.minio,
1378
- directories.minio,
1379
- config.S3_PORT,
1380
- `http://${config.S3_HOST}:${config.S3_PORT}`,
1934
+ const objectStorageDataExists = await plugins.smartfs.directory(
1935
+ directories.objectstorage,
1936
+ ).exists();
1937
+ const objectStorageLabels = getServiceContainerLabels({
1938
+ projectPath: process.cwd(),
1939
+ service: 'objectstorage',
1940
+ dataPath: directories.objectstorage,
1941
+ });
1942
+ const objectStorageId = await this.discoverContainerIdByExactName(
1943
+ containers.objectstorage,
1944
+ () => ({ timeoutMs: 60_000 }),
1381
1945
  );
1946
+ let objectStorageStatus: ContainerStatus = 'not_exists';
1947
+ let objectStorageOwnership: IServiceStatus['ownership'] = 'absent';
1948
+ if (objectStorageId) {
1949
+ const inspection = await this.inspectObjectStorageIdentity(
1950
+ objectStorageId,
1951
+ containers.objectstorage,
1952
+ () => ({ timeoutMs: 60_000 }),
1953
+ );
1954
+ objectStorageStatus = inspection.running ? 'running' : 'stopped';
1955
+ objectStorageOwnership = hasExactServiceContainerLabels(
1956
+ inspection.labels,
1957
+ objectStorageLabels,
1958
+ )
1959
+ ? 'owned'
1960
+ : 'conflict';
1961
+ }
1962
+ const objectstorage: IServiceStatus = {
1963
+ service: 'objectstorage',
1964
+ enabled: this.isServiceEnabled('objectstorage'),
1965
+ container: containers.objectstorage,
1966
+ status: objectStorageStatus,
1967
+ port: config.S3_PORT,
1968
+ connectionString: `http://127.0.0.1:${config.S3_PORT}`,
1969
+ managementUi: `http://127.0.0.1:${config.S3_UI_PORT}`,
1970
+ dataDirectory: directories.objectstorage,
1971
+ dataSizeBytes: objectStorageDataExists
1972
+ ? await helpers.getDirectorySize(directories.objectstorage)
1973
+ : 0,
1974
+ portAvailable:
1975
+ objectStorageStatus === 'not_exists'
1976
+ ? await helpers.isPortAvailable(parseInt(config.S3_PORT))
1977
+ : null,
1978
+ ownership: objectStorageOwnership,
1979
+ };
1382
1980
  const elasticsearch = await describe(
1383
1981
  'elasticsearch',
1384
1982
  containers.elasticsearch,
@@ -1387,14 +1985,35 @@ export class ServiceManager {
1387
1985
  config.ELASTICSEARCH_URL,
1388
1986
  );
1389
1987
 
1988
+ const projectPath = plugins.path.resolve(process.cwd());
1989
+ const registryProjects = await this.globalRegistry.getAllProjects();
1990
+ const registryProject = Object.entries(registryProjects).find(
1991
+ ([registeredPath]) => plugins.path.resolve(registeredPath) === projectPath,
1992
+ )?.[1];
1993
+ const legacyDataDirectory = getLegacyMinioDataDirectory(projectPath);
1994
+ const legacyContainerExists = await this.discoverContainerIdByExactName(
1995
+ containers.legacyMinio,
1996
+ () => ({ timeoutMs: 60_000 }),
1997
+ );
1998
+
1390
1999
  return {
1391
2000
  project: {
1392
2001
  name: config.PROJECT_NAME,
1393
2002
  path: process.cwd(),
1394
2003
  enabledServices: this.enabledServices || [],
1395
2004
  },
1396
- services: { mongodb: mongo, minio, elasticsearch },
1397
- totalDataBytes: mongo.dataSizeBytes + minio.dataSizeBytes + elasticsearch.dataSizeBytes,
2005
+ services: { mongodb: mongo, objectstorage, elasticsearch },
2006
+ legacy: {
2007
+ ...(registryProject?.legacy?.minioContainer
2008
+ ? { minioContainer: registryProject.legacy.minioContainer }
2009
+ : legacyContainerExists
2010
+ ? { minioContainer: containers.legacyMinio }
2011
+ : {}),
2012
+ minioDataDirectory: legacyDataDirectory,
2013
+ minioDataExists: await plugins.smartfs.directory(legacyDataDirectory).exists(),
2014
+ },
2015
+ totalDataBytes:
2016
+ mongo.dataSizeBytes + objectstorage.dataSizeBytes + elasticsearch.dataSizeBytes,
1398
2017
  };
1399
2018
  }
1400
2019
 
@@ -1406,6 +2025,7 @@ export class ServiceManager {
1406
2025
 
1407
2026
  const config = this.config.getConfig();
1408
2027
  const containers = this.config.getContainerNames();
2028
+ const structuredStatus = await this.collectStatus();
1409
2029
 
1410
2030
  logger.log('info', `Project: ${config.PROJECT_NAME}`);
1411
2031
  console.log();
@@ -1448,47 +2068,61 @@ export class ServiceManager {
1448
2068
  break;
1449
2069
  }
1450
2070
 
1451
- // MinIO status
1452
- const minioStatus = await this.docker.getStatus(containers.minio);
1453
- switch (minioStatus) {
2071
+ // ObjectStorage status
2072
+ const objectstorage = structuredStatus.services.objectstorage;
2073
+ if (objectstorage.ownership === 'conflict') {
2074
+ logger.log(
2075
+ 'error',
2076
+ `📦 S3/ObjectStorage: ownership conflict for ${objectstorage.container}; refusing to treat it as managed`,
2077
+ );
2078
+ } else switch (objectstorage.status) {
1454
2079
  case 'running':
1455
- logger.log('ok', '📦 S3/MinIO: 🟢 Running');
1456
- logger.log('info', ` ├─ Container: ${containers.minio}`);
1457
- logger.log('info', ` ├─ API: http://${config.S3_HOST}:${config.S3_PORT}`);
1458
- logger.log('info', ` ├─ Console: http://${config.S3_HOST}:${config.S3_CONSOLE_PORT}`);
2080
+ logger.log('ok', '📦 S3/ObjectStorage: 🟢 Running');
2081
+ logger.log('info', ` ├─ Container: ${containers.objectstorage}`);
2082
+ logger.log('info', ` ├─ API: http://127.0.0.1:${config.S3_PORT}`);
2083
+ logger.log('info', ` ├─ Management UI: http://127.0.0.1:${config.S3_UI_PORT}`);
1459
2084
  logger.log('info', ` └─ Bucket: ${config.S3_BUCKET}`);
1460
2085
  break;
1461
2086
  case 'stopped':
1462
- logger.log('note', '📦 S3/MinIO: 🟡 Stopped');
1463
- logger.log('info', ` ├─ Container: ${containers.minio}`);
2087
+ logger.log('note', '📦 S3/ObjectStorage: 🟡 Stopped');
2088
+ logger.log('info', ` ├─ Container: ${containers.objectstorage}`);
1464
2089
  logger.log('info', ` ├─ API Port: ${config.S3_PORT}`);
1465
- logger.log('info', ` └─ Console Port: ${config.S3_CONSOLE_PORT}`);
2090
+ logger.log('info', ` └─ Management UI Port: ${config.S3_UI_PORT}`);
1466
2091
  break;
1467
2092
  case 'not_exists':
1468
- logger.log('info', '📦 S3/MinIO: ⚪ Not installed');
2093
+ logger.log('info', '📦 S3/ObjectStorage: ⚪ Not installed');
1469
2094
  // Check port availability
1470
2095
  const s3Port = parseInt(config.S3_PORT);
1471
- const s3ConsolePort = parseInt(config.S3_CONSOLE_PORT);
2096
+ const s3UiPort = parseInt(config.S3_UI_PORT);
1472
2097
  const s3Available = await helpers.isPortAvailable(s3Port);
1473
- const consoleAvailable = await helpers.isPortAvailable(s3ConsolePort);
2098
+ const uiAvailable = await helpers.isPortAvailable(s3UiPort);
1474
2099
 
1475
- if (!s3Available || !consoleAvailable) {
2100
+ if (!s3Available || !uiAvailable) {
1476
2101
  if (!s3Available) {
1477
2102
  logger.log('error', ` ├─ ⚠️ API Port ${s3Port} is in use`);
1478
2103
  } else {
1479
2104
  logger.log('info', ` ├─ API Port ${s3Port} is available`);
1480
2105
  }
1481
- if (!consoleAvailable) {
1482
- logger.log('error', ` └─ ⚠️ Console Port ${s3ConsolePort} is in use`);
2106
+ if (!uiAvailable) {
2107
+ logger.log('error', ` └─ ⚠️ Management UI Port ${s3UiPort} is in use`);
1483
2108
  } else {
1484
- logger.log('info', ` └─ Console Port ${s3ConsolePort} is available`);
2109
+ logger.log('info', ` └─ Management UI Port ${s3UiPort} is available`);
1485
2110
  }
1486
2111
  } else {
1487
2112
  logger.log('info', ` ├─ API Port ${s3Port} is available`);
1488
- logger.log('info', ` └─ Console Port ${s3ConsolePort} is available`);
2113
+ logger.log('info', ` └─ Management UI Port ${s3UiPort} is available`);
1489
2114
  }
1490
2115
  break;
1491
2116
  }
2117
+ if (
2118
+ structuredStatus.legacy.minioContainer ||
2119
+ structuredStatus.legacy.minioDataExists
2120
+ ) {
2121
+ logger.log(
2122
+ 'note',
2123
+ ' Legacy MinIO resources are preserved and block automatic ObjectStorage startup; run `gitzone services start s3` for exact migration instructions.',
2124
+ );
2125
+ }
1492
2126
 
1493
2127
  // Elasticsearch status
1494
2128
  const esStatus = await this.docker.getStatus(containers.elasticsearch);
@@ -1531,7 +2165,7 @@ export class ServiceManager {
1531
2165
  const directories = this.config.getDataDirectories();
1532
2166
  const entries: Array<{ label: string; path: string }> = [
1533
2167
  { label: 'MongoDB', path: directories.mongo },
1534
- { label: 'S3/MinIO', path: directories.minio },
2168
+ { label: 'S3/ObjectStorage', path: directories.objectstorage },
1535
2169
  { label: 'Elasticsearch', path: directories.elasticsearch },
1536
2170
  ];
1537
2171
 
@@ -1546,11 +2180,22 @@ export class ServiceManager {
1546
2180
  total += size;
1547
2181
  logger.log('info', ` ${entry.label}: ${helpers.formatBytes(size)} (${entry.path})`);
1548
2182
  }
2183
+ const legacyDataPath = getLegacyMinioDataDirectory(process.cwd());
2184
+ const legacyDataExists = await plugins.smartfs.directory(legacyDataPath).exists();
1549
2185
  if (total === 0) {
1550
- logger.log('info', ' No service data directories present');
2186
+ logger.log('info', ' No canonical service data directories present');
2187
+ } else {
2188
+ logger.log('info', ` Total: ${helpers.formatBytes(total)}`);
2189
+ }
2190
+ if (legacyDataExists) {
2191
+ logger.log(
2192
+ 'note',
2193
+ ` Legacy MinIO data is preserved and excluded from cleanup: ${legacyDataPath}`,
2194
+ );
2195
+ }
2196
+ if (total === 0 && !legacyDataExists) {
1551
2197
  return;
1552
2198
  }
1553
- logger.log('info', ` Total: ${helpers.formatBytes(total)}`);
1554
2199
  logger.log(
1555
2200
  'note',
1556
2201
  ' Remove containers but keep data: `gitzone services remove`; remove data too: `gitzone services clean`',
@@ -1578,18 +2223,20 @@ export class ServiceManager {
1578
2223
  logger.log('info', ` Connection: ${this.config.getMongoConnectionString()}`);
1579
2224
 
1580
2225
  console.log();
1581
- logger.log('note', 'S3/MinIO:');
2226
+ logger.log('note', 'S3/ObjectStorage:');
1582
2227
  logger.log('info', ` Host: ${config.S3_HOST}`);
1583
2228
  logger.log('info', ` API Port: ${config.S3_PORT}`);
1584
- logger.log('info', ` Console Port: ${config.S3_CONSOLE_PORT}`);
2229
+ logger.log('info', ` Management UI Port: ${config.S3_UI_PORT}`);
1585
2230
  logger.log('info', ' Access Key: ***');
1586
2231
  logger.log('info', ' Secret Key: ***');
2232
+ logger.log('info', ' Admin Password: ***');
1587
2233
  logger.log('info', ` Bucket: ${config.S3_BUCKET}`);
1588
2234
  logger.log('info', ` Use SSL: ${config.S3_USESSL}`);
1589
- logger.log('info', ` Container: ${this.config.getContainerNames().minio}`);
1590
- logger.log('info', ` Data: ${this.config.getDataDirectories().minio}`);
2235
+ logger.log('info', ` Region: ${config.S3_REGION}`);
2236
+ logger.log('info', ` Container: ${this.config.getContainerNames().objectstorage}`);
2237
+ logger.log('info', ` Data: ${this.config.getDataDirectories().objectstorage}`);
1591
2238
  logger.log('info', ` Endpoint: ${config.S3_ENDPOINT}`);
1592
- logger.log('info', ` Console URL: http://${config.S3_HOST}:${config.S3_CONSOLE_PORT}`);
2239
+ logger.log('info', ` Management UI: http://127.0.0.1:${config.S3_UI_PORT}`);
1593
2240
 
1594
2241
  console.log();
1595
2242
  logger.log('note', 'Elasticsearch:');
@@ -1646,14 +2293,48 @@ export class ServiceManager {
1646
2293
  }
1647
2294
  break;
1648
2295
 
1649
- case 'minio':
2296
+ case 'objectstorage':
1650
2297
  case 's3':
1651
- if (await this.docker.isRunning(containers.minio)) {
1652
- helpers.printHeader(`S3/MinIO Logs (last ${lines} lines)`);
1653
- const logs = await this.docker.logs(containers.minio, lines);
1654
- console.log(logs);
1655
- } else {
1656
- logger.log('note', 'S3/MinIO container is not running');
2298
+ {
2299
+ const directories = this.config.getDataDirectories();
2300
+ const labels = getServiceContainerLabels({
2301
+ projectPath: process.cwd(),
2302
+ service: 'objectstorage',
2303
+ dataPath: directories.objectstorage,
2304
+ });
2305
+ const timeout = () => ({ timeoutMs: 60_000 });
2306
+ const id = await this.discoverContainerIdByExactName(
2307
+ containers.objectstorage,
2308
+ timeout,
2309
+ );
2310
+ if (!id) {
2311
+ logger.log('note', 'S3/ObjectStorage container does not exist');
2312
+ break;
2313
+ }
2314
+ await this.revalidateObjectStorageContainer(
2315
+ id,
2316
+ containers.objectstorage,
2317
+ labels,
2318
+ timeout,
2319
+ );
2320
+ helpers.printHeader(`S3/ObjectStorage Logs (last ${lines} lines)`);
2321
+ const result = await this.docker.execArgv(
2322
+ ['logs', '--tail', String(lines), id],
2323
+ timeout(),
2324
+ );
2325
+ const config = this.config.getConfig();
2326
+ let output = `${result.stdout}${result.stderr}`;
2327
+ const secrets = Array.from(
2328
+ new Set([
2329
+ config.S3_ACCESSKEY,
2330
+ config.S3_SECRETKEY,
2331
+ config.S3_ADMIN_PASSWORD,
2332
+ ].filter(Boolean)),
2333
+ ).sort((secretA, secretB) => secretB.length - secretA.length);
2334
+ for (const secret of secrets) {
2335
+ output = output.split(secret).join('***');
2336
+ }
2337
+ console.log(output);
1657
2338
  }
1658
2339
  break;
1659
2340
 
@@ -1672,7 +2353,7 @@ export class ServiceManager {
1672
2353
  case '':
1673
2354
  await this.showLogs('mongo', lines);
1674
2355
  console.log();
1675
- await this.showLogs('minio', lines);
2356
+ await this.showLogs('objectstorage', lines);
1676
2357
  console.log();
1677
2358
  await this.showLogs('elasticsearch', lines);
1678
2359
  break;
@@ -1687,42 +2368,175 @@ export class ServiceManager {
1687
2368
  * Remove containers
1688
2369
  */
1689
2370
  public async removeContainers(): Promise<void> {
1690
- const containers = this.config.getContainerNames();
1691
- let removed = false;
2371
+ await this.withProjectServiceLocks(process.cwd(), () => this.removeContainersLocked());
2372
+ }
1692
2373
 
1693
- if (await this.docker.exists(containers.mongo)) {
1694
- if (await this.docker.remove(containers.mongo, true)) {
1695
- logger.log('ok', ' MongoDB container removed ✓');
1696
- removed = true;
2374
+ private async removeContainersLocked(): Promise<void> {
2375
+ await this.config.reloadFromDisk();
2376
+ const containers = this.config.getContainerNames();
2377
+ const directories = this.config.getDataDirectories();
2378
+ const projectPath = plugins.path.resolve(process.cwd());
2379
+ const registryProjects = await this.globalRegistry.getAllProjects();
2380
+ const registryProject = Object.entries(registryProjects).find(
2381
+ ([registeredPathArg]) => plugins.path.resolve(registeredPathArg) === projectPath,
2382
+ )?.[1];
2383
+ const targets = new Map<
2384
+ string,
2385
+ { service: TServiceName; name: string; registered: boolean }
2386
+ >();
2387
+ const addTarget = (
2388
+ serviceArg: TServiceName,
2389
+ nameArg: string | undefined,
2390
+ registeredArg: boolean,
2391
+ ): void => {
2392
+ if (!nameArg) return;
2393
+ const existing = targets.get(nameArg);
2394
+ if (existing && existing.service !== serviceArg) {
2395
+ throw new Error(`Refusing ambiguous service container name: ${nameArg}`);
1697
2396
  }
1698
- }
1699
-
1700
- if (await this.docker.exists(containers.minio)) {
1701
- if (await this.docker.remove(containers.minio, true)) {
1702
- logger.log('ok', ' S3/MinIO container removed ✓');
1703
- removed = true;
2397
+ targets.set(nameArg, {
2398
+ service: serviceArg,
2399
+ name: nameArg,
2400
+ registered: registeredArg || existing?.registered === true,
2401
+ });
2402
+ };
2403
+ addTarget('mongodb', containers.mongo, false);
2404
+ addTarget('objectstorage', containers.objectstorage, false);
2405
+ addTarget('elasticsearch', containers.elasticsearch, false);
2406
+ addTarget('mongodb', registryProject?.containers.mongo, true);
2407
+ addTarget('objectstorage', registryProject?.containers.objectstorage, true);
2408
+ addTarget('elasticsearch', registryProject?.containers.elasticsearch, true);
2409
+
2410
+ const plannedRemovals: Array<{
2411
+ service: TServiceName;
2412
+ name: string;
2413
+ inspection: IContainerInspectInfo;
2414
+ }> = [];
2415
+ for (const target of targets.values()) {
2416
+ const id = await this.discoverContainerIdByExactName(
2417
+ target.name,
2418
+ () => ({ timeoutMs: 60_000 }),
2419
+ );
2420
+ if (!id) continue;
2421
+ const inspections = await this.docker.inspectMany([id]);
2422
+ const inspection = inspections[0];
2423
+ if (!inspection || inspection.id !== id || inspection.name !== target.name) {
2424
+ throw new Error(`Refusing ambiguous service container identity: ${target.name}`);
2425
+ }
2426
+ const expectedLabels = getServiceContainerLabels({
2427
+ projectPath,
2428
+ service: target.service,
2429
+ dataPath:
2430
+ target.service === 'mongodb'
2431
+ ? directories.mongo
2432
+ : target.service === 'objectstorage'
2433
+ ? directories.objectstorage
2434
+ : directories.elasticsearch,
2435
+ });
2436
+ const labelOwned = hasExactServiceContainerLabels(inspection.labels, expectedLabels);
2437
+ const hasGitZoneLabels = Object.keys(inspection.labels).some((keyArg) =>
2438
+ keyArg.startsWith('git.zone.'),
2439
+ );
2440
+ const registryClaims = Object.entries(registryProjects).flatMap(
2441
+ ([registeredPathArg, projectArg]) => {
2442
+ const claims: Array<{ projectPath: string; service: TServiceName }> = [];
2443
+ if (projectArg.containers.mongo === target.name) {
2444
+ claims.push({ projectPath: plugins.path.resolve(registeredPathArg), service: 'mongodb' });
2445
+ }
2446
+ if (projectArg.containers.objectstorage === target.name) {
2447
+ claims.push({
2448
+ projectPath: plugins.path.resolve(registeredPathArg),
2449
+ service: 'objectstorage',
2450
+ });
2451
+ }
2452
+ if (projectArg.containers.elasticsearch === target.name) {
2453
+ claims.push({
2454
+ projectPath: plugins.path.resolve(registeredPathArg),
2455
+ service: 'elasticsearch',
2456
+ });
2457
+ }
2458
+ return claims;
2459
+ },
2460
+ );
2461
+ const registryOwned =
2462
+ !hasGitZoneLabels &&
2463
+ target.registered &&
2464
+ target.service !== 'objectstorage' &&
2465
+ registryClaims.length === 1 &&
2466
+ registryClaims[0].projectPath === projectPath &&
2467
+ registryClaims[0].service === target.service;
2468
+ if (!labelOwned && !registryOwned) {
2469
+ throw new Error(`Refusing to remove container with unproven ownership: ${target.name}`);
1704
2470
  }
2471
+ plannedRemovals.push({ service: target.service, name: target.name, inspection });
1705
2472
  }
1706
2473
 
1707
- if (await this.docker.exists(containers.elasticsearch)) {
1708
- if (await this.docker.remove(containers.elasticsearch, true)) {
1709
- logger.log('ok', ' Elasticsearch container removed ✓');
1710
- removed = true;
1711
- }
2474
+ if (
2475
+ plannedRemovals.some((removalArg) => removalArg.service === 'objectstorage') &&
2476
+ (await plugins.smartfs.directory(directories.objectstorage).exists())
2477
+ ) {
2478
+ await this.repairObjectStorageMarkerFromOwnedContainer(directories.objectstorage);
2479
+ }
2480
+ for (const removal of plannedRemovals) {
2481
+ await this.docker.execArgv(['rm', '-f', removal.inspection.id], { timeoutMs: 60_000 });
2482
+ const label =
2483
+ removal.service === 'mongodb'
2484
+ ? 'MongoDB'
2485
+ : removal.service === 'objectstorage'
2486
+ ? 'S3/ObjectStorage'
2487
+ : 'Elasticsearch';
2488
+ logger.log('ok', ` ${label} container removed ✓`);
1712
2489
  }
1713
2490
 
1714
- if (!removed) {
2491
+ if (plannedRemovals.length === 0) {
1715
2492
  logger.log('note', ' No containers to remove');
1716
2493
  }
1717
2494
 
1718
- // Check if all containers are gone, then unregister from global registry
1719
- const mongoExists = await this.docker.exists(containers.mongo);
1720
- const minioExists = await this.docker.exists(containers.minio);
1721
- const esExists = await this.docker.exists(containers.elasticsearch);
2495
+ // Re-read and prove every current claim absent while all service locks are
2496
+ // still held. A concurrent internal start cannot appear between this check
2497
+ // and the compare-delete registry transaction.
2498
+ const currentProjects = await this.globalRegistry.getAllProjects();
2499
+ const currentProject = Object.entries(currentProjects).find(
2500
+ ([registeredPathArg]) => plugins.path.resolve(registeredPathArg) === projectPath,
2501
+ )?.[1];
2502
+ const legacyDataExists = await plugins.smartfs.directory(
2503
+ getLegacyMinioDataDirectory(projectPath),
2504
+ ).exists();
2505
+ if (currentProject && !currentProject.legacy?.minioContainer && !legacyDataExists) {
2506
+ const labeledIds = await this.docker.listIds([
2507
+ `label=git.zone.project-path=${projectPath}`,
2508
+ ]);
2509
+ let registeredContainerExists = labeledIds.length > 0;
2510
+ for (const containerName of Object.values(currentProject.containers)) {
2511
+ if (!containerName) continue;
2512
+ if (
2513
+ await this.discoverContainerIdByExactName(
2514
+ containerName,
2515
+ () => ({ timeoutMs: 60_000 }),
2516
+ )
2517
+ ) {
2518
+ registeredContainerExists = true;
2519
+ break;
2520
+ }
2521
+ }
2522
+ if (!registeredContainerExists) {
2523
+ await this.globalRegistry.unregisterProject(process.cwd(), currentProject);
2524
+ }
2525
+ }
2526
+ }
1722
2527
 
1723
- if (!mongoExists && !minioExists && !esExists) {
1724
- await this.globalRegistry.unregisterProject(process.cwd());
2528
+ private async withProjectServiceLocks<T>(
2529
+ projectPathArg: string,
2530
+ actionArg: () => Promise<T>,
2531
+ serviceIndexArg = 0,
2532
+ ): Promise<T> {
2533
+ const service = serviceNames[serviceIndexArg];
2534
+ if (!service) {
2535
+ return actionArg();
1725
2536
  }
2537
+ return withServiceDataOperationLock(projectPathArg, service, () =>
2538
+ this.withProjectServiceLocks(projectPathArg, actionArg, serviceIndexArg + 1),
2539
+ );
1726
2540
  }
1727
2541
 
1728
2542
  /**
@@ -1732,18 +2546,23 @@ export class ServiceManager {
1732
2546
  const directories = this.config.getDataDirectories();
1733
2547
  const targets: Array<{ label: string; service: TServiceName; path: string }> = [
1734
2548
  { label: 'MongoDB', service: 'mongodb', path: directories.mongo },
1735
- { label: 'S3/MinIO', service: 'minio', path: directories.minio },
2549
+ { label: 'S3/ObjectStorage', service: 'objectstorage', path: directories.objectstorage },
1736
2550
  { label: 'Elasticsearch', service: 'elasticsearch', path: directories.elasticsearch },
1737
2551
  ];
1738
2552
 
1739
2553
  let cleaned = false;
1740
2554
  for (const target of targets) {
1741
- if (!(await plugins.smartfs.directory(target.path).exists())) {
2555
+ const removed = await this.docker.removeDataDirectory(
2556
+ target.path,
2557
+ serviceImages[target.service],
2558
+ {
2559
+ projectPath: process.cwd(),
2560
+ service: target.service,
2561
+ },
2562
+ );
2563
+ if (!removed) {
1742
2564
  continue;
1743
2565
  }
1744
- // Escalates to a privileged one-off container when needed; throws rather
1745
- // than leaving a partially deleted (and for MongoDB, corrupt) directory.
1746
- await this.docker.removeDataDirectory(target.path, serviceImages[target.service]);
1747
2566
  logger.log('ok', ` ${target.label} data removed ✓`);
1748
2567
  cleaned = true;
1749
2568
  }
@@ -1751,6 +2570,13 @@ export class ServiceManager {
1751
2570
  if (!cleaned) {
1752
2571
  logger.log('note', ' No data to clean');
1753
2572
  }
2573
+ const legacyDataPath = getLegacyMinioDataDirectory(process.cwd());
2574
+ if (await plugins.smartfs.directory(legacyDataPath).exists()) {
2575
+ logger.log(
2576
+ 'note',
2577
+ ` Legacy MinIO data was preserved and not modified: ${legacyDataPath}`,
2578
+ );
2579
+ }
1754
2580
  }
1755
2581
 
1756
2582
  /**
@@ -1760,7 +2586,7 @@ export class ServiceManager {
1760
2586
  logger.log('note', 'Select which services to enable for this project:');
1761
2587
  console.log();
1762
2588
 
1763
- const currentServices = this.enabledServices || ['mongodb', 'minio', 'elasticsearch'];
2589
+ const currentServices = this.enabledServices || ['mongodb', 'objectstorage', 'elasticsearch'];
1764
2590
 
1765
2591
  const smartinteract = new plugins.smartinteract.SmartInteract();
1766
2592
  const response = await smartinteract.askQuestion({
@@ -1769,13 +2595,13 @@ export class ServiceManager {
1769
2595
  message: 'Which services do you want to enable?',
1770
2596
  choices: [
1771
2597
  { name: 'MongoDB', value: 'mongodb' },
1772
- { name: 'MinIO (S3)', value: 'minio' },
2598
+ { name: 'ObjectStorage (S3)', value: 'objectstorage' },
1773
2599
  { name: 'Elasticsearch', value: 'elasticsearch' }
1774
2600
  ],
1775
2601
  default: currentServices
1776
2602
  });
1777
2603
 
1778
- const enabledServices = response.value || ['mongodb', 'minio', 'elasticsearch'];
2604
+ const enabledServices = response.value || ['mongodb', 'objectstorage', 'elasticsearch'];
1779
2605
  this.enabledServices = enabledServices;
1780
2606
 
1781
2607
  // Save to .smartconfig.json
@@ -1789,31 +2615,10 @@ export class ServiceManager {
1789
2615
  */
1790
2616
  public async reconfigure(): Promise<void> {
1791
2617
  helpers.printHeader('Reconfiguring Services');
1792
-
1793
- const containers = this.config.getContainerNames();
1794
-
1795
- // Stop existing containers
1796
- logger.log('note', '🛑 Stopping existing containers...');
2618
+ await this.withProjectServiceLocks(process.cwd(), () => this.reconfigureLocked());
1797
2619
 
1798
- if (await this.docker.exists(containers.mongo)) {
1799
- await this.docker.stop(containers.mongo);
1800
- logger.log('ok', ' MongoDB stopped ✓');
1801
- }
1802
-
1803
- if (await this.docker.exists(containers.minio)) {
1804
- await this.docker.stop(containers.minio);
1805
- logger.log('ok', ' S3/MinIO stopped ✓');
1806
- }
1807
-
1808
- if (await this.docker.exists(containers.elasticsearch)) {
1809
- await this.docker.stop(containers.elasticsearch);
1810
- logger.log('ok', ' Elasticsearch stopped ✓');
1811
- }
1812
-
1813
- // Reconfigure ports
1814
- await this.config.reconfigurePorts();
1815
-
1816
- // Ask if user wants to restart services
2620
+ // Ask if user wants to restart services after releasing the reconfiguration
2621
+ // locks; startAll acquires the same locks one service at a time.
1817
2622
  const smartinteract = new plugins.smartinteract.SmartInteract();
1818
2623
  const response = await smartinteract.askQuestion({
1819
2624
  name: 'restart',
@@ -1825,6 +2630,21 @@ export class ServiceManager {
1825
2630
  if (response.value) {
1826
2631
  console.log();
1827
2632
  await this.startAll();
2633
+ await this.registerWithGlobalRegistry();
1828
2634
  }
1829
2635
  }
2636
+
2637
+ private async reconfigureLocked(): Promise<void> {
2638
+ await this.config.reloadFromDisk();
2639
+ await this.assertNoLegacyMinioResources(() => ({ timeoutMs: 60_000 }));
2640
+
2641
+ // Stop existing containers
2642
+ logger.log('note', '🛑 Stopping existing containers...');
2643
+ await this.stopMongoDBLocked();
2644
+ await this.stopObjectStorageLocked();
2645
+ await this.stopElasticsearchLocked();
2646
+
2647
+ // Reconfigure ports
2648
+ await this.config.reconfigurePorts();
2649
+ }
1830
2650
  }