@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
@@ -1,6 +1,17 @@
1
1
  import * as plugins from './mod.plugins.js';
2
2
  import * as helpers from './helpers.js';
3
3
  import { logger } from '../gitzone.logging.js';
4
+ import {
5
+ getServiceDataDirectory,
6
+ markerProvesServiceDataDirectory,
7
+ readServiceDataMarker,
8
+ serviceBindOperation,
9
+ serviceDataOwner,
10
+ serviceOperationLabel,
11
+ serviceToolLabel,
12
+ type TServiceName,
13
+ } from './classes.servicedatamarker.js';
14
+ import { withServiceDataOperationLock } from './classes.interprocesslock.js';
4
15
 
5
16
  export type ContainerStatus = 'running' | 'stopped' | 'not_exists';
6
17
 
@@ -28,6 +39,8 @@ export interface IDockerRunOptions {
28
39
  export interface IDockerCreateArgvOptions extends Omit<IDockerRunOptions, 'command'> {
29
40
  /** Container command passed as exact argv entries. */
30
41
  command?: readonly string[];
42
+ user?: string;
43
+ entrypoint?: string;
31
44
  }
32
45
 
33
46
  export interface IDockerExecArgvOptions {
@@ -47,6 +60,130 @@ export interface IDockerArgvExecutionErrorOptions {
47
60
  signal?: NodeJS.Signals;
48
61
  }
49
62
 
63
+ export interface IFencedBindOperationOptions {
64
+ image: string;
65
+ hostPath: string;
66
+ containerPath: string;
67
+ entrypoint: string;
68
+ command: readonly string[];
69
+ labels: Record<string, string>;
70
+ executionOptions: () => IDockerExecArgvOptions;
71
+ rollbackTimeoutMs?: number;
72
+ }
73
+
74
+ interface IFencedBindRuntimeOptions extends IFencedBindOperationOptions {
75
+ name: string;
76
+ labels: Record<string, string>;
77
+ }
78
+
79
+ export const fencedBindOperationSpecLabel = 'git.zone.operation-spec';
80
+ export const fencedBindOperationInvocationLabel = 'git.zone.operation-invocation';
81
+ export const fencedBindOperationSpec = '1';
82
+ export const fencedBindOperationStaleAfterMs = 60_000;
83
+
84
+ /** One Docker-reserved lock name for every canonical host bind target. */
85
+ export const getFencedBindOperationName = (hostPathArg: string): string => {
86
+ const targetHash = plugins.crypto
87
+ .createHash('sha256')
88
+ .update(plugins.path.resolve(hostPathArg))
89
+ .digest('hex')
90
+ .slice(0, 24);
91
+ return `gitzone-services-bind-${targetHash}`;
92
+ };
93
+
94
+ export interface IRemoveDataDirectoryOptions {
95
+ projectPath: string;
96
+ service: TServiceName;
97
+ preDeleteCheck?: () => Promise<void>;
98
+ }
99
+
100
+ interface IServiceDataDeletionIntent {
101
+ owner: typeof serviceDataOwner;
102
+ kind: 'service-data-deletion';
103
+ schemaVersion: 1;
104
+ projectPath: string;
105
+ service: TServiceName;
106
+ canonicalPath: string;
107
+ quarantinePath: string;
108
+ token: string;
109
+ createdAt: string;
110
+ }
111
+
112
+ const getServiceDataDeletionIntentPath = (
113
+ projectPathArg: string,
114
+ serviceArg: TServiceName,
115
+ ): string =>
116
+ plugins.path.join(
117
+ plugins.path.resolve(projectPathArg),
118
+ '.nogit',
119
+ `.gitzone-delete-${serviceArg}.json`,
120
+ );
121
+
122
+ const getServiceDataQuarantinePath = (canonicalPathArg: string, tokenArg: string): string =>
123
+ `${plugins.path.resolve(canonicalPathArg)}.gitzone-delete-${tokenArg}`;
124
+
125
+ const readServiceDataDeletionIntent = async (
126
+ projectPathArg: string,
127
+ serviceArg: TServiceName,
128
+ ): Promise<IServiceDataDeletionIntent | undefined> => {
129
+ const projectPath = plugins.path.resolve(projectPathArg);
130
+ const canonicalPath = getServiceDataDirectory(projectPath, serviceArg);
131
+ const intentPath = getServiceDataDeletionIntentPath(projectPath, serviceArg);
132
+ try {
133
+ const stats = await plugins.fs.lstat(intentPath);
134
+ if (stats.isSymbolicLink() || !stats.isFile()) {
135
+ throw new Error(`Refusing non-regular service data deletion intent: ${intentPath}`);
136
+ }
137
+ } catch (error) {
138
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
139
+ return undefined;
140
+ }
141
+ throw error;
142
+ }
143
+ let parsed: unknown;
144
+ try {
145
+ parsed = JSON.parse(await plugins.fs.readFile(intentPath, 'utf8'));
146
+ } catch (error) {
147
+ throw new Error(`Refusing invalid service data deletion intent: ${intentPath}`, {
148
+ cause: error,
149
+ });
150
+ }
151
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
152
+ throw new Error(`Refusing invalid service data deletion intent: ${intentPath}`);
153
+ }
154
+ const intent = parsed as Record<string, unknown>;
155
+ const allowedKeys = [
156
+ 'owner',
157
+ 'kind',
158
+ 'schemaVersion',
159
+ 'projectPath',
160
+ 'service',
161
+ 'canonicalPath',
162
+ 'quarantinePath',
163
+ 'token',
164
+ 'createdAt',
165
+ ];
166
+ const token = intent.token;
167
+ if (
168
+ Object.keys(intent).length !== allowedKeys.length ||
169
+ !Object.keys(intent).every((keyArg) => allowedKeys.includes(keyArg)) ||
170
+ intent.owner !== serviceDataOwner ||
171
+ intent.kind !== 'service-data-deletion' ||
172
+ intent.schemaVersion !== 1 ||
173
+ intent.projectPath !== projectPath ||
174
+ intent.service !== serviceArg ||
175
+ intent.canonicalPath !== canonicalPath ||
176
+ typeof token !== 'string' ||
177
+ !/^[a-f0-9]{32}$/u.test(token) ||
178
+ intent.quarantinePath !== getServiceDataQuarantinePath(canonicalPath, token) ||
179
+ typeof intent.createdAt !== 'string' ||
180
+ !intent.createdAt
181
+ ) {
182
+ throw new Error(`Refusing invalid service data deletion intent: ${intentPath}`);
183
+ }
184
+ return intent as unknown as IServiceDataDeletionIntent;
185
+ };
186
+
50
187
  /** Sanitised failure from the shell-free Docker execution path. */
