@git.zone/cli 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/classes.gitzoneconfig.js +1 -1
  3. package/dist_ts/classes.project.js +1 -1
  4. package/dist_ts/gitzone.cli.js +1 -1
  5. package/dist_ts/gitzone.logging.js +1 -1
  6. package/dist_ts/helpers.changelog.js +1 -1
  7. package/dist_ts/helpers.climode.js +1 -1
  8. package/dist_ts/helpers.smartconfig.js +61 -11
  9. package/dist_ts/helpers.smartconfigmigrations.js +1 -1
  10. package/dist_ts/helpers.tsdocker.js +1 -1
  11. package/dist_ts/helpers.workflow.js +1 -1
  12. package/dist_ts/index.js +1 -1
  13. package/dist_ts/mod_audit/index.js +1 -1
  14. package/dist_ts/mod_commit/index.js +1 -1
  15. package/dist_ts/mod_commit/mod.helpers.js +1 -1
  16. package/dist_ts/mod_commit/mod.plugins.js +1 -1
  17. package/dist_ts/mod_commit/mod.ui.js +1 -1
  18. package/dist_ts/mod_config/classes.commitconfig.js +1 -1
  19. package/dist_ts/mod_config/classes.releaseconfig.js +1 -1
  20. package/dist_ts/mod_config/index.js +2 -2
  21. package/dist_ts/mod_config/mod.plugins.js +1 -1
  22. package/dist_ts/mod_deprecate/index.js +1 -1
  23. package/dist_ts/mod_deprecate/mod.plugins.js +1 -1
  24. package/dist_ts/mod_docker/classes.dockerpruner.js +7 -1
  25. package/dist_ts/mod_docker/index.js +1 -1
  26. package/dist_ts/mod_docker/mod.plugins.js +1 -1
  27. package/dist_ts/mod_format/classes.baseformatter.js +1 -1
  28. package/dist_ts/mod_format/classes.diffreporter.js +1 -1
  29. package/dist_ts/mod_format/classes.formatcontext.js +1 -1
  30. package/dist_ts/mod_format/classes.formatplanner.js +1 -1
  31. package/dist_ts/mod_format/classes.formatstats.js +1 -1
  32. package/dist_ts/mod_format/formatters/assets.formatter.js +1 -1
  33. package/dist_ts/mod_format/formatters/cleanup.formatter.js +1 -1
  34. package/dist_ts/mod_format/formatters/copy.formatter.js +1 -1
  35. package/dist_ts/mod_format/formatters/gitignore.formatter.js +1 -1
  36. package/dist_ts/mod_format/formatters/license.formatter.js +1 -1
  37. package/dist_ts/mod_format/formatters/packagejson.formatter.js +1 -1
  38. package/dist_ts/mod_format/formatters/prettier.formatter.js +1 -1
  39. package/dist_ts/mod_format/formatters/readme.formatter.js +1 -1
  40. package/dist_ts/mod_format/formatters/smartconfig.formatter.js +1 -1
  41. package/dist_ts/mod_format/formatters/templates.formatter.js +1 -1
  42. package/dist_ts/mod_format/formatters/tsconfig.formatter.js +1 -1
  43. package/dist_ts/mod_format/index.js +1 -1
  44. package/dist_ts/mod_format/interfaces.format.js +1 -1
  45. package/dist_ts/mod_format/mod.plugins.js +1 -1
  46. package/dist_ts/mod_helpers/index.js +1 -1
  47. package/dist_ts/mod_helpers/mod.plugins.js +1 -1
  48. package/dist_ts/mod_meta/index.js +1 -1
  49. package/dist_ts/mod_meta/meta.classes.meta.js +1 -1
  50. package/dist_ts/mod_meta/meta.interfaces.js +1 -1
  51. package/dist_ts/mod_meta/meta.plugins.js +1 -1
  52. package/dist_ts/mod_open/index.js +1 -1
  53. package/dist_ts/mod_open/mod.plugins.js +1 -1
  54. package/dist_ts/mod_release/index.js +4 -4
  55. package/dist_ts/mod_release/mod.plugins.js +1 -1
  56. package/dist_ts/mod_services/classes.dockercontainer.d.ts +50 -3
  57. package/dist_ts/mod_services/classes.dockercontainer.js +646 -84
  58. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -24
  59. package/dist_ts/mod_services/classes.globalregistry.js +210 -62
  60. package/dist_ts/mod_services/classes.globalregistrystore.d.ts +48 -0
  61. package/dist_ts/mod_services/classes.globalregistrystore.js +156 -0
  62. package/dist_ts/mod_services/classes.interprocesslock.d.ts +21 -0
  63. package/dist_ts/mod_services/classes.interprocesslock.js +141 -0
  64. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +15 -12
  65. package/dist_ts/mod_services/classes.serviceconfiguration.js +188 -91
  66. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +32 -7
  67. package/dist_ts/mod_services/classes.servicedatamarker.js +145 -49
  68. package/dist_ts/mod_services/classes.servicemanager.d.ts +70 -28
  69. package/dist_ts/mod_services/classes.servicemanager.js +995 -421
  70. package/dist_ts/mod_services/classes.servicenames.d.ts +8 -0
  71. package/dist_ts/mod_services/classes.servicenames.js +54 -0
  72. package/dist_ts/mod_services/classes.serviceoptions.js +1 -1
  73. package/dist_ts/mod_services/classes.servicepruner.d.ts +3 -0
  74. package/dist_ts/mod_services/classes.servicepruner.js +208 -46
  75. package/dist_ts/mod_services/helpers.js +1 -1
  76. package/dist_ts/mod_services/index.d.ts +2 -0
  77. package/dist_ts/mod_services/index.js +81 -58
  78. package/dist_ts/mod_services/mod.plugins.js +1 -1
  79. package/dist_ts/mod_standard/index.js +2 -2
  80. package/dist_ts/mod_standard/mod.plugins.js +1 -1
  81. package/dist_ts/mod_start/index.js +1 -1
  82. package/dist_ts/mod_start/mod.plugins.js +1 -1
  83. package/dist_ts/mod_template/index.js +1 -1
  84. package/dist_ts/mod_template/mod.plugins.js +1 -1
  85. package/dist_ts/mod_tools/classes.packagemanager.js +1 -1
  86. package/dist_ts/mod_tools/index.js +1 -1
  87. package/dist_ts/mod_tools/mod.plugins.js +1 -1
  88. package/dist_ts/paths.js +1 -1
  89. package/dist_ts/plugins.d.ts +3 -1
  90. package/dist_ts/plugins.js +4 -2
  91. package/dist_ts_migration/001.service-selection.d.ts +9 -0
  92. package/dist_ts_migration/001.service-selection.js +51 -0
  93. package/dist_ts_migration/002.runtime-config.d.ts +19 -0
  94. package/dist_ts_migration/002.runtime-config.js +170 -0
  95. package/dist_ts_migration/003.service-data-marker.d.ts +9 -0
  96. package/dist_ts_migration/003.service-data-marker.js +156 -0
  97. package/dist_ts_migration/004.global-registry.d.ts +13 -0
  98. package/dist_ts_migration/004.global-registry.js +165 -0
  99. package/dist_ts_migration/index.d.ts +11 -0
  100. package/dist_ts_migration/index.js +59 -0
  101. package/package.json +6 -3
  102. package/readme.hints.md +102 -25
  103. package/readme.md +123 -28
  104. package/readme.plan.md +29 -16
  105. package/ts/00_commitinfo_data.ts +1 -1
  106. package/ts/helpers.smartconfig.ts +63 -10
  107. package/ts/mod_config/index.ts +1 -1
  108. package/ts/mod_docker/classes.dockerpruner.ts +6 -0
  109. package/ts/mod_release/index.ts +3 -3
  110. package/ts/mod_services/classes.dockercontainer.ts +912 -96
  111. package/ts/mod_services/classes.globalregistry.ts +290 -89
  112. package/ts/mod_services/classes.globalregistrystore.ts +279 -0
  113. package/ts/mod_services/classes.interprocesslock.ts +189 -0
  114. package/ts/mod_services/classes.serviceconfiguration.ts +215 -108
  115. package/ts/mod_services/classes.servicedatamarker.ts +210 -52
  116. package/ts/mod_services/classes.servicemanager.ts +1332 -512
  117. package/ts/mod_services/classes.servicenames.ts +76 -0
  118. package/ts/mod_services/classes.servicepruner.ts +310 -52
  119. package/ts/mod_services/index.ts +89 -62
  120. package/ts/mod_standard/index.ts +1 -1
  121. package/ts/plugins.ts +4 -0
  122. package/ts_migration/001.service-selection.ts +77 -0
  123. package/ts_migration/002.runtime-config.ts +218 -0
  124. package/ts_migration/003.service-data-marker.ts +216 -0
  125. package/ts_migration/004.global-registry.ts +272 -0
  126. 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
