@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.
Files changed (28) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/mod_services/classes.dockercontainer.d.ts +92 -0
  3. package/dist_ts/mod_services/classes.dockercontainer.js +225 -6
  4. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -0
  5. package/dist_ts/mod_services/classes.globalregistry.js +23 -1
  6. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +39 -1
  7. package/dist_ts/mod_services/classes.serviceconfiguration.js +89 -22
  8. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +93 -0
  9. package/dist_ts/mod_services/classes.servicedatamarker.js +166 -0
  10. package/dist_ts/mod_services/classes.servicemanager.d.ts +103 -3
  11. package/dist_ts/mod_services/classes.servicemanager.js +352 -100
  12. package/dist_ts/mod_services/classes.servicepruner.d.ts +102 -0
  13. package/dist_ts/mod_services/classes.servicepruner.js +410 -0
  14. package/dist_ts/mod_services/helpers.d.ts +15 -0
  15. package/dist_ts/mod_services/helpers.js +57 -1
  16. package/dist_ts/mod_services/index.js +307 -53
  17. package/package.json +4 -3
  18. package/readme.hints.md +88 -0
  19. package/readme.md +71 -5
  20. package/ts/00_commitinfo_data.ts +1 -1
  21. package/ts/mod_services/classes.dockercontainer.ts +269 -9
  22. package/ts/mod_services/classes.globalregistry.ts +27 -0
  23. package/ts/mod_services/classes.serviceconfiguration.ts +105 -24
  24. package/ts/mod_services/classes.servicedatamarker.ts +228 -0
  25. package/ts/mod_services/classes.servicemanager.ts +480 -117
  26. package/ts/mod_services/classes.servicepruner.ts +532 -0
  27. package/ts/mod_services/helpers.ts +60 -0
  28. package/ts/mod_services/index.ts +437 -58
@@ -2,6 +2,14 @@ import * as plugins from './mod.plugins.js';
2
2
  import * as helpers from './helpers.js';
3
3
  import { logger } from '../gitzone.logging.js';
4
4
  import { DockerContainer } from './classes.dockercontainer.js';
5
+ import { getServiceDataDirectory } from './classes.servicedatamarker.js';
6
+
7
+ /** Hosts that are considered local for the purposes of the no-auth guard. */
8
+ const localMongoHosts = ['localhost', '127.0.0.1', '::1'];
9
+
10
+ export const isLocalMongoHost = (hostArg: string): boolean => {
11
+ return localMongoHosts.includes(hostArg.trim().toLowerCase());
12
+ };
5
13
 
