@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as plugins from './mod.plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
getCliConfigValueFromData,
|
|
4
|
+
readSmartconfigFile,
|
|
5
|
+
setCliConfigValueInData,
|
|
6
|
+
writeSmartconfigFile,
|
|
7
|
+
} from '../helpers.smartconfig.js';
|
|
8
|
+
import type { TServiceName } from './classes.servicedatamarker.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Committed, per-service configuration.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately a SIBLING of `@git.zone/cli.services` rather than a richer
|
|
14
|
+
* `services` value. `@git.zone/tsdeploy` derives a workload's
|
|
15
|
+
* `requiredCapabilities` from that array and **throws** on any element that is
|
|
16
|
+
* not a canonical lowercase string, so an array of objects — or a mixed array —
|
|
17
|
+
* would hard-fail real deployments. `services` therefore stays exactly
|
|
18
|
+
* `string[]`, and configuration lives here where TsDeploy never looks.
|
|
19
|
+
*
|
|
20
|
+
* Config path: `@git.zone/cli.serviceOptions`.
|
|
21
|
+
*/
|
|
22
|
+
export const serviceOptionsConfigKey = 'serviceOptions';
|
|
23
|
+
|
|
24
|
+
export interface IMongodbServiceOptions {
|
|
25
|
+
/**
|
|
26
|
+
* When false, mongod runs without authentication and is published on
|
|
27
|
+
* loopback only. Opt-in, for runtimes whose `node:crypto` cannot complete a
|
|
28
|
+
* SCRAM handshake (notably Deno). Absent means enabled.
|
|
29
|
+
*/
|
|
30
|
+
auth?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IServiceOptions {
|
|
34
|
+
mongodb?: IMongodbServiceOptions;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
|
38
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read declared service options from `.smartconfig.json`.
|
|
43
|
+
*
|
|
44
|
+
* Unknown or malformed values are ignored rather than throwing: this file is
|
|
45
|
+
* hand-edited, and a typo must not make every services command unusable. The
|
|
46
|
+
* effect of ignoring is always the safe default (auth enabled).
|
|
47
|
+
*/
|
|
48
|
+
export const readServiceOptions = async (
|
|
49
|
+
cwd: string = process.cwd(),
|
|
50
|
+
): Promise<IServiceOptions> => {
|
|
51
|
+
const smartconfigData = await readSmartconfigFile(cwd);
|
|
52
|
+
const rawOptions = getCliConfigValueFromData(smartconfigData, serviceOptionsConfigKey);
|
|
53
|
+
if (!isPlainObject(rawOptions)) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const options: IServiceOptions = {};
|
|
58
|
+
if (isPlainObject(rawOptions.mongodb) && typeof rawOptions.mongodb.auth === 'boolean') {
|
|
59
|
+
options.mongodb = { auth: rawOptions.mongodb.auth };
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Declare whether MongoDB enforces authentication for this project.
|
|
66
|
+
*
|
|
67
|
+
* Writing `true` removes the declaration instead of recording it: the default
|
|
68
|
+
* is already "enabled", and an absent key keeps committed config free of noise
|
|
69
|
+
* while remaining unambiguous to an older CLI that ignores this key entirely.
|
|
70
|
+
*/
|
|
71
|
+
export const writeMongodbAuthOption = async (
|
|
72
|
+
authEnabledArg: boolean,
|
|
73
|
+
cwd: string = process.cwd(),
|
|
74
|
+
): Promise<void> => {
|
|
75
|
+
const smartconfigData = await readSmartconfigFile(cwd);
|
|
76
|
+
const rawOptions = getCliConfigValueFromData(smartconfigData, serviceOptionsConfigKey);
|
|
77
|
+
const nextOptions: Record<string, any> = isPlainObject(rawOptions) ? { ...rawOptions } : {};
|
|
78
|
+
|
|
79
|
+
if (authEnabledArg) {
|
|
80
|
+
if (isPlainObject(nextOptions.mongodb)) {
|
|
81
|
+
const { auth, ...restOfMongodb } = nextOptions.mongodb;
|
|
82
|
+
if (Object.keys(restOfMongodb).length > 0) {
|
|
83
|
+
nextOptions.mongodb = restOfMongodb;
|
|
84
|
+
} else {
|
|
85
|
+
delete nextOptions.mongodb;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
nextOptions.mongodb = { ...(isPlainObject(nextOptions.mongodb) ? nextOptions.mongodb : {}), auth: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Object.keys(nextOptions).length === 0) {
|
|
93
|
+
// Drop the key entirely rather than leaving an empty object behind.
|
|
94
|
+
const cliConfig = smartconfigData['@git.zone/cli'];
|
|
95
|
+
if (isPlainObject(cliConfig)) {
|
|
96
|
+
delete cliConfig[serviceOptionsConfigKey];
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
setCliConfigValueInData(smartconfigData, serviceOptionsConfigKey, nextOptions);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await writeSmartconfigFile(smartconfigData, cwd);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where an effective setting came from, so the CLI can say so out loud.
|
|
107
|
+
*/
|
|
108
|
+
export type TServiceOptionSource = 'declared' | 'local' | 'default';
|
|
109
|
+
|
|
110
|
+
export interface IResolvedMongoAuth {
|
|
111
|
+
authEnabled: boolean;
|
|
112
|
+
source: TServiceOptionSource;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the effective MongoDB auth mode.
|
|
117
|
+
*
|
|
118
|
+
* A committed declaration wins over `.nogit/env.json`. That ordering is what
|
|
119
|
+
* makes a fresh checkout reproducible: without it, a stale local env.json would
|
|
120
|
+
* silently diverge from what the repository declares. When nothing is declared,
|
|
121
|
+
* an existing local value is preserved so projects configured before
|
|
122
|
+
* `serviceOptions` existed keep working.
|
|
123
|
+
*/
|
|
124
|
+
export const resolveMongoAuth = (
|
|
125
|
+
declaredOptionsArg: IServiceOptions,
|
|
126
|
+
localValueArg: boolean | undefined,
|
|
127
|
+
): IResolvedMongoAuth => {
|
|
128
|
+
if (typeof declaredOptionsArg.mongodb?.auth === 'boolean') {
|
|
129
|
+
return { authEnabled: declaredOptionsArg.mongodb.auth, source: 'declared' };
|
|
130
|
+
}
|
|
131
|
+
if (typeof localValueArg === 'boolean') {
|
|
132
|
+
return { authEnabled: localValueArg, source: 'local' };
|
|
133
|
+
}
|
|
134
|
+
return { authEnabled: true, source: 'default' };
|
|
135
|
+
};
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import * as plugins from './mod.plugins.js';
|
|
2
|
+
import * as helpers from './helpers.js';
|
|
3
|
+
import { DockerContainer, type IContainerInspectInfo } from './classes.dockercontainer.js';
|
|
4
|
+
import { GlobalRegistry, type IRegisteredProject } from './classes.globalregistry.js';
|
|
5
|
+
import {
|
|
6
|
+
getServiceDataDirectory,
|
|
7
|
+
isSafeServiceDataPath,
|
|
8
|
+
isServiceOwnedByLabels,
|
|
9
|
+
readServiceDataMarker,
|
|
10
|
+
serviceImages,
|
|
11
|
+
serviceNames,
|
|
12
|
+
serviceToolLabel,
|
|
13
|
+
type TServiceName,
|
|
14
|
+
} from './classes.servicedatamarker.js';
|
|
15
|
+
import { logger } from '../gitzone.logging.js';
|
|
16
|
+
|
|
17
|
+
/** Default inactivity threshold before a project is even considered stale. */
|
|
18
|
+
export const defaultStaleDays = 30;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How a project was classified.
|
|
22
|
+
*
|
|
23
|
+
* - `live` — recent activity; never a reclamation candidate.
|
|
24
|
+
* - `stale` — project still on disk but inactive past the threshold.
|
|
25
|
+
* - `orphaned` — project directory no longer exists.
|
|
26
|
+
* - `unknown` — insufficient evidence to classify; never reclaimed.
|
|
27
|
+
*/
|
|
28
|
+
export type TServiceProjectState = 'live' | 'stale' | 'orphaned' | 'unknown';
|
|
29
|
+
|
|
30
|
+
/** Why a candidate was left alone. */
|
|
31
|
+
export interface IServiceSkipReason {
|
|
32
|
+
target: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IServiceDataDirReport {
|
|
37
|
+
service: TServiceName;
|
|
38
|
+
path: string;
|
|
39
|
+
exists: boolean;
|
|
40
|
+
sizeBytes: number;
|
|
41
|
+
reclaimable: boolean;
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface IServiceContainerReport {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
state: string;
|
|
49
|
+
running: boolean;
|
|
50
|
+
reclaimable: boolean;
|
|
51
|
+
reason: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface IServiceProjectReport {
|
|
55
|
+
projectPath: string;
|
|
56
|
+
projectName: string;
|
|
57
|
+
state: TServiceProjectState;
|
|
58
|
+
stateReason: string;
|
|
59
|
+
lastActive: number | null;
|
|
60
|
+
inactiveDays: number | null;
|
|
61
|
+
projectDirExists: boolean;
|
|
62
|
+
registered: boolean;
|
|
63
|
+
markerPresent: boolean;
|
|
64
|
+
containers: IServiceContainerReport[];
|
|
65
|
+
dataDirectories: IServiceDataDirReport[];
|
|
66
|
+
totalDataBytes: number;
|
|
67
|
+
reclaimableDataBytes: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IServicePrunePlan {
|
|
71
|
+
staleDays: number;
|
|
72
|
+
dockerAvailable: boolean;
|
|
73
|
+
projects: IServiceProjectReport[];
|
|
74
|
+
registryEntriesToRemove: string[];
|
|
75
|
+
containersToRemove: Array<{ id: string; name: string; projectPath: string }>;
|
|
76
|
+
dataDirsToRemove: Array<{ projectPath: string; service: TServiceName; path: string; sizeBytes: number }>;
|
|
77
|
+
skipped: IServiceSkipReason[];
|
|
78
|
+
totals: {
|
|
79
|
+
projects: number;
|
|
80
|
+
totalDataBytes: number;
|
|
81
|
+
reclaimableDataBytes: number;
|
|
82
|
+
/** Bytes that would be reclaimable if the holding containers were stopped first. */
|
|
83
|
+
blockedByRunningBytes: number;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IServicePrunerOptions {
|
|
88
|
+
staleDays?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface IProjectCandidate {
|
|
92
|
+
projectPath: string;
|
|
93
|
+
projectName: string;
|
|
94
|
+
registryEntry?: IRegisteredProject;
|
|
95
|
+
containers: IContainerInspectInfo[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dayInMs = 24 * 60 * 60 * 1000;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Plans and applies reclamation of resources created by `gitzone services`.
|
|
102
|
+
*
|
|
103
|
+
* Design mirrors `@git.zone/tsdocker`'s `TsDockerPruner`: build an explicit
|
|
104
|
+
* plan, print it, and only mutate on an explicit apply that re-verifies every
|
|
105
|
+
* single item. Nothing is ever matched by image or by bare name pattern, and
|
|
106
|
+
* every ambiguity resolves to "skip".
|
|
107
|
+
*/
|
|
108
|
+
export class ServicePruner {
|
|
109
|
+
private docker: DockerContainer;
|
|
110
|
+
private globalRegistry: GlobalRegistry;
|
|
111
|
+
private staleDays: number;
|
|
112
|
+
|
|
113
|
+
constructor(optionsArg: IServicePrunerOptions = {}) {
|
|
114
|
+
this.docker = new DockerContainer();
|
|
115
|
+
this.globalRegistry = GlobalRegistry.getInstance();
|
|
116
|
+
this.staleDays = optionsArg.staleDays ?? defaultStaleDays;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a reclamation plan. Read-only: touches nothing.
|
|
121
|
+
*/
|
|
122
|
+
public async createPlan(): Promise<IServicePrunePlan> {
|
|
123
|
+
const skipped: IServiceSkipReason[] = [];
|
|
124
|
+
const registryProjects = await this.globalRegistry.getAllProjects();
|
|
125
|
+
|
|
126
|
+
// Collect tool-owned containers. A Docker failure must not be mistaken for
|
|
127
|
+
// "no containers exist", so we record availability and fail closed below.
|
|
128
|
+
let dockerAvailable = true;
|
|
129
|
+
let labeledContainers: IContainerInspectInfo[] = [];
|
|
130
|
+
let runningMountSources: string[] = [];
|
|
131
|
+
try {
|
|
132
|
+
const ids = await this.docker.listIds([`label=git.zone.tool=${serviceToolLabel}`]);
|
|
133
|
+
labeledContainers = await this.docker.inspectMany(ids);
|
|
134
|
+
runningMountSources = await this.docker.listRunningMountSources();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
dockerAvailable = false;
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
+
skipped.push({
|
|
139
|
+
target: 'docker',
|
|
140
|
+
reason: `docker unavailable (${message}); container and data reclamation disabled`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Containers recorded in the registry but not labeled (created by older
|
|
145
|
+
// versions). Their names are only trusted when exactly one registry entry
|
|
146
|
+
// claims them.
|
|
147
|
+
const nameClaims = new Map<string, string[]>();
|
|
148
|
+
for (const [projectPath, project] of Object.entries(registryProjects)) {
|
|
149
|
+
for (const containerName of Object.values(project.containers)) {
|
|
150
|
+
if (!containerName) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const claims = nameClaims.get(containerName) || [];
|
|
154
|
+
claims.push(projectPath);
|
|
155
|
+
nameClaims.set(containerName, claims);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let legacyContainers: IContainerInspectInfo[] = [];
|
|
160
|
+
if (dockerAvailable && nameClaims.size > 0) {
|
|
161
|
+
try {
|
|
162
|
+
// One `docker ps -a` for all claimed names rather than one per name:
|
|
163
|
+
// each shell-out is a spawned child process, and this runs on every
|
|
164
|
+
// prune. Identity is established by the exact-name match below, so the
|
|
165
|
+
// listing only needs to be a superset.
|
|
166
|
+
const labeledIds = new Set(labeledContainers.map((container) => container.id));
|
|
167
|
+
const allIds = await this.docker.listIds([]);
|
|
168
|
+
const candidateIds = allIds.filter((id) => !labeledIds.has(id));
|
|
169
|
+
const inspected = await this.docker.inspectMany(candidateIds);
|
|
170
|
+
legacyContainers = inspected.filter((container) => nameClaims.has(container.name));
|
|
171
|
+
} catch (error) {
|
|
172
|
+
dockerAvailable = false;
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
skipped.push({
|
|
175
|
+
target: 'docker',
|
|
176
|
+
reason: `docker unavailable (${message}); container and data reclamation disabled`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Group every known container and registry entry under a project path.
|
|
182
|
+
const candidates = new Map<string, IProjectCandidate>();
|
|
183
|
+
const ensureCandidate = (projectPathArg: string, projectNameArg: string): IProjectCandidate => {
|
|
184
|
+
const projectPath = plugins.path.resolve(projectPathArg);
|
|
185
|
+
const existing = candidates.get(projectPath);
|
|
186
|
+
if (existing) {
|
|
187
|
+
return existing;
|
|
188
|
+
}
|
|
189
|
+
const candidate: IProjectCandidate = {
|
|
190
|
+
projectPath,
|
|
191
|
+
projectName: projectNameArg,
|
|
192
|
+
containers: [],
|
|
193
|
+
};
|
|
194
|
+
candidates.set(projectPath, candidate);
|
|
195
|
+
return candidate;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
for (const [projectPath, project] of Object.entries(registryProjects)) {
|
|
199
|
+
const candidate = ensureCandidate(projectPath, project.projectName);
|
|
200
|
+
candidate.registryEntry = project;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const container of labeledContainers) {
|
|
204
|
+
const projectPath = container.labels['git.zone.project-path'];
|
|
205
|
+
if (!projectPath) {
|
|
206
|
+
skipped.push({
|
|
207
|
+
target: container.name,
|
|
208
|
+
reason: 'labeled as tool-owned but carries no git.zone.project-path',
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const candidate = ensureCandidate(projectPath, plugins.path.basename(projectPath));
|
|
213
|
+
candidate.containers.push(container);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Projects whose container ownership cannot be resolved. Ambiguity must
|
|
217
|
+
// *tighten* the decision, never loosen it: if we cannot attribute a
|
|
218
|
+
// container, we also cannot claim that a project has no container running,
|
|
219
|
+
// so such projects are forced to `unknown` and nothing is reclaimed.
|
|
220
|
+
const ambiguousProjectPaths = new Set<string>();
|
|
221
|
+
|
|
222
|
+
for (const container of legacyContainers) {
|
|
223
|
+
const claims = nameClaims.get(container.name) || [];
|
|
224
|
+
if (claims.length !== 1) {
|
|
225
|
+
skipped.push({
|
|
226
|
+
target: container.name,
|
|
227
|
+
reason: `unlabeled container claimed by ${claims.length} registered projects; ownership ambiguous`,
|
|
228
|
+
});
|
|
229
|
+
for (const claim of claims) {
|
|
230
|
+
ambiguousProjectPaths.add(plugins.path.resolve(claim));
|
|
231
|
+
ensureCandidate(claim, plugins.path.basename(claim));
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const projectPath = claims[0];
|
|
236
|
+
const candidate = ensureCandidate(projectPath, plugins.path.basename(projectPath));
|
|
237
|
+
candidate.containers.push(container);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Classify and decide.
|
|
241
|
+
const projects: IServiceProjectReport[] = [];
|
|
242
|
+
const registryEntriesToRemove: string[] = [];
|
|
243
|
+
const containersToRemove: Array<{ id: string; name: string; projectPath: string }> = [];
|
|
244
|
+
const dataDirsToRemove: Array<{
|
|
245
|
+
projectPath: string;
|
|
246
|
+
service: TServiceName;
|
|
247
|
+
path: string;
|
|
248
|
+
sizeBytes: number;
|
|
249
|
+
}> = [];
|
|
250
|
+
let blockedByRunningBytes = 0;
|
|
251
|
+
|
|
252
|
+
for (const candidate of [...candidates.values()].sort((first, second) =>
|
|
253
|
+
first.projectPath.localeCompare(second.projectPath),
|
|
254
|
+
)) {
|
|
255
|
+
const projectDirExists = await plugins.smartfs.directory(candidate.projectPath).exists();
|
|
256
|
+
const marker = await readServiceDataMarker(candidate.projectPath);
|
|
257
|
+
const lastActive = candidate.registryEntry?.lastActive ?? null;
|
|
258
|
+
const inactiveDays =
|
|
259
|
+
lastActive === null ? null : Math.floor((Date.now() - lastActive) / dayInMs);
|
|
260
|
+
|
|
261
|
+
let state: TServiceProjectState;
|
|
262
|
+
let stateReason: string;
|
|
263
|
+
if (ambiguousProjectPaths.has(candidate.projectPath)) {
|
|
264
|
+
state = 'unknown';
|
|
265
|
+
stateReason =
|
|
266
|
+
'a container name for this project is claimed by more than one project; ownership ambiguous';
|
|
267
|
+
} else if (!projectDirExists) {
|
|
268
|
+
state = 'orphaned';
|
|
269
|
+
stateReason = 'project directory no longer exists';
|
|
270
|
+
} else if (lastActive === null) {
|
|
271
|
+
state = 'unknown';
|
|
272
|
+
stateReason = 'no lastActive timestamp recorded; cannot prove inactivity';
|
|
273
|
+
} else if ((inactiveDays as number) > this.staleDays) {
|
|
274
|
+
state = 'stale';
|
|
275
|
+
stateReason = `inactive for ${inactiveDays} days (threshold ${this.staleDays})`;
|
|
276
|
+
} else {
|
|
277
|
+
state = 'live';
|
|
278
|
+
stateReason = `active ${inactiveDays} day(s) ago`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const anyContainerRunning = candidate.containers.some((container) => container.running);
|
|
282
|
+
|
|
283
|
+
// Containers
|
|
284
|
+
const containerReports: IServiceContainerReport[] = [];
|
|
285
|
+
for (const container of candidate.containers) {
|
|
286
|
+
let reclaimable = false;
|
|
287
|
+
let reason: string;
|
|
288
|
+
if (!dockerAvailable) {
|
|
289
|
+
reason = 'docker unavailable';
|
|
290
|
+
} else if (state === 'live') {
|
|
291
|
+
reason = 'project is live';
|
|
292
|
+
} else if (state === 'unknown') {
|
|
293
|
+
reason = 'project state unknown';
|
|
294
|
+
} else if (container.running) {
|
|
295
|
+
reason = 'container is running; stop it first';
|
|
296
|
+
} else {
|
|
297
|
+
reclaimable = true;
|
|
298
|
+
reason = `project ${state}, container stopped`;
|
|
299
|
+
}
|
|
300
|
+
containerReports.push({
|
|
301
|
+
id: container.id,
|
|
302
|
+
name: container.name,
|
|
303
|
+
state: container.state,
|
|
304
|
+
running: container.running,
|
|
305
|
+
reclaimable,
|
|
306
|
+
reason,
|
|
307
|
+
});
|
|
308
|
+
if (reclaimable) {
|
|
309
|
+
containersToRemove.push({
|
|
310
|
+
id: container.id,
|
|
311
|
+
name: container.name,
|
|
312
|
+
projectPath: candidate.projectPath,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Data directories
|
|
318
|
+
const ownershipProven = !!marker || !!candidate.registryEntry;
|
|
319
|
+
const dataReports: IServiceDataDirReport[] = [];
|
|
320
|
+
let totalDataBytes = 0;
|
|
321
|
+
let reclaimableDataBytes = 0;
|
|
322
|
+
|
|
323
|
+
for (const service of serviceNames) {
|
|
324
|
+
const dataPath = getServiceDataDirectory(candidate.projectPath, service);
|
|
325
|
+
const exists = await plugins.smartfs.directory(dataPath).exists();
|
|
326
|
+
const sizeBytes = exists ? await helpers.getDirectorySize(dataPath) : 0;
|
|
327
|
+
totalDataBytes += sizeBytes;
|
|
328
|
+
|
|
329
|
+
let reclaimable = false;
|
|
330
|
+
let reason: string;
|
|
331
|
+
const mountConflict = runningMountSources.find((source) =>
|
|
332
|
+
helpers.pathsOverlap(dataPath, source),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
if (!exists) {
|
|
336
|
+
reason = 'no data directory';
|
|
337
|
+
} else if (!dockerAvailable) {
|
|
338
|
+
reason = 'docker unavailable; cannot prove no container uses this data';
|
|
339
|
+
} else if (!isSafeServiceDataPath(candidate.projectPath, dataPath)) {
|
|
340
|
+
reason = 'path outside the allowed service data shape';
|
|
341
|
+
} else if (!ownershipProven) {
|
|
342
|
+
reason = 'no marker and no registry entry; ownership unproven';
|
|
343
|
+
} else if (state === 'live') {
|
|
344
|
+
reason = 'project is live';
|
|
345
|
+
} else if (state === 'unknown') {
|
|
346
|
+
reason = 'project state unknown';
|
|
347
|
+
} else if (state === 'orphaned') {
|
|
348
|
+
// Data lives inside the project directory, which is gone.
|
|
349
|
+
reason = 'project directory absent';
|
|
350
|
+
} else if (mountConflict) {
|
|
351
|
+
reason = `mounted by a running container (${mountConflict})`;
|
|
352
|
+
blockedByRunningBytes += sizeBytes;
|
|
353
|
+
} else if (anyContainerRunning) {
|
|
354
|
+
reason = 'a container for this project is still running';
|
|
355
|
+
blockedByRunningBytes += sizeBytes;
|
|
356
|
+
} else {
|
|
357
|
+
reclaimable = true;
|
|
358
|
+
reason = `project stale, no container running, ownership proven by ${marker ? 'marker' : 'registry'}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
dataReports.push({ service, path: dataPath, exists, sizeBytes, reclaimable, reason });
|
|
362
|
+
if (reclaimable) {
|
|
363
|
+
reclaimableDataBytes += sizeBytes;
|
|
364
|
+
dataDirsToRemove.push({
|
|
365
|
+
projectPath: candidate.projectPath,
|
|
366
|
+
service,
|
|
367
|
+
path: dataPath,
|
|
368
|
+
sizeBytes,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Registry entry: safe to drop only once no container remains. For
|
|
374
|
+
// containers created before labels existed, the registry claim is the only
|
|
375
|
+
// thing that can ever identify them again — dropping it while one still
|
|
376
|
+
// exists would make it invisible to prune, `stop -g` and `cleanup -g`,
|
|
377
|
+
// leaving it running forever under `restart: unless-stopped`.
|
|
378
|
+
if (state === 'orphaned' && candidate.registryEntry) {
|
|
379
|
+
if (candidate.containers.length === 0) {
|
|
380
|
+
registryEntriesToRemove.push(candidate.projectPath);
|
|
381
|
+
} else {
|
|
382
|
+
skipped.push({
|
|
383
|
+
target: candidate.projectPath,
|
|
384
|
+
reason: `project directory is gone but ${candidate.containers.length} container(s) remain; keeping the registry entry so they stay identifiable`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
projects.push({
|
|
390
|
+
projectPath: candidate.projectPath,
|
|
391
|
+
projectName: candidate.registryEntry?.projectName || candidate.projectName,
|
|
392
|
+
state,
|
|
393
|
+
stateReason,
|
|
394
|
+
lastActive,
|
|
395
|
+
inactiveDays,
|
|
396
|
+
projectDirExists,
|
|
397
|
+
registered: !!candidate.registryEntry,
|
|
398
|
+
markerPresent: !!marker,
|
|
399
|
+
containers: containerReports,
|
|
400
|
+
dataDirectories: dataReports,
|
|
401
|
+
totalDataBytes,
|
|
402
|
+
reclaimableDataBytes,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
staleDays: this.staleDays,
|
|
408
|
+
dockerAvailable,
|
|
409
|
+
projects,
|
|
410
|
+
registryEntriesToRemove,
|
|
411
|
+
containersToRemove,
|
|
412
|
+
dataDirsToRemove,
|
|
413
|
+
skipped,
|
|
414
|
+
totals: {
|
|
415
|
+
projects: projects.length,
|
|
416
|
+
totalDataBytes: projects.reduce((sum, project) => sum + project.totalDataBytes, 0),
|
|
417
|
+
reclaimableDataBytes: projects.reduce(
|
|
418
|
+
(sum, project) => sum + project.reclaimableDataBytes,
|
|
419
|
+
0,
|
|
420
|
+
),
|
|
421
|
+
blockedByRunningBytes,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Apply a plan, re-verifying every item immediately before it is removed.
|
|
428
|
+
*
|
|
429
|
+
* Any drift between plan and current reality aborts with an error rather than
|
|
430
|
+
* removing something that may since have become live.
|
|
431
|
+
*/
|
|
432
|
+
public async applyPlan(planArg: IServicePrunePlan): Promise<void> {
|
|
433
|
+
if (!planArg.dockerAvailable && (planArg.containersToRemove.length > 0 || planArg.dataDirsToRemove.length > 0)) {
|
|
434
|
+
throw new Error('Refusing to apply container or data removal without a reachable Docker daemon');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Containers first, so their mounts stop protecting the data we then remove.
|
|
438
|
+
if (planArg.containersToRemove.length > 0) {
|
|
439
|
+
const current = await this.docker.inspectMany(
|
|
440
|
+
planArg.containersToRemove.map((container) => container.id),
|
|
441
|
+
);
|
|
442
|
+
for (const planned of planArg.containersToRemove) {
|
|
443
|
+
const container = current.find((candidate) => candidate.id === planned.id);
|
|
444
|
+
if (!container) {
|
|
445
|
+
throw new Error(`Refusing to remove vanished container: ${planned.name}`);
|
|
446
|
+
}
|
|
447
|
+
if (container.running) {
|
|
448
|
+
throw new Error(`Refusing to remove running container: ${container.name}`);
|
|
449
|
+
}
|
|
450
|
+
const labelOwned = isServiceOwnedByLabels(container.labels);
|
|
451
|
+
const registryOwned = await this.isClaimedByExactlyOneProject(container.name, planned.projectPath);
|
|
452
|
+
if (!labelOwned && !registryOwned) {
|
|
453
|
+
throw new Error(`Refusing to remove container with unproven ownership: ${container.name}`);
|
|
454
|
+
}
|
|
455
|
+
if (!(await this.docker.remove(container.name, false))) {
|
|
456
|
+
throw new Error(`Failed to remove container: ${container.name}`);
|
|
457
|
+
}
|
|
458
|
+
logger.log('ok', `Removed container ${container.name}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Data directories. Cheap, local invariants are re-checked first so an
|
|
463
|
+
// invalid plan is rejected before any Docker call and before anything is
|
|
464
|
+
// deleted; only then is the live mount table consulted.
|
|
465
|
+
if (planArg.dataDirsToRemove.length > 0) {
|
|
466
|
+
const currentProjects = await this.globalRegistry.getAllProjects();
|
|
467
|
+
for (const planned of planArg.dataDirsToRemove) {
|
|
468
|
+
if (!isSafeServiceDataPath(planned.projectPath, planned.path)) {
|
|
469
|
+
throw new Error(`Refusing unsafe service data path: ${planned.path}`);
|
|
470
|
+
}
|
|
471
|
+
const marker = await readServiceDataMarker(planned.projectPath);
|
|
472
|
+
const registered = await this.globalRegistry.isRegistered(planned.projectPath);
|
|
473
|
+
if (!marker && !registered) {
|
|
474
|
+
throw new Error(`Refusing data path with unproven ownership: ${planned.path}`);
|
|
475
|
+
}
|
|
476
|
+
// Re-verify staleness: the project may have been started between
|
|
477
|
+
// planning and applying, which would make this live data.
|
|
478
|
+
const currentEntry = Object.entries(currentProjects).find(
|
|
479
|
+
([projectPath]) => plugins.path.resolve(projectPath) === planned.projectPath,
|
|
480
|
+
)?.[1];
|
|
481
|
+
if (currentEntry) {
|
|
482
|
+
const inactiveDays = Math.floor((Date.now() - currentEntry.lastActive) / dayInMs);
|
|
483
|
+
if (inactiveDays <= this.staleDays) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`Refusing data path for a project that became active: ${planned.path} (${inactiveDays}d)`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const runningMountSources = await this.docker.listRunningMountSources();
|
|
492
|
+
for (const planned of planArg.dataDirsToRemove) {
|
|
493
|
+
const conflict = runningMountSources.find((source) =>
|
|
494
|
+
helpers.pathsOverlap(planned.path, source),
|
|
495
|
+
);
|
|
496
|
+
if (conflict) {
|
|
497
|
+
throw new Error(`Refusing mounted data path: ${planned.path} is used by ${conflict}`);
|
|
498
|
+
}
|
|
499
|
+
if (!(await plugins.smartfs.directory(planned.path).exists())) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
// Escalates to a privileged one-off container when the contents belong to
|
|
503
|
+
// the container user; throws rather than deleting only part of the tree.
|
|
504
|
+
await this.docker.removeDataDirectory(planned.path, serviceImages[planned.service]);
|
|
505
|
+
logger.log('ok', `Removed data directory ${planned.path}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Registry metadata last, so a failure above leaves the claim intact.
|
|
510
|
+
for (const projectPath of planArg.registryEntriesToRemove) {
|
|
511
|
+
if (await plugins.smartfs.directory(projectPath).exists()) {
|
|
512
|
+
throw new Error(`Refusing to unregister an existing project: ${projectPath}`);
|
|
513
|
+
}
|
|
514
|
+
await this.globalRegistry.unregisterProject(projectPath);
|
|
515
|
+
logger.log('ok', `Unregistered ${projectPath}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async isClaimedByExactlyOneProject(
|
|
520
|
+
containerNameArg: string,
|
|
521
|
+
projectPathArg: string,
|
|
522
|
+
): Promise<boolean> {
|
|
523
|
+
const projects = await this.globalRegistry.getAllProjects();
|
|
524
|
+
const claims: string[] = [];
|
|
525
|
+
for (const [projectPath, project] of Object.entries(projects)) {
|
|
526
|
+
if (Object.values(project.containers).includes(containerNameArg)) {
|
|
527
|
+
claims.push(plugins.path.resolve(projectPath));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return claims.length === 1 && claims[0] === plugins.path.resolve(projectPathArg);
|
|
531
|
+
}
|
|
532
|
+
}
|