@git.zone/cli 3.2.1 → 4.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.
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/classes.gitzoneconfig.js +1 -1
- package/dist_ts/classes.project.js +1 -1
- package/dist_ts/gitzone.cli.js +1 -1
- package/dist_ts/gitzone.logging.js +1 -1
- package/dist_ts/helpers.changelog.js +1 -1
- package/dist_ts/helpers.climode.js +1 -1
- package/dist_ts/helpers.smartconfig.js +61 -11
- package/dist_ts/helpers.smartconfigmigrations.js +1 -1
- package/dist_ts/helpers.tsdocker.js +1 -1
- package/dist_ts/helpers.workflow.js +1 -1
- package/dist_ts/index.js +1 -1
- package/dist_ts/mod_audit/index.js +1 -1
- package/dist_ts/mod_commit/index.js +1 -1
- package/dist_ts/mod_commit/mod.helpers.js +1 -1
- package/dist_ts/mod_commit/mod.plugins.js +1 -1
- package/dist_ts/mod_commit/mod.ui.js +1 -1
- package/dist_ts/mod_config/classes.commitconfig.js +1 -1
- package/dist_ts/mod_config/classes.releaseconfig.js +1 -1
- package/dist_ts/mod_config/index.js +2 -2
- package/dist_ts/mod_config/mod.plugins.js +1 -1
- package/dist_ts/mod_deprecate/index.js +1 -1
- package/dist_ts/mod_deprecate/mod.plugins.js +1 -1
- package/dist_ts/mod_docker/classes.dockerpruner.js +7 -1
- package/dist_ts/mod_docker/index.js +1 -1
- package/dist_ts/mod_docker/mod.plugins.js +1 -1
- package/dist_ts/mod_format/classes.baseformatter.js +1 -1
- package/dist_ts/mod_format/classes.diffreporter.js +1 -1
- package/dist_ts/mod_format/classes.formatcontext.js +1 -1
- package/dist_ts/mod_format/classes.formatplanner.js +1 -1
- package/dist_ts/mod_format/classes.formatstats.js +1 -1
- package/dist_ts/mod_format/formatters/assets.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/cleanup.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/copy.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/gitignore.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/license.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/packagejson.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/prettier.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/readme.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/smartconfig.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/templates.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/tsconfig.formatter.js +1 -1
- package/dist_ts/mod_format/index.js +1 -1
- package/dist_ts/mod_format/interfaces.format.js +1 -1
- package/dist_ts/mod_format/mod.plugins.js +1 -1
- package/dist_ts/mod_helpers/index.js +1 -1
- package/dist_ts/mod_helpers/mod.plugins.js +1 -1
- package/dist_ts/mod_meta/index.js +1 -1
- package/dist_ts/mod_meta/meta.classes.meta.js +1 -1
- package/dist_ts/mod_meta/meta.interfaces.js +1 -1
- package/dist_ts/mod_meta/meta.plugins.js +1 -1
- package/dist_ts/mod_open/index.js +1 -1
- package/dist_ts/mod_open/mod.plugins.js +1 -1
- package/dist_ts/mod_release/index.js +1 -1
- package/dist_ts/mod_release/mod.plugins.js +1 -1
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +50 -3
- package/dist_ts/mod_services/classes.dockercontainer.js +646 -84
- package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -24
- package/dist_ts/mod_services/classes.globalregistry.js +210 -62
- package/dist_ts/mod_services/classes.globalregistrystore.d.ts +48 -0
- package/dist_ts/mod_services/classes.globalregistrystore.js +156 -0
- package/dist_ts/mod_services/classes.interprocesslock.d.ts +21 -0
- package/dist_ts/mod_services/classes.interprocesslock.js +141 -0
- package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +15 -12
- package/dist_ts/mod_services/classes.serviceconfiguration.js +188 -91
- package/dist_ts/mod_services/classes.servicedatamarker.d.ts +32 -7
- package/dist_ts/mod_services/classes.servicedatamarker.js +145 -49
- package/dist_ts/mod_services/classes.servicemanager.d.ts +70 -28
- package/dist_ts/mod_services/classes.servicemanager.js +995 -421
- package/dist_ts/mod_services/classes.servicenames.d.ts +8 -0
- package/dist_ts/mod_services/classes.servicenames.js +54 -0
- package/dist_ts/mod_services/classes.serviceoptions.js +1 -1
- package/dist_ts/mod_services/classes.servicepruner.d.ts +3 -0
- package/dist_ts/mod_services/classes.servicepruner.js +208 -46
- package/dist_ts/mod_services/helpers.js +1 -1
- package/dist_ts/mod_services/index.d.ts +2 -0
- package/dist_ts/mod_services/index.js +81 -58
- package/dist_ts/mod_services/mod.plugins.js +1 -1
- package/dist_ts/mod_standard/index.js +2 -2
- package/dist_ts/mod_standard/mod.plugins.js +1 -1
- package/dist_ts/mod_start/index.js +1 -1
- package/dist_ts/mod_start/mod.plugins.js +1 -1
- package/dist_ts/mod_template/index.js +1 -1
- package/dist_ts/mod_template/mod.plugins.js +1 -1
- package/dist_ts/mod_tools/classes.packagemanager.js +1 -1
- package/dist_ts/mod_tools/index.js +1 -1
- package/dist_ts/mod_tools/mod.plugins.js +1 -1
- package/dist_ts/paths.js +1 -1
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +4 -2
- package/dist_ts_migration/001.service-selection.d.ts +9 -0
- package/dist_ts_migration/001.service-selection.js +51 -0
- package/dist_ts_migration/002.runtime-config.d.ts +19 -0
- package/dist_ts_migration/002.runtime-config.js +170 -0
- package/dist_ts_migration/003.service-data-marker.d.ts +9 -0
- package/dist_ts_migration/003.service-data-marker.js +156 -0
- package/dist_ts_migration/004.global-registry.d.ts +13 -0
- package/dist_ts_migration/004.global-registry.js +165 -0
- package/dist_ts_migration/index.d.ts +11 -0
- package/dist_ts_migration/index.js +59 -0
- package/package.json +6 -3
- package/readme.hints.md +102 -25
- package/readme.md +123 -28
- package/readme.plan.md +29 -16
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.smartconfig.ts +63 -10
- package/ts/mod_config/index.ts +1 -1
- package/ts/mod_docker/classes.dockerpruner.ts +6 -0
- package/ts/mod_services/classes.dockercontainer.ts +912 -96
- package/ts/mod_services/classes.globalregistry.ts +290 -89
- package/ts/mod_services/classes.globalregistrystore.ts +279 -0
- package/ts/mod_services/classes.interprocesslock.ts +189 -0
- package/ts/mod_services/classes.serviceconfiguration.ts +215 -108
- package/ts/mod_services/classes.servicedatamarker.ts +210 -52
- package/ts/mod_services/classes.servicemanager.ts +1332 -512
- package/ts/mod_services/classes.servicenames.ts +76 -0
- package/ts/mod_services/classes.servicepruner.ts +310 -52
- package/ts/mod_services/index.ts +89 -62
- package/ts/mod_standard/index.ts +1 -1
- package/ts/plugins.ts +4 -0
- package/ts_migration/001.service-selection.ts +77 -0
- package/ts_migration/002.runtime-config.ts +218 -0
- package/ts_migration/003.service-data-marker.ts +216 -0
- package/ts_migration/004.global-registry.ts +272 -0
- 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
|
|
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
|
-
|
|
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
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
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
|
|
121
|
+
export interface IObjectStorageReconciliationClock {
|
|
71
122
|
now(): number;
|
|
72
123
|
delay(millisecondsArg: number): Promise<void>;
|
|
73
124
|
}
|
|
74
125
|
|
|
75
|
-
export interface
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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?:
|
|
137
|
+
clock?: IObjectStorageReconciliationClock;
|
|
83
138
|
}
|
|
84
139
|
|
|
85
|
-
const
|
|
140
|
+
const defaultObjectStorageClock: IObjectStorageReconciliationClock = {
|
|
86
141
|
now: () => Date.now(),
|
|
87
142
|
delay: async (millisecondsArg) => plugins.smartdelay.delayFor(millisecondsArg),
|
|
88
143
|
};
|
|
89
144
|
|
|
90
|
-
class
|
|
145
|
+
class ObjectStorageStageError extends Error {
|
|
91
146
|
constructor(stageArg: string, timedOutArg: boolean, causeArg?: unknown) {
|
|
92
147
|
const message = timedOutArg
|
|
93
|
-
? `
|
|
94
|
-
: `
|
|
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 = '
|
|
151
|
+
this.name = 'ObjectStorageStageError';
|
|
97
152
|
}
|
|
98
153
|
}
|
|
99
154
|
|
|
100
|
-
const
|
|
155
|
+
const objectStorageStageError = (
|
|
101
156
|
stageArg: string,
|
|
102
157
|
timedOutArg: boolean,
|
|
103
158
|
causeArg?: unknown,
|
|
104
|
-
):
|
|
105
|
-
return new
|
|
159
|
+
): ObjectStorageStageError => {
|
|
160
|
+
return new ObjectStorageStageError(stageArg, timedOutArg, causeArg);
|
|
106
161
|
};
|
|
107
162
|
|
|
108
|
-
const
|
|
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('
|
|
166
|
+
: new Error('ObjectStorage lifecycle failed with a non-Error rejection.');
|
|
119
167
|
};
|
|
120
168
|
|
|
121
|
-
interface
|
|
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
|
|
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
|
-
):
|
|
280
|
+
): IObjectStorageContainerInspection => {
|
|
138
281
|
let parsed: unknown;
|
|
139
282
|
try {
|
|
140
283
|
parsed = JSON.parse(stdoutArg);
|
|
141
284
|
} catch {
|
|
142
|
-
throw new Error(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
171
|
-
if (
|
|
172
|
-
throw new Error(
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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.
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
426
|
+
const sendWithDeadline = async (
|
|
224
427
|
stageArg: string,
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 (
|
|
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: '
|
|
644
|
+
{ name: 'ObjectStorage (S3)', value: 'objectstorage' },
|
|
396
645
|
{ name: 'Elasticsearch', value: 'elasticsearch' }
|
|
397
646
|
],
|
|
398
|
-
default: ['mongodb', '
|
|
647
|
+
default: ['mongodb', 'objectstorage', 'elasticsearch']
|
|
399
648
|
});
|
|
400
649
|
|
|
401
|
-
const enabledServices = response.value || ['mongodb', '
|
|
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
|
-
|
|
408
|
-
|
|
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
|
|
418
|
-
|
|
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
|
-
|
|
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).
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
734
|
+
s3Ui: parseInt(config.S3_UI_PORT),
|
|
494
735
|
elasticsearch: parseInt(config.ELASTICSEARCH_PORT),
|
|
495
736
|
},
|
|
496
|
-
enabledServices: this.enabledServices || ['mongodb', '
|
|
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('
|
|
751
|
+
if (this.isServiceEnabled('objectstorage')) {
|
|
511
752
|
if (!first) console.log();
|
|
512
|
-
await this.
|
|
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.
|
|
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
|
|
1112
|
+
private async inspectObjectStorageContainer(
|
|
850
1113
|
containerReferenceArg: string,
|
|
851
1114
|
expectedContainerNameArg: string,
|
|
852
1115
|
dockerTimeoutArg: () => { timeoutMs: number },
|
|
853
|
-
): Promise<
|
|
1116
|
+
): Promise<IObjectStorageContainerInspection> {
|
|
854
1117
|
const result = await this.docker.execArgv(
|
|
855
1118
|
['inspect', containerReferenceArg],
|
|
856
1119
|
dockerTimeoutArg(),
|
|
857
1120
|
);
|
|
858
|
-
return
|
|
1121
|
+
return parseObjectStorageInspection(result.stdout, expectedContainerNameArg);
|
|
859
1122
|
}
|
|
860
1123
|
|
|
861
|
-
private async
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
): Promise<
|
|
866
|
-
const
|
|
867
|
-
|
|
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
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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
|
-
|
|
905
|
-
|
|
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
|
|
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<
|
|
920
|
-
const inspection = await this.
|
|
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
|
|
1197
|
+
`Refusing to use ObjectStorage container ${expectedContainerNameArg}: ` +
|
|
928
1198
|
'its immutable id changed during reconciliation.',
|
|
929
1199
|
);
|
|
930
1200
|
}
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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.
|
|
1546
|
+
const remaining = Math.floor(deadlineAt - this.objectStorageClock.now());
|
|
963
1547
|
if (remaining <= 0) {
|
|
964
|
-
throw
|
|
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 (
|
|
1558
|
+
const startById = async (
|
|
1559
|
+
containerIdArg: string,
|
|
1560
|
+
ownedByInvocationArg: boolean,
|
|
1561
|
+
): Promise<void> => {
|
|
973
1562
|
const executionOptions = dockerTimeout();
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1587
|
+
this.assertObjectStorageOwnership(initialInspection, expectedLabels);
|
|
1588
|
+
await this.repairObjectStorageMarkerFromOwnedContainer(directories.objectstorage);
|
|
1589
|
+
} else {
|
|
1590
|
+
await this.config.validateObjectStoragePorts();
|
|
1009
1591
|
}
|
|
1010
1592
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1627
|
+
await this.revalidateObjectStorageContainer(
|
|
1078
1628
|
activeInspection.id,
|
|
1079
|
-
containers.
|
|
1080
|
-
|
|
1081
|
-
activeAllowsLegacyRegistry,
|
|
1629
|
+
containers.objectstorage,
|
|
1630
|
+
expectedLabels,
|
|
1082
1631
|
dockerTimeout,
|
|
1083
1632
|
);
|
|
1084
|
-
await
|
|
1085
|
-
|
|
1086
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
1097
|
-
|
|
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(
|
|
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
|
-
'
|
|
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.
|
|
1115
|
-
logger.log('info', `
|
|
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', `
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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,
|
|
1397
|
-
|
|
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
|
-
//
|
|
1452
|
-
const
|
|
1453
|
-
|
|
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/
|
|
1456
|
-
logger.log('info', ` ├─ Container: ${containers.
|
|
1457
|
-
logger.log('info', ` ├─ API: http
|
|
1458
|
-
logger.log('info', ` ├─
|
|
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/
|
|
1463
|
-
logger.log('info', ` ├─ Container: ${containers.
|
|
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', ` └─
|
|
2090
|
+
logger.log('info', ` └─ Management UI Port: ${config.S3_UI_PORT}`);
|
|
1466
2091
|
break;
|
|
1467
2092
|
case 'not_exists':
|
|
1468
|
-
logger.log('info', '📦 S3/
|
|
2093
|
+
logger.log('info', '📦 S3/ObjectStorage: ⚪ Not installed');
|
|
1469
2094
|
// Check port availability
|
|
1470
2095
|
const s3Port = parseInt(config.S3_PORT);
|
|
1471
|
-
const
|
|
2096
|
+
const s3UiPort = parseInt(config.S3_UI_PORT);
|
|
1472
2097
|
const s3Available = await helpers.isPortAvailable(s3Port);
|
|
1473
|
-
const
|
|
2098
|
+
const uiAvailable = await helpers.isPortAvailable(s3UiPort);
|
|
1474
2099
|
|
|
1475
|
-
if (!s3Available || !
|
|
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 (!
|
|
1482
|
-
logger.log('error', ` └─ ⚠️
|
|
2106
|
+
if (!uiAvailable) {
|
|
2107
|
+
logger.log('error', ` └─ ⚠️ Management UI Port ${s3UiPort} is in use`);
|
|
1483
2108
|
} else {
|
|
1484
|
-
logger.log('info', ` └─
|
|
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', ` └─
|
|
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/
|
|
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/
|
|
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', `
|
|
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', `
|
|
1590
|
-
logger.log('info', `
|
|
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', `
|
|
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 '
|
|
2296
|
+
case 'objectstorage':
|
|
1650
2297
|
case 's3':
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
const
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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('
|
|
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
|
-
|
|
1691
|
-
|
|
2371
|
+
await this.withProjectServiceLocks(process.cwd(), () => this.removeContainersLocked());
|
|
2372
|
+
}
|
|
1692
2373
|
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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 (
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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 (
|
|
2491
|
+
if (plannedRemovals.length === 0) {
|
|
1715
2492
|
logger.log('note', ' No containers to remove');
|
|
1716
2493
|
}
|
|
1717
2494
|
|
|
1718
|
-
//
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
const
|
|
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
|
-
|
|
1724
|
-
|
|
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/
|
|
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
|
-
|
|
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', '
|
|
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: '
|
|
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', '
|
|
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
|
|
1799
|
-
|
|
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
|
}
|