6
14
  export interface IServiceConfig {
7
15
  PROJECT_NAME: string;
@@ -11,6 +19,12 @@ export interface IServiceConfig {
11
19
  MONGODB_USER: string;
12
20
  MONGODB_PASS: string;
13
21
  MONGODB_URL: string;
22
+ /**
23
+ * When false, mongod runs without authentication and is published on
24
+ * loopback only. Opt-in, for runtimes whose `node:crypto` cannot complete a
25
+ * SCRAM handshake (notably Deno). Defaults to true.
26
+ */
27
+ MONGODB_AUTH_ENABLED: boolean;
14
28
  S3_HOST: string;
15
29
  S3_PORT: string;
16
30
  S3_CONSOLE_PORT: string;
@@ -61,6 +75,67 @@ export class ServiceConfiguration {
61
75
  public getConfig(): IServiceConfig {
62
76
  return this.config;
63
77
  }
78
+
79
+ /**
80
+ * Whether mongod should enforce authentication. Defaults to true; only an
81
+ * explicit `false` in `.nogit/env.json` disables it.
82
+ */
83
+ public isMongoAuthEnabled(): boolean {
84
+ return this.config.MONGODB_AUTH_ENABLED !== false;
85
+ }
86
+
87
+ /**
88
+ * Build MONGODB_URL from the current fields.
89
+ *
90
+ * Credentials are omitted entirely when auth is disabled, so the stored URL
91
+ * never advertises a username that mongod would not accept.
92
+ */
93
+ private buildMongoUrl(): string {
94
+ const credentials = this.isMongoAuthEnabled()
95
+ ? `${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@`
96
+ : '';
97
+ const authQuery = this.isMongoAuthEnabled() ? '?authSource=admin' : '';
98
+ return `mongodb://${credentials}${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}${authQuery}`;
99
+ }
100
+
101
+ /**
102
+ * Recompute every derived field from the primitive ones.
103
+ */
104
+ private updateDerivedFields(): void {
105
+ this.config.MONGODB_URL = this.buildMongoUrl();
106
+ this.config.S3_ENDPOINT = this.config.S3_HOST;
107
+ this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
108
+ }
109
+
110
+ /**
111
+ * Refuse an unauthenticated mongod that would be reachable off-host.
112
+ *
113
+ * Disabling auth is only defensible while the database is bound to loopback.
114
+ * Rather than trusting the operator to keep MONGODB_HOST local, this fails
115
+ * closed on the combination.
116
+ */
117
+ public assertMongoExposureIsSafe(): void {
118
+ if (this.isMongoAuthEnabled()) {
119
+ return;
120
+ }
121
+ if (!isLocalMongoHost(this.config.MONGODB_HOST)) {
122
+ throw new Error(
123
+ `Refusing to run MongoDB without authentication on non-local host "${this.config.MONGODB_HOST}". ` +
124
+ 'Set MONGODB_HOST to localhost in .nogit/env.json, or re-enable auth with ' +
125
+ '`gitzone services auth mongodb on`.',
126
+ );
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Persist the MongoDB auth mode and recompute derived fields.
132
+ */
133
+ public async setMongoAuthEnabled(enabledArg: boolean): Promise<void> {
134
+ this.config.MONGODB_AUTH_ENABLED = enabledArg;
135
+ this.updateDerivedFields();
136
+ this.assertMongoExposureIsSafe();
137
+ await this.saveConfig();
138
+ }
64
139
 
65
140
  /**
66
141
  * Save the configuration to file
@@ -132,6 +207,7 @@ export class ServiceConfiguration {
132
207
  MONGODB_USER: mongoUser,
133
208
  MONGODB_PASS: mongoPass,
134
209
  MONGODB_URL: `mongodb://${mongoUser}:${mongoPass}@${mongoHost}:${mongoPortStr}/${mongoName}?authSource=admin`,
210
+ MONGODB_AUTH_ENABLED: true,
135
211
  S3_HOST: s3Host,
136
212
  S3_PORT: s3PortStr,
137
213
  S3_CONSOLE_PORT: s3ConsolePort.toString(),
@@ -202,9 +278,15 @@ export class ServiceConfiguration {
202
278
  updated = true;
203
279
  }
204
280
 
281
+ if (this.config.MONGODB_AUTH_ENABLED === undefined) {
282
+ this.config.MONGODB_AUTH_ENABLED = true;
283
+ fieldsAdded.push('MONGODB_AUTH_ENABLED');
284
+ updated = true;
285
+ }
286
+
205
287
  // Always update MONGODB_URL based on current settings
206
288
  const oldUrl = this.config.MONGODB_URL;
207
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
289
+ this.config.MONGODB_URL = this.buildMongoUrl();
208
290
  if (oldUrl !== this.config.MONGODB_URL) {
209
291
  fieldsAdded.push('MONGODB_URL');
210
292
  updated = true;
@@ -254,7 +336,9 @@ export class ServiceConfiguration {
254
336
  updated = true;
255
337
  }
256
338
 
257
- if (!this.config.S3_USESSL) {
339
+ // `undefined`, not falsy: a stored `false` is a valid value, and treating it
340
+ // as missing made every run report S3_USESSL as newly added.
341
+ if (this.config.S3_USESSL === undefined) {
258
342
  this.config.S3_USESSL = false;
259
343
  fieldsAdded.push('S3_USESSL');
260
344
  updated = true;
@@ -313,7 +397,11 @@ export class ServiceConfiguration {
313
397
  */
314
398
  public getMongoConnectionString(useNetworkIp: boolean = false): string {
315
399
  const host = useNetworkIp ? '${networkIp}' : this.config.MONGODB_HOST;
316
- return `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${host}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
400
+ const credentials = this.isMongoAuthEnabled()
401
+ ? `${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@`
402
+ : '';
403
+ const authQuery = this.isMongoAuthEnabled() ? 'authSource=admin&' : '';
404
+ return `mongodb://${credentials}${host}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?${authQuery}directConnection=true`;
317
405
  }
318
406
 
319
407
  /**
@@ -328,13 +416,16 @@ export class ServiceConfiguration {
328
416
  }
329
417
 
330
418
  /**
331
- * Get data directories
419
+ * Get data directories.
420
+ *
421
+ * Derived through `getServiceDataDirectory` so that creation here and
422
+ * reclamation in `ServicePruner` can never disagree about the shape.
332
423
  */
333
424
  public getDataDirectories() {
334
425
  return {
335
- mongo: plugins.path.join(process.cwd(), '.nogit', 'mongodata'),
336
- minio: plugins.path.join(process.cwd(), '.nogit', 'miniodata'),
337
- elasticsearch: plugins.path.join(process.cwd(), '.nogit', 'esdata')
426
+ mongo: getServiceDataDirectory(process.cwd(), 'mongodb'),
427
+ minio: getServiceDataDirectory(process.cwd(), 'minio'),
428
+ elasticsearch: getServiceDataDirectory(process.cwd(), 'elasticsearch')
338
429
  };
339
430
  }
340
431
 
@@ -349,8 +440,10 @@ export class ServiceConfiguration {
349
440
  const mongoStatus = await this.docker.getStatus(containers.mongo);
350
441
  if (mongoStatus !== 'not_exists') {
351
442
  const portMappings = await this.docker.getPortMappings(containers.mongo);
352
- if (portMappings && portMappings['27017']) {
353
- const dockerPort = portMappings['27017'];
443
+ // legacy containers map 27017; replica-set containers map port:port
444
+ const dockerPort =
445
+ portMappings && (portMappings['27017'] ?? Object.values(portMappings)[0]);
446
+ if (dockerPort) {
354
447
  if (this.config.MONGODB_PORT !== dockerPort) {
355
448
  logger.log('note', `📍 Syncing MongoDB port from Docker: ${dockerPort}`);
356
449
  this.config.MONGODB_PORT = dockerPort;
@@ -398,11 +491,7 @@ export class ServiceConfiguration {
398
491
  }
399
492
 
400
493
  if (updated) {
401
- // Update derived fields
402
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
403
- this.config.S3_ENDPOINT = this.config.S3_HOST;
404
- this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
405
-
494
+ this.updateDerivedFields();
406
495
  await this.saveConfig();
407
496
  logger.log('ok', '✅ Configuration synced with Docker containers');
408
497
  }
@@ -468,11 +557,7 @@ export class ServiceConfiguration {
468
557
  }
469
558
 
470
559
  if (updated) {
471
- // Update derived fields
472
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
473
- this.config.S3_ENDPOINT = this.config.S3_HOST;
474
- this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
475
-
560
+ this.updateDerivedFields();
476
561
  await this.saveConfig();
477
562
  }
478
563
 
@@ -502,11 +587,7 @@ export class ServiceConfiguration {
502
587
  this.config.S3_CONSOLE_PORT = s3ConsolePort.toString();
503
588
  this.config.ELASTICSEARCH_PORT = esPort;
504
589
 
505
- // Update derived fields
506
- this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`;
507
- this.config.S3_ENDPOINT = this.config.S3_HOST;
508
- this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
509
-
590
+ this.updateDerivedFields();
510
591
  await this.saveConfig();
511
592
 
512
593
  logger.log('ok', '✅ New port configuration:');
@@ -0,0 +1,228 @@
1
+ import * as plugins from './mod.plugins.js';
2
+
3
+ /**
4
+ * Canonical service identifiers used across mod_services.
5
+ *
6
+ * These intentionally mirror the values stored in `@git.zone/cli.services`.
7
+ * That configuration array is a *deployment* input consumed by
8
+ * `@git.zone/tsdeploy` (`deriveRequiredCapabilities`), so the strings and the
9
+ * array shape must never change here.
10
+ */
11
+ export type TServiceName = 'mongodb' | 'minio' | 'elasticsearch';
12
+
13
+ export const serviceNames: TServiceName[] = ['mongodb', 'minio', 'elasticsearch'];
14
+
15
+ /** Owner recorded in markers and container labels. */
16
+ export const serviceDataOwner = '@git.zone/cli';
17
+
18
+ /** Value of the `git.zone.tool` label on containers created by `gitzone services`. */
19
+ export const serviceToolLabel = 'gitzone-services';
20
+
21
+ /** Marker file name, stored in `<projectPath>/.nogit/`. */
22
+ export const serviceDataMarkerFile = '.gitzone-services.json';
23
+
24
+ /**
25
+ * Directory basename used below `.nogit/` for each service's bind-mounted data.
26
+ * This mapping is the only shape `gitzone services` ever creates, and prune
27
+ * refuses to touch anything that does not match it exactly.
28
+ */
29
+ export const serviceDataDirectoryNames: { [key in TServiceName]: string } = {
30
+ mongodb: 'mongodata',
31
+ minio: 'miniodata',
32
+ elasticsearch: 'esdata',
33
+ };
34
+
35
+ /**
36
+ * Image used to run each service, and therefore the image available to perform
37
+ * a privileged cleanup of that service's data directory: if the data exists,
38
+ * the service ran, so its image is present locally.
39
+ */
40
+ export const serviceImages: { [key in TServiceName]: string } = {
41
+ mongodb: 'mongo:7.0',
42
+ minio: 'minio/minio',
43
+ elasticsearch: 'elasticsearch:8.11.0',
44
+ };
45
+
46
+ export interface IServiceDataMarkerEntry {
47
+ service: TServiceName;
48
+ path: string;
49
+ }
50
+
51
+ export interface IServiceDataMarker {
52
+ owner: string;
53
+ kind: 'service-data';
54
+ schemaVersion: 1;
55
+ projectPath: string;
56
+ safeToPrune: true;
57
+ createdAt: string;
58
+ dataDirectories: IServiceDataMarkerEntry[];
59
+ }
60
+
61
+ const isPlainObject = (value: unknown): value is Record<string, any> => {
62
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
63
+ };
64
+
65
+ /** Absolute path of the marker file for a project. */
66
+ export const getServiceDataMarkerPath = (projectPathArg: string): string => {
67
+ return plugins.path.join(plugins.path.resolve(projectPathArg), '.nogit', serviceDataMarkerFile);
68
+ };
69
+
70
+ /**
71
+ * Expected absolute data directory path for a service in a project.
72
+ * `mod_services` must always derive data paths through this helper so that
73
+ * creation and reclamation agree on a single shape.
74
+ */
75
+ export const getServiceDataDirectory = (
76
+ projectPathArg: string,
77
+ serviceArg: TServiceName,
78
+ ): string => {
79
+ return plugins.path.join(
80
+ plugins.path.resolve(projectPathArg),
81
+ '.nogit',
82
+ serviceDataDirectoryNames[serviceArg],
83
+ );
84
+ };
85
+
86
+ /**
87
+ * True only when `candidatePathArg` is exactly one of the three data
88
+ * directories `gitzone services` creates for `projectPathArg`.
89
+ *
90
+ * This is an allowlist, not a denylist: any path that is not byte-for-byte one
91
+ * of the expected resolved paths is rejected. It is the last line of defence
92
+ * before a recursive delete.
93
+ */
94
+ export const isSafeServiceDataPath = (
95
+ projectPathArg: string,
96
+ candidatePathArg: string,
97
+ ): boolean => {
98
+ const candidate = plugins.path.resolve(candidatePathArg);
99
+ for (const service of serviceNames) {
100
+ if (getServiceDataDirectory(projectPathArg, service) === candidate) {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ };
106
+
107
+ /** Validate an unknown value as a usable marker. */
108
+ export const isValidServiceDataMarker = (markerArg: unknown): markerArg is IServiceDataMarker => {
109
+ if (!isPlainObject(markerArg)) {
110
+ return false;
111
+ }
112
+ if (markerArg.owner !== serviceDataOwner) {
113
+ return false;
114
+ }
115
+ if (markerArg.kind !== 'service-data') {
116
+ return false;
117
+ }
118
+ if (markerArg.schemaVersion !== 1) {
119
+ return false;
120
+ }
121
+ if (markerArg.safeToPrune !== true) {
122
+ return false;
123
+ }
124
+ if (typeof markerArg.projectPath !== 'string' || !markerArg.projectPath) {
125
+ return false;
126
+ }
127
+ if (!Array.isArray(markerArg.dataDirectories)) {
128
+ return false;
129
+ }
130
+ for (const entry of markerArg.dataDirectories) {
131
+ if (!isPlainObject(entry)) {
132
+ return false;
133
+ }
134
+ if (!serviceNames.includes(entry.service)) {
135
+ return false;
136
+ }
137
+ if (typeof entry.path !== 'string' || !entry.path) {
138
+ return false;
139
+ }
140
+ }
141
+ return true;
142
+ };
143
+
144
+ /** Read and validate the marker for a project, or undefined when absent/invalid. */
145
+ export const readServiceDataMarker = async (
146
+ projectPathArg: string,
147
+ ): Promise<IServiceDataMarker | undefined> => {
148
+ const markerPath = getServiceDataMarkerPath(projectPathArg);
149
+ try {
150
+ if (!(await plugins.smartfs.file(markerPath).exists())) {
151
+ return undefined;
152
+ }
153
+ const content = (await plugins.smartfs.file(markerPath).encoding('utf8').read()) as string;
154
+ const parsed = JSON.parse(content);
155
+ return isValidServiceDataMarker(parsed) ? parsed : undefined;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ };
160
+
161
+ /**
162
+ * Record that `gitzone services` owns a data directory for this project.
163
+ *
164
+ * The marker lives in `.nogit/` rather than inside the bind-mounted data
165
+ * directory itself, so it can never confuse mongod, MinIO or Elasticsearch at
166
+ * runtime. Entries are merged so enabling a service later does not drop the
167
+ * claim on previously created directories.
168
+ */
169
+ export const recordServiceDataDirectory = async (
170
+ projectPathArg: string,
171
+ serviceArg: TServiceName,
172
+ ): Promise<void> => {
173
+ const projectPath = plugins.path.resolve(projectPathArg);
174
+ const dataPath = getServiceDataDirectory(projectPath, serviceArg);
175
+ const existingMarker = await readServiceDataMarker(projectPath);
176
+
177
+ const entries: IServiceDataMarkerEntry[] = existingMarker
178
+ ? existingMarker.dataDirectories.filter((entry) => entry.service !== serviceArg)
179
+ : [];
180
+ entries.push({ service: serviceArg, path: dataPath });
181
+ entries.sort((entryA, entryB) => entryA.service.localeCompare(entryB.service));
182
+
183
+ const marker: IServiceDataMarker = {
184
+ owner: serviceDataOwner,
185
+ kind: 'service-data',
186
+ schemaVersion: 1,
187
+ projectPath,
188
+ safeToPrune: true,
189
+ createdAt: existingMarker?.createdAt || new Date().toISOString(),
190
+ dataDirectories: entries,
191
+ };
192
+
193
+ const markerPath = getServiceDataMarkerPath(projectPath);
194
+ await plugins.smartfs.directory(plugins.path.dirname(markerPath)).recursive().create();
195
+ await plugins.smartfs
196
+ .file(markerPath)
197
+ .encoding('utf8')
198
+ .write(`${JSON.stringify(marker, null, 2)}\n`);
199
+ };
200
+
201
+ /**
202
+ * Labels applied to every container `gitzone services` creates.
203
+ *
204
+ * Mirrors the `git.zone.*` namespace already established by
205
+ * `@git.zone/tsdocker` so prune logic can identify tool-owned resources
206
+ * without ever matching on image or bare name.
207
+ */
208
+ export const getServiceContainerLabels = (optionsArg: {
209
+ projectPath: string;
210
+ service: TServiceName;
211
+ dataPath: string;
212
+ }): { [key: string]: string } => {
213
+ return {
214
+ 'git.zone.tool': serviceToolLabel,
215
+ 'git.zone.service': optionsArg.service,
216
+ 'git.zone.project-path': plugins.path.resolve(optionsArg.projectPath),
217
+ 'git.zone.data-path': plugins.path.resolve(optionsArg.dataPath),
218
+ 'git.zone.safe-to-prune': 'true',
219
+ };
220
+ };
221
+
222
+ /** True when a container's labels prove `gitzone services` created it. */
223
+ export const isServiceOwnedByLabels = (labelsArg: { [key: string]: string }): boolean => {
224
+ return (
225
+ labelsArg['git.zone.tool'] === serviceToolLabel &&
226
+ labelsArg['git.zone.safe-to-prune'] === 'true'
227
+ );
228
+ };