@git.zone/cli 3.2.1 → 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.
- 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.js +1 -1
- 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.js +1 -1
- 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 +2 -2
- 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/index.js +1 -1
- 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 +123 -28
- package/readme.plan.md +29 -16
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.smartconfig.ts +63 -10
- package/ts/mod_config/index.ts +1 -1
- package/ts/mod_docker/classes.dockerpruner.ts +6 -0
- 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
|
@@ -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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 (
|
|
479
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
} catch
|
|
486
|
-
//
|
|
1299
|
+
await this.deleteDataDirectory(quarantinePath);
|
|
1300
|
+
} catch {
|
|
1301
|
+
// Fall through to root-helper removal.
|
|
487
1302
|
}
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
495
|
-
`${imageArg} is not available locally to perform a
|
|
496
|
-
`Pull ${imageArg} and retry
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
1322
|
+
await this.deleteDataDirectory(quarantinePath);
|
|
515
1323
|
} catch (error) {
|
|
516
1324
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
517
|
-
throw new Error(
|
|
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(
|
|
521
|
-
throw new Error(`Refusing to report success: ${
|
|
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
|
|
568
|
-
|
|
569
|
-
|
|
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.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
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
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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.
|
|
688
|
-
|
|
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
|
-
|
|
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) {
|