@git.zone/cli 3.2.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +0 -1
- 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.d.ts +0 -2
- package/dist_ts/helpers.workflow.js +23 -12
- 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.d.ts +3 -3
- package/dist_ts/mod_commit/mod.helpers.js +10 -10
- 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 +23 -24
- 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/helpers.releasebranch.d.ts +46 -0
- package/dist_ts/mod_release/helpers.releasebranch.js +579 -0
- package/dist_ts/mod_release/index.d.ts +10 -0
- package/dist_ts/mod_release/index.js +300 -92
- 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 +144 -41
- package/readme.plan.md +29 -16
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.smartconfig.ts +63 -10
- package/ts/helpers.workflow.ts +41 -12
- package/ts/mod_commit/mod.helpers.ts +12 -8
- package/ts/mod_config/index.ts +22 -23
- package/ts/mod_docker/classes.dockerpruner.ts +6 -0
- package/ts/mod_release/helpers.releasebranch.ts +1282 -0
- package/ts/mod_release/index.ts +608 -129
- 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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_NAMESPACE,
|
|
3
|
+
getCliConfigValueFromData,
|
|
4
|
+
getCliNamespaceConfig,
|
|
5
|
+
readSmartconfigFile,
|
|
6
|
+
} from '../helpers.smartconfig.js';
|
|
7
|
+
import type { TServiceName } from './classes.servicedatamarker.js';
|
|
8
|
+
|
|
9
|
+
export const allowedServiceNames: TServiceName[] = [
|
|
10
|
+
'mongodb',
|
|
11
|
+
'objectstorage',
|
|
12
|
+
'elasticsearch',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Normalize interactive/CLI input. The legacy provider name is deliberately absent. */
|
|
16
|
+
export const normalizeUserServiceName = (serviceArg: string): string => {
|
|
17
|
+
switch (serviceArg) {
|
|
18
|
+
case 'mongo':
|
|
19
|
+
case 'mongodb':
|
|
20
|
+
return 'mongodb';
|
|
21
|
+
case 'objectstorage':
|
|
22
|
+
case 's3':
|
|
23
|
+
return 'objectstorage';
|
|
24
|
+
case 'elastic':
|
|
25
|
+
case 'elasticsearch':
|
|
26
|
+
case 'es':
|
|
27
|
+
return 'elasticsearch';
|
|
28
|
+
default:
|
|
29
|
+
return serviceArg;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const normalizeUserServiceNames = (servicesArg: readonly string[]): string[] => {
|
|
34
|
+
return Array.from(
|
|
35
|
+
new Set(
|
|
36
|
+
servicesArg
|
|
37
|
+
.map((serviceArg) => normalizeUserServiceName(serviceArg.trim()))
|
|
38
|
+
.filter(Boolean),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const assertSupportedCliSchema = (cliConfigArg: Record<string, any>): void => {
|
|
44
|
+
if (
|
|
45
|
+
cliConfigArg.schemaVersion !== undefined &&
|
|
46
|
+
(!Number.isInteger(cliConfigArg.schemaVersion) ||
|
|
47
|
+
cliConfigArg.schemaVersion < 1 ||
|
|
48
|
+
cliConfigArg.schemaVersion > 2)
|
|
49
|
+
) {
|
|
50
|
+
throw new Error(`${CLI_NAMESPACE} uses an unsupported schema version.`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Read only canonical persisted service names after the startup migration ran. */
|
|
55
|
+
export const readPersistedServiceConfiguration = async (
|
|
56
|
+
cwdArg: string = process.cwd(),
|
|
57
|
+
): Promise<string[]> => {
|
|
58
|
+
const smartconfigData = await readSmartconfigFile(cwdArg);
|
|
59
|
+
assertSupportedCliSchema(getCliNamespaceConfig(smartconfigData));
|
|
60
|
+
const rawServices = getCliConfigValueFromData(smartconfigData, 'services');
|
|
61
|
+
if (rawServices === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(rawServices) || !rawServices.every((service) => typeof service === 'string')) {
|
|
65
|
+
throw new Error('Persisted services configuration must be an array of service names.');
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
rawServices.some(
|
|
69
|
+
(service) => !allowedServiceNames.some((allowedService) => allowedService === service),
|
|
70
|
+
) ||
|
|
71
|
+
new Set(rawServices).size !== rawServices.length
|
|
72
|
+
) {
|
|
73
|
+
throw new Error('Persisted services configuration is not canonical.');
|
|
74
|
+
}
|
|
75
|
+
return [...rawServices];
|
|
76
|
+
};
|
|
@@ -3,15 +3,21 @@ import * as helpers from './helpers.js';
|
|
|
3
3
|
import { DockerContainer, type IContainerInspectInfo } from './classes.dockercontainer.js';
|
|
4
4
|
import { GlobalRegistry, type IRegisteredProject } from './classes.globalregistry.js';
|
|
5
5
|
import {
|
|
6
|
+
getServiceContainerLabels,
|
|
6
7
|
getServiceDataDirectory,
|
|
8
|
+
hasExactServiceContainerLabels,
|
|
7
9
|
isSafeServiceDataPath,
|
|
8
|
-
|
|
10
|
+
isLegacyMinioContainerLabels,
|
|
11
|
+
isServiceOperationHelperLabels,
|
|
12
|
+
markerProvesServiceDataDirectory,
|
|
9
13
|
readServiceDataMarker,
|
|
14
|
+
recordServiceDataDirectory,
|
|
10
15
|
serviceImages,
|
|
11
16
|
serviceNames,
|
|
12
17
|
serviceToolLabel,
|
|
13
18
|
type TServiceName,
|
|
14
19
|
} from './classes.servicedatamarker.js';
|
|
20
|
+
import { withServiceDataOperationLock } from './classes.interprocesslock.js';
|
|
15
21
|
import { logger } from '../gitzone.logging.js';
|
|
16
22
|
|
|
17
23
|
/** Default inactivity threshold before a project is even considered stale. */
|
|
@@ -72,7 +78,12 @@ export interface IServicePrunePlan {
|
|
|
72
78
|
dockerAvailable: boolean;
|
|
73
79
|
projects: IServiceProjectReport[];
|
|
74
80
|
registryEntriesToRemove: string[];
|
|
75
|
-
containersToRemove: Array<{
|
|
81
|
+
containersToRemove: Array<{
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
projectPath: string;
|
|
85
|
+
service: TServiceName;
|
|
86
|
+
}>;
|
|
76
87
|
dataDirsToRemove: Array<{ projectPath: string; service: TServiceName; path: string; sizeBytes: number }>;
|
|
77
88
|
skipped: IServiceSkipReason[];
|
|
78
89
|
totals: {
|
|
@@ -97,6 +108,44 @@ interface IProjectCandidate {
|
|
|
97
108
|
|
|
98
109
|
const dayInMs = 24 * 60 * 60 * 1000;
|
|
99
110
|
|
|
111
|
+
const hasGitZoneLabels = (labelsArg: Record<string, string>): boolean => {
|
|
112
|
+
return Object.keys(labelsArg).some((keyArg) => keyArg.startsWith('git.zone.'));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const getRegisteredServiceForContainer = (
|
|
116
|
+
projectArg: IRegisteredProject | undefined,
|
|
117
|
+
containerNameArg: string,
|
|
118
|
+
): TServiceName | undefined => {
|
|
119
|
+
if (!projectArg) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const matches: TServiceName[] = [];
|
|
123
|
+
if (projectArg.containers.mongo === containerNameArg) matches.push('mongodb');
|
|
124
|
+
if (projectArg.containers.objectstorage === containerNameArg) matches.push('objectstorage');
|
|
125
|
+
if (projectArg.containers.elasticsearch === containerNameArg) matches.push('elasticsearch');
|
|
126
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const hasCanonicalServiceLabels = (
|
|
130
|
+
containerArg: IContainerInspectInfo,
|
|
131
|
+
projectPathArg: string,
|
|
132
|
+
): boolean => {
|
|
133
|
+
const service = serviceNames.find(
|
|
134
|
+
(serviceArg) => containerArg.labels['git.zone.service'] === serviceArg,
|
|
135
|
+
);
|
|
136
|
+
if (!service) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return hasExactServiceContainerLabels(
|
|
140
|
+
containerArg.labels,
|
|
141
|
+
getServiceContainerLabels({
|
|
142
|
+
projectPath: projectPathArg,
|
|
143
|
+
service,
|
|
144
|
+
dataPath: getServiceDataDirectory(projectPathArg, service),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
100
149
|
/**
|
|
101
150
|
* Plans and applies reclamation of resources created by `gitzone services`.
|
|
102
151
|
*
|
|
@@ -145,6 +194,7 @@ export class ServicePruner {
|
|
|
145
194
|
// versions). Their names are only trusted when exactly one registry entry
|
|
146
195
|
// claims them.
|
|
147
196
|
const nameClaims = new Map<string, string[]>();
|
|
197
|
+
const legacyNameClaims = new Map<string, string[]>();
|
|
148
198
|
for (const [projectPath, project] of Object.entries(registryProjects)) {
|
|
149
199
|
for (const containerName of Object.values(project.containers)) {
|
|
150
200
|
if (!containerName) {
|
|
@@ -154,10 +204,15 @@ export class ServicePruner {
|
|
|
154
204
|
claims.push(projectPath);
|
|
155
205
|
nameClaims.set(containerName, claims);
|
|
156
206
|
}
|
|
207
|
+
if (project.legacy?.minioContainer) {
|
|
208
|
+
const claims = legacyNameClaims.get(project.legacy.minioContainer) || [];
|
|
209
|
+
claims.push(projectPath);
|
|
210
|
+
legacyNameClaims.set(project.legacy.minioContainer, claims);
|
|
211
|
+
}
|
|
157
212
|
}
|
|
158
213
|
|
|
159
214
|
let legacyContainers: IContainerInspectInfo[] = [];
|
|
160
|
-
if (dockerAvailable && nameClaims.size > 0) {
|
|
215
|
+
if (dockerAvailable && (nameClaims.size > 0 || legacyNameClaims.size > 0)) {
|
|
161
216
|
try {
|
|
162
217
|
// One `docker ps -a` for all claimed names rather than one per name:
|
|
163
218
|
// each shell-out is a spawned child process, and this runs on every
|
|
@@ -167,7 +222,9 @@ export class ServicePruner {
|
|
|
167
222
|
const allIds = await this.docker.listIds([]);
|
|
168
223
|
const candidateIds = allIds.filter((id) => !labeledIds.has(id));
|
|
169
224
|
const inspected = await this.docker.inspectMany(candidateIds);
|
|
170
|
-
legacyContainers = inspected.filter(
|
|
225
|
+
legacyContainers = inspected.filter(
|
|
226
|
+
(container) => nameClaims.has(container.name) || legacyNameClaims.has(container.name),
|
|
227
|
+
);
|
|
171
228
|
} catch (error) {
|
|
172
229
|
dockerAvailable = false;
|
|
173
230
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -220,7 +277,10 @@ export class ServicePruner {
|
|
|
220
277
|
const ambiguousProjectPaths = new Set<string>();
|
|
221
278
|
|
|
222
279
|
for (const container of legacyContainers) {
|
|
223
|
-
const claims =
|
|
280
|
+
const claims = [
|
|
281
|
+
...(nameClaims.get(container.name) || []),
|
|
282
|
+
...(legacyNameClaims.get(container.name) || []),
|
|
283
|
+
];
|
|
224
284
|
if (claims.length !== 1) {
|
|
225
285
|
skipped.push({
|
|
226
286
|
target: container.name,
|
|
@@ -240,7 +300,12 @@ export class ServicePruner {
|
|
|
240
300
|
// Classify and decide.
|
|
241
301
|
const projects: IServiceProjectReport[] = [];
|
|
242
302
|
const registryEntriesToRemove: string[] = [];
|
|
243
|
-
const containersToRemove: Array<{
|
|
303
|
+
const containersToRemove: Array<{
|
|
304
|
+
id: string;
|
|
305
|
+
name: string;
|
|
306
|
+
projectPath: string;
|
|
307
|
+
service: TServiceName;
|
|
308
|
+
}> = [];
|
|
244
309
|
const dataDirsToRemove: Array<{
|
|
245
310
|
projectPath: string;
|
|
246
311
|
service: TServiceName;
|
|
@@ -278,14 +343,36 @@ export class ServicePruner {
|
|
|
278
343
|
stateReason = `active ${inactiveDays} day(s) ago`;
|
|
279
344
|
}
|
|
280
345
|
|
|
281
|
-
const anyContainerRunning = candidate.containers.some((container) => container.running);
|
|
282
|
-
|
|
283
346
|
// Containers
|
|
284
347
|
const containerReports: IServiceContainerReport[] = [];
|
|
285
348
|
for (const container of candidate.containers) {
|
|
286
349
|
let reclaimable = false;
|
|
287
350
|
let reason: string;
|
|
288
|
-
|
|
351
|
+
const labelOwned = hasCanonicalServiceLabels(container, candidate.projectPath);
|
|
352
|
+
const registryClaims = nameClaims.get(container.name) || [];
|
|
353
|
+
const registeredService = getRegisteredServiceForContainer(
|
|
354
|
+
candidate.registryEntry,
|
|
355
|
+
container.name,
|
|
356
|
+
);
|
|
357
|
+
const registryOwned =
|
|
358
|
+
!hasGitZoneLabels(container.labels) &&
|
|
359
|
+
registeredService !== undefined &&
|
|
360
|
+
registeredService !== 'objectstorage' &&
|
|
361
|
+
registryClaims.length === 1 &&
|
|
362
|
+
plugins.path.resolve(registryClaims[0]) === candidate.projectPath;
|
|
363
|
+
const legacyContainer =
|
|
364
|
+
isLegacyMinioContainerLabels(container.labels) ||
|
|
365
|
+
legacyNameClaims.has(container.name);
|
|
366
|
+
const operationHelper = isServiceOperationHelperLabels(container.labels);
|
|
367
|
+
if (legacyContainer) {
|
|
368
|
+
reason = 'legacy MinIO migration resource; preserved and never pruned';
|
|
369
|
+
} else if (operationHelper) {
|
|
370
|
+
reason = 'transient service operation helper; owned only by its invocation';
|
|
371
|
+
} else if (!labelOwned && !registryOwned) {
|
|
372
|
+
reason = hasGitZoneLabels(container.labels)
|
|
373
|
+
? 'GitZone ownership labels are incomplete or noncanonical'
|
|
374
|
+
: 'container has no unambiguous registry ownership claim';
|
|
375
|
+
} else if (!dockerAvailable) {
|
|
289
376
|
reason = 'docker unavailable';
|
|
290
377
|
} else if (state === 'live') {
|
|
291
378
|
reason = 'project is live';
|
|
@@ -306,24 +393,52 @@ export class ServicePruner {
|
|
|
306
393
|
reason,
|
|
307
394
|
});
|
|
308
395
|
if (reclaimable) {
|
|
396
|
+
const service =
|
|
397
|
+
serviceNames.find(
|
|
398
|
+
(serviceArg) => container.labels['git.zone.service'] === serviceArg,
|
|
399
|
+
) || registeredService;
|
|
400
|
+
if (!service) {
|
|
401
|
+
throw new Error(`Could not identify service for container ${container.name}.`);
|
|
402
|
+
}
|
|
309
403
|
containersToRemove.push({
|
|
310
404
|
id: container.id,
|
|
311
405
|
name: container.name,
|
|
312
406
|
projectPath: candidate.projectPath,
|
|
407
|
+
service,
|
|
313
408
|
});
|
|
314
409
|
}
|
|
315
410
|
}
|
|
316
|
-
|
|
317
411
|
// Data directories
|
|
318
|
-
const ownershipProven = !!marker || !!candidate.registryEntry;
|
|
319
412
|
const dataReports: IServiceDataDirReport[] = [];
|
|
320
413
|
let totalDataBytes = 0;
|
|
321
414
|
let reclaimableDataBytes = 0;
|
|
322
415
|
|
|
323
416
|
for (const service of serviceNames) {
|
|
324
417
|
const dataPath = getServiceDataDirectory(candidate.projectPath, service);
|
|
325
|
-
const
|
|
326
|
-
|
|
418
|
+
const pendingDeletionPath = await this.docker.getPendingDataDeletionPath(
|
|
419
|
+
candidate.projectPath,
|
|
420
|
+
service,
|
|
421
|
+
);
|
|
422
|
+
const preservedContainer = containerReports.find(
|
|
423
|
+
(containerArg, indexArg) =>
|
|
424
|
+
!containerArg.reclaimable &&
|
|
425
|
+
(!pendingDeletionPath ||
|
|
426
|
+
!isServiceOperationHelperLabels(candidate.containers[indexArg].labels)),
|
|
427
|
+
);
|
|
428
|
+
const ownershipProven =
|
|
429
|
+
service === 'objectstorage'
|
|
430
|
+
? markerProvesServiceDataDirectory(marker, candidate.projectPath, service) ||
|
|
431
|
+
candidate.containers.some(
|
|
432
|
+
(containerArg) =>
|
|
433
|
+
containerArg.labels['git.zone.service'] === 'objectstorage' &&
|
|
434
|
+
hasCanonicalServiceLabels(containerArg, candidate.projectPath),
|
|
435
|
+
)
|
|
436
|
+
: !!marker || !!candidate.registryEntry;
|
|
437
|
+
const canonicalExists = await plugins.smartfs.directory(dataPath).exists();
|
|
438
|
+
const exists = canonicalExists || !!pendingDeletionPath;
|
|
439
|
+
const sizeBytes = exists
|
|
440
|
+
? await helpers.getDirectorySize(canonicalExists ? dataPath : pendingDeletionPath!)
|
|
441
|
+
: 0;
|
|
327
442
|
totalDataBytes += sizeBytes;
|
|
328
443
|
|
|
329
444
|
let reclaimable = false;
|
|
@@ -350,9 +465,8 @@ export class ServicePruner {
|
|
|
350
465
|
} else if (mountConflict) {
|
|
351
466
|
reason = `mounted by a running container (${mountConflict})`;
|
|
352
467
|
blockedByRunningBytes += sizeBytes;
|
|
353
|
-
} else if (
|
|
354
|
-
reason =
|
|
355
|
-
blockedByRunningBytes += sizeBytes;
|
|
468
|
+
} else if (preservedContainer) {
|
|
469
|
+
reason = `container ${preservedContainer.name} is preserved or still active`;
|
|
356
470
|
} else {
|
|
357
471
|
reclaimable = true;
|
|
358
472
|
reason = `project stale, no container running, ownership proven by ${marker ? 'marker' : 'registry'}`;
|
|
@@ -376,12 +490,19 @@ export class ServicePruner {
|
|
|
376
490
|
// exists would make it invisible to prune, `stop -g` and `cleanup -g`,
|
|
377
491
|
// leaving it running forever under `restart: unless-stopped`.
|
|
378
492
|
if (state === 'orphaned' && candidate.registryEntry) {
|
|
379
|
-
if (candidate.
|
|
493
|
+
if (candidate.registryEntry.legacy?.minioContainer) {
|
|
494
|
+
skipped.push({
|
|
495
|
+
target: candidate.projectPath,
|
|
496
|
+
reason: `preserving legacy MinIO registry reference: ${candidate.registryEntry.legacy.minioContainer}`,
|
|
497
|
+
});
|
|
498
|
+
} else if (dockerAvailable && candidate.containers.length === 0) {
|
|
380
499
|
registryEntriesToRemove.push(candidate.projectPath);
|
|
381
500
|
} else {
|
|
382
501
|
skipped.push({
|
|
383
502
|
target: candidate.projectPath,
|
|
384
|
-
reason:
|
|
503
|
+
reason: dockerAvailable
|
|
504
|
+
? `project directory is gone but ${candidate.containers.length} container(s) remain; keeping the registry entry so they stay identifiable`
|
|
505
|
+
: 'project directory is gone but Docker is unavailable; keeping the registry entry so containers remain identifiable',
|
|
385
506
|
});
|
|
386
507
|
}
|
|
387
508
|
}
|
|
@@ -430,32 +551,71 @@ export class ServicePruner {
|
|
|
430
551
|
* removing something that may since have become live.
|
|
431
552
|
*/
|
|
432
553
|
public async applyPlan(planArg: IServicePrunePlan): Promise<void> {
|
|
433
|
-
if (
|
|
434
|
-
|
|
554
|
+
if (
|
|
555
|
+
!planArg.dockerAvailable &&
|
|
556
|
+
(planArg.containersToRemove.length > 0 ||
|
|
557
|
+
planArg.dataDirsToRemove.length > 0 ||
|
|
558
|
+
planArg.registryEntriesToRemove.length > 0)
|
|
559
|
+
) {
|
|
560
|
+
throw new Error('Refusing to apply service pruning without a reachable Docker daemon');
|
|
435
561
|
}
|
|
436
562
|
|
|
437
563
|
// Containers first, so their mounts stop protecting the data we then remove.
|
|
438
564
|
if (planArg.containersToRemove.length > 0) {
|
|
439
|
-
const current = await this.docker.inspectMany(
|
|
440
|
-
planArg.containersToRemove.map((container) => container.id),
|
|
441
|
-
);
|
|
442
565
|
for (const planned of planArg.containersToRemove) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
566
|
+
await withServiceDataOperationLock(planned.projectPath, planned.service, async () => {
|
|
567
|
+
const [container] = await this.docker.inspectMany([planned.id]);
|
|
568
|
+
if (!container || container.id !== planned.id || container.name !== planned.name) {
|
|
569
|
+
throw new Error(`Refusing to remove vanished or changed container: ${planned.name}`);
|
|
570
|
+
}
|
|
571
|
+
if (container.running) {
|
|
572
|
+
throw new Error(`Refusing to remove running container: ${container.name}`);
|
|
573
|
+
}
|
|
574
|
+
if (await plugins.smartfs.directory(planned.projectPath).exists()) {
|
|
575
|
+
const currentEntry = Object.entries(await this.globalRegistry.getAllProjects()).find(
|
|
576
|
+
([projectPathArg]) =>
|
|
577
|
+
plugins.path.resolve(projectPathArg) === planned.projectPath,
|
|
578
|
+
)?.[1];
|
|
579
|
+
if (!currentEntry) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`Refusing container for a project without current activity evidence: ${container.name}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const inactiveDays = Math.floor(
|
|
585
|
+
(Date.now() - currentEntry.lastActive) / dayInMs,
|
|
586
|
+
);
|
|
587
|
+
if (inactiveDays <= this.staleDays) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Refusing container for a project that became active: ${container.name} (${inactiveDays}d)`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const labelOwned = hasCanonicalServiceLabels(container, planned.projectPath);
|
|
594
|
+
const registryOwned =
|
|
595
|
+
!hasGitZoneLabels(container.labels) &&
|
|
596
|
+
(await this.isClaimedByExactlyOneProject(container.name, planned.projectPath));
|
|
597
|
+
if (
|
|
598
|
+
isLegacyMinioContainerLabels(container.labels) ||
|
|
599
|
+
isServiceOperationHelperLabels(container.labels) ||
|
|
600
|
+
(await this.isLegacyContainerReference(container.name))
|
|
601
|
+
) {
|
|
602
|
+
throw new Error(`Refusing to remove preserved service container: ${container.name}`);
|
|
603
|
+
}
|
|
604
|
+
if (!labelOwned && !registryOwned) {
|
|
605
|
+
throw new Error(`Refusing to remove container with unproven ownership: ${container.name}`);
|
|
606
|
+
}
|
|
607
|
+
if (
|
|
608
|
+
labelOwned &&
|
|
609
|
+
container.labels['git.zone.service'] === 'objectstorage' &&
|
|
610
|
+
(await plugins.smartfs
|
|
611
|
+
.directory(getServiceDataDirectory(planned.projectPath, 'objectstorage'))
|
|
612
|
+
.exists())
|
|
613
|
+
) {
|
|
614
|
+
await recordServiceDataDirectory(planned.projectPath, 'objectstorage');
|
|
615
|
+
}
|
|
616
|
+
await this.docker.execArgv(['rm', container.id], { timeoutMs: 60_000 });
|
|
617
|
+
logger.log('ok', `Removed container ${container.name}`);
|
|
618
|
+
});
|
|
459
619
|
}
|
|
460
620
|
}
|
|
461
621
|
|
|
@@ -470,7 +630,15 @@ export class ServicePruner {
|
|
|
470
630
|
}
|
|
471
631
|
const marker = await readServiceDataMarker(planned.projectPath);
|
|
472
632
|
const registered = await this.globalRegistry.isRegistered(planned.projectPath);
|
|
473
|
-
|
|
633
|
+
const ownershipProven =
|
|
634
|
+
planned.service === 'objectstorage'
|
|
635
|
+
? markerProvesServiceDataDirectory(
|
|
636
|
+
marker,
|
|
637
|
+
planned.projectPath,
|
|
638
|
+
planned.service,
|
|
639
|
+
)
|
|
640
|
+
: !!marker || registered;
|
|
641
|
+
if (!ownershipProven) {
|
|
474
642
|
throw new Error(`Refusing data path with unproven ownership: ${planned.path}`);
|
|
475
643
|
}
|
|
476
644
|
// Re-verify staleness: the project may have been started between
|
|
@@ -496,23 +664,87 @@ export class ServicePruner {
|
|
|
496
664
|
if (conflict) {
|
|
497
665
|
throw new Error(`Refusing mounted data path: ${planned.path} is used by ${conflict}`);
|
|
498
666
|
}
|
|
499
|
-
|
|
667
|
+
const pendingDeletionPath = await this.docker.getPendingDataDeletionPath(
|
|
668
|
+
planned.projectPath,
|
|
669
|
+
planned.service,
|
|
670
|
+
);
|
|
671
|
+
if (
|
|
672
|
+
!(await plugins.smartfs.directory(planned.path).exists()) &&
|
|
673
|
+
!pendingDeletionPath
|
|
674
|
+
) {
|
|
500
675
|
continue;
|
|
501
676
|
}
|
|
502
|
-
// Escalates to a
|
|
677
|
+
// Escalates to a root one-off container when the contents belong to
|
|
503
678
|
// the container user; throws rather than deleting only part of the tree.
|
|
504
|
-
await this.docker.removeDataDirectory(
|
|
679
|
+
await this.docker.removeDataDirectory(
|
|
680
|
+
planned.path,
|
|
681
|
+
serviceImages[planned.service],
|
|
682
|
+
{
|
|
683
|
+
projectPath: planned.projectPath,
|
|
684
|
+
service: planned.service,
|
|
685
|
+
preDeleteCheck: async () => {
|
|
686
|
+
const latestProjects = await this.globalRegistry.getAllProjects();
|
|
687
|
+
const latestEntry = Object.entries(latestProjects).find(
|
|
688
|
+
([projectPathArg]) =>
|
|
689
|
+
plugins.path.resolve(projectPathArg) === planned.projectPath,
|
|
690
|
+
)?.[1];
|
|
691
|
+
if (!latestEntry) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`Refusing data path without a current activity record: ${planned.path}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const inactiveDays = Math.floor(
|
|
697
|
+
(Date.now() - latestEntry.lastActive) / dayInMs,
|
|
698
|
+
);
|
|
699
|
+
if (inactiveDays <= this.staleDays) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`Refusing data path for a project that became active: ${planned.path} (${inactiveDays}d)`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
);
|
|
505
707
|
logger.log('ok', `Removed data directory ${planned.path}`);
|
|
506
708
|
}
|
|
507
709
|
}
|
|
508
710
|
|
|
509
711
|
// Registry metadata last, so a failure above leaves the claim intact.
|
|
510
712
|
for (const projectPath of planArg.registryEntriesToRemove) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
713
|
+
await this.withProjectServiceLocks(projectPath, async () => {
|
|
714
|
+
if (await plugins.smartfs.directory(projectPath).exists()) {
|
|
715
|
+
throw new Error(`Refusing to unregister an existing project: ${projectPath}`);
|
|
716
|
+
}
|
|
717
|
+
const currentProject = Object.entries(await this.globalRegistry.getAllProjects()).find(
|
|
718
|
+
([registeredPathArg]) => plugins.path.resolve(registeredPathArg) === projectPath,
|
|
719
|
+
)?.[1];
|
|
720
|
+
if (!currentProject) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (currentProject.legacy?.minioContainer) {
|
|
724
|
+
throw new Error(
|
|
725
|
+
`Refusing to unregister preserved legacy container evidence: ${currentProject.legacy.minioContainer}`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
const projectContainerIds = await this.docker.listIds([
|
|
729
|
+
`label=git.zone.project-path=${projectPath}`,
|
|
730
|
+
]);
|
|
731
|
+
if (projectContainerIds.length > 0) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
`Refusing to unregister ${projectPath}: a project-labeled container appeared after planning.`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
for (const containerName of Object.values(currentProject.containers)) {
|
|
737
|
+
if (!containerName) continue;
|
|
738
|
+
const ids = await this.docker.listIds([`name=^/${containerName}$`]);
|
|
739
|
+
if (ids.length > 0) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Refusing to unregister ${projectPath}: container ${containerName} appeared after planning.`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
await this.globalRegistry.unregisterProject(projectPath, currentProject);
|
|
746
|
+
logger.log('ok', `Unregistered ${projectPath}`);
|
|
747
|
+
});
|
|
516
748
|
}
|
|
517
749
|
}
|
|
518
750
|
|
|
@@ -521,12 +753,38 @@ export class ServicePruner {
|
|
|
521
753
|
projectPathArg: string,
|
|
522
754
|
): Promise<boolean> {
|
|
523
755
|
const projects = await this.globalRegistry.getAllProjects();
|
|
524
|
-
const claims: string
|
|
756
|
+
const claims: Array<{ projectPath: string; service: TServiceName }> = [];
|
|
525
757
|
for (const [projectPath, project] of Object.entries(projects)) {
|
|
526
|
-
|
|
527
|
-
|
|
758
|
+
const service = getRegisteredServiceForContainer(project, containerNameArg);
|
|
759
|
+
if (service) {
|
|
760
|
+
claims.push({ projectPath: plugins.path.resolve(projectPath), service });
|
|
528
761
|
}
|
|
529
762
|
}
|
|
530
|
-
return
|
|
763
|
+
return (
|
|
764
|
+
claims.length === 1 &&
|
|
765
|
+
claims[0].service !== 'objectstorage' &&
|
|
766
|
+
claims[0].projectPath === plugins.path.resolve(projectPathArg)
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private async withProjectServiceLocks<T>(
|
|
771
|
+
projectPathArg: string,
|
|
772
|
+
actionArg: () => Promise<T>,
|
|
773
|
+
serviceIndexArg = 0,
|
|
774
|
+
): Promise<T> {
|
|
775
|
+
const service = serviceNames[serviceIndexArg];
|
|
776
|
+
if (!service) {
|
|
777
|
+
return actionArg();
|
|
778
|
+
}
|
|
779
|
+
return withServiceDataOperationLock(projectPathArg, service, () =>
|
|
780
|
+
this.withProjectServiceLocks(projectPathArg, actionArg, serviceIndexArg + 1),
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private async isLegacyContainerReference(containerNameArg: string): Promise<boolean> {
|
|
785
|
+
const projects = await this.globalRegistry.getAllProjects();
|
|
786
|
+
return Object.values(projects).some(
|
|
787
|
+
(project) => project.legacy?.minioContainer === containerNameArg,
|
|
788
|
+
);
|
|
531
789
|
}
|
|
532
790
|
}
|