@git.zone/cli 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +92 -0
- package/dist_ts/mod_services/classes.dockercontainer.js +225 -6
- package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -0
- package/dist_ts/mod_services/classes.globalregistry.js +23 -1
- package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +39 -1
- package/dist_ts/mod_services/classes.serviceconfiguration.js +89 -22
- package/dist_ts/mod_services/classes.servicedatamarker.d.ts +93 -0
- package/dist_ts/mod_services/classes.servicedatamarker.js +166 -0
- package/dist_ts/mod_services/classes.servicemanager.d.ts +103 -3
- package/dist_ts/mod_services/classes.servicemanager.js +352 -100
- package/dist_ts/mod_services/classes.servicepruner.d.ts +102 -0
- package/dist_ts/mod_services/classes.servicepruner.js +410 -0
- package/dist_ts/mod_services/helpers.d.ts +15 -0
- package/dist_ts/mod_services/helpers.js +57 -1
- package/dist_ts/mod_services/index.js +307 -53
- package/package.json +4 -3
- package/readme.hints.md +88 -0
- package/readme.md +71 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mod_services/classes.dockercontainer.ts +269 -9
- package/ts/mod_services/classes.globalregistry.ts +27 -0
- package/ts/mod_services/classes.serviceconfiguration.ts +105 -24
- package/ts/mod_services/classes.servicedatamarker.ts +228 -0
- package/ts/mod_services/classes.servicemanager.ts +480 -117
- package/ts/mod_services/classes.servicepruner.ts +532 -0
- package/ts/mod_services/helpers.ts +60 -0
- package/ts/mod_services/index.ts +437 -58
|
@@ -3,7 +3,43 @@ import * as helpers from './helpers.js';
|
|
|
3
3
|
import { ServiceConfiguration } from './classes.serviceconfiguration.js';
|
|
4
4
|
import { DockerContainer } from './classes.dockercontainer.js';
|
|
5
5
|
import { GlobalRegistry } from './classes.globalregistry.js';
|
|
6
|
+
import {
|
|
7
|
+
getServiceContainerLabels,
|
|
8
|
+
recordServiceDataDirectory,
|
|
9
|
+
serviceImages,
|
|
10
|
+
type TServiceName,
|
|
11
|
+
} from './classes.servicedatamarker.js';
|
|
6
12
|
import { logger } from '../gitzone.logging.js';
|
|
13
|
+
import type { ContainerStatus } from './classes.dockercontainer.js';
|
|
14
|
+
|
|
15
|
+
export interface IServiceStatus {
|
|
16
|
+
service: TServiceName;
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
container: string;
|
|
19
|
+
status: ContainerStatus;
|
|
20
|
+
port: string;
|
|
21
|
+
connectionString: string;
|
|
22
|
+
dataDirectory: string;
|
|
23
|
+
dataSizeBytes: number;
|
|
24
|
+
/** Only meaningful when the container does not exist yet. */
|
|
25
|
+
portAvailable: boolean | null;
|
|
26
|
+
/** MongoDB only: whether the instance enforces authentication. */
|
|
27
|
+
authEnabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IServicesStatus {
|
|
31
|
+
project: {
|
|
32
|
+
name: string;
|
|
33
|
+
path: string;
|
|
34
|
+
enabledServices: string[];
|
|
35
|
+
};
|
|
36
|
+
services: {
|
|
37
|
+
mongodb: IServiceStatus;
|
|
38
|
+
minio: IServiceStatus;
|
|
39
|
+
elasticsearch: IServiceStatus;
|
|
40
|
+
};
|
|
41
|
+
totalDataBytes: number;
|
|
42
|
+
}
|
|
7
43
|
|
|
8
44
|
export class ServiceManager {
|
|
9
45
|
private config: ServiceConfiguration;
|
|
@@ -31,11 +67,32 @@ export class ServiceManager {
|
|
|
31
67
|
await this.config.loadOrCreate();
|
|
32
68
|
logger.log('info', `📋 Project: ${this.config.getConfig().PROJECT_NAME}`);
|
|
33
69
|
|
|
70
|
+
// The unsafe-exposure guard is deliberately NOT enforced here. init() runs
|
|
71
|
+
// for every project command, so refusing at this point would also block
|
|
72
|
+
// stop, remove, clean, status and logs — the commands needed to recover from
|
|
73
|
+
// the misconfiguration. It is enforced where it matters, on the path that
|
|
74
|
+
// actually starts an unauthenticated database.
|
|
75
|
+
if (!this.config.isMongoAuthEnabled()) {
|
|
76
|
+
logger.log('note', '⚠️ MongoDB auth is disabled for this project (loopback only)');
|
|
77
|
+
}
|
|
78
|
+
|
|
34
79
|
// Load service selection from .smartconfig.json
|
|
35
80
|
await this.loadServiceConfiguration();
|
|
36
81
|
|
|
37
82
|
// Validate and update ports if needed
|
|
38
83
|
await this.config.validateAndUpdatePorts();
|
|
84
|
+
|
|
85
|
+
// Any services command run in a project counts as activity. Without this
|
|
86
|
+
// lastActive only advanced on start, so an actively used project could look
|
|
87
|
+
// stale to the reaper.
|
|
88
|
+
await this.globalRegistry.touchProject(process.cwd());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Expose the resolved service configuration to the command layer.
|
|
93
|
+
*/
|
|
94
|
+
public getConfiguration(): ServiceConfiguration {
|
|
95
|
+
return this.config;
|
|
39
96
|
}
|
|
40
97
|
|
|
41
98
|
/**
|
|
@@ -113,9 +170,32 @@ export class ServiceManager {
|
|
|
113
170
|
}
|
|
114
171
|
|
|
115
172
|
/**
|
|
116
|
-
*
|
|
173
|
+
* Prepare a service's data directory: create it and record tool ownership.
|
|
174
|
+
*
|
|
175
|
+
* The marker written here is what later allows `gitzone services prune` to
|
|
176
|
+
* prove the directory is reclaimable instead of guessing from its path.
|
|
117
177
|
*/
|
|
118
|
-
private async
|
|
178
|
+
private async prepareDataDirectory(
|
|
179
|
+
serviceArg: TServiceName,
|
|
180
|
+
dataPathArg: string,
|
|
181
|
+
): Promise<{ [key: string]: string }> {
|
|
182
|
+
await plugins.smartfs.directory(dataPathArg).recursive().create();
|
|
183
|
+
await recordServiceDataDirectory(process.cwd(), serviceArg);
|
|
184
|
+
return getServiceContainerLabels({
|
|
185
|
+
projectPath: process.cwd(),
|
|
186
|
+
service: serviceArg,
|
|
187
|
+
dataPath: dataPathArg,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register this project with the global registry.
|
|
193
|
+
*
|
|
194
|
+
* Public and called from the command layer after any start path: previously
|
|
195
|
+
* only `startAll` registered, so `gitzone services start mongo` created a
|
|
196
|
+
* container the registry never knew about and prune could never account for.
|
|
197
|
+
*/
|
|
198
|
+
public async registerWithGlobalRegistry(): Promise<void> {
|
|
119
199
|
const config = this.config.getConfig();
|
|
120
200
|
const containers = this.config.getContainerNames();
|
|
121
201
|
|
|
@@ -157,85 +237,81 @@ export class ServiceManager {
|
|
|
157
237
|
await this.startElasticsearch();
|
|
158
238
|
first = false;
|
|
159
239
|
}
|
|
160
|
-
|
|
161
|
-
// Register with global registry
|
|
162
|
-
await this.registerWithGlobalRegistry();
|
|
163
240
|
}
|
|
164
241
|
|
|
165
242
|
/**
|
|
166
|
-
* Stop
|
|
243
|
+
* Stop every service container belonging to this project.
|
|
244
|
+
*
|
|
245
|
+
* Deliberately not filtered by the enabled-service list: disabling a service
|
|
246
|
+
* previously left its container running with no command able to stop it,
|
|
247
|
+
* which is how projects accumulated forgotten containers. Stopping a service
|
|
248
|
+
* that is not running is a no-op, so covering all three is always safe.
|
|
167
249
|
*/
|
|
168
250
|
public async stopAll(): Promise<void> {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
if (this.isServiceEnabled('minio')) {
|
|
176
|
-
if (!first) console.log();
|
|
177
|
-
await this.stopMinIO();
|
|
178
|
-
first = false;
|
|
179
|
-
}
|
|
180
|
-
if (this.isServiceEnabled('elasticsearch')) {
|
|
181
|
-
if (!first) console.log();
|
|
182
|
-
await this.stopElasticsearch();
|
|
183
|
-
first = false;
|
|
184
|
-
}
|
|
251
|
+
await this.stopMongoDB();
|
|
252
|
+
console.log();
|
|
253
|
+
await this.stopMinIO();
|
|
254
|
+
console.log();
|
|
255
|
+
await this.stopElasticsearch();
|
|
185
256
|
}
|
|
186
257
|
|
|
187
258
|
/**
|
|
188
259
|
* Start MongoDB service
|
|
260
|
+
*
|
|
261
|
+
* MongoDB runs as a single-node replica set so that multi-document
|
|
262
|
+
* transactions work. Auth + replSet requires a keyfile; mongod listens on
|
|
263
|
+
* MONGODB_PORT inside the container too, so the advertised member address
|
|
264
|
+
* (localhost:MONGODB_PORT) is reachable from host and container alike.
|
|
189
265
|
*/
|
|
190
266
|
public async startMongoDB(): Promise<void> {
|
|
191
267
|
logger.log('note', '📦 MongoDB:');
|
|
192
|
-
|
|
268
|
+
|
|
193
269
|
const config = this.config.getConfig();
|
|
194
270
|
const containers = this.config.getContainerNames();
|
|
195
271
|
const directories = this.config.getDataDirectories();
|
|
196
|
-
|
|
197
|
-
// Ensure data directory exists
|
|
198
|
-
await
|
|
199
|
-
|
|
272
|
+
|
|
273
|
+
// Ensure data directory exists and is marked as tool-owned
|
|
274
|
+
await this.prepareDataDirectory('mongodb', directories.mongo);
|
|
275
|
+
|
|
200
276
|
const status = await this.docker.getStatus(containers.mongo);
|
|
201
|
-
|
|
277
|
+
|
|
278
|
+
// Containers created before replica-set support, with a changed port, or
|
|
279
|
+
// running the other auth mode must be recreated; the data is preserved.
|
|
280
|
+
let needsRecreate = false;
|
|
281
|
+
if (status !== 'not_exists') {
|
|
282
|
+
const containerCmd = await this.getMongoContainerCmd(containers.mongo);
|
|
283
|
+
const hasReplSet = containerCmd.includes('--replSet');
|
|
284
|
+
const hasKeyFile = containerCmd.includes('--keyFile');
|
|
285
|
+
const portMappings = await this.docker.getPortMappings(containers.mongo);
|
|
286
|
+
const portMatches =
|
|
287
|
+
!!portMappings && portMappings[config.MONGODB_PORT] === config.MONGODB_PORT;
|
|
288
|
+
const authMatches = hasKeyFile === this.config.isMongoAuthEnabled();
|
|
289
|
+
needsRecreate = !hasReplSet || !portMatches || !authMatches;
|
|
290
|
+
if (!authMatches) {
|
|
291
|
+
logger.log(
|
|
292
|
+
'note',
|
|
293
|
+
` Auth mode changed to ${this.config.isMongoAuthEnabled() ? 'enabled' : 'disabled'}, recreating container...`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
202
298
|
switch (status) {
|
|
203
299
|
case 'running':
|
|
204
|
-
|
|
300
|
+
if (!needsRecreate) {
|
|
301
|
+
logger.log('ok', ' Already running ✓');
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
logger.log('note', ' Upgrading to single-node replica set, recreating container...');
|
|
305
|
+
await this.docker.remove(containers.mongo, true);
|
|
306
|
+
await this.createMongoContainer();
|
|
205
307
|
break;
|
|
206
|
-
|
|
308
|
+
|
|
207
309
|
case 'stopped':
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (mongoPortMappings && mongoPortMappings['27017'] !== config.MONGODB_PORT) {
|
|
211
|
-
logger.log('note', ' Port configuration changed, recreating container...');
|
|
310
|
+
if (needsRecreate) {
|
|
311
|
+
logger.log('note', ' Configuration changed, recreating container...');
|
|
212
312
|
await this.docker.remove(containers.mongo, true);
|
|
213
|
-
|
|
214
|
-
const success = await this.docker.run({
|
|
215
|
-
name: containers.mongo,
|
|
216
|
-
image: 'mongo:7.0',
|
|
217
|
-
ports: {
|
|
218
|
-
[`0.0.0.0:${config.MONGODB_PORT}`]: '27017'
|
|
219
|
-
},
|
|
220
|
-
volumes: {
|
|
221
|
-
[directories.mongo]: '/data/db'
|
|
222
|
-
},
|
|
223
|
-
environment: {
|
|
224
|
-
MONGO_INITDB_ROOT_USERNAME: config.MONGODB_USER,
|
|
225
|
-
MONGO_INITDB_ROOT_PASSWORD: config.MONGODB_PASS,
|
|
226
|
-
MONGO_INITDB_DATABASE: config.MONGODB_NAME
|
|
227
|
-
},
|
|
228
|
-
restart: 'unless-stopped',
|
|
229
|
-
command: '--bind_ip_all'
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
if (success) {
|
|
233
|
-
logger.log('ok', ' Recreated with new port ✓');
|
|
234
|
-
} else {
|
|
235
|
-
logger.log('error', ' Failed to recreate container');
|
|
236
|
-
}
|
|
313
|
+
await this.createMongoContainer();
|
|
237
314
|
} else {
|
|
238
|
-
// Ports match, just start the container
|
|
239
315
|
if (await this.docker.start(containers.mongo)) {
|
|
240
316
|
logger.log('ok', ' Started ✓');
|
|
241
317
|
} else {
|
|
@@ -243,44 +319,218 @@ export class ServiceManager {
|
|
|
243
319
|
}
|
|
244
320
|
}
|
|
245
321
|
break;
|
|
246
|
-
|
|
322
|
+
|
|
247
323
|
case 'not_exists':
|
|
248
324
|
logger.log('note', ' Creating container...');
|
|
249
|
-
|
|
250
|
-
const success = await this.docker.run({
|
|
251
|
-
name: containers.mongo,
|
|
252
|
-
image: 'mongo:7.0',
|
|
253
|
-
ports: {
|
|
254
|
-
[`0.0.0.0:${config.MONGODB_PORT}`]: '27017'
|
|
255
|
-
},
|
|
256
|
-
volumes: {
|
|
257
|
-
[directories.mongo]: '/data/db'
|
|
258
|
-
},
|
|
259
|
-
environment: {
|
|
260
|
-
MONGO_INITDB_ROOT_USERNAME: config.MONGODB_USER,
|
|
261
|
-
MONGO_INITDB_ROOT_PASSWORD: config.MONGODB_PASS,
|
|
262
|
-
MONGO_INITDB_DATABASE: config.MONGODB_NAME
|
|
263
|
-
},
|
|
264
|
-
restart: 'unless-stopped',
|
|
265
|
-
command: '--bind_ip_all'
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
if (success) {
|
|
269
|
-
logger.log('ok', ' Created and started ✓');
|
|
270
|
-
} else {
|
|
271
|
-
logger.log('error', ' Failed to create container');
|
|
272
|
-
}
|
|
325
|
+
await this.createMongoContainer();
|
|
273
326
|
break;
|
|
274
327
|
}
|
|
275
|
-
|
|
328
|
+
|
|
329
|
+
await this.ensureMongoRootUser();
|
|
330
|
+
await this.ensureMongoReplicaSetInitiated();
|
|
331
|
+
|
|
276
332
|
logger.log('info', ` Container: ${containers.mongo}`);
|
|
277
333
|
logger.log('info', ` Port: ${config.MONGODB_PORT}`);
|
|
334
|
+
if (!this.config.isMongoAuthEnabled()) {
|
|
335
|
+
logger.log('note', ' ⚠️ Auth: DISABLED — published on loopback only (127.0.0.1)');
|
|
336
|
+
}
|
|
278
337
|
logger.log('info', ` Connection: ${this.config.getMongoConnectionString()}`);
|
|
279
|
-
|
|
280
|
-
// Show Compass connection string
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
338
|
+
|
|
339
|
+
// Show Compass connection string. Only meaningful while the database is
|
|
340
|
+
// reachable off-host, which is exactly the auth-enabled case.
|
|
341
|
+
if (this.config.isMongoAuthEnabled()) {
|
|
342
|
+
const networkIp = await helpers.getLocalNetworkIp();
|
|
343
|
+
const compassString = `mongodb://${config.MONGODB_USER}:${config.MONGODB_PASS}@${networkIp}:${config.MONGODB_PORT}/${config.MONGODB_NAME}?authSource=admin&directConnection=true`;
|
|
344
|
+
logger.log('ok', ` Compass: ${compassString}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Create the MongoDB container as a single-node replica set
|
|
350
|
+
*/
|
|
351
|
+
private async createMongoContainer(): Promise<void> {
|
|
352
|
+
const config = this.config.getConfig();
|
|
353
|
+
const containers = this.config.getContainerNames();
|
|
354
|
+
const directories = this.config.getDataDirectories();
|
|
355
|
+
const authEnabled = this.config.isMongoAuthEnabled();
|
|
356
|
+
|
|
357
|
+
// Never create an unauthenticated instance that is reachable off-host.
|
|
358
|
+
this.config.assertMongoExposureIsSafe();
|
|
359
|
+
|
|
360
|
+
const labels = getServiceContainerLabels({
|
|
361
|
+
projectPath: process.cwd(),
|
|
362
|
+
service: 'mongodb',
|
|
363
|
+
dataPath: directories.mongo,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Auth + replSet requires a keyfile. Without auth no keyfile is needed, and
|
|
367
|
+
// the root-user env vars must be omitted because the official entrypoint
|
|
368
|
+
// turns them into `--auth`.
|
|
369
|
+
const command = authEnabled
|
|
370
|
+
? `--replSet rs0 --keyFile /data/db/mongo-keyfile --port ${config.MONGODB_PORT} --bind_ip_all`
|
|
371
|
+
: `--replSet rs0 --port ${config.MONGODB_PORT} --bind_ip_all`;
|
|
372
|
+
const environment: { [key: string]: string } = {
|
|
373
|
+
MONGO_INITDB_DATABASE: config.MONGODB_NAME,
|
|
374
|
+
};
|
|
375
|
+
if (authEnabled) {
|
|
376
|
+
environment.MONGO_INITDB_ROOT_USERNAME = config.MONGODB_USER;
|
|
377
|
+
environment.MONGO_INITDB_ROOT_PASSWORD = config.MONGODB_PASS;
|
|
378
|
+
}
|
|
379
|
+
// Publishing scope is the actual exposure control: mongod's --bind_ip_all
|
|
380
|
+
// only binds inside the container's netns.
|
|
381
|
+
const publishHost = authEnabled ? '0.0.0.0' : '127.0.0.1';
|
|
382
|
+
|
|
383
|
+
if (authEnabled) {
|
|
384
|
+
await this.ensureMongoKeyfile(directories.mongo);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const success = await this.docker.run({
|
|
388
|
+
name: containers.mongo,
|
|
389
|
+
image: 'mongo:7.0',
|
|
390
|
+
ports: {
|
|
391
|
+
[`${publishHost}:${config.MONGODB_PORT}`]: config.MONGODB_PORT
|
|
392
|
+
},
|
|
393
|
+
volumes: {
|
|
394
|
+
[directories.mongo]: '/data/db'
|
|
395
|
+
},
|
|
396
|
+
environment,
|
|
397
|
+
labels,
|
|
398
|
+
restart: 'unless-stopped',
|
|
399
|
+
command
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (success) {
|
|
403
|
+
logger.log('ok', ' Created and started ✓');
|
|
404
|
+
} else {
|
|
405
|
+
logger.log('error', ' Failed to create container');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Generate the replica-set keyfile inside the data volume if missing.
|
|
411
|
+
* Created through a root one-off container so ownership (mongodb uid 999)
|
|
412
|
+
* is correct under both rootful and rootless Docker.
|
|
413
|
+
*/
|
|
414
|
+
private async ensureMongoKeyfile(mongoDataDir: string): Promise<void> {
|
|
415
|
+
const keyfilePath = plugins.path.join(mongoDataDir, 'mongo-keyfile');
|
|
416
|
+
if (await plugins.smartfs.file(keyfilePath).exists()) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
logger.log('note', ' Generating replica set keyfile...');
|
|
420
|
+
await this.docker.runOneOff({
|
|
421
|
+
image: 'mongo:7.0',
|
|
422
|
+
command:
|
|
423
|
+
'openssl rand -base64 756 > /data/db/mongo-keyfile && chown 999:999 /data/db/mongo-keyfile && chmod 400 /data/db/mongo-keyfile',
|
|
424
|
+
volumes: { [mongoDataDir]: '/data/db' },
|
|
425
|
+
user: '0'
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The mongod argv the container was created with
|
|
431
|
+
*/
|
|
432
|
+
private async getMongoContainerCmd(containerName: string): Promise<string[]> {
|
|
433
|
+
const info = await this.docker.inspect(containerName);
|
|
434
|
+
return info?.[0]?.Config?.Cmd ?? [];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Make sure the configured root user exists when auth is enabled.
|
|
439
|
+
*
|
|
440
|
+
* `MONGO_INITDB_ROOT_USERNAME` only takes effect on an empty dbpath, so data
|
|
441
|
+
* that was first created with auth disabled has no user at all. Enabling auth
|
|
442
|
+
* over it would leave an unusable database. MongoDB's localhost exception is
|
|
443
|
+
* the designed bootstrap path for exactly this: while zero users exist, a
|
|
444
|
+
* connection from localhost — which, inside the container, is what this is —
|
|
445
|
+
* may create the first one. If users already exist the exception does not
|
|
446
|
+
* apply and creation fails, so this can never overwrite or escalate anything.
|
|
447
|
+
*/
|
|
448
|
+
private async ensureMongoRootUser(): Promise<void> {
|
|
449
|
+
if (!this.config.isMongoAuthEnabled()) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const config = this.config.getConfig();
|
|
453
|
+
const containers = this.config.getContainerNames();
|
|
454
|
+
|
|
455
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
456
|
+
const probe = await this.docker.execCombined(
|
|
457
|
+
containers.mongo,
|
|
458
|
+
`mongosh --quiet --port ${config.MONGODB_PORT} ` +
|
|
459
|
+
`-u "${config.MONGODB_USER}" -p "${config.MONGODB_PASS}" --authenticationDatabase admin ` +
|
|
460
|
+
`--eval 'print("AUTH_OK")'`,
|
|
461
|
+
);
|
|
462
|
+
if (probe.output.includes('AUTH_OK')) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (probe.output.includes('Authentication failed')) {
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
// A missing or dead container will never become reachable; retrying 30
|
|
469
|
+
// times only delays a failure that is already terminal.
|
|
470
|
+
if (probe.output.includes('No such container') || probe.output.includes('is restarting')) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`MongoDB container "${containers.mongo}" is not running (${probe.output.trim()}). ` +
|
|
473
|
+
`Inspect it with \`gitzone services logs mongo 100\`.`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
// mongod not accepting connections yet
|
|
477
|
+
await plugins.smartdelay.delayFor(1000);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
logger.log('note', ' No usable root user in existing data, bootstrapping...');
|
|
481
|
+
const createScript =
|
|
482
|
+
`db.getSiblingDB("admin").createUser({user: "${config.MONGODB_USER}", ` +
|
|
483
|
+
`pwd: "${config.MONGODB_PASS}", roles: [{role: "root", db: "admin"}]}); ` +
|
|
484
|
+
'print("USER_CREATED");';
|
|
485
|
+
const create = await this.docker.execCombined(
|
|
486
|
+
containers.mongo,
|
|
487
|
+
`mongosh --quiet --port ${config.MONGODB_PORT} --eval '${createScript}'`,
|
|
488
|
+
);
|
|
489
|
+
if (create.output.includes('USER_CREATED')) {
|
|
490
|
+
logger.log('ok', ' Root user created ✓');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
throw new Error(
|
|
495
|
+
`MongoDB rejected the configured credentials for "${containers.mongo}" and the root user could not be ` +
|
|
496
|
+
'bootstrapped (the localhost exception only applies while no users exist). Either correct ' +
|
|
497
|
+
'MONGODB_USER/MONGODB_PASS in .nogit/env.json to match the existing data, or discard the data with ' +
|
|
498
|
+
'`gitzone services clean`.',
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Initiate the single-node replica set once mongod is reachable
|
|
504
|
+
*/
|
|
505
|
+
private async ensureMongoReplicaSetInitiated(): Promise<boolean> {
|
|
506
|
+
const config = this.config.getConfig();
|
|
507
|
+
const containers = this.config.getContainerNames();
|
|
508
|
+
const evalScript =
|
|
509
|
+
'try { rs.status(); print("RS_OK"); } catch (e) { ' +
|
|
510
|
+
'if (e.codeName === "NotYetInitialized") { ' +
|
|
511
|
+
`rs.initiate({_id: "rs0", members: [{_id: 0, host: "localhost:${config.MONGODB_PORT}"}]}); ` +
|
|
512
|
+
'print("RS_INITIATED"); } else { print("RS_ERROR: " + e.message); } }';
|
|
513
|
+
const authArgs = this.config.isMongoAuthEnabled()
|
|
514
|
+
? `-u "${config.MONGODB_USER}" -p "${config.MONGODB_PASS}" --authenticationDatabase admin `
|
|
515
|
+
: '';
|
|
516
|
+
const command =
|
|
517
|
+
`mongosh --quiet --port ${config.MONGODB_PORT} ` +
|
|
518
|
+
authArgs +
|
|
519
|
+
`--eval '${evalScript}'`;
|
|
520
|
+
|
|
521
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
522
|
+
const output = await this.docker.exec(containers.mongo, command);
|
|
523
|
+
if (output.includes('RS_INITIATED')) {
|
|
524
|
+
logger.log('ok', ' Replica set initiated ✓');
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
if (output.includes('RS_OK')) {
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
await plugins.smartdelay.delayFor(1000);
|
|
531
|
+
}
|
|
532
|
+
logger.log('error', ' Replica set not ready after 30s — transactions unavailable');
|
|
533
|
+
return false;
|
|
284
534
|
}
|
|
285
535
|
|
|
286
536
|
/**
|
|
@@ -293,9 +543,9 @@ export class ServiceManager {
|
|
|
293
543
|
const containers = this.config.getContainerNames();
|
|
294
544
|
const directories = this.config.getDataDirectories();
|
|
295
545
|
|
|
296
|
-
// Ensure data directory exists
|
|
297
|
-
await
|
|
298
|
-
|
|
546
|
+
// Ensure data directory exists and is marked as tool-owned
|
|
547
|
+
const minioLabels = await this.prepareDataDirectory('minio', directories.minio);
|
|
548
|
+
|
|
299
549
|
const status = await this.docker.getStatus(containers.minio);
|
|
300
550
|
|
|
301
551
|
switch (status) {
|
|
@@ -326,10 +576,11 @@ export class ServiceManager {
|
|
|
326
576
|
MINIO_ROOT_USER: config.S3_ACCESSKEY,
|
|
327
577
|
MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
|
|
328
578
|
},
|
|
579
|
+
labels: minioLabels,
|
|
329
580
|
restart: 'unless-stopped',
|
|
330
581
|
command: 'server /data --console-address ":9001"'
|
|
331
582
|
});
|
|
332
|
-
|
|
583
|
+
|
|
333
584
|
if (success) {
|
|
334
585
|
logger.log('ok', ' Recreated with new ports ✓');
|
|
335
586
|
|
|
@@ -378,10 +629,11 @@ export class ServiceManager {
|
|
|
378
629
|
MINIO_ROOT_USER: config.S3_ACCESSKEY,
|
|
379
630
|
MINIO_ROOT_PASSWORD: config.S3_SECRETKEY
|
|
380
631
|
},
|
|
632
|
+
labels: minioLabels,
|
|
381
633
|
restart: 'unless-stopped',
|
|
382
634
|
command: 'server /data --console-address ":9001"'
|
|
383
635
|
});
|
|
384
|
-
|
|
636
|
+
|
|
385
637
|
if (success) {
|
|
386
638
|
logger.log('ok', ' Created and started ✓');
|
|
387
639
|
|
|
@@ -423,8 +675,8 @@ export class ServiceManager {
|
|
|
423
675
|
const containers = this.config.getContainerNames();
|
|
424
676
|
const directories = this.config.getDataDirectories();
|
|
425
677
|
|
|
426
|
-
// Ensure data directory exists
|
|
427
|
-
await
|
|
678
|
+
// Ensure data directory exists and is marked as tool-owned
|
|
679
|
+
const esLabels = await this.prepareDataDirectory('elasticsearch', directories.elasticsearch);
|
|
428
680
|
|
|
429
681
|
const status = await this.docker.getStatus(containers.elasticsearch);
|
|
430
682
|
|
|
@@ -455,6 +707,7 @@ export class ServiceManager {
|
|
|
455
707
|
'ELASTIC_PASSWORD': config.ELASTICSEARCH_PASS,
|
|
456
708
|
'ES_JAVA_OPTS': '-Xms512m -Xmx512m'
|
|
457
709
|
},
|
|
710
|
+
labels: esLabels,
|
|
458
711
|
restart: 'unless-stopped'
|
|
459
712
|
});
|
|
460
713
|
|
|
@@ -491,6 +744,7 @@ export class ServiceManager {
|
|
|
491
744
|
'ELASTIC_PASSWORD': config.ELASTICSEARCH_PASS,
|
|
492
745
|
'ES_JAVA_OPTS': '-Xms512m -Xmx512m'
|
|
493
746
|
},
|
|
747
|
+
labels: esLabels,
|
|
494
748
|
restart: 'unless-stopped'
|
|
495
749
|
});
|
|
496
750
|
|
|
@@ -569,18 +823,88 @@ export class ServiceManager {
|
|
|
569
823
|
}
|
|
570
824
|
}
|
|
571
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Collect service status as structured data.
|
|
828
|
+
*
|
|
829
|
+
* This is the machine-readable surface behind `gitzone services status
|
|
830
|
+
* --json`; consumers such as test suites can read a connection string from it
|
|
831
|
+
* without scraping human output or re-deriving it from `.nogit/env.json`.
|
|
832
|
+
*/
|
|
833
|
+
public async collectStatus(): Promise<IServicesStatus> {
|
|
834
|
+
const config = this.config.getConfig();
|
|
835
|
+
const containers = this.config.getContainerNames();
|
|
836
|
+
const directories = this.config.getDataDirectories();
|
|
837
|
+
|
|
838
|
+
const describe = async (
|
|
839
|
+
service: TServiceName,
|
|
840
|
+
containerName: string,
|
|
841
|
+
dataPath: string,
|
|
842
|
+
port: string,
|
|
843
|
+
connectionString: string,
|
|
844
|
+
): Promise<IServiceStatus> => {
|
|
845
|
+
const status = await this.docker.getStatus(containerName);
|
|
846
|
+
const dataExists = await plugins.smartfs.directory(dataPath).exists();
|
|
847
|
+
return {
|
|
848
|
+
service,
|
|
849
|
+
enabled: this.isServiceEnabled(service),
|
|
850
|
+
container: containerName,
|
|
851
|
+
status,
|
|
852
|
+
port,
|
|
853
|
+
connectionString,
|
|
854
|
+
dataDirectory: dataPath,
|
|
855
|
+
dataSizeBytes: dataExists ? await helpers.getDirectorySize(dataPath) : 0,
|
|
856
|
+
portAvailable:
|
|
857
|
+
status === 'not_exists' ? await helpers.isPortAvailable(parseInt(port)) : null,
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
const mongo = await describe(
|
|
862
|
+
'mongodb',
|
|
863
|
+
containers.mongo,
|
|
864
|
+
directories.mongo,
|
|
865
|
+
config.MONGODB_PORT,
|
|
866
|
+
this.config.getMongoConnectionString(),
|
|
867
|
+
);
|
|
868
|
+
mongo.authEnabled = this.config.isMongoAuthEnabled();
|
|
869
|
+
|
|
870
|
+
const minio = await describe(
|
|
871
|
+
'minio',
|
|
872
|
+
containers.minio,
|
|
873
|
+
directories.minio,
|
|
874
|
+
config.S3_PORT,
|
|
875
|
+
`http://${config.S3_HOST}:${config.S3_PORT}`,
|
|
876
|
+
);
|
|
877
|
+
const elasticsearch = await describe(
|
|
878
|
+
'elasticsearch',
|
|
879
|
+
containers.elasticsearch,
|
|
880
|
+
directories.elasticsearch,
|
|
881
|
+
config.ELASTICSEARCH_PORT,
|
|
882
|
+
config.ELASTICSEARCH_URL,
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
project: {
|
|
887
|
+
name: config.PROJECT_NAME,
|
|
888
|
+
path: process.cwd(),
|
|
889
|
+
enabledServices: this.enabledServices || [],
|
|
890
|
+
},
|
|
891
|
+
services: { mongodb: mongo, minio, elasticsearch },
|
|
892
|
+
totalDataBytes: mongo.dataSizeBytes + minio.dataSizeBytes + elasticsearch.dataSizeBytes,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
572
896
|
/**
|
|
573
897
|
* Show service status
|
|
574
898
|
*/
|
|
575
899
|
public async showStatus(): Promise<void> {
|
|
576
900
|
helpers.printHeader('Service Status');
|
|
577
|
-
|
|
901
|
+
|
|
578
902
|
const config = this.config.getConfig();
|
|
579
903
|
const containers = this.config.getContainerNames();
|
|
580
|
-
|
|
904
|
+
|
|
581
905
|
logger.log('info', `Project: ${config.PROJECT_NAME}`);
|
|
582
906
|
console.log();
|
|
583
|
-
|
|
907
|
+
|
|
584
908
|
// MongoDB status
|
|
585
909
|
const mongoStatus = await this.docker.getStatus(containers.mongo);
|
|
586
910
|
switch (mongoStatus) {
|
|
@@ -588,8 +912,11 @@ export class ServiceManager {
|
|
|
588
912
|
logger.log('ok', '📦 MongoDB: 🟢 Running');
|
|
589
913
|
logger.log('info', ` ├─ Container: ${containers.mongo}`);
|
|
590
914
|
logger.log('info', ` ├─ Port: ${config.MONGODB_PORT}`);
|
|
915
|
+
if (!this.config.isMongoAuthEnabled()) {
|
|
916
|
+
logger.log('note', ' ├─ ⚠️ Auth: DISABLED (loopback only)');
|
|
917
|
+
}
|
|
591
918
|
logger.log('info', ` ├─ Connection: ${this.config.getMongoConnectionString()}`);
|
|
592
|
-
|
|
919
|
+
|
|
593
920
|
// Show Compass connection string
|
|
594
921
|
const networkIp = await helpers.getLocalNetworkIp();
|
|
595
922
|
const compassString = `mongodb://${config.MONGODB_USER}:${config.MONGODB_PASS}@${networkIp}:${config.MONGODB_PORT}/${config.MONGODB_NAME}?authSource=admin`;
|
|
@@ -682,6 +1009,44 @@ export class ServiceManager {
|
|
|
682
1009
|
}
|
|
683
1010
|
break;
|
|
684
1011
|
}
|
|
1012
|
+
|
|
1013
|
+
await this.showDiskUsage();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Report on-disk size of this project's service data.
|
|
1018
|
+
*
|
|
1019
|
+
* Reporting comes before any deletion: knowing what a project holds is what
|
|
1020
|
+
* makes reclaiming it a decision rather than a guess.
|
|
1021
|
+
*/
|
|
1022
|
+
public async showDiskUsage(): Promise<void> {
|
|
1023
|
+
const directories = this.config.getDataDirectories();
|
|
1024
|
+
const entries: Array<{ label: string; path: string }> = [
|
|
1025
|
+
{ label: 'MongoDB', path: directories.mongo },
|
|
1026
|
+
{ label: 'S3/MinIO', path: directories.minio },
|
|
1027
|
+
{ label: 'Elasticsearch', path: directories.elasticsearch },
|
|
1028
|
+
];
|
|
1029
|
+
|
|
1030
|
+
console.log();
|
|
1031
|
+
logger.log('note', 'Data on disk:');
|
|
1032
|
+
let total = 0;
|
|
1033
|
+
for (const entry of entries) {
|
|
1034
|
+
if (!(await plugins.smartfs.directory(entry.path).exists())) {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const size = await helpers.getDirectorySize(entry.path);
|
|
1038
|
+
total += size;
|
|
1039
|
+
logger.log('info', ` ${entry.label}: ${helpers.formatBytes(size)} (${entry.path})`);
|
|
1040
|
+
}
|
|
1041
|
+
if (total === 0) {
|
|
1042
|
+
logger.log('info', ' No service data directories present');
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
logger.log('info', ` Total: ${helpers.formatBytes(total)}`);
|
|
1046
|
+
logger.log(
|
|
1047
|
+
'note',
|
|
1048
|
+
' Remove containers but keep data: `gitzone services remove`; remove data too: `gitzone services clean`',
|
|
1049
|
+
);
|
|
685
1050
|
}
|
|
686
1051
|
|
|
687
1052
|
/**
|
|
@@ -857,23 +1222,21 @@ export class ServiceManager {
|
|
|
857
1222
|
*/
|
|
858
1223
|
public async cleanData(): Promise<void> {
|
|
859
1224
|
const directories = this.config.getDataDirectories();
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
cleaned = true;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
if (await plugins.smartfs.directory(directories.minio).exists()) {
|
|
869
|
-
await plugins.smartfs.directory(directories.minio).recursive().delete();
|
|
870
|
-
logger.log('ok', ' S3/MinIO data removed ✓');
|
|
871
|
-
cleaned = true;
|
|
872
|
-
}
|
|
1225
|
+
const targets: Array<{ label: string; service: TServiceName; path: string }> = [
|
|
1226
|
+
{ label: 'MongoDB', service: 'mongodb', path: directories.mongo },
|
|
1227
|
+
{ label: 'S3/MinIO', service: 'minio', path: directories.minio },
|
|
1228
|
+
{ label: 'Elasticsearch', service: 'elasticsearch', path: directories.elasticsearch },
|
|
1229
|
+
];
|
|
873
1230
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1231
|
+
let cleaned = false;
|
|
1232
|
+
for (const target of targets) {
|
|
1233
|
+
if (!(await plugins.smartfs.directory(target.path).exists())) {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
// Escalates to a privileged one-off container when needed; throws rather
|
|
1237
|
+
// than leaving a partially deleted (and for MongoDB, corrupt) directory.
|
|
1238
|
+
await this.docker.removeDataDirectory(target.path, serviceImages[target.service]);
|
|
1239
|
+
logger.log('ok', ` ${target.label} data removed ✓`);
|
|
877
1240
|
cleaned = true;
|
|
878
1241
|
}
|
|
879
1242
|
|