@git.zone/cli 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/gitzone.cli.js +11 -2
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +81 -0
- package/dist_ts/mod_services/classes.dockercontainer.js +205 -10
- package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -0
- package/dist_ts/mod_services/classes.globalregistry.js +23 -1
- package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +52 -1
- package/dist_ts/mod_services/classes.serviceconfiguration.js +116 -20
- package/dist_ts/mod_services/classes.servicedatamarker.d.ts +93 -0
- package/dist_ts/mod_services/classes.servicedatamarker.js +166 -0
- package/dist_ts/mod_services/classes.servicemanager.d.ts +91 -5
- package/dist_ts/mod_services/classes.servicemanager.js +274 -64
- package/dist_ts/mod_services/classes.serviceoptions.d.ts +58 -0
- package/dist_ts/mod_services/classes.serviceoptions.js +93 -0
- package/dist_ts/mod_services/classes.servicepruner.d.ts +102 -0
- package/dist_ts/mod_services/classes.servicepruner.js +410 -0
- package/dist_ts/mod_services/helpers.d.ts +15 -0
- package/dist_ts/mod_services/helpers.js +57 -1
- package/dist_ts/mod_services/index.js +307 -53
- package/dist_ts/mod_tools/classes.packagemanager.d.ts +13 -0
- package/dist_ts/mod_tools/classes.packagemanager.js +42 -1
- package/dist_ts/mod_tools/index.js +4 -1
- package/package.json +3 -2
- package/readme.hints.md +105 -0
- package/readme.md +123 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/gitzone.cli.ts +10 -0
- package/ts/mod_services/classes.dockercontainer.ts +244 -13
- package/ts/mod_services/classes.globalregistry.ts +27 -0
- package/ts/mod_services/classes.serviceconfiguration.ts +148 -27
- package/ts/mod_services/classes.servicedatamarker.ts +228 -0
- package/ts/mod_services/classes.servicemanager.ts +394 -72
- package/ts/mod_services/classes.serviceoptions.ts +135 -0
- package/ts/mod_services/classes.servicepruner.ts +532 -0
- package/ts/mod_services/helpers.ts +60 -0
- package/ts/mod_services/index.ts +437 -58
- package/ts/mod_tools/classes.packagemanager.ts +54 -0
- package/ts/mod_tools/index.ts +5 -0
package/readme.md
CHANGED
|
@@ -446,20 +446,138 @@ gitzone services set mongodb,minio
|
|
|
446
446
|
# Check status
|
|
447
447
|
gitzone services status
|
|
448
448
|
|
|
449
|
+
# Machine-readable status, including connection strings and data sizes
|
|
450
|
+
gitzone services status --json
|
|
451
|
+
|
|
449
452
|
# Print MongoDB Compass connection string
|
|
450
453
|
gitzone services compass
|
|
451
454
|
|
|
452
455
|
# Show logs
|
|
453
456
|
gitzone services logs mongo 50
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
|
|
460
|
+
|
|
461
|
+
### Consuming a service programmatically
|
|
454
462
|
|
|
455
|
-
|
|
456
|
-
|
|
463
|
+
`gitzone services status --json` emits only JSON on stdout, so a test suite or
|
|
464
|
+
script can read a live connection string without parsing human output:
|
|
457
465
|
|
|
458
|
-
|
|
459
|
-
gitzone services
|
|
466
|
+
```bash
|
|
467
|
+
gitzone services status --json | jq -r '.services.mongodb.connectionString'
|
|
460
468
|
```
|
|
461
469
|
|
|
462
|
-
|
|
470
|
+
### Cleanup levels
|
|
471
|
+
|
|
472
|
+
Cleanup is tiered, from fully resumable to irreversible:
|
|
473
|
+
|
|
474
|
+
| Command | Containers | Data | Notes |
|
|
475
|
+
| --- | --- | --- | --- |
|
|
476
|
+
| `gitzone services stop` | kept (stopped) | kept | fully resumable |
|
|
477
|
+
| `gitzone services remove` | removed | kept | resumable; `--yes` to skip the prompt |
|
|
478
|
+
| `gitzone services clean` | removed | **removed** | irreversible; needs a typed `yes` or `--yes` |
|
|
479
|
+
| `gitzone services prune` | see below | see below | machine-wide; dry run unless `--apply` |
|
|
480
|
+
|
|
481
|
+
`clean` and `prune` remove data written by the container user (mongod runs as
|
|
482
|
+
uid 999, MinIO as root) by escalating to a short-lived privileged container
|
|
483
|
+
scoped to that one directory. They either remove a directory completely or fail
|
|
484
|
+
— a partial delete would leave a corrupt database behind.
|
|
485
|
+
|
|
486
|
+
### Reclaiming space across projects
|
|
487
|
+
|
|
488
|
+
Service data is per project and survives container removal, so it accumulates.
|
|
489
|
+
`gitzone services prune` reports what every registered project holds and what
|
|
490
|
+
can be reclaimed. It is read-only unless `--apply` is passed:
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
# Report only: what exists, what is reclaimable, and why
|
|
494
|
+
gitzone services prune
|
|
495
|
+
|
|
496
|
+
# Change the inactivity threshold (default 30 days)
|
|
497
|
+
gitzone services prune --stale-days 90
|
|
498
|
+
|
|
499
|
+
# Actually reclaim, non-interactively
|
|
500
|
+
gitzone services prune --apply --yes
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
A project is only a candidate when there is positive evidence it is finished
|
|
504
|
+
with: its directory is gone, or it has been inactive past the threshold with no
|
|
505
|
+
container running. Anything ambiguous — an unlabeled container claimed by more
|
|
506
|
+
than one project, an unreachable Docker daemon, a directory still mounted by a
|
|
507
|
+
running container — is reported and skipped rather than reclaimed. Containers
|
|
508
|
+
are identified by the `git.zone.tool=gitzone-services` label or by an
|
|
509
|
+
unambiguous registry claim, never by image or name pattern, so pruning cannot
|
|
510
|
+
touch containers created by anything else.
|
|
511
|
+
|
|
512
|
+
### MongoDB authentication
|
|
513
|
+
|
|
514
|
+
MongoDB runs as a single-node replica set with authentication enabled, so
|
|
515
|
+
multi-document transactions work. Authentication can be disabled per project for
|
|
516
|
+
runtimes whose `node:crypto` cannot complete a SCRAM handshake (notably Deno):
|
|
517
|
+
|
|
518
|
+
```bash
|
|
519
|
+
gitzone services auth mongodb off
|
|
520
|
+
gitzone services start mongo
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
This is opt-in and never implicit. With authentication disabled the database is
|
|
524
|
+
published on `127.0.0.1` only, and the combination of no authentication with a
|
|
525
|
+
non-local `MONGODB_HOST` is refused outright. Transactions continue to work, and
|
|
526
|
+
`gitzone services status` reports the mode. Re-enabling authentication over data
|
|
527
|
+
created without it bootstraps the configured root user through MongoDB's
|
|
528
|
+
localhost exception.
|
|
529
|
+
|
|
530
|
+
The setting is recorded in `.smartconfig.json`, so it is committed and a fresh
|
|
531
|
+
clone or CI run reproduces it without any manual step:
|
|
532
|
+
|
|
533
|
+
```json
|
|
534
|
+
{
|
|
535
|
+
"@git.zone/cli": {
|
|
536
|
+
"services": ["mongodb"],
|
|
537
|
+
"serviceOptions": {
|
|
538
|
+
"mongodb": { "auth": false }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
`serviceOptions` is a **sibling** of `services`, never a richer `services`
|
|
545
|
+
value. `services` must stay a flat array of canonical lowercase strings because
|
|
546
|
+
`@git.zone/tsdeploy` derives a workload's `requiredCapabilities` from it and
|
|
547
|
+
rejects any other shape.
|
|
548
|
+
|
|
549
|
+
A committed declaration takes precedence over `.nogit/env.json`, so a stale
|
|
550
|
+
local file cannot silently diverge from what the repository declares. When
|
|
551
|
+
nothing is declared, an existing local value is preserved. When neither exists,
|
|
552
|
+
authentication is enabled. Because a declaration affects everyone who clones the
|
|
553
|
+
repository, `services status` states where the setting came from:
|
|
554
|
+
|
|
555
|
+
```text
|
|
556
|
+
⚠️ Auth: DISABLED (loopback only), declared in .smartconfig.json (applies to every checkout)
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
An older CLI that predates `serviceOptions` ignores the key and starts MongoDB
|
|
560
|
+
with authentication enabled — it degrades to the secure default, never the
|
|
561
|
+
insecure one.
|
|
562
|
+
|
|
563
|
+
### Checking which version is running
|
|
564
|
+
|
|
565
|
+
`gitzone --version` prints the bare version on the first line, followed by the
|
|
566
|
+
path it resolved from. A stale copy in a legacy pnpm global root can otherwise
|
|
567
|
+
make it look like an older version is installed when it is not:
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
gitzone --version
|
|
571
|
+
# 2.25.0
|
|
572
|
+
# resolved from: /home/you/.local/share/pnpm/store/v11/links/@git.zone/cli/2.25.0/…
|
|
573
|
+
|
|
574
|
+
gitzone --version --json
|
|
575
|
+
# {"version":"2.25.0","resolvedFrom":"…"}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
`gitzone tools update` also removes inert copies of managed packages left behind
|
|
579
|
+
in legacy global roots, provided the active root already supplies them and no
|
|
580
|
+
command shim still points there.
|
|
463
581
|
|
|
464
582
|
## Templates
|
|
465
583
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@git.zone/cli',
|
|
6
|
-
version: '2.
|
|
6
|
+
version: '2.25.0',
|
|
7
7
|
description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
|
|
8
8
|
}
|
package/ts/gitzone.cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getProcessUserArgv,
|
|
5
5
|
getRawCliMode,
|
|
6
6
|
parseCliArgv,
|
|
7
|
+
printJson,
|
|
7
8
|
} from "./helpers.climode.js";
|
|
8
9
|
import { commitinfo } from "./00_commitinfo_data.js";
|
|
9
10
|
|
|
@@ -130,7 +131,16 @@ export let run = async () => {
|
|
|
130
131
|
}
|
|
131
132
|
const argvArg = parseCliArgv(getProcessUserArgv());
|
|
132
133
|
if (argvArg.v || argvArg.version) {
|
|
134
|
+
if (rawCliMode.output === "json") {
|
|
135
|
+
printJson({ version: packageVersion, resolvedFrom: paths.packageDir });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// The bare version stays on its own line so `gitzone --version` remains
|
|
139
|
+
// parseable by scripts. The resolved path is added underneath because a
|
|
140
|
+
// stale copy in a legacy pnpm global root can otherwise make people
|
|
141
|
+
// conclude they are running a version they are not.
|
|
133
142
|
console.log(packageVersion);
|
|
143
|
+
console.log(`resolved from: ${paths.packageDir}`);
|
|
134
144
|
return;
|
|
135
145
|
}
|
|
136
146
|
await runParsedCommand(argvArg);
|
|
@@ -10,25 +10,57 @@ export interface IDockerRunOptions {
|
|
|
10
10
|
ports?: { [key: string]: string };
|
|
11
11
|
volumes?: { [key: string]: string };
|
|
12
12
|
environment?: { [key: string]: string };
|
|
13
|
+
labels?: { [key: string]: string };
|
|
13
14
|
restart?: string;
|
|
14
15
|
command?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** Container facts needed to decide, safely, whether a container may be removed. */
|
|
19
|
+
export interface IContainerInspectInfo {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
state: string;
|
|
23
|
+
running: boolean;
|
|
24
|
+
labels: { [key: string]: string };
|
|
25
|
+
/** Resolved host-side sources of every bind mount. */
|
|
26
|
+
mountSources: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Single-quote a value for safe interpolation into a bash command. */
|
|
30
|
+
export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
|
|
31
|
+
|
|
17
32
|
export class DockerContainer {
|
|
18
33
|
private smartshell: plugins.smartshell.Smartshell;
|
|
19
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Wall-clock bound for docker queries and in-container commands.
|
|
36
|
+
*
|
|
37
|
+
* smartshell only arms a timeout when one is passed, so without this a wedged
|
|
38
|
+
* `docker exec` never settles and retry loops that look bounded ("30
|
|
39
|
+
* attempts") hang forever. Mirrors the `timeout Ns docker …` approach used by
|
|
40
|
+
* @git.zone/tsdocker.
|
|
41
|
+
*/
|
|
42
|
+
private queryTimeoutSeconds = 60;
|
|
43
|
+
/** Longer bound for privileged cleanup, which may delete a large tree. */
|
|
44
|
+
private cleanupTimeoutSeconds = 600;
|
|
45
|
+
|
|
20
46
|
constructor() {
|
|
21
47
|
this.smartshell = new plugins.smartshell.Smartshell({
|
|
22
48
|
executor: 'bash',
|
|
23
49
|
});
|
|
24
50
|
}
|
|
25
|
-
|
|
51
|
+
|
|
52
|
+
/** `docker` prefixed with a hard wall-clock bound. */
|
|
53
|
+
private bounded(seconds: number): string {
|
|
54
|
+
return `timeout ${seconds}s docker`;
|
|
55
|
+
}
|
|
56
|
+
|
|
26
57
|
/**
|
|
27
58
|
* Check if Docker is installed and available
|
|
28
59
|
*/
|
|
29
60
|
public async checkDocker(): Promise<boolean> {
|
|
30
61
|
try {
|
|
31
|
-
|
|
62
|
+
// Silent: this must not print to stdout, which would corrupt --json output.
|
|
63
|
+
const result = await this.smartshell.execSilent('docker --version');
|
|
32
64
|
return result.exitCode === 0;
|
|
33
65
|
} catch (error) {
|
|
34
66
|
return false;
|
|
@@ -120,17 +152,25 @@ export class DockerContainer {
|
|
|
120
152
|
// Add volumes
|
|
121
153
|
if (options.volumes) {
|
|
122
154
|
for (const [hostPath, containerPath] of Object.entries(options.volumes)) {
|
|
123
|
-
command += ` -v
|
|
155
|
+
command += ` -v ${shellQuote(`${hostPath}:${containerPath}`)}`;
|
|
124
156
|
}
|
|
125
157
|
}
|
|
126
|
-
|
|
127
|
-
// Add environment variables
|
|
158
|
+
|
|
159
|
+
// Add environment variables. Values come from .nogit/env.json, which is
|
|
160
|
+
// user-editable, so they must not be interpolated unquoted.
|
|
128
161
|
if (options.environment) {
|
|
129
162
|
for (const [key, value] of Object.entries(options.environment)) {
|
|
130
|
-
command += ` -e ${key}
|
|
163
|
+
command += ` -e ${shellQuote(`${key}=${value}`)}`;
|
|
131
164
|
}
|
|
132
165
|
}
|
|
133
|
-
|
|
166
|
+
|
|
167
|
+
// Add labels (used to identify tool-owned containers during prune)
|
|
168
|
+
if (options.labels) {
|
|
169
|
+
for (const [key, value] of Object.entries(options.labels)) {
|
|
170
|
+
command += ` --label ${shellQuote(`${key}=${value}`)}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
134
174
|
// Add restart policy
|
|
135
175
|
if (options.restart) {
|
|
136
176
|
command += ` --restart ${options.restart}`;
|
|
@@ -162,17 +202,24 @@ export class DockerContainer {
|
|
|
162
202
|
command: string;
|
|
163
203
|
volumes?: { [key: string]: string };
|
|
164
204
|
user?: string;
|
|
205
|
+
/** Shell used inside the container. `sh` is available in every service image. */
|
|
206
|
+
shell?: string;
|
|
165
207
|
}): Promise<boolean> {
|
|
166
|
-
let command =
|
|
208
|
+
let command = `${this.bounded(this.cleanupTimeoutSeconds)} run --rm`;
|
|
167
209
|
if (options.user) {
|
|
168
|
-
command += ` --user ${options.user}`;
|
|
210
|
+
command += ` --user ${shellQuote(options.user)}`;
|
|
169
211
|
}
|
|
170
212
|
if (options.volumes) {
|
|
171
213
|
for (const [hostPath, containerPath] of Object.entries(options.volumes)) {
|
|
172
|
-
|
|
214
|
+
// Quoted: this path reaches a root `rm -rf`, so a project directory
|
|
215
|
+
// containing `$(...)`, a backtick or a quote must not reach the shell
|
|
216
|
+
// unescaped. The path allowlist constrains shape, not metacharacters.
|
|
217
|
+
command += ` -v ${shellQuote(`${hostPath}:${containerPath}`)}`;
|
|
173
218
|
}
|
|
174
219
|
}
|
|
175
|
-
|
|
220
|
+
// The image entrypoint must be bypassed: service images start their daemon.
|
|
221
|
+
command += ` --entrypoint ${shellQuote(options.shell || 'bash')} ${shellQuote(options.image)}`;
|
|
222
|
+
command += ` -c ${shellQuote(options.command)}`;
|
|
176
223
|
try {
|
|
177
224
|
const result = await this.smartshell.exec(command);
|
|
178
225
|
return result.exitCode === 0;
|
|
@@ -183,12 +230,88 @@ export class DockerContainer {
|
|
|
183
230
|
}
|
|
184
231
|
}
|
|
185
232
|
|
|
233
|
+
/**
|
|
234
|
+
* True when an image is already present locally.
|
|
235
|
+
*/
|
|
236
|
+
public async imageExists(imageArg: string): Promise<boolean> {
|
|
237
|
+
try {
|
|
238
|
+
const result = await this.smartshell.execSilent(
|
|
239
|
+
`${this.bounded(this.queryTimeoutSeconds)} image inspect ${shellQuote(imageArg)}`,
|
|
240
|
+
);
|
|
241
|
+
return result.exitCode === 0;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Remove a service data directory, escalating to a root one-off container
|
|
249
|
+
* when the invoking user cannot delete the contents.
|
|
250
|
+
*
|
|
251
|
+
* Service containers write as their own uid (mongod as 999, MinIO as root)
|
|
252
|
+
* with restrictive modes, so a plain recursive delete only removes part of the
|
|
253
|
+
* tree. For MongoDB that left a half-deleted, corrupt WiredTiger dataset that
|
|
254
|
+
* crash-looped on the next start — so a partial delete is worse than none.
|
|
255
|
+
* This either fully removes the directory or throws.
|
|
256
|
+
*
|
|
257
|
+
* The one-off container mounts only the target directory, so `rm -rf` inside
|
|
258
|
+
* it cannot reach anything else on the host.
|
|
259
|
+
*/
|
|
260
|
+
public async removeDataDirectory(hostPathArg: string, imageArg: string): Promise<void> {
|
|
261
|
+
const hostPath = plugins.path.resolve(hostPathArg);
|
|
262
|
+
if (!(await plugins.smartfs.directory(hostPath).exists())) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Fast path: the current user may already own everything.
|
|
267
|
+
try {
|
|
268
|
+
await plugins.smartfs.directory(hostPath).recursive().delete();
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// fall through to privileged removal
|
|
271
|
+
}
|
|
272
|
+
if (!(await plugins.smartfs.directory(hostPath).exists())) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!(await this.imageExists(imageArg))) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Cannot remove ${hostPath}: its contents belong to the container user and image ` +
|
|
279
|
+
`${imageArg} is not available locally to perform a privileged cleanup. ` +
|
|
280
|
+
`Pull ${imageArg} and retry, or remove the directory manually as root.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Empty the mounted directory from inside a root container, then remove the
|
|
285
|
+
// now-empty directory from the host.
|
|
286
|
+
const emptied = await this.runOneOff({
|
|
287
|
+
image: imageArg,
|
|
288
|
+
shell: 'sh',
|
|
289
|
+
command: 'rm -rf /gitzone-target/..?* /gitzone-target/.[!.]* /gitzone-target/*',
|
|
290
|
+
volumes: { [hostPath]: '/gitzone-target' },
|
|
291
|
+
user: '0',
|
|
292
|
+
});
|
|
293
|
+
if (!emptied) {
|
|
294
|
+
throw new Error(`Privileged cleanup of ${hostPath} failed`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
await plugins.smartfs.directory(hostPath).recursive().delete();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
301
|
+
throw new Error(`Could not remove ${hostPath} after privileged cleanup: ${errorMessage}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (await plugins.smartfs.directory(hostPath).exists()) {
|
|
305
|
+
throw new Error(`Refusing to report success: ${hostPath} still exists`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
186
309
|
/**
|
|
187
310
|
* Execute a command in a running container
|
|
188
311
|
*/
|
|
189
312
|
public async exec(containerName: string, command: string): Promise<string> {
|
|
190
313
|
try {
|
|
191
|
-
const result = await this.smartshell.exec(
|
|
314
|
+
const result = await this.smartshell.exec(`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`);
|
|
192
315
|
if (result.exitCode === 0) {
|
|
193
316
|
return result.stdout;
|
|
194
317
|
}
|
|
@@ -198,6 +321,28 @@ export class DockerContainer {
|
|
|
198
321
|
}
|
|
199
322
|
}
|
|
200
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Execute a command in a running container, returning stdout and stderr
|
|
326
|
+
* together along with the exit code.
|
|
327
|
+
*
|
|
328
|
+
* `exec` discards output on a non-zero exit and drops stderr entirely, which
|
|
329
|
+
* hides diagnostics that a caller may need to branch on.
|
|
330
|
+
*/
|
|
331
|
+
public async execCombined(
|
|
332
|
+
containerName: string,
|
|
333
|
+
command: string,
|
|
334
|
+
): Promise<{ exitCode: number; output: string }> {
|
|
335
|
+
try {
|
|
336
|
+
const result = await this.smartshell.execSilent(
|
|
337
|
+
`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command} 2>&1`,
|
|
338
|
+
);
|
|
339
|
+
return { exitCode: result.exitCode, output: result.stdout || '' };
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
342
|
+
return { exitCode: -1, output: errorMessage };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
201
346
|
/**
|
|
202
347
|
* Get container logs
|
|
203
348
|
*/
|
|
@@ -289,4 +434,90 @@ export class DockerContainer {
|
|
|
289
434
|
return null;
|
|
290
435
|
}
|
|
291
436
|
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Run a `docker ps` query and return matching container ids.
|
|
440
|
+
*
|
|
441
|
+
* Throws when Docker itself fails, so callers can fail closed instead of
|
|
442
|
+
* mistaking an unreachable daemon for "no containers exist".
|
|
443
|
+
*/
|
|
444
|
+
public async listIds(filterArgs: string[], includeStopped: boolean = true): Promise<string[]> {
|
|
445
|
+
const filters = filterArgs.map((filter) => `--filter ${shellQuote(filter)}`).join(' ');
|
|
446
|
+
const allFlag = includeStopped ? '-a' : '';
|
|
447
|
+
const result = await this.smartshell.execSilent(
|
|
448
|
+
`${this.bounded(this.queryTimeoutSeconds)} ps ${allFlag} ${filters} --format '{{.ID}}'`,
|
|
449
|
+
);
|
|
450
|
+
if (result.exitCode !== 0) {
|
|
451
|
+
throw new Error(result.stderr || result.stdout || 'docker ps failed');
|
|
452
|
+
}
|
|
453
|
+
if (!result.stdout.trim()) {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
return result.stdout
|
|
457
|
+
.trim()
|
|
458
|
+
.split(/\r?\n/)
|
|
459
|
+
.map((line) => line.trim())
|
|
460
|
+
.filter(Boolean);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Inspect many containers at once and normalise the parts prune relies on.
|
|
465
|
+
* Throws on Docker failure so ambiguity never reads as absence.
|
|
466
|
+
*/
|
|
467
|
+
public async inspectMany(idsArg: string[]): Promise<IContainerInspectInfo[]> {
|
|
468
|
+
if (idsArg.length === 0) {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
const result = await this.smartshell.execSilent(
|
|
472
|
+
`${this.bounded(this.queryTimeoutSeconds)} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
|
|
473
|
+
);
|
|
474
|
+
if (result.exitCode !== 0) {
|
|
475
|
+
throw new Error(result.stderr || result.stdout || 'docker inspect failed');
|
|
476
|
+
}
|
|
477
|
+
let parsed: any;
|
|
478
|
+
try {
|
|
479
|
+
parsed = JSON.parse(result.stdout);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw new Error('docker inspect returned unparseable output');
|
|
482
|
+
}
|
|
483
|
+
if (!Array.isArray(parsed)) {
|
|
484
|
+
throw new Error('docker inspect returned unexpected output');
|
|
485
|
+
}
|
|
486
|
+
return parsed.map((container: any) => {
|
|
487
|
+
const labels = (container?.Config?.Labels || {}) as { [key: string]: string };
|
|
488
|
+
const mountSources: string[] = [];
|
|
489
|
+
for (const mount of container?.Mounts || []) {
|
|
490
|
+
if (mount?.Source) {
|
|
491
|
+
mountSources.push(plugins.path.resolve(mount.Source));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
id: String(container?.Id || ''),
|
|
496
|
+
name: String(container?.Name || '').replace(/^\//, ''),
|
|
497
|
+
state: String(container?.State?.Status || 'unknown'),
|
|
498
|
+
running: container?.State?.Running === true,
|
|
499
|
+
labels,
|
|
500
|
+
mountSources,
|
|
501
|
+
};
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Resolved host paths bind-mounted by every *currently running* container on
|
|
507
|
+
* the daemon, regardless of which tool created it.
|
|
508
|
+
*
|
|
509
|
+
* This is the primitive that makes data reclamation safe: a directory that is
|
|
510
|
+
* mounted by anything running is never a deletion candidate.
|
|
511
|
+
*/
|
|
512
|
+
public async listRunningMountSources(): Promise<string[]> {
|
|
513
|
+
const ids = await this.listIds([], false);
|
|
514
|
+
const containers = await this.inspectMany(ids);
|
|
515
|
+
const sources = new Set<string>();
|
|
516
|
+
for (const container of containers) {
|
|
517
|
+
for (const source of container.mountSources) {
|
|
518
|
+
sources.add(source);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return [...sources];
|
|
522
|
+
}
|
|
292
523
|
}
|
|
@@ -170,6 +170,33 @@ export class GlobalRegistry {
|
|
|
170
170
|
return { stopped, failed };
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Cheap staleness summary for a non-blocking hint after ordinary commands.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately filesystem-only: no Docker calls and no directory sizing, so
|
|
177
|
+
* it stays fast enough to run after every `start` or `status`.
|
|
178
|
+
*/
|
|
179
|
+
public async getStaleSummary(
|
|
180
|
+
staleDaysArg: number,
|
|
181
|
+
): Promise<{ orphaned: number; stale: number }> {
|
|
182
|
+
const projects = await this.getAllProjects();
|
|
183
|
+
const thresholdMs = staleDaysArg * 24 * 60 * 60 * 1000;
|
|
184
|
+
let orphaned = 0;
|
|
185
|
+
let stale = 0;
|
|
186
|
+
|
|
187
|
+
for (const [projectPath, project] of Object.entries(projects)) {
|
|
188
|
+
if (!(await plugins.smartfs.directory(projectPath).exists())) {
|
|
189
|
+
orphaned++;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (Date.now() - project.lastActive > thresholdMs) {
|
|
193
|
+
stale++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { orphaned, stale };
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
/**
|
|
174
201
|
* Remove stale registry entries (projects that no longer exist on disk)
|
|
175
202
|
*/
|