@git.zone/cli 2.23.0 → 2.25.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/gitzone.cli.js +11 -2
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +81 -0
- package/dist_ts/mod_services/classes.dockercontainer.js +205 -10
- 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 +52 -1
- package/dist_ts/mod_services/classes.serviceconfiguration.js +116 -20
- 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 +91 -5
- package/dist_ts/mod_services/classes.servicemanager.js +274 -64
- package/dist_ts/mod_services/classes.serviceoptions.d.ts +58 -0
- package/dist_ts/mod_services/classes.serviceoptions.js +93 -0
- 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/dist_ts/mod_tools/classes.packagemanager.d.ts +13 -0
- package/dist_ts/mod_tools/classes.packagemanager.js +42 -1
- package/dist_ts/mod_tools/index.js +4 -1
- package/package.json +3 -2
- package/readme.hints.md +105 -0
- package/readme.md +123 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/gitzone.cli.ts +10 -0
- package/ts/mod_services/classes.dockercontainer.ts +244 -13
- package/ts/mod_services/classes.globalregistry.ts +27 -0
- package/ts/mod_services/classes.serviceconfiguration.ts +148 -27
- package/ts/mod_services/classes.servicedatamarker.ts +228 -0
- package/ts/mod_services/classes.servicemanager.ts +394 -72
- package/ts/mod_services/classes.serviceoptions.ts +135 -0
- 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
- package/ts/mod_tools/classes.packagemanager.ts +54 -0
- package/ts/mod_tools/index.ts +5 -0
|
@@ -2,6 +2,20 @@ 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
|
+
import {
|
|
7
|
+
readServiceOptions,
|
|
8
|
+
resolveMongoAuth,
|
|
9
|
+
writeMongodbAuthOption,
|
|
10
|
+
type TServiceOptionSource,
|
|
11
|
+
} from './classes.serviceoptions.js';
|
|
12
|
+
|
|
13
|
+
/** Hosts that are considered local for the purposes of the no-auth guard. */
|
|
14
|
+
const localMongoHosts = ['localhost', '127.0.0.1', '::1'];
|
|
15
|
+
|
|
16
|
+
export const isLocalMongoHost = (hostArg: string): boolean => {
|
|
17
|
+
return localMongoHosts.includes(hostArg.trim().toLowerCase());
|
|
18
|
+
};
|
|
5
19
|
|
|
6
20
|
export interface IServiceConfig {
|
|
7
21
|
PROJECT_NAME: string;
|
|
@@ -11,6 +25,12 @@ export interface IServiceConfig {
|
|
|
11
25
|
MONGODB_USER: string;
|
|
12
26
|
MONGODB_PASS: string;
|
|
13
27
|
MONGODB_URL: string;
|
|
28
|
+
/**
|
|
29
|
+
* When false, mongod runs without authentication and is published on
|
|
30
|
+
* loopback only. Opt-in, for runtimes whose `node:crypto` cannot complete a
|
|
31
|
+
* SCRAM handshake (notably Deno). Defaults to true.
|
|
32
|
+
*/
|
|
33
|
+
MONGODB_AUTH_ENABLED: boolean;
|
|
14
34
|
S3_HOST: string;
|
|
15
35
|
S3_PORT: string;
|
|
16
36
|
S3_CONSOLE_PORT: string;
|
|
@@ -30,30 +50,60 @@ export class ServiceConfiguration {
|
|
|
30
50
|
private configPath: string;
|
|
31
51
|
private config!: IServiceConfig;
|
|
32
52
|
private docker: DockerContainer;
|
|
33
|
-
|
|
53
|
+
/** Where the effective MongoDB auth mode came from. */
|
|
54
|
+
private mongoAuthSource: TServiceOptionSource = 'default';
|
|
55
|
+
|
|
34
56
|
constructor() {
|
|
35
57
|
this.configPath = plugins.path.join(process.cwd(), '.nogit', 'env.json');
|
|
36
58
|
this.docker = new DockerContainer();
|
|
37
59
|
}
|
|
38
|
-
|
|
60
|
+
|
|
39
61
|
/**
|
|
40
62
|
* Load or create the configuration
|
|
41
63
|
*/
|
|
42
64
|
public async loadOrCreate(): Promise<IServiceConfig> {
|
|
43
65
|
await this.ensureNogitDirectory();
|
|
44
|
-
|
|
66
|
+
|
|
45
67
|
if (await this.configExists()) {
|
|
46
68
|
await this.loadConfig();
|
|
47
69
|
await this.updateMissingFields();
|
|
48
70
|
} else {
|
|
49
71
|
await this.createDefaultConfig();
|
|
50
72
|
}
|
|
51
|
-
|
|
73
|
+
|
|
74
|
+
// A committed declaration in .smartconfig.json overrides the local runtime
|
|
75
|
+
// value, so a fresh checkout reproduces the declared setup.
|
|
76
|
+
await this.applyDeclaredServiceOptions();
|
|
77
|
+
|
|
52
78
|
// Sync ports from existing Docker containers if they exist
|
|
53
79
|
await this.syncPortsFromDocker();
|
|
54
|
-
|
|
80
|
+
|
|
55
81
|
return this.config;
|
|
56
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fold `@git.zone/cli.serviceOptions` into the runtime configuration.
|
|
86
|
+
*/
|
|
87
|
+
private async applyDeclaredServiceOptions(): Promise<void> {
|
|
88
|
+
const declaredOptions = await readServiceOptions(process.cwd());
|
|
89
|
+
const resolved = resolveMongoAuth(declaredOptions, this.config.MONGODB_AUTH_ENABLED);
|
|
90
|
+
this.mongoAuthSource = resolved.source;
|
|
91
|
+
|
|
92
|
+
if (this.config.MONGODB_AUTH_ENABLED !== resolved.authEnabled) {
|
|
93
|
+
this.config.MONGODB_AUTH_ENABLED = resolved.authEnabled;
|
|
94
|
+
this.updateDerivedFields();
|
|
95
|
+
await this.saveConfig();
|
|
96
|
+
logger.log(
|
|
97
|
+
'note',
|
|
98
|
+
`📍 MongoDB auth ${resolved.authEnabled ? 'enabled' : 'disabled'} by .smartconfig.json declaration`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Where the effective MongoDB auth mode came from. */
|
|
104
|
+
public getMongoAuthSource(): TServiceOptionSource {
|
|
105
|
+
return this.mongoAuthSource;
|
|
106
|
+
}
|
|
57
107
|
|
|
58
108
|
/**
|
|
59
109
|
* Get the current configuration
|
|
@@ -61,6 +111,73 @@ export class ServiceConfiguration {
|
|
|
61
111
|
public getConfig(): IServiceConfig {
|
|
62
112
|
return this.config;
|
|
63
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether mongod should enforce authentication. Defaults to true; only an
|
|
117
|
+
* explicit `false` in `.nogit/env.json` disables it.
|
|
118
|
+
*/
|
|
119
|
+
public isMongoAuthEnabled(): boolean {
|
|
120
|
+
return this.config.MONGODB_AUTH_ENABLED !== false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build MONGODB_URL from the current fields.
|
|
125
|
+
*
|
|
126
|
+
* Credentials are omitted entirely when auth is disabled, so the stored URL
|
|
127
|
+
* never advertises a username that mongod would not accept.
|
|
128
|
+
*/
|
|
129
|
+
private buildMongoUrl(): string {
|
|
130
|
+
const credentials = this.isMongoAuthEnabled()
|
|
131
|
+
? `${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@`
|
|
132
|
+
: '';
|
|
133
|
+
const authQuery = this.isMongoAuthEnabled() ? '?authSource=admin' : '';
|
|
134
|
+
return `mongodb://${credentials}${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}${authQuery}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Recompute every derived field from the primitive ones.
|
|
139
|
+
*/
|
|
140
|
+
private updateDerivedFields(): void {
|
|
141
|
+
this.config.MONGODB_URL = this.buildMongoUrl();
|
|
142
|
+
this.config.S3_ENDPOINT = this.config.S3_HOST;
|
|
143
|
+
this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Refuse an unauthenticated mongod that would be reachable off-host.
|
|
148
|
+
*
|
|
149
|
+
* Disabling auth is only defensible while the database is bound to loopback.
|
|
150
|
+
* Rather than trusting the operator to keep MONGODB_HOST local, this fails
|
|
151
|
+
* closed on the combination.
|
|
152
|
+
*/
|
|
153
|
+
public assertMongoExposureIsSafe(): void {
|
|
154
|
+
if (this.isMongoAuthEnabled()) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!isLocalMongoHost(this.config.MONGODB_HOST)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Refusing to run MongoDB without authentication on non-local host "${this.config.MONGODB_HOST}". ` +
|
|
160
|
+
'Set MONGODB_HOST to localhost in .nogit/env.json, or re-enable auth with ' +
|
|
161
|
+
'`gitzone services auth mongodb on`.',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Persist the MongoDB auth mode.
|
|
168
|
+
*
|
|
169
|
+
* Written to both `.smartconfig.json` (the committed declaration, so a fresh
|
|
170
|
+
* checkout or CI reproduces it) and `.nogit/env.json` (the resolved runtime
|
|
171
|
+
* value consumed by tooling). Writing both keeps them from drifting.
|
|
172
|
+
*/
|
|
173
|
+
public async setMongoAuthEnabled(enabledArg: boolean): Promise<void> {
|
|
174
|
+
this.config.MONGODB_AUTH_ENABLED = enabledArg;
|
|
175
|
+
this.updateDerivedFields();
|
|
176
|
+
this.assertMongoExposureIsSafe();
|
|
177
|
+
await this.saveConfig();
|
|
178
|
+
await writeMongodbAuthOption(enabledArg, process.cwd());
|
|
179
|
+
this.mongoAuthSource = enabledArg ? 'default' : 'declared';
|
|
180
|
+
}
|
|
64
181
|
|
|
65
182
|
/**
|
|
66
183
|
* Save the configuration to file
|
|
@@ -132,6 +249,7 @@ export class ServiceConfiguration {
|
|
|
132
249
|
MONGODB_USER: mongoUser,
|
|
133
250
|
MONGODB_PASS: mongoPass,
|
|
134
251
|
MONGODB_URL: `mongodb://${mongoUser}:${mongoPass}@${mongoHost}:${mongoPortStr}/${mongoName}?authSource=admin`,
|
|
252
|
+
MONGODB_AUTH_ENABLED: true,
|
|
135
253
|
S3_HOST: s3Host,
|
|
136
254
|
S3_PORT: s3PortStr,
|
|
137
255
|
S3_CONSOLE_PORT: s3ConsolePort.toString(),
|
|
@@ -202,9 +320,15 @@ export class ServiceConfiguration {
|
|
|
202
320
|
updated = true;
|
|
203
321
|
}
|
|
204
322
|
|
|
323
|
+
if (this.config.MONGODB_AUTH_ENABLED === undefined) {
|
|
324
|
+
this.config.MONGODB_AUTH_ENABLED = true;
|
|
325
|
+
fieldsAdded.push('MONGODB_AUTH_ENABLED');
|
|
326
|
+
updated = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
205
329
|
// Always update MONGODB_URL based on current settings
|
|
206
330
|
const oldUrl = this.config.MONGODB_URL;
|
|
207
|
-
this.config.MONGODB_URL =
|
|
331
|
+
this.config.MONGODB_URL = this.buildMongoUrl();
|
|
208
332
|
if (oldUrl !== this.config.MONGODB_URL) {
|
|
209
333
|
fieldsAdded.push('MONGODB_URL');
|
|
210
334
|
updated = true;
|
|
@@ -254,7 +378,9 @@ export class ServiceConfiguration {
|
|
|
254
378
|
updated = true;
|
|
255
379
|
}
|
|
256
380
|
|
|
257
|
-
|
|
381
|
+
// `undefined`, not falsy: a stored `false` is a valid value, and treating it
|
|
382
|
+
// as missing made every run report S3_USESSL as newly added.
|
|
383
|
+
if (this.config.S3_USESSL === undefined) {
|
|
258
384
|
this.config.S3_USESSL = false;
|
|
259
385
|
fieldsAdded.push('S3_USESSL');
|
|
260
386
|
updated = true;
|
|
@@ -313,7 +439,11 @@ export class ServiceConfiguration {
|
|
|
313
439
|
*/
|
|
314
440
|
public getMongoConnectionString(useNetworkIp: boolean = false): string {
|
|
315
441
|
const host = useNetworkIp ? '${networkIp}' : this.config.MONGODB_HOST;
|
|
316
|
-
|
|
442
|
+
const credentials = this.isMongoAuthEnabled()
|
|
443
|
+
? `${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@`
|
|
444
|
+
: '';
|
|
445
|
+
const authQuery = this.isMongoAuthEnabled() ? 'authSource=admin&' : '';
|
|
446
|
+
return `mongodb://${credentials}${host}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?${authQuery}directConnection=true`;
|
|
317
447
|
}
|
|
318
448
|
|
|
319
449
|
/**
|
|
@@ -328,13 +458,16 @@ export class ServiceConfiguration {
|
|
|
328
458
|
}
|
|
329
459
|
|
|
330
460
|
/**
|
|
331
|
-
* Get data directories
|
|
461
|
+
* Get data directories.
|
|
462
|
+
*
|
|
463
|
+
* Derived through `getServiceDataDirectory` so that creation here and
|
|
464
|
+
* reclamation in `ServicePruner` can never disagree about the shape.
|
|
332
465
|
*/
|
|
333
466
|
public getDataDirectories() {
|
|
334
467
|
return {
|
|
335
|
-
mongo:
|
|
336
|
-
minio:
|
|
337
|
-
elasticsearch:
|
|
468
|
+
mongo: getServiceDataDirectory(process.cwd(), 'mongodb'),
|
|
469
|
+
minio: getServiceDataDirectory(process.cwd(), 'minio'),
|
|
470
|
+
elasticsearch: getServiceDataDirectory(process.cwd(), 'elasticsearch')
|
|
338
471
|
};
|
|
339
472
|
}
|
|
340
473
|
|
|
@@ -400,11 +533,7 @@ export class ServiceConfiguration {
|
|
|
400
533
|
}
|
|
401
534
|
|
|
402
535
|
if (updated) {
|
|
403
|
-
|
|
404
|
-
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`;
|
|
405
|
-
this.config.S3_ENDPOINT = this.config.S3_HOST;
|
|
406
|
-
this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
|
|
407
|
-
|
|
536
|
+
this.updateDerivedFields();
|
|
408
537
|
await this.saveConfig();
|
|
409
538
|
logger.log('ok', '✅ Configuration synced with Docker containers');
|
|
410
539
|
}
|
|
@@ -470,11 +599,7 @@ export class ServiceConfiguration {
|
|
|
470
599
|
}
|
|
471
600
|
|
|
472
601
|
if (updated) {
|
|
473
|
-
|
|
474
|
-
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`;
|
|
475
|
-
this.config.S3_ENDPOINT = this.config.S3_HOST;
|
|
476
|
-
this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
|
|
477
|
-
|
|
602
|
+
this.updateDerivedFields();
|
|
478
603
|
await this.saveConfig();
|
|
479
604
|
}
|
|
480
605
|
|
|
@@ -504,11 +629,7 @@ export class ServiceConfiguration {
|
|
|
504
629
|
this.config.S3_CONSOLE_PORT = s3ConsolePort.toString();
|
|
505
630
|
this.config.ELASTICSEARCH_PORT = esPort;
|
|
506
631
|
|
|
507
|
-
|
|
508
|
-
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`;
|
|
509
|
-
this.config.S3_ENDPOINT = this.config.S3_HOST;
|
|
510
|
-
this.config.ELASTICSEARCH_URL = `http://${this.config.ELASTICSEARCH_USER}:${this.config.ELASTICSEARCH_PASS}@${this.config.ELASTICSEARCH_HOST}:${this.config.ELASTICSEARCH_PORT}`;
|
|
511
|
-
|
|
632
|
+
this.updateDerivedFields();
|
|
512
633
|
await this.saveConfig();
|
|
513
634
|
|
|
514
635
|
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
|
+
};
|