@git.zone/cli 2.22.0 → 2.24.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/mod_services/classes.dockercontainer.d.ts +92 -0
- package/dist_ts/mod_services/classes.dockercontainer.js +225 -6
- 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 +39 -1
- package/dist_ts/mod_services/classes.serviceconfiguration.js +89 -22
- 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 +103 -3
- package/dist_ts/mod_services/classes.servicemanager.js +352 -100
- 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/package.json +4 -3
- package/readme.hints.md +88 -0
- package/readme.md +71 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mod_services/classes.dockercontainer.ts +269 -9
- package/ts/mod_services/classes.globalregistry.ts +27 -0
- package/ts/mod_services/classes.serviceconfiguration.ts +105 -24
- package/ts/mod_services/classes.servicedatamarker.ts +228 -0
- package/ts/mod_services/classes.servicemanager.ts +480 -117
- 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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git.zone/cli",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.24.0",
|
|
5
5
|
"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.",
|
|
6
6
|
"main": "dist_ts/index.js",
|
|
7
7
|
"typings": "dist_ts/index.d.ts",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"homepage": "https://code.foss.global/git.zone/cli#README",
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@git.zone/tsbuild": "^4.4.2",
|
|
43
|
-
"@git.zone/tsrun": "^2.0.
|
|
43
|
+
"@git.zone/tsrun": "^2.0.5",
|
|
44
44
|
"@git.zone/tstest": "^3.6.6",
|
|
45
45
|
"@types/node": "^25.9.3"
|
|
46
46
|
},
|
|
@@ -90,7 +90,8 @@
|
|
|
90
90
|
"last 1 chrome versions"
|
|
91
91
|
],
|
|
92
92
|
"scripts": {
|
|
93
|
-
"test": "(pnpm run clean && pnpm run prepareTest && pnpm run testCli && pnpm run testFormat && pnpm run testCommit && pnpm run testDeprecate && pnpm run testVersion && pnpm run testReadme && pnpm run testUpdate && pnpm run testTemplateNpm && pnpm run testTemplateLit) && rm -rf .nogit/test",
|
|
93
|
+
"test": "(pnpm run testUnit && pnpm run clean && pnpm run prepareTest && pnpm run testCli && pnpm run testFormat && pnpm run testCommit && pnpm run testDeprecate && pnpm run testVersion && pnpm run testReadme && pnpm run testUpdate && pnpm run testTemplateNpm && pnpm run testTemplateLit) && rm -rf .nogit/test",
|
|
94
|
+
"testUnit": "tstest 'test.*.node.ts'",
|
|
94
95
|
"build": "tsbuild tsfolders",
|
|
95
96
|
"clean": "(rm -rf .nogit/test/)",
|
|
96
97
|
"prepareTest": "(mkdir -p .nogit && git clone https://gitlab.com/sandboxzone/sandbox-npmts.git .nogit/test/)",
|
package/readme.hints.md
CHANGED
|
@@ -127,6 +127,94 @@ The commit module now supports `-y/--yes` flag for non-interactive commits:
|
|
|
127
127
|
- Fully backward compatible with interactive mode
|
|
128
128
|
- CI/CD friendly for automated workflows
|
|
129
129
|
|
|
130
|
+
## Services Module (`mod_services`) — Cleanup Design
|
|
131
|
+
|
|
132
|
+
### Why data accumulated
|
|
133
|
+
|
|
134
|
+
`gitzone services clean` never actually worked for MongoDB or MinIO. Service
|
|
135
|
+
containers write as their own uid — mongod as 999 with `drwx------` on
|
|
136
|
+
subdirectories, MinIO as root under `.minio.sys` — so a recursive delete run as
|
|
137
|
+
the invoking user removed only the part of the tree it owned. For MongoDB the
|
|
138
|
+
result was a half-deleted WiredTiger dataset that crash-looped on the next start
|
|
139
|
+
(`WiredTigerHS.wt file is corrupted or missing`), kept alive indefinitely by
|
|
140
|
+
`restart: unless-stopped`. Users hit a failing `clean`, gave up, and the data
|
|
141
|
+
stayed. This was the root cause of multi-GB accumulation, not a missing feature.
|
|
142
|
+
|
|
143
|
+
`DockerContainer.removeDataDirectory` fixes it: try a native delete, and if the
|
|
144
|
+
directory survives, empty it from a short-lived `--user 0` container that mounts
|
|
145
|
+
only that directory. It verifies the directory is gone and throws otherwise — a
|
|
146
|
+
partial delete is worse than no delete.
|
|
147
|
+
|
|
148
|
+
### Identifying tool-owned resources
|
|
149
|
+
|
|
150
|
+
Follows the `git.zone.*` label convention already established by
|
|
151
|
+
`@git.zone/tsdocker`. Containers created by `gitzone services` carry:
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
git.zone.tool=gitzone-services
|
|
155
|
+
git.zone.service=mongodb|minio|elasticsearch
|
|
156
|
+
git.zone.project-path=<abs path>
|
|
157
|
+
git.zone.data-path=<abs path>
|
|
158
|
+
git.zone.safe-to-prune=true
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Data directories are additionally claimed by a marker at
|
|
162
|
+
`<project>/.nogit/.gitzone-services.json`. The marker deliberately lives outside
|
|
163
|
+
the bind-mounted directory so it can never confuse mongod, MinIO or
|
|
164
|
+
Elasticsearch at runtime.
|
|
165
|
+
|
|
166
|
+
Nothing is ever matched by image or by bare name pattern. Containers predating
|
|
167
|
+
labels are only identified when exactly one registry entry claims their name.
|
|
168
|
+
|
|
169
|
+
### Prune safety model (`classes.servicepruner.ts`)
|
|
170
|
+
|
|
171
|
+
Mirrors `TsDockerPruner`: build a plan, print it, and mutate only on `--apply`
|
|
172
|
+
with every item re-verified immediately before removal.
|
|
173
|
+
|
|
174
|
+
Projects classify as `live`, `stale`, `orphaned`, or `unknown`. Only `stale` and
|
|
175
|
+
`orphaned` are candidates; `unknown` never is. Ambiguity must *tighten* the
|
|
176
|
+
decision — a container name claimed by two projects forces both to `unknown`,
|
|
177
|
+
because an unattributable container also means "no container is running" cannot
|
|
178
|
+
be proven. Getting this backwards initially made 1.18 GB look reclaimable when
|
|
179
|
+
it was not.
|
|
180
|
+
|
|
181
|
+
Invariants, enforced at plan time and again at apply time:
|
|
182
|
+
|
|
183
|
+
- A data directory is never removed while any running container bind-mounts it
|
|
184
|
+
or an overlapping path (`helpers.pathsOverlap`).
|
|
185
|
+
- A path must match `<project>/.nogit/{mongodata,miniodata,esdata}` exactly
|
|
186
|
+
(`isSafeServiceDataPath`); this is an allowlist, checked after resolution.
|
|
187
|
+
- Ownership must be proven by marker or registry entry.
|
|
188
|
+
- Staleness is re-checked at apply time, so a project started between plan and
|
|
189
|
+
apply aborts the removal.
|
|
190
|
+
- An unreachable Docker daemon disables all container and data reclamation, so
|
|
191
|
+
ambiguity never reads as absence.
|
|
192
|
+
|
|
193
|
+
### MongoDB auth modes
|
|
194
|
+
|
|
195
|
+
Default is authenticated, single-node replica set with a keyfile.
|
|
196
|
+
`gitzone services auth mongodb off` opts into a no-auth instance for runtimes
|
|
197
|
+
that cannot complete a SCRAM handshake over `node:crypto` (Deno). In that mode
|
|
198
|
+
the port is published on `127.0.0.1` only — publishing scope is the real
|
|
199
|
+
exposure control, since `--bind_ip_all` binds inside the container netns — and a
|
|
200
|
+
non-local `MONGODB_HOST` is refused. `MONGO_INITDB_ROOT_*` must be omitted in
|
|
201
|
+
no-auth mode because the official entrypoint turns them into `--auth`.
|
|
202
|
+
|
|
203
|
+
Re-enabling auth over data created without it finds no root user, because
|
|
204
|
+
`MONGO_INITDB_ROOT_*` only applies to an empty dbpath. `ensureMongoRootUser`
|
|
205
|
+
bootstraps it through MongoDB's localhost exception, which by design only works
|
|
206
|
+
while zero users exist and therefore cannot escalate anything.
|
|
207
|
+
|
|
208
|
+
### Coupling: services config is a deployment input
|
|
209
|
+
|
|
210
|
+
`@git.zone/cli.services` is read by `@git.zone/tsdeploy`
|
|
211
|
+
(`deriveRequiredCapabilities` in `classes.cloudlydeployment.ts`) to derive a
|
|
212
|
+
workload's `requiredCapabilities`. It **throws** unless the value is a flat array
|
|
213
|
+
of unique, non-empty, lowercase canonical strings. Never add sub-keys under it
|
|
214
|
+
and never change the persisted names — turning it into an object would fail real
|
|
215
|
+
deployments. Any new services configuration belongs in `.nogit/env.json` or a
|
|
216
|
+
sibling key.
|
|
217
|
+
|
|
130
218
|
## Development Tips
|
|
131
219
|
|
|
132
220
|
- Always check readme.plan.md for ongoing improvement plans
|
package/readme.md
CHANGED
|
@@ -446,20 +446,86 @@ 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.
|
|
463
529
|
|
|
464
530
|
## Templates
|
|
465
531
|
|
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.24.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
|
}
|
|
@@ -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}`;
|
|
@@ -154,12 +194,124 @@ export class DockerContainer {
|
|
|
154
194
|
}
|
|
155
195
|
}
|
|
156
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Run a one-off command in a temporary container (docker run --rm)
|
|
199
|
+
*/
|
|
200
|
+
public async runOneOff(options: {
|
|
201
|
+
image: string;
|
|
202
|
+
command: string;
|
|
203
|
+
volumes?: { [key: string]: string };
|
|
204
|
+
user?: string;
|
|
205
|
+
/** Shell used inside the container. `sh` is available in every service image. */
|
|
206
|
+
shell?: string;
|
|
207
|
+
}): Promise<boolean> {
|
|
208
|
+
let command = `${this.bounded(this.cleanupTimeoutSeconds)} run --rm`;
|
|
209
|
+
if (options.user) {
|
|
210
|
+
command += ` --user ${shellQuote(options.user)}`;
|
|
211
|
+
}
|
|
212
|
+
if (options.volumes) {
|
|
213
|
+
for (const [hostPath, containerPath] of Object.entries(options.volumes)) {
|
|
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}`)}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
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)}`;
|
|
223
|
+
try {
|
|
224
|
+
const result = await this.smartshell.exec(command);
|
|
225
|
+
return result.exitCode === 0;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
228
|
+
logger.log('error', `Failed to run one-off container: ${errorMessage}`);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
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
|
+
|
|
157
309
|
/**
|
|
158
310
|
* Execute a command in a running container
|
|
159
311
|
*/
|
|
160
312
|
public async exec(containerName: string, command: string): Promise<string> {
|
|
161
313
|
try {
|
|
162
|
-
const result = await this.smartshell.exec(
|
|
314
|
+
const result = await this.smartshell.exec(`${this.bounded(this.queryTimeoutSeconds)} exec ${containerName} ${command}`);
|
|
163
315
|
if (result.exitCode === 0) {
|
|
164
316
|
return result.stdout;
|
|
165
317
|
}
|
|
@@ -169,6 +321,28 @@ export class DockerContainer {
|
|
|
169
321
|
}
|
|
170
322
|
}
|
|
171
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
|
+
|
|
172
346
|
/**
|
|
173
347
|
* Get container logs
|
|
174
348
|
*/
|
|
@@ -260,4 +434,90 @@ export class DockerContainer {
|
|
|
260
434
|
return null;
|
|
261
435
|
}
|
|
262
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
|
+
}
|
|
263
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
|
*/
|