- isServiceOwnedByLabels,
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<{ id: string; name: string; projectPath: string }>;
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((container) => nameClaims.has(container.name));
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 = nameClaims.get(container.name) || [];
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<{ id: string; name: string; projectPath: string }> = [];
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
- if (!dockerAvailable) {
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 exists = await plugins.smartfs.directory(dataPath).exists();
326
- const sizeBytes = exists ? await helpers.getDirectorySize(dataPath) : 0;
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 (anyContainerRunning) {
354
- reason = 'a container for this project is still running';
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.containers.length === 0) {
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: `project directory is gone but ${candidate.containers.length} container(s) remain; keeping the registry entry so they stay identifiable`,
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 (!planArg.dockerAvailable && (planArg.containersToRemove.length > 0 || planArg.dataDirsToRemove.length > 0)) {
434
- throw new Error('Refusing to apply container or data removal without a reachable Docker daemon');
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
- const container = current.find((candidate) => candidate.id === planned.id);
444
- if (!container) {
445
- throw new Error(`Refusing to remove vanished container: ${planned.name}`);
446
- }
447
- if (container.running) {
448
- throw new Error(`Refusing to remove running container: ${container.name}`);
449
- }
450
- const labelOwned = isServiceOwnedByLabels(container.labels);
451
- const registryOwned = await this.isClaimedByExactlyOneProject(container.name, planned.projectPath);
452
- if (!labelOwned && !registryOwned) {
453
- throw new Error(`Refusing to remove container with unproven ownership: ${container.name}`);
454
- }
455
- if (!(await this.docker.remove(container.name, false))) {
456
- throw new Error(`Failed to remove container: ${container.name}`);
457
- }
458
- logger.log('ok', `Removed container ${container.name}`);
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
- if (!marker && !registered) {
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
- if (!(await plugins.smartfs.directory(planned.path).exists())) {
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 privileged one-off container when the contents belong to
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(planned.path, serviceImages[planned.service]);
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
- if (await plugins.smartfs.directory(projectPath).exists()) {
512
- throw new Error(`Refusing to unregister an existing project: ${projectPath}`);
513
- }
514
- await this.globalRegistry.unregisterProject(projectPath);
515
- logger.log('ok', `Unregistered ${projectPath}`);
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
- if (Object.values(project.containers).includes(containerNameArg)) {
527
- claims.push(plugins.path.resolve(projectPath));
758
+ const service = getRegisteredServiceForContainer(project, containerNameArg);
759
+ if (service) {
760
+ claims.push({ projectPath: plugins.path.resolve(projectPath), service });
528
761
  }
529
762
  }
530
- return claims.length === 1 && claims[0] === plugins.path.resolve(projectPathArg);
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
  }