51
188
  export class DockerArgvExecutionError extends Error {
52
189
  public readonly exitCode?: number;
@@ -78,6 +215,38 @@ export interface IContainerInspectInfo {
78
215
  mountSources: string[];
79
216
  }
80
217
 
218
+ const isExactStoppedBindHelperForTarget = (
219
+ containerArg: IContainerInspectInfo,
220
+ hostPathArg: string,
221
+ optionsArg: IRemoveDataDirectoryOptions,
222
+ ): boolean => {
223
+ const invocationId = containerArg.labels[fencedBindOperationInvocationLabel];
224
+ const expectedLabels: Record<string, string> = {
225
+ 'git.zone.tool': serviceToolLabel,
226
+ 'git.zone.service': optionsArg.service,
227
+ 'git.zone.project-path': plugins.path.resolve(optionsArg.projectPath),
228
+ 'git.zone.data-path': plugins.path.resolve(hostPathArg),
229
+ 'git.zone.safe-to-prune': 'false',
230
+ [serviceOperationLabel]: serviceBindOperation,
231
+ [fencedBindOperationSpecLabel]: fencedBindOperationSpec,
232
+ [fencedBindOperationInvocationLabel]: invocationId,
233
+ };
234
+ const actualGitZoneLabels = Object.entries(containerArg.labels).filter(([keyArg]) =>
235
+ keyArg.startsWith('git.zone.'),
236
+ );
237
+ return (
238
+ !containerArg.running &&
239
+ containerArg.name === getFencedBindOperationName(hostPathArg) &&
240
+ /^[a-f0-9]{32}$/u.test(invocationId || '') &&
241
+ actualGitZoneLabels.length === Object.keys(expectedLabels).length &&
242
+ Object.entries(expectedLabels).every(
243
+ ([keyArg, valueArg]) => containerArg.labels[keyArg] === valueArg,
244
+ ) &&
245
+ containerArg.mountSources.length === 1 &&
246
+ plugins.path.resolve(containerArg.mountSources[0]) === plugins.path.resolve(hostPathArg)
247
+ );
248
+ };
249
+
81
250
  /** Single-quote a value for safe interpolation into a bash command. */
82
251
  export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
83
252
 
@@ -141,6 +310,104 @@ export const matchDockerUlimits = (
141
310
  return 'match';
142
311
  };
143
312
 
313
+ const isUnknownRecord = (valueArg: unknown): valueArg is Record<string, any> => {
314
+ return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
315
+ };
316
+
317
+ interface IFencedBindInspection {
318
+ id: string;
319
+ name: string;
320
+ createdAtMs: number;
321
+ image: string;
322
+ running: boolean;
323
+ status: string;
324
+ exitCode: number;
325
+ user: string;
326
+ entrypoint: string[];
327
+ command: string[];
328
+ labels: Record<string, string>;
329
+ mounts: Array<{ source: string; destination: string; readWrite: boolean }>;
330
+ }
331
+
332
+ const parseFencedBindInspection = (
333
+ stdoutArg: string,
334
+ expectedNameArg?: string,
335
+ ): IFencedBindInspection => {
336
+ let parsed: unknown;
337
+ try {
338
+ parsed = JSON.parse(stdoutArg);
339
+ } catch {
340
+ throw new Error(`Docker returned an invalid bind-operation inspection for ${expectedNameArg}.`);
341
+ }
342
+ if (!Array.isArray(parsed) || parsed.length !== 1 || !isUnknownRecord(parsed[0])) {
343
+ throw new Error(`Docker returned an invalid bind-operation inspection for ${expectedNameArg}.`);
344
+ }
345
+ const inspected = parsed[0];
346
+ const config = inspected.Config;
347
+ const state = inspected.State;
348
+ const normalizedName = String(inspected.Name || '').replace(/^\//u, '');
349
+ const createdAtMs = Date.parse(String(inspected.Created || ''));
350
+ if (
351
+ typeof inspected.Id !== 'string' ||
352
+ !/^[a-f0-9]{64}$/u.test(inspected.Id) ||
353
+ !/^gitzone-services-bind-[a-f0-9]{24}$/u.test(normalizedName) ||
354
+ (expectedNameArg !== undefined && normalizedName !== expectedNameArg) ||
355
+ !Number.isFinite(createdAtMs) ||
356
+ !isUnknownRecord(config) ||
357
+ typeof config.Image !== 'string' ||
358
+ typeof config.User !== 'string' ||
359
+ !Array.isArray(config.Entrypoint) ||
360
+ !config.Entrypoint.every((entry: unknown) => typeof entry === 'string') ||
361
+ !Array.isArray(config.Cmd) ||
362
+ !config.Cmd.every((entry: unknown) => typeof entry === 'string') ||
363
+ !isUnknownRecord(config.Labels) ||
364
+ !isUnknownRecord(state) ||
365
+ typeof state.Running !== 'boolean' ||
366
+ typeof state.Status !== 'string' ||
367
+ typeof state.ExitCode !== 'number' ||
368
+ !Array.isArray(inspected.Mounts)
369
+ ) {
370
+ throw new Error(`Docker returned an invalid bind-operation inspection for ${expectedNameArg}.`);
371
+ }
372
+ const labels: Record<string, string> = {};
373
+ for (const [key, value] of Object.entries(config.Labels)) {
374
+ if (typeof value !== 'string') {
375
+ throw new Error(`Docker returned an invalid bind-operation inspection for ${expectedNameArg}.`);
376
+ }
377
+ labels[key] = value;
378
+ }
379
+ const mounts = inspected.Mounts.map((mountArg: unknown) => {
380
+ if (
381
+ !isUnknownRecord(mountArg) ||
382
+ mountArg.Type !== 'bind' ||
383
+ typeof mountArg.Source !== 'string' ||
384
+ typeof mountArg.Destination !== 'string' ||
385
+ typeof mountArg.RW !== 'boolean'
386
+ ) {
387
+ throw new Error(`Docker returned an invalid bind-operation inspection for ${expectedNameArg}.`);
388
+ }
389
+ return {
390
+ source: plugins.path.resolve(mountArg.Source),
391
+ destination: mountArg.Destination,
392
+ readWrite: mountArg.RW,
393
+ };
394
+ });
395
+ return {
396
+ id: inspected.Id,
397
+ name: normalizedName,
398
+ createdAtMs,
399
+ image: config.Image,
400
+ running: state.Running,
401
+ status: state.Status,
402
+ exitCode: state.ExitCode,
403
+ user: config.User,
404
+ entrypoint: [...config.Entrypoint],
405
+ command: [...config.Cmd],
406
+ labels,
407
+ mounts,
408
+ };
409
+ };
410
+
144
411
  export class DockerContainer {
145
412
  private smartshell: plugins.smartshell.Smartshell;
146
413
  /**
@@ -152,8 +419,10 @@ export class DockerContainer {
152
419
  * @git.zone/tsdocker.
153
420
  */
154
421
  private queryTimeoutSeconds = 60;
155
- /** Longer bound for privileged cleanup, which may delete a large tree. */
422
+ /** Longer bound for root-helper cleanup, which may delete a large tree. */
156
423
  private cleanupTimeoutSeconds = 600;
424
+ private bindOperationDelay = async (millisecondsArg: number): Promise<void> =>
425
+ plugins.smartdelay.delayFor(millisecondsArg);
157
426
 
158
427
  constructor() {
159
428
  this.smartshell = new plugins.smartshell.Smartshell({
@@ -259,6 +528,12 @@ export class DockerContainer {
259
528
  if (optionsArg.restart) {
260
529
  argv.push('--restart', optionsArg.restart);
261
530
  }
531
+ if (optionsArg.user) {
532
+ argv.push('--user', optionsArg.user);
533
+ }
534
+ if (optionsArg.entrypoint) {
535
+ argv.push('--entrypoint', optionsArg.entrypoint);
536
+ }
262
537
 
263
538
  argv.push(optionsArg.image);
264
539
  if (optionsArg.command) {
@@ -268,15 +543,332 @@ export class DockerContainer {
268
543
  return this.execArgvInternal(argv, executionOptionsArg, childEnvironment);
269
544
  }
270
545
 
546
+ private assertFencedBindInspection(
547
+ inspectionArg: IFencedBindInspection,
548
+ optionsArg: IFencedBindRuntimeOptions,
549
+ ): void {
550
+ const expectedGitZoneLabels = Object.entries(optionsArg.labels).filter(([key]) =>
551
+ key.startsWith('git.zone.'),
552
+ );
553
+ const actualGitZoneLabels = Object.entries(inspectionArg.labels).filter(([key]) =>
554
+ key.startsWith('git.zone.'),
555
+ );
556
+ const labelsMatch =
557
+ expectedGitZoneLabels.length === actualGitZoneLabels.length &&
558
+ expectedGitZoneLabels.every(
559
+ ([key, value]) => inspectionArg.labels[key] === value,
560
+ );
561
+ const mountsMatch =
562
+ inspectionArg.mounts.length === 1 &&
563
+ inspectionArg.mounts[0].source === plugins.path.resolve(optionsArg.hostPath) &&
564
+ inspectionArg.mounts[0].destination === optionsArg.containerPath &&
565
+ inspectionArg.mounts[0].readWrite;
566
+ if (
567
+ inspectionArg.name !== optionsArg.name ||
568
+ inspectionArg.image !== optionsArg.image ||
569
+ inspectionArg.user !== '0' ||
570
+ inspectionArg.entrypoint.length !== 1 ||
571
+ inspectionArg.entrypoint[0] !== optionsArg.entrypoint ||
572
+ inspectionArg.command.length !== optionsArg.command.length ||
573
+ !inspectionArg.command.every((entry, index) => entry === optionsArg.command[index]) ||
574
+ !labelsMatch ||
575
+ !mountsMatch
576
+ ) {
577
+ throw new Error(
578
+ `Refusing bind-operation container ${optionsArg.name}: exact ownership and configuration are not proven.`,
579
+ );
580
+ }
581
+ }
582
+
583
+ private assertRecoverableFencedBindInspection(
584
+ inspectionArg: IFencedBindInspection,
585
+ optionsArg: IFencedBindOperationOptions,
586
+ expectedNameArg: string,
587
+ ): void {
588
+ const expectedLabels = {
589
+ ...optionsArg.labels,
590
+ [fencedBindOperationSpecLabel]: fencedBindOperationSpec,
591
+ };
592
+ const expectedGitZoneLabels = Object.entries(expectedLabels).filter(([key]) =>
593
+ key.startsWith('git.zone.'),
594
+ );
595
+ const actualGitZoneLabels = Object.entries(inspectionArg.labels).filter(([key]) =>
596
+ key.startsWith('git.zone.'),
597
+ );
598
+ const invocationId = inspectionArg.labels[fencedBindOperationInvocationLabel];
599
+ const labelsMatch =
600
+ actualGitZoneLabels.length === expectedGitZoneLabels.length + 1 &&
601
+ expectedGitZoneLabels.every(
602
+ ([key, value]) => inspectionArg.labels[key] === value,
603
+ ) &&
604
+ typeof invocationId === 'string' &&
605
+ /^[a-f0-9]{32}$/u.test(invocationId);
606
+ const mountMatches =
607
+ inspectionArg.mounts.length === 1 &&
608
+ inspectionArg.mounts[0].source === plugins.path.resolve(optionsArg.hostPath) &&
609
+ inspectionArg.mounts[0].readWrite;
610
+ if (
611
+ inspectionArg.name !== expectedNameArg ||
612
+ inspectionArg.image !== optionsArg.image ||
613
+ inspectionArg.user !== '0' ||
614
+ inspectionArg.entrypoint.length !== 1 ||
615
+ inspectionArg.entrypoint[0] !== optionsArg.entrypoint ||
616
+ inspectionArg.command.length !== optionsArg.command.length ||
617
+ !inspectionArg.command.every(
618
+ (entryArg, indexArg) => entryArg === optionsArg.command[indexArg],
619
+ ) ||
620
+ !labelsMatch ||
621
+ !mountMatches ||
622
+ inspectionArg.mounts[0].destination !== optionsArg.containerPath
623
+ ) {
624
+ throw new Error(
625
+ `Refusing bind-operation container ${expectedNameArg}: canonical helper ownership is not proven.`,
626
+ );
627
+ }
628
+ }
629
+
630
+ private async readFencedBindInspection(
631
+ referenceArg: string,
632
+ expectedNameArg: string,
633
+ executionOptionsArg: IDockerExecArgvOptions,
634
+ ): Promise<IFencedBindInspection> {
635
+ const result = await this.execArgv(['inspect', referenceArg], executionOptionsArg);
636
+ const inspection = parseFencedBindInspection(result.stdout, expectedNameArg);
637
+ if (/^[a-f0-9]{64}$/u.test(referenceArg) && inspection.id !== referenceArg) {
638
+ throw new Error(
639
+ `Refusing bind-operation container ${expectedNameArg}: its immutable id changed.`,
640
+ );
641
+ }
642
+ return inspection;
643
+ }
644
+
645
+ private async inspectFencedBindOperation(
646
+ referenceArg: string,
647
+ optionsArg: IFencedBindRuntimeOptions,
648
+ executionOptionsArg: IDockerExecArgvOptions,
649
+ ): Promise<IFencedBindInspection> {
650
+ const inspection = await this.readFencedBindInspection(
651
+ referenceArg,
652
+ optionsArg.name,
653
+ executionOptionsArg,
654
+ );
655
+ this.assertFencedBindInspection(inspection, optionsArg);
656
+ return inspection;
657
+ }
658
+
659
+ private async discoverFencedBindOperationId(
660
+ nameArg: string,
661
+ executionOptionsArg: IDockerExecArgvOptions,
662
+ ): Promise<string | undefined> {
663
+ const result = await this.execArgv(
664
+ [
665
+ 'container',
666
+ 'ls',
667
+ '-a',
668
+ '--no-trunc',
669
+ '--filter',
670
+ `name=^/${nameArg}$`,
671
+ '--format',
672
+ '{{.ID}}',
673
+ ],
674
+ executionOptionsArg,
675
+ );
676
+ const ids = result.stdout
677
+ .split(/\r?\n/u)
678
+ .map((entry) => entry.trim())
679
+ .filter(Boolean);
680
+ if (ids.length === 0) {
681
+ return undefined;
682
+ }
683
+ if (ids.length !== 1 || !/^[a-f0-9]{64}$/u.test(ids[0])) {
684
+ throw new Error(`Docker returned ambiguous bind-operation identity for ${nameArg}.`);
685
+ }
686
+ return ids[0];
687
+ }
688
+
689
+ private async reclaimStaleFencedBindOperation(
690
+ optionsArg: IFencedBindOperationOptions,
691
+ nameArg: string,
692
+ ): Promise<void> {
693
+ const existingId = await this.discoverFencedBindOperationId(
694
+ nameArg,
695
+ optionsArg.executionOptions(),
696
+ );
697
+ if (!existingId) {
698
+ return;
699
+ }
700
+ const inspection = await this.readFencedBindInspection(
701
+ existingId,
702
+ nameArg,
703
+ optionsArg.executionOptions(),
704
+ );
705
+ this.assertRecoverableFencedBindInspection(inspection, optionsArg, nameArg);
706
+ if (inspection.running) {
707
+ throw new Error(`Bind-operation container ${nameArg} is still running.`);
708
+ }
709
+ if (inspection.status !== 'created' && inspection.status !== 'exited') {
710
+ throw new Error(
711
+ `Refusing bind-operation container ${nameArg}: its stopped state is not proven.`,
712
+ );
713
+ }
714
+ if (Date.now() - inspection.createdAtMs < fencedBindOperationStaleAfterMs) {
715
+ throw new Error(
716
+ `Bind-operation container ${nameArg} may belong to another active invocation.`,
717
+ );
718
+ }
719
+ // Non-forced removal is the atomic handoff: if another process starts the
720
+ // helper after inspection, Docker refuses removal instead of killing it.
721
+ await this.execArgv(['rm', inspection.id], optionsArg.executionOptions());
722
+ }
723
+
724
+ /**
725
+ * Run one root bind-mount operation with a stopped-create and immutable-ID fence.
726
+ * Every ambiguous or failed path proves ownership again before forced removal.
727
+ */
728
+ public async runFencedBindOperation(optionsArg: IFencedBindOperationOptions): Promise<void> {
729
+ if (
730
+ Object.hasOwn(optionsArg.labels, fencedBindOperationSpecLabel) ||
731
+ Object.hasOwn(optionsArg.labels, fencedBindOperationInvocationLabel)
732
+ ) {
733
+ throw new Error('Fenced bind operation labels contain reserved lifecycle keys.');
734
+ }
735
+ if (
736
+ optionsArg.labels[serviceOperationLabel] !== serviceBindOperation ||
737
+ optionsArg.labels['git.zone.safe-to-prune'] !== 'false'
738
+ ) {
739
+ throw new Error('Fenced bind operation labels do not declare the protected helper contract.');
740
+ }
741
+ const operationName = getFencedBindOperationName(optionsArg.hostPath);
742
+ const invocationId = plugins.crypto.randomBytes(16).toString('hex');
743
+ const runtimeOptions: IFencedBindRuntimeOptions = {
744
+ ...optionsArg,
745
+ name: operationName,
746
+ labels: {
747
+ ...optionsArg.labels,
748
+ [fencedBindOperationSpecLabel]: fencedBindOperationSpec,
749
+ [fencedBindOperationInvocationLabel]: invocationId,
750
+ },
751
+ };
752
+ await this.reclaimStaleFencedBindOperation(optionsArg, operationName);
753
+
754
+ const normalizeError = (errorArg: unknown): Error =>
755
+ errorArg instanceof Error
756
+ ? errorArg
757
+ : new Error('Docker bind operation failed with a non-Error rejection.');
758
+ const rollbackErrors: Error[] = [];
759
+ let operationError: Error | undefined;
760
+ let containerId: string | undefined;
761
+
762
+ try {
763
+ const creation = await this.createArgv(
764
+ {
765
+ name: operationName,
766
+ image: runtimeOptions.image,
767
+ volumes: {
768
+ [plugins.path.resolve(runtimeOptions.hostPath)]: runtimeOptions.containerPath,
769
+ },
770
+ labels: runtimeOptions.labels,
771
+ user: '0',
772
+ entrypoint: runtimeOptions.entrypoint,
773
+ command: runtimeOptions.command,
774
+ },
775
+ runtimeOptions.executionOptions(),
776
+ );
777
+ const createdContainerId = creation.stdout.trim();
778
+ if (!/^[a-f0-9]{64}$/u.test(createdContainerId)) {
779
+ throw new Error(`Docker returned an invalid id for bind operation ${operationName}.`);
780
+ }
781
+ containerId = createdContainerId;
782
+ const created = await this.inspectFencedBindOperation(
783
+ containerId,
784
+ runtimeOptions,
785
+ runtimeOptions.executionOptions(),
786
+ );
787
+ if (created.running || created.status !== 'created') {
788
+ throw new Error(`Bind-operation container ${operationName} was not created stopped.`);
789
+ }
790
+
791
+ await this.execArgv(
792
+ ['start', '--attach', containerId],
793
+ runtimeOptions.executionOptions(),
794
+ );
795
+ const completed = await this.inspectFencedBindOperation(
796
+ containerId,
797
+ runtimeOptions,
798
+ runtimeOptions.executionOptions(),
799
+ );
800
+ if (completed.running || completed.status !== 'exited' || completed.exitCode !== 0) {
801
+ throw new Error(`Bind-operation container ${operationName} did not exit successfully.`);
802
+ }
803
+ await this.execArgv(['rm', containerId], runtimeOptions.executionOptions());
804
+ return;
805
+ } catch (error) {
806
+ operationError = normalizeError(error);
807
+ }
808
+
809
+ if (!containerId) {
810
+ const rollbackTimeoutMs = runtimeOptions.rollbackTimeoutMs ?? 5_000;
811
+ const rollbackDeadlineAt = Date.now() + rollbackTimeoutMs;
812
+ while (!containerId) {
813
+ const remainingMs = Math.floor(rollbackDeadlineAt - Date.now());
814
+ if (remainingMs <= 0) {
815
+ break;
816
+ }
817
+ try {
818
+ const discoveredId = await this.discoverFencedBindOperationId(
819
+ operationName,
820
+ { timeoutMs: remainingMs },
821
+ );
822
+ if (discoveredId) {
823
+ const inspection = await this.inspectFencedBindOperation(
824
+ discoveredId,
825
+ runtimeOptions,
826
+ { timeoutMs: Math.max(1, rollbackDeadlineAt - Date.now()) },
827
+ );
828
+ containerId = inspection.id;
829
+ break;
830
+ }
831
+ } catch (error) {
832
+ rollbackErrors.push(normalizeError(error));
833
+ break;
834
+ }
835
+ const remainingAfterDiscovery = Math.floor(rollbackDeadlineAt - Date.now());
836
+ if (remainingAfterDiscovery <= 0) {
837
+ break;
838
+ }
839
+ await this.bindOperationDelay(Math.min(100, remainingAfterDiscovery));
840
+ }
841
+ }
842
+ if (containerId) {
843
+ try {
844
+ await this.inspectFencedBindOperation(containerId, runtimeOptions, {
845
+ timeoutMs: runtimeOptions.rollbackTimeoutMs ?? 5_000,
846
+ });
847
+ await this.execArgv(['rm', '-f', containerId], {
848
+ timeoutMs: runtimeOptions.rollbackTimeoutMs ?? 5_000,
849
+ });
850
+ } catch (error) {
851
+ rollbackErrors.push(normalizeError(error));
852
+ }
853
+ }
854
+
855
+ if (rollbackErrors.length > 0) {
856
+ throw new AggregateError(
857
+ [operationError!, ...rollbackErrors],
858
+ `Docker bind operation ${operationName} failed and cleanup could not be confirmed.`,
859
+ );
860
+ }
861
+ throw operationError!;
862
+ }
863
+
271
864
  /**
272
865
  * Check if Docker is installed and available
273
866
  */
274
867
  public async checkDocker(): Promise<boolean> {
275
868
  try {
276
- // Silent: this must not print to stdout, which would corrupt --json output.
277
- const result = await this.smartshell.execSilent('docker --version');
278
- return result.exitCode === 0;
279
- } catch (error) {
869
+ await this.execArgv(['--version'], { timeoutMs: this.queryTimeoutSeconds * 1_000 });
870
+ return true;
871
+ } catch {
280
872
  return false;
281
873
  }
282
874
  }
@@ -285,29 +877,17 @@ export class DockerContainer {
285
877
  * Get container status
286
878
  */
287
879
  public async getStatus(containerName: string): Promise<ContainerStatus> {
288
- try {
289
- // Check if running
290
- const runningResult = await this.smartshell.exec(
291
- `docker ps --format '{{.Names}}' | grep -q "^${containerName}$"`
292
- );
293
-
294
- if (runningResult.exitCode === 0) {
295
- return 'running';
296
- }
297
-
298
- // Check if exists but stopped
299
- const existsResult = await this.smartshell.exec(
300
- `docker ps -a --format '{{.Names}}' | grep -q "^${containerName}$"`
301
- );
302
-
303
- if (existsResult.exitCode === 0) {
304
- return 'stopped';
305
- }
306
-
307
- return 'not_exists';
308
- } catch (error) {
880
+ const ids = await this.listIds([`name=^/${containerName}$`]);
881
+ if (ids.length === 0) {
309
882
  return 'not_exists';
310
883
  }
884
+ const matches = (await this.inspectMany(ids)).filter(
885
+ (containerArg) => containerArg.name === containerName,
886
+ );
887
+ if (matches.length !== 1) {
888
+ throw new Error(`Docker returned ambiguous identity for container ${containerName}.`);
889
+ }
890
+ return matches[0].running ? 'running' : 'stopped';
311
891
  }
312
892
 
313
893
  /**
@@ -315,9 +895,11 @@ export class DockerContainer {
315
895
  */
316
896
  public async start(containerName: string): Promise<boolean> {
317
897
  try {
318
- const result = await this.smartshell.exec(`docker start ${containerName}`);
319
- return result.exitCode === 0;
320
- } catch (error) {
898
+ await this.execArgv(['start', containerName], {
899
+ timeoutMs: this.queryTimeoutSeconds * 1_000,
900
+ });
901
+ return true;
902
+ } catch {
321
903
  return false;
322
904
  }
323
905
  }
@@ -327,9 +909,11 @@ export class DockerContainer {
327
909
  */
328
910
  public async stop(containerName: string): Promise<boolean> {
329
911
  try {
330
- const result = await this.smartshell.exec(`docker stop ${containerName}`);
331
- return result.exitCode === 0;
332
- } catch (error) {
912
+ await this.execArgv(['stop', containerName], {
913
+ timeoutMs: this.queryTimeoutSeconds * 1_000,
914
+ });
915
+ return true;
916
+ } catch {
333
917
  return false;
334
918
  }
335
919
  }
@@ -339,10 +923,11 @@ export class DockerContainer {
339
923
  */
340
924
  public async remove(containerName: string, force: boolean = false): Promise<boolean> {
341
925
  try {
342
- const forceFlag = force ? '-f' : '';
343
- const result = await this.smartshell.exec(`docker rm ${forceFlag} ${containerName}`);
344
- return result.exitCode === 0;
345
- } catch (error) {
926
+ await this.execArgv(['rm', ...(force ? ['-f'] : []), containerName], {
927
+ timeoutMs: this.queryTimeoutSeconds * 1_000,
928
+ });
929
+ return true;
930
+ } catch {
346
931
  return false;
347
932
  }
348
933
  }
@@ -351,10 +936,10 @@ export class DockerContainer {
351
936
  * Run a new container
352
937
  */
353
938
  public async run(options: IDockerRunOptions): Promise<boolean> {
354
- let command = 'docker run -d';
939
+ let command = `${this.bounded(this.queryTimeoutSeconds)} run -d`;
355
940
 
356
941
  // Add name
357
- command += ` --name ${options.name}`;
942
+ command += ` --name ${shellQuote(options.name)}`;
358
943
 
359
944
  // Add ports
360
945
  if (options.ports) {
@@ -393,7 +978,7 @@ export class DockerContainer {
393
978
  }
394
979
 
395
980
  // Add image
396
- command += ` ${options.image}`;
981
+ command += ` ${shellQuote(options.image)}`;
397
982
 
398
983
  // Add command if provided
399
984
  if (options.command) {
@@ -460,11 +1045,109 @@ export class DockerContainer {
460
1045
  }
461
1046
  }
462
1047
 
1048
+ /** Exact persisted quarantine path for an interrupted deletion, if present. */
1049
+ public async getPendingDataDeletionPath(
1050
+ projectPathArg: string,
1051
+ serviceArg: TServiceName,
1052
+ ): Promise<string | undefined> {
1053
+ const intent = await readServiceDataDeletionIntent(projectPathArg, serviceArg);
1054
+ if (!intent) {
1055
+ return undefined;
1056
+ }
1057
+ if (await this.assertRegularDeletionDirectory(intent.quarantinePath)) {
1058
+ return intent.quarantinePath;
1059
+ }
1060
+ if (await this.assertRegularDeletionDirectory(intent.canonicalPath)) {
1061
+ return intent.canonicalPath;
1062
+ }
1063
+ // The intent still needs a locked cleanup pass even when deletion finished
1064
+ // just before the process was interrupted.
1065
+ return intent.quarantinePath;
1066
+ }
1067
+
1068
+ private getDataCleanupOperation(
1069
+ hostPathArg: string,
1070
+ imageArg: string,
1071
+ optionsArg: IRemoveDataDirectoryOptions,
1072
+ ): IFencedBindOperationOptions {
1073
+ const hostPath = plugins.path.resolve(hostPathArg);
1074
+ return {
1075
+ image: imageArg,
1076
+ hostPath,
1077
+ containerPath: '/gitzone-target',
1078
+ entrypoint: '/bin/sh',
1079
+ command: [
1080
+ '-c',
1081
+ 'rm -rf /gitzone-target/..?* /gitzone-target/.[!.]* /gitzone-target/*',
1082
+ ],
1083
+ labels: {
1084
+ 'git.zone.tool': serviceToolLabel,
1085
+ 'git.zone.service': optionsArg.service,
1086
+ 'git.zone.project-path': plugins.path.resolve(optionsArg.projectPath),
1087
+ 'git.zone.data-path': hostPath,
1088
+ 'git.zone.safe-to-prune': 'false',
1089
+ [serviceOperationLabel]: serviceBindOperation,
1090
+ },
1091
+ executionOptions: () => ({ timeoutMs: this.cleanupTimeoutSeconds * 1_000 }),
1092
+ rollbackTimeoutMs: this.queryTimeoutSeconds * 1_000,
1093
+ };
1094
+ }
1095
+
1096
+ private async writeServiceDataDeletionIntent(
1097
+ intentArg: IServiceDataDeletionIntent,
1098
+ ): Promise<void> {
1099
+ const intentPath = getServiceDataDeletionIntentPath(
1100
+ intentArg.projectPath,
1101
+ intentArg.service,
1102
+ );
1103
+ const tempPath = `${intentPath}.tmp-${process.pid}-${plugins.crypto
1104
+ .randomBytes(12)
1105
+ .toString('hex')}`;
1106
+ let handle: Awaited<ReturnType<typeof plugins.fs.open>> | undefined;
1107
+ try {
1108
+ handle = await plugins.fs.open(tempPath, 'wx', 0o600);
1109
+ await handle.writeFile(`${JSON.stringify(intentArg, null, 2)}\n`, 'utf8');
1110
+ await handle.sync();
1111
+ await handle.close();
1112
+ handle = undefined;
1113
+ await plugins.fs.rename(tempPath, intentPath);
1114
+ } catch (error) {
1115
+ await handle?.close().catch(() => {});
1116
+ await plugins.fs.rm(tempPath, { force: true }).catch(() => {});
1117
+ throw new Error(`Could not persist service data deletion intent: ${intentPath}`, {
1118
+ cause: error,
1119
+ });
1120
+ }
1121
+ }
1122
+
1123
+ private async assertRegularDeletionDirectory(pathArg: string): Promise<boolean> {
1124
+ try {
1125
+ const stats = await plugins.fs.lstat(pathArg);
1126
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
1127
+ throw new Error(`Refusing non-directory service data cleanup target: ${pathArg}`);
1128
+ }
1129
+ return true;
1130
+ } catch (error) {
1131
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
1132
+ return false;
1133
+ }
1134
+ throw error;
1135
+ }
1136
+ }
1137
+
1138
+ private async renameDataDirectory(sourcePathArg: string, targetPathArg: string): Promise<void> {
1139
+ await plugins.fs.rename(sourcePathArg, targetPathArg);
1140
+ }
1141
+
1142
+ private async deleteDataDirectory(pathArg: string): Promise<void> {
1143
+ await plugins.fs.rm(pathArg, { recursive: true, force: true });
1144
+ }
1145
+
463
1146
  /**
464
1147
  * Remove a service data directory, escalating to a root one-off container
465
1148
  * when the invoking user cannot delete the contents.
466
1149
  *
467
- * Service containers write as their own uid (mongod as 999, MinIO as root)
1150
+ * Service containers write as their own uid
468
1151
  * with restrictive modes, so a plain recursive delete only removes part of the
469
1152
  * tree. For MongoDB that left a half-deleted, corrupt WiredTiger dataset that
470
1153
  * crash-looped on the next start — so a partial delete is worse than none.
@@ -473,53 +1156,182 @@ export class DockerContainer {
473
1156
  * The one-off container mounts only the target directory, so `rm -rf` inside
474
1157
  * it cannot reach anything else on the host.
475
1158
  */
476
- public async removeDataDirectory(hostPathArg: string, imageArg: string): Promise<void> {
1159
+ public async removeDataDirectory(
1160
+ hostPathArg: string,
1161
+ imageArg: string,
1162
+ optionsArg: IRemoveDataDirectoryOptions,
1163
+ ): Promise<boolean> {
1164
+ return withServiceDataOperationLock(
1165
+ optionsArg.projectPath,
1166
+ optionsArg.service,
1167
+ () => this.removeDataDirectoryLocked(hostPathArg, imageArg, optionsArg),
1168
+ );
1169
+ }
1170
+
1171
+ private async removeDataDirectoryLocked(
1172
+ hostPathArg: string,
1173
+ imageArg: string,
1174
+ optionsArg: IRemoveDataDirectoryOptions,
1175
+ ): Promise<boolean> {
1176
+ await optionsArg.preDeleteCheck?.();
1177
+ const projectPath = plugins.path.resolve(optionsArg.projectPath);
477
1178
  const hostPath = plugins.path.resolve(hostPathArg);
478
- if (!(await plugins.smartfs.directory(hostPath).exists())) {
479
- return;
1179
+ if (hostPath !== getServiceDataDirectory(projectPath, optionsArg.service)) {
1180
+ throw new Error(`Refusing cleanup outside the canonical service data path: ${hostPath}`);
1181
+ }
1182
+ for (const pathComponent of [projectPath, plugins.path.dirname(hostPath)]) {
1183
+ const stats = await plugins.fs.lstat(pathComponent);
1184
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
1185
+ throw new Error(`Refusing symbolic or non-directory cleanup path component: ${pathComponent}`);
1186
+ }
1187
+ }
1188
+
1189
+ let intent = await readServiceDataDeletionIntent(projectPath, optionsArg.service);
1190
+ let canonicalExists = await this.assertRegularDeletionDirectory(hostPath);
1191
+ let quarantinePath: string;
1192
+ if (intent) {
1193
+ quarantinePath = intent.quarantinePath;
1194
+ } else {
1195
+ const token = plugins.crypto.randomBytes(16).toString('hex');
1196
+ quarantinePath = getServiceDataQuarantinePath(hostPath, token);
1197
+ intent = {
1198
+ owner: serviceDataOwner,
1199
+ kind: 'service-data-deletion',
1200
+ schemaVersion: 1,
1201
+ projectPath,
1202
+ service: optionsArg.service,
1203
+ canonicalPath: hostPath,
1204
+ quarantinePath,
1205
+ token,
1206
+ createdAt: new Date().toISOString(),
1207
+ };
1208
+ }
1209
+ let quarantineExists = await this.assertRegularDeletionDirectory(quarantinePath);
1210
+ if (canonicalExists && quarantineExists) {
1211
+ throw new Error(
1212
+ `Refusing ambiguous service data deletion with both canonical and quarantine paths present: ${hostPath}`,
1213
+ );
480
1214
  }
481
1215
 
482
- // Fast path: the current user may already own everything.
1216
+ if (optionsArg.service === 'objectstorage' && (canonicalExists || quarantineExists)) {
1217
+ const marker = await readServiceDataMarker(projectPath);
1218
+ if (
1219
+ !markerProvesServiceDataDirectory(
1220
+ marker,
1221
+ projectPath,
1222
+ optionsArg.service,
1223
+ )
1224
+ ) {
1225
+ throw new Error(`Refusing ObjectStorage cleanup with unproven ownership: ${hostPath}`);
1226
+ }
1227
+ }
1228
+ const targetPaths = [hostPath, quarantinePath];
1229
+ const serviceContainerFilters = [
1230
+ `label=git.zone.project-path=${projectPath}`,
1231
+ `label=git.zone.service=${optionsArg.service}`,
1232
+ ];
1233
+ let serviceContainerIds = await this.listIds(serviceContainerFilters);
1234
+ if (serviceContainerIds.length > 0) {
1235
+ const serviceContainers = await this.inspectMany(serviceContainerIds);
1236
+ const helperTargets = new Set<string>();
1237
+ for (const container of serviceContainers) {
1238
+ const helperTarget = targetPaths.find((targetPathArg) =>
1239
+ isExactStoppedBindHelperForTarget(container, targetPathArg, optionsArg),
1240
+ );
1241
+ if (!helperTarget) {
1242
+ throw new Error(
1243
+ `Refusing service data cleanup while container ${container.name} exists.`,
1244
+ );
1245
+ }
1246
+ helperTargets.add(helperTarget);
1247
+ }
1248
+ for (const helperTarget of helperTargets) {
1249
+ await this.reclaimStaleFencedBindOperation(
1250
+ this.getDataCleanupOperation(helperTarget, imageArg, optionsArg),
1251
+ getFencedBindOperationName(helperTarget),
1252
+ );
1253
+ }
1254
+ serviceContainerIds = await this.listIds(serviceContainerFilters);
1255
+ if (serviceContainerIds.length > 0) {
1256
+ const [container] = await this.inspectMany(serviceContainerIds);
1257
+ throw new Error(
1258
+ `Refusing service data cleanup while container ${container?.name || serviceContainerIds[0]} exists.`,
1259
+ );
1260
+ }
1261
+ }
1262
+ const containerMountSources = await this.listAllMountSources();
1263
+ const mountConflict = containerMountSources.find((sourceArg) =>
1264
+ targetPaths.some((targetPathArg) => helpers.pathsOverlap(targetPathArg, sourceArg)),
1265
+ );
1266
+ if (mountConflict) {
1267
+ throw new Error(
1268
+ `Refusing mounted service data path: ${hostPath} or its quarantine is used by ${mountConflict}`,
1269
+ );
1270
+ }
1271
+
1272
+ if (!canonicalExists && !quarantineExists) {
1273
+ if (await plugins.smartfs.file(getServiceDataDeletionIntentPath(projectPath, optionsArg.service)).exists()) {
1274
+ await plugins.fs.rm(getServiceDataDeletionIntentPath(projectPath, optionsArg.service));
1275
+ return true;
1276
+ }
1277
+ return false;
1278
+ }
1279
+
1280
+ if (!(await plugins.smartfs.file(getServiceDataDeletionIntentPath(projectPath, optionsArg.service)).exists())) {
1281
+ await this.writeServiceDataDeletionIntent(intent);
1282
+ }
1283
+ if (canonicalExists) {
1284
+ try {
1285
+ await this.renameDataDirectory(hostPath, quarantinePath);
1286
+ } catch (error) {
1287
+ throw new Error(
1288
+ `Could not quarantine ${hostPath} before deletion; the persisted intent is safe to retry.`,
1289
+ { cause: error },
1290
+ );
1291
+ }
1292
+ canonicalExists = false;
1293
+ quarantineExists = true;
1294
+ }
1295
+
1296
+ // All destructive work happens under the persisted quarantine name. A
1297
+ // failure can leave bytes to resume, but never a partial canonical dataset.
483
1298
  try {
484
- await plugins.smartfs.directory(hostPath).recursive().delete();
485
- } catch (error) {
486
- // fall through to privileged removal
1299
+ await this.deleteDataDirectory(quarantinePath);
1300
+ } catch {
1301
+ // Fall through to root-helper removal.
487
1302
  }
488
- if (!(await plugins.smartfs.directory(hostPath).exists())) {
489
- return;
1303
+ quarantineExists = await this.assertRegularDeletionDirectory(quarantinePath);
1304
+ if (!quarantineExists) {
1305
+ await plugins.fs.rm(getServiceDataDeletionIntentPath(projectPath, optionsArg.service));
1306
+ return true;
490
1307
  }
491
1308
 
492
1309
  if (!(await this.imageExists(imageArg))) {
493
1310
  throw new Error(
494
- `Cannot remove ${hostPath}: its contents belong to the container user and image ` +
495
- `${imageArg} is not available locally to perform a privileged cleanup. ` +
496
- `Pull ${imageArg} and retry, or remove the directory manually as root.`,
1311
+ `Cannot finish removing quarantined data at ${quarantinePath}: its contents belong to the container user and image ` +
1312
+ `${imageArg} is not available locally to perform a root-helper cleanup. ` +
1313
+ `Pull ${imageArg} and retry; the canonical path remains absent and the deletion intent is preserved.`,
497
1314
  );
498
1315
  }
499
1316
 
500
- // Empty the mounted directory from inside a root container, then remove the
501
- // now-empty directory from the host.
502
- const emptied = await this.runOneOff({
503
- image: imageArg,
504
- shell: 'sh',
505
- command: 'rm -rf /gitzone-target/..?* /gitzone-target/.[!.]* /gitzone-target/*',
506
- volumes: { [hostPath]: '/gitzone-target' },
507
- user: '0',
508
- });
509
- if (!emptied) {
510
- throw new Error(`Privileged cleanup of ${hostPath} failed`);
511
- }
1317
+ await this.runFencedBindOperation(
1318
+ this.getDataCleanupOperation(quarantinePath, imageArg, optionsArg),
1319
+ );
512
1320
 
513
1321
  try {
514
- await plugins.smartfs.directory(hostPath).recursive().delete();
1322
+ await this.deleteDataDirectory(quarantinePath);
515
1323
  } catch (error) {
516
1324
  const errorMessage = error instanceof Error ? error.message : String(error);
517
- throw new Error(`Could not remove ${hostPath} after privileged cleanup: ${errorMessage}`);
1325
+ throw new Error(
1326
+ `Could not remove quarantine ${quarantinePath} after root-helper cleanup: ${errorMessage}`,
1327
+ );
518
1328
  }
519
1329
 
520
- if (await plugins.smartfs.directory(hostPath).exists()) {
521
- throw new Error(`Refusing to report success: ${hostPath} still exists`);
1330
+ if (await plugins.smartfs.directory(quarantinePath).exists()) {
1331
+ throw new Error(`Refusing to report success: quarantine ${quarantinePath} still exists`);
522
1332
  }
1333
+ await plugins.fs.rm(getServiceDataDeletionIntentPath(projectPath, optionsArg.service));
1334
+ return true;
523
1335
  }
524
1336
 
525
1337
  /**
@@ -564,9 +1376,11 @@ export class DockerContainer {
564
1376
  */
565
1377
  public async logs(containerName: string, lines?: number): Promise<string> {
566
1378
  try {
567
- const tailFlag = lines ? `--tail ${lines}` : '';
568
- const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`);
569
- return result.combinedOutput;
1379
+ const result = await this.execArgv(
1380
+ ['logs', ...(lines ? ['--tail', String(lines)] : []), containerName],
1381
+ { timeoutMs: this.queryTimeoutSeconds * 1_000 },
1382
+ );
1383
+ return `${result.stdout}${result.stderr}`;
570
1384
  } catch (error) {
571
1385
  const errorMessage = error instanceof Error ? error.message : String(error);
572
1386
  return `Error getting logs: ${errorMessage}`;
@@ -607,12 +1421,11 @@ export class DockerContainer {
607
1421
  */
608
1422
  public async inspect(containerName: string): Promise<any> {
609
1423
  try {
610
- const result = await this.smartshell.execSilent(`docker inspect ${containerName}`);
611
- if (result.exitCode === 0) {
612
- return JSON.parse(result.stdout);
613
- }
614
- return null;
615
- } catch (error) {
1424
+ const result = await this.execArgv(['inspect', containerName], {
1425
+ timeoutMs: this.queryTimeoutSeconds * 1_000,
1426
+ });
1427
+ return JSON.parse(result.stdout);
1428
+ } catch {
616
1429
  return null;
617
1430
  }
618
1431
  }
@@ -623,10 +1436,8 @@ export class DockerContainer {
623
1436
  public async getPortMappings(containerName: string): Promise<{ [key: string]: string } | null> {
624
1437
  try {
625
1438
  // Use docker inspect without format to get full JSON, then extract PortBindings
626
- const result = await this.smartshell.execSilent(`docker inspect ${containerName}`);
627
-
628
- if (result.exitCode === 0 && result.stdout) {
629
- const inspectData = JSON.parse(result.stdout);
1439
+ const inspectData = await this.inspect(containerName);
1440
+ if (inspectData) {
630
1441
  if (inspectData && inspectData[0] && inspectData[0].HostConfig && inspectData[0].HostConfig.PortBindings) {
631
1442
  const portBindings = inspectData[0].HostConfig.PortBindings;
632
1443
  const mappings: { [key: string]: string } = {};
@@ -658,14 +1469,13 @@ export class DockerContainer {
658
1469
  * mistaking an unreachable daemon for "no containers exist".
659
1470
  */
660
1471
  public async listIds(filterArgs: string[], includeStopped: boolean = true): Promise<string[]> {
661
- const filters = filterArgs.map((filter) => `--filter ${shellQuote(filter)}`).join(' ');
662
- const allFlag = includeStopped ? '-a' : '';
663
- const result = await this.smartshell.execSilent(
664
- `${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
665
- );
666
- if (result.exitCode !== 0) {
667
- throw new Error(result.combinedOutput);
668
- }
1472
+ const argv = ['ps'];
1473
+ if (includeStopped) argv.push('-a');
1474
+ for (const filter of filterArgs) argv.push('--filter', filter);
1475
+ argv.push('--format', '{{.ID}}');
1476
+ const result = await this.execArgv(argv, {
1477
+ timeoutMs: this.queryTimeoutSeconds * 1_000,
1478
+ });
669
1479
  if (!result.stdout.trim()) {
670
1480
  return [];
671
1481
  }
@@ -684,12 +1494,9 @@ export class DockerContainer {
684
1494
  if (idsArg.length === 0) {
685
1495
  return [];
686
1496
  }
687
- const result = await this.smartshell.execSilent(
688
- `${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
689
- );
690
- if (result.exitCode !== 0) {
691
- throw new Error(result.combinedOutput);
692
- }
1497
+ const result = await this.execArgv(['inspect', ...idsArg], {
1498
+ timeoutMs: this.queryTimeoutSeconds * 1_000,
1499
+ });
693
1500
  let parsed: any;
694
1501
  try {
695
1502
  parsed = JSON.parse(result.stdout);
@@ -726,7 +1533,16 @@ export class DockerContainer {
726
1533
  * mounted by anything running is never a deletion candidate.
727
1534
  */
728
1535
  public async listRunningMountSources(): Promise<string[]> {
729
- const ids = await this.listIds([], false);
1536
+ return this.listMountSources(false);
1537
+ }
1538
+
1539
+ /** Resolved host bind paths configured on running and stopped containers. */
1540
+ public async listAllMountSources(): Promise<string[]> {
1541
+ return this.listMountSources(true);
1542
+ }
1543
+
1544
+ private async listMountSources(includeStoppedArg: boolean): Promise<string[]> {
1545
+ const ids = await this.listIds([], includeStoppedArg);
730
1546
  const containers = await this.inspectMany(ids);
731
1547
  const sources = new Set<string>();
732
1548
  for (const container of containers) {