@celilo/cli 0.22.0 → 0.23.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/CELILO_SUBSYSTEMS.md +34 -2
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +4 -5
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/events.ts +34 -3
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +3 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/completion.ts +4 -0
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +45 -20
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/services/alerting/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/alerting/sweep-runner.ts +11 -1
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/fleet-checks.ts +48 -0
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.ts +2 -39
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- package/src/test-utils/setup-test-db.ts +13 -23
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo module pause <id>` / `celilo module unpause <id>`.
|
|
3
|
+
*
|
|
4
|
+
* A thin adapter (Rule 10.5): parse flags, build the plan, confirm, execute,
|
|
5
|
+
* render. All the decisions live in `services/module-pause.ts` (pure planning)
|
|
6
|
+
* and the injected deps below (the side effects).
|
|
7
|
+
*
|
|
8
|
+
* See openspec/changes/module-pause-lifecycle/.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { eq } from 'drizzle-orm';
|
|
13
|
+
import { ProxmoxClient } from '../../api-clients/proxmox';
|
|
14
|
+
import { getModuleStoragePath } from '../../config/paths';
|
|
15
|
+
import { type DbClient, getDb } from '../../db/client';
|
|
16
|
+
import { moduleSystems, modules } from '../../db/schema';
|
|
17
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
18
|
+
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
19
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
20
|
+
import { getServiceCredentials } from '../../services/container-service';
|
|
21
|
+
import { deployModule } from '../../services/module-deploy';
|
|
22
|
+
import { checkInFlight } from '../../services/module-operations';
|
|
23
|
+
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
24
|
+
import {
|
|
25
|
+
type ExecutionReport,
|
|
26
|
+
type InfraStopOutcome,
|
|
27
|
+
type ModuleSnapshot,
|
|
28
|
+
type PauseDeps,
|
|
29
|
+
type PausePlan,
|
|
30
|
+
PauseRefusedError,
|
|
31
|
+
actedOn,
|
|
32
|
+
describeMachineStopInfra,
|
|
33
|
+
executePause,
|
|
34
|
+
executeUnpause,
|
|
35
|
+
planPause,
|
|
36
|
+
planUnpause,
|
|
37
|
+
} from '../../services/module-pause';
|
|
38
|
+
import {
|
|
39
|
+
registerModuleSubscriptions,
|
|
40
|
+
unregisterModuleSubscriptions,
|
|
41
|
+
} from '../../services/module-subscriptions';
|
|
42
|
+
import { getArg, hasFlag } from '../parser';
|
|
43
|
+
import { log } from '../prompts';
|
|
44
|
+
import type { CommandResult } from '../types';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every module celilo knows about, in the shape the planner wants. The planner
|
|
48
|
+
* is pure, so the whole fleet is read once here rather than queried per step.
|
|
49
|
+
*
|
|
50
|
+
* A module whose stored manifest no longer parses is dropped rather than
|
|
51
|
+
* failing the command: it cannot be a graph node, and refusing to pause the
|
|
52
|
+
* fleet because one unrelated manifest went stale would be the wrong trade.
|
|
53
|
+
*/
|
|
54
|
+
function loadFleet(db: DbClient): ModuleSnapshot[] {
|
|
55
|
+
const snapshots: ModuleSnapshot[] = [];
|
|
56
|
+
for (const row of db.select().from(modules).all()) {
|
|
57
|
+
const parsed = ModuleManifestSchema.safeParse(row.manifestData);
|
|
58
|
+
if (!parsed.success) continue;
|
|
59
|
+
snapshots.push({
|
|
60
|
+
id: row.id,
|
|
61
|
+
state: row.state,
|
|
62
|
+
pausedAt: row.pausedAt,
|
|
63
|
+
pauseReason: row.pauseReason,
|
|
64
|
+
manifest: parsed.data,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return snapshots;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** moduleId → description, for the in-flight refusal (task 3.8). */
|
|
71
|
+
function inFlightByModule(): Map<string, string> {
|
|
72
|
+
const map = new Map<string, string>();
|
|
73
|
+
for (const conflict of checkInFlight()) {
|
|
74
|
+
map.set(conflict.operation.moduleId, conflict.describe);
|
|
75
|
+
}
|
|
76
|
+
return map;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `--stop-infra` (design D2, revised). Opt-in, and deliberately NOT what a pause
|
|
81
|
+
* means: pausing `caddy` to swap the *firewall* must not take every website
|
|
82
|
+
* down.
|
|
83
|
+
*
|
|
84
|
+
* It acts ONLY on infrastructure celilo provisioned for this module:
|
|
85
|
+
* - celilo-provisioned LXC/VM -> stopped; celilo created it, so it is celilo's
|
|
86
|
+
* - machine-pool system -> not applicable, by design (see below)
|
|
87
|
+
* - systemless driver -> nothing to stop
|
|
88
|
+
*
|
|
89
|
+
* The flag is a convenience for "I actually want the box off". Pause's real job
|
|
90
|
+
* is control-plane quiescence, and the provider swap this feature exists for
|
|
91
|
+
* never needs the flag at all.
|
|
92
|
+
*/
|
|
93
|
+
async function stopModuleInfrastructure(db: DbClient, moduleId: string): Promise<InfraStopOutcome> {
|
|
94
|
+
const systems = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all();
|
|
95
|
+
|
|
96
|
+
if (systems.length === 0) {
|
|
97
|
+
// A driver module (`greenwave`, `axon`, `namecheap`) declares no
|
|
98
|
+
// `requires.system` — it talks to a device over HTTP. Nothing to stop, and
|
|
99
|
+
// that is a normal outcome to report, not an error (spec scenario
|
|
100
|
+
// "Shutdown on a systemless driver module reports nothing to stop").
|
|
101
|
+
return { stopped: false, detail: 'no infrastructure to stop' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const detail: string[] = [];
|
|
105
|
+
for (const system of systems) {
|
|
106
|
+
if (system.infraType === 'machine') {
|
|
107
|
+
// NOT APPLICABLE, by design — not a missing feature (design D2, revised).
|
|
108
|
+
//
|
|
109
|
+
// `--stop-infra` acts only on infrastructure celilo PROVISIONED for the
|
|
110
|
+
// module. A machine-pool system is operator-pre-provisioned: it may
|
|
111
|
+
// predate celilo, and it may run things celilo has never heard of — not
|
|
112
|
+
// merely other celilo modules, but arbitrary operator work. Powering it
|
|
113
|
+
// off, or stopping services on it, reaches outside what celilo owns.
|
|
114
|
+
//
|
|
115
|
+
// Same principle as the sizing rule: a module must not own a host-level
|
|
116
|
+
// fact, because the host outlives any one module's config. Framing this
|
|
117
|
+
// as "celilo cannot identify the service unit" would be wrong — it
|
|
118
|
+
// implies a capability gap, when the answer is that this is not celilo's
|
|
119
|
+
// to stop.
|
|
120
|
+
detail.push(describeMachineStopInfra(system.hostname, moduleId));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (system.vmid == null || !system.serviceId) {
|
|
125
|
+
detail.push(`${system.hostname}: no container recorded, nothing to stop`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const credentials = await getServiceCredentials(system.serviceId);
|
|
130
|
+
if (!('api_url' in credentials)) {
|
|
131
|
+
detail.push(`${system.hostname}: container service is not Proxmox, nothing to stop`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const client = new ProxmoxClient(credentials);
|
|
136
|
+
const result = await client.setGuestPower(system.vmid, 'lxc', 'shutdown');
|
|
137
|
+
detail.push(
|
|
138
|
+
result.success
|
|
139
|
+
? `stopped ${system.hostname} (vmid ${system.vmid})`
|
|
140
|
+
: `could not stop ${system.hostname} (vmid ${system.vmid}): ${result.message}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { stopped: true, detail: detail.join('; ') };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildDeps(db: DbClient): PauseDeps {
|
|
148
|
+
return {
|
|
149
|
+
db,
|
|
150
|
+
unsubscribe: (moduleId) => {
|
|
151
|
+
// The primary quiescence mechanism: with no subscriber rows the
|
|
152
|
+
// dispatcher has nothing to deliver to. `run-named-hook` guards the
|
|
153
|
+
// paths that do not go through the bus.
|
|
154
|
+
unregisterModuleSubscriptions(moduleId);
|
|
155
|
+
},
|
|
156
|
+
resubscribe: (moduleId) => {
|
|
157
|
+
// A deploy does NOT re-register subscriptions (only import and
|
|
158
|
+
// `module update` do), so unpause has to — otherwise the module comes
|
|
159
|
+
// back deployed but permanently deaf.
|
|
160
|
+
const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
161
|
+
if (!row) return;
|
|
162
|
+
const manifest = row.manifestData as ModuleManifest;
|
|
163
|
+
registerModuleSubscriptions(manifest, join(getModuleStoragePath(), moduleId));
|
|
164
|
+
},
|
|
165
|
+
redeploy: async (moduleId) => {
|
|
166
|
+
try {
|
|
167
|
+
const result = await deployModule(moduleId, db, {});
|
|
168
|
+
return { success: result.success, error: result.error };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
stopInfrastructure: (moduleId) => stopModuleInfrastructure(db, moduleId),
|
|
174
|
+
startOperation,
|
|
175
|
+
completeOperation,
|
|
176
|
+
failOperation,
|
|
177
|
+
now: () => new Date(),
|
|
178
|
+
log: (message) => log.info(message),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The ordered plan, rendered for `--dry-run` and for the confirmation. */
|
|
183
|
+
function renderPlan(plan: PausePlan): string {
|
|
184
|
+
const verb = plan.action === 'pause' ? 'Pause' : 'Unpause';
|
|
185
|
+
const order = plan.action === 'pause' ? 'consumers first' : 'providers first';
|
|
186
|
+
const lines = [
|
|
187
|
+
plan.cascade
|
|
188
|
+
? `${verb} ${plan.requested} and its transitive consumers (${order}):`
|
|
189
|
+
: `${verb} ${plan.requested}:`,
|
|
190
|
+
];
|
|
191
|
+
plan.steps.forEach((step, index) => {
|
|
192
|
+
const suffix = step.disposition === 'act' ? '' : ` — skip (${step.note})`;
|
|
193
|
+
lines.push(` ${index + 1}. ${step.moduleId}${suffix}`);
|
|
194
|
+
});
|
|
195
|
+
if (plan.stopInfra) {
|
|
196
|
+
lines.push('', 'Infrastructure will also be stopped (--stop-infra).');
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderReport(report: ExecutionReport): string {
|
|
202
|
+
const lines = report.outcomes.map((o) => {
|
|
203
|
+
const mark = o.result === 'acted' ? '✓' : o.result === 'skipped' ? '·' : '✗';
|
|
204
|
+
return ` ${mark} ${o.moduleId}${o.detail ? ` — ${o.detail}` : ''}`;
|
|
205
|
+
});
|
|
206
|
+
const acted = report.outcomes.filter((o) => o.result === 'acted').length;
|
|
207
|
+
const skipped = report.outcomes.filter((o) => o.result === 'skipped').length;
|
|
208
|
+
const failed = report.outcomes.filter((o) => o.result === 'failed').length;
|
|
209
|
+
lines.push('', `${acted} changed, ${skipped} already done, ${failed} failed.`);
|
|
210
|
+
return lines.join('\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Confirmation for a cascade. An event-bus interview question, never a stdin
|
|
215
|
+
* prompt (design D6), so the operation is drivable headlessly — by CI, by the
|
|
216
|
+
* MCP, by a remote responder. `--yes` satisfies it without asking.
|
|
217
|
+
*/
|
|
218
|
+
async function confirmCascade(plan: PausePlan, yes: boolean): Promise<boolean> {
|
|
219
|
+
if (!plan.cascade || yes) return true;
|
|
220
|
+
const affected = actedOn(plan);
|
|
221
|
+
if (affected.length === 0) return true;
|
|
222
|
+
|
|
223
|
+
return withInterviewSession(() =>
|
|
224
|
+
askConfirm({
|
|
225
|
+
scope: `module-${plan.action}:${plan.requested}`,
|
|
226
|
+
key: 'cascade',
|
|
227
|
+
message: `${plan.action === 'pause' ? 'Pause' : 'Unpause'} ${affected.length} module(s): ${affected.join(', ')}?`,
|
|
228
|
+
description: renderPlan(plan),
|
|
229
|
+
defaultValue: false,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function handleModulePause(
|
|
235
|
+
args: string[],
|
|
236
|
+
flags: Record<string, string | boolean> = {},
|
|
237
|
+
): Promise<CommandResult> {
|
|
238
|
+
const moduleId = getArg(args, 0);
|
|
239
|
+
if (!moduleId) {
|
|
240
|
+
return {
|
|
241
|
+
success: false,
|
|
242
|
+
error:
|
|
243
|
+
'Module ID is required\n\nUsage: celilo module pause <id> [--cascade] [--stop-infra] [--dry-run] [--yes] [--reason "..."]',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const db = getDb();
|
|
248
|
+
const reason = typeof flags.reason === 'string' ? flags.reason : null;
|
|
249
|
+
|
|
250
|
+
let plan: PausePlan;
|
|
251
|
+
try {
|
|
252
|
+
plan = planPause({
|
|
253
|
+
moduleId,
|
|
254
|
+
fleet: loadFleet(db),
|
|
255
|
+
cascade: hasFlag(flags, 'cascade'),
|
|
256
|
+
stopInfra: hasFlag(flags, 'stop-infra'),
|
|
257
|
+
inFlight: inFlightByModule(),
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err instanceof PauseRefusedError) return { success: false, error: err.message };
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (hasFlag(flags, 'dry-run')) {
|
|
265
|
+
return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) {
|
|
269
|
+
return { success: false, error: 'Cancelled — nothing was paused' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const report = await executePause(plan, buildDeps(db), reason);
|
|
273
|
+
return report.success
|
|
274
|
+
? { success: true, message: renderReport(report) }
|
|
275
|
+
: { success: false, error: renderReport(report) };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function handleModuleUnpause(
|
|
279
|
+
args: string[],
|
|
280
|
+
flags: Record<string, string | boolean> = {},
|
|
281
|
+
): Promise<CommandResult> {
|
|
282
|
+
const moduleId = getArg(args, 0);
|
|
283
|
+
if (!moduleId) {
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
error:
|
|
287
|
+
'Module ID is required\n\nUsage: celilo module unpause <id> [--cascade] [--dry-run] [--yes]',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const db = getDb();
|
|
292
|
+
|
|
293
|
+
let plan: PausePlan;
|
|
294
|
+
try {
|
|
295
|
+
plan = planUnpause({
|
|
296
|
+
moduleId,
|
|
297
|
+
fleet: loadFleet(db),
|
|
298
|
+
cascade: hasFlag(flags, 'cascade'),
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (err instanceof PauseRefusedError) return { success: false, error: err.message };
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (hasFlag(flags, 'dry-run')) {
|
|
306
|
+
return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) {
|
|
310
|
+
return { success: false, error: 'Cancelled — nothing was unpaused' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const report = await executeUnpause(plan, buildDeps(db));
|
|
314
|
+
return report.success
|
|
315
|
+
? { success: true, message: renderReport(report) }
|
|
316
|
+
: { success: false, error: renderReport(report) };
|
|
317
|
+
}
|
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
} from '../../services/celilo-events';
|
|
25
25
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
26
26
|
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
27
|
+
import {
|
|
28
|
+
type DependentCandidate,
|
|
29
|
+
describeRemovalRefusal,
|
|
30
|
+
findRemovalBlockers,
|
|
31
|
+
} from '../../services/remove-guard';
|
|
27
32
|
import { cleanupWebRoutesForModule } from '../../services/web-route-cleanup';
|
|
28
33
|
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
29
34
|
import { log } from '../prompts';
|
|
@@ -81,23 +86,26 @@ export async function handleModuleRemove(
|
|
|
81
86
|
.all();
|
|
82
87
|
|
|
83
88
|
if (providedCapabilities.length > 0) {
|
|
84
|
-
const providedNames =
|
|
89
|
+
const providedNames = providedCapabilities.map((c) => c.capabilityName);
|
|
85
90
|
const otherModules = db.select().from(modules).where(ne(modules.id, moduleId)).all();
|
|
86
91
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
const candidates: DependentCandidate[] = [];
|
|
93
|
+
for (const m of otherModules) {
|
|
94
|
+
const parsed = ModuleManifestSchema.safeParse(m.manifestData);
|
|
95
|
+
if (!parsed.success) continue;
|
|
96
|
+
candidates.push({
|
|
97
|
+
id: m.id,
|
|
98
|
+
manifest: parsed.data,
|
|
99
|
+
paused: m.state === 'PAUSED',
|
|
100
|
+
deployed: !(['IMPORTED', 'VALIDATED', 'CONFIGURED'] as string[]).includes(m.state),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const blockers = findRemovalBlockers(providedNames, candidates);
|
|
105
|
+
if (blockers.length > 0) {
|
|
98
106
|
return {
|
|
99
107
|
success: false,
|
|
100
|
-
error:
|
|
108
|
+
error: describeRemovalRefusal(moduleId, blockers),
|
|
101
109
|
};
|
|
102
110
|
}
|
|
103
111
|
}
|
|
@@ -163,9 +171,9 @@ async function performModuleRemove(
|
|
|
163
171
|
// prompt on hook failure.
|
|
164
172
|
const manifestForHook = module.manifestData as { hooks?: { on_uninstall?: unknown } } | undefined;
|
|
165
173
|
if (manifestForHook?.hooks?.on_uninstall) {
|
|
166
|
-
// Match build-stream's TTY detection: in non-TTY contexts (tests,
|
|
167
|
-
//
|
|
168
|
-
//
|
|
174
|
+
// Match build-stream's TTY detection: in non-TTY contexts (tests, pipes,
|
|
175
|
+
// CI) skipAnimation prevents the gauge's setInterval and raw-stdin
|
|
176
|
+
// handlers from blocking process exit.
|
|
169
177
|
const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
|
|
170
178
|
const gauge = new FuelGauge(`${moduleId}: on_uninstall`, {
|
|
171
179
|
skipAnimation: !isInteractive,
|
|
@@ -173,35 +181,65 @@ async function performModuleRemove(
|
|
|
173
181
|
gauge.start();
|
|
174
182
|
const logger = createGaugeLogger(gauge, moduleId, 'on_uninstall');
|
|
175
183
|
const hookResult = await runNamedHook(moduleId, 'on_uninstall', db, logger, {});
|
|
184
|
+
|
|
176
185
|
if (hookResult.success) {
|
|
177
186
|
gauge.stop(true);
|
|
178
187
|
} else {
|
|
179
188
|
gauge.stop(false);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
189
|
+
const reason = hookResult.error ?? 'unknown error';
|
|
190
|
+
|
|
191
|
+
if (!force) {
|
|
192
|
+
// STOP, and leave the module ERRORED. Do not ask.
|
|
193
|
+
//
|
|
194
|
+
// `on_uninstall` is what withdraws a module's cross-module state —
|
|
195
|
+
// caddy unexposes its ports and clears its Caddyfile, a firewall
|
|
196
|
+
// provider withdraws its forwards. If it failed, that state is still
|
|
197
|
+
// out there and we do not know how much of it the hook managed before
|
|
198
|
+
// dying. Deleting the module now orphans whatever is left, with no
|
|
199
|
+
// record of what to go clean up.
|
|
200
|
+
//
|
|
201
|
+
// This previously asked "continue removing anyway?" in a TTY and
|
|
202
|
+
// silently continued when not one. Both were wrong. The prompt offers
|
|
203
|
+
// the orphaning decision at the least informed possible moment — inside
|
|
204
|
+
// a Y/N, before anyone has looked at the machine — and the non-TTY
|
|
205
|
+
// default made orphaning the norm for every scripted removal.
|
|
206
|
+
//
|
|
207
|
+
// The module is marked ERROR rather than left reading INSTALLED,
|
|
208
|
+
// because the previous behaviour recorded the failure only in a
|
|
209
|
+
// `module_operations` row: `module list` and `system doctor` both
|
|
210
|
+
// showed the module as perfectly healthy while its teardown had failed.
|
|
211
|
+
//
|
|
212
|
+
// The way forward is deliberate: go look, clean up by hand, then
|
|
213
|
+
// re-run with --force, which means "I have remediated; drop the
|
|
214
|
+
// record."
|
|
215
|
+
db.update(modules)
|
|
216
|
+
.set({
|
|
217
|
+
state: 'ERROR',
|
|
218
|
+
errorMessage: `on_uninstall failed: ${reason}`,
|
|
219
|
+
updatedAt: new Date(),
|
|
220
|
+
})
|
|
221
|
+
.where(eq(modules.id, moduleId))
|
|
222
|
+
.run();
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: [
|
|
227
|
+
`Cannot remove '${moduleId}': its on_uninstall hook failed, so cross-module state it registered (port forwards, DNS records, web routes) may still exist.`,
|
|
228
|
+
'',
|
|
229
|
+
` ${reason}`,
|
|
230
|
+
'',
|
|
231
|
+
`'${moduleId}' is now marked ERROR and has NOT been removed. Inspect what the hook left behind, clean it up, then re-run:`,
|
|
232
|
+
` celilo module remove ${moduleId} --force`,
|
|
233
|
+
'',
|
|
234
|
+
'--force means "I have remediated this by hand; delete the record anyway".',
|
|
235
|
+
].join('\n'),
|
|
236
|
+
};
|
|
204
237
|
}
|
|
238
|
+
|
|
239
|
+
log.warn(`on_uninstall failed: ${reason}`);
|
|
240
|
+
log.info(
|
|
241
|
+
'--force set: continuing removal. Any state the hook left behind is yours to clean up.',
|
|
242
|
+
);
|
|
205
243
|
}
|
|
206
244
|
}
|
|
207
245
|
|
|
@@ -62,10 +62,9 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
|
|
|
62
62
|
.where(eq(capabilities.moduleId, moduleId))
|
|
63
63
|
.all();
|
|
64
64
|
|
|
65
|
-
// Build sections as multi-line blocks joined by \n\n.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// per-line extra spacing.
|
|
65
|
+
// Build sections as multi-line blocks joined by \n\n. index.ts writes the
|
|
66
|
+
// whole message to stdout verbatim, so the blank line between sections is
|
|
67
|
+
// exactly what the operator sees.
|
|
69
68
|
const sections: string[] = [];
|
|
70
69
|
|
|
71
70
|
// Section 1: Module metadata
|
|
@@ -143,7 +143,7 @@ description: fixture
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
|
|
146
|
-
// We can't easily intercept
|
|
146
|
+
// We can't easily intercept the log helpers' output without adding test
|
|
147
147
|
// hooks, so instead we exercise that the call simply succeeds and
|
|
148
148
|
// returns the structured outcome — the sweep relies on this to
|
|
149
149
|
// render its own output without duplicates. A non-quiet call
|
|
@@ -60,7 +60,7 @@ function compareDescending(a: ParsedUbuntu, b: ParsedUbuntu): number {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* Build the option list used by the select prompt and tests.
|
|
63
|
-
* Exported so tests can assert on filtering/sorting without
|
|
63
|
+
* Exported so tests can assert on filtering/sorting without driving a prompt.
|
|
64
64
|
*/
|
|
65
65
|
export function buildUbuntuOptions(appliances: ProxmoxAppliance[]): ParsedUbuntu[] {
|
|
66
66
|
return appliances
|
|
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm';
|
|
|
10
10
|
import { getDb } from '../../db/client';
|
|
11
11
|
import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema';
|
|
12
12
|
import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
|
|
13
|
+
import { formatPausedDuration } from '../../services/module-pause';
|
|
13
14
|
import type { CommandResult } from '../types';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -26,10 +27,21 @@ async function determineModuleStatus(
|
|
|
26
27
|
generatedPath: string,
|
|
27
28
|
dbState: string,
|
|
28
29
|
): Promise<{
|
|
29
|
-
status:
|
|
30
|
+
status:
|
|
31
|
+
| 'IMPORTED'
|
|
32
|
+
| 'CONFIGURED'
|
|
33
|
+
| 'GENERATED'
|
|
34
|
+
| 'DEPLOYED'
|
|
35
|
+
| 'VERIFIED'
|
|
36
|
+
| 'NEEDS_UPDATE'
|
|
37
|
+
| 'PAUSED';
|
|
30
38
|
missingCount?: number;
|
|
31
39
|
}> {
|
|
32
|
-
// Deployed states come directly from the DB — don't infer from filesystem
|
|
40
|
+
// Deployed states come directly from the DB — don't infer from filesystem.
|
|
41
|
+
// PAUSED is checked FIRST: a paused module still has a generated/ directory
|
|
42
|
+
// and a full config, so every derivation below it would report it as an
|
|
43
|
+
// ordinary deployed module and the pause would be invisible here.
|
|
44
|
+
if (dbState === 'PAUSED') return { status: 'PAUSED' };
|
|
33
45
|
if (dbState === 'VERIFIED') return { status: 'VERIFIED' };
|
|
34
46
|
if (dbState === 'INSTALLED') return { status: 'DEPLOYED' };
|
|
35
47
|
|
|
@@ -120,6 +132,9 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
120
132
|
);
|
|
121
133
|
|
|
122
134
|
// Status icon
|
|
135
|
+
// A paused module is deliberately out of service, so it must never
|
|
136
|
+
// carry the same ✓ as a healthy one — that is exactly how a pause turns
|
|
137
|
+
// into an outage nobody notices.
|
|
123
138
|
const icon =
|
|
124
139
|
statusInfo.status === 'VERIFIED' ||
|
|
125
140
|
statusInfo.status === 'DEPLOYED' ||
|
|
@@ -128,7 +143,14 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
128
143
|
: '⚠';
|
|
129
144
|
|
|
130
145
|
lines.push(` ${icon} ${module.id} (v${module.version})`);
|
|
131
|
-
|
|
146
|
+
if (statusInfo.status === 'PAUSED') {
|
|
147
|
+
// State plus AGE — a pause with alerting suppressed is only safe if
|
|
148
|
+
// how long it has run is impossible to miss (design D7).
|
|
149
|
+
lines.push(` Status: PAUSED (${formatPausedDuration(module.pausedAt)})`);
|
|
150
|
+
if (module.pauseReason) lines.push(` Paused: ${module.pauseReason}`);
|
|
151
|
+
} else {
|
|
152
|
+
lines.push(` Status: ${statusInfo.status}`);
|
|
153
|
+
}
|
|
132
154
|
|
|
133
155
|
// Type: VPS or Container
|
|
134
156
|
const zone = getSingularSystemSpec(manifest)?.zone;
|
package/src/cli/completion.ts
CHANGED
|
@@ -194,6 +194,8 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
194
194
|
'terraform-unlock',
|
|
195
195
|
'types',
|
|
196
196
|
'validate',
|
|
197
|
+
'pause',
|
|
198
|
+
'unpause',
|
|
197
199
|
];
|
|
198
200
|
return filterSuggestions(subcommands, args[1] || '');
|
|
199
201
|
}
|
|
@@ -437,6 +439,8 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
437
439
|
'terraform-unlock',
|
|
438
440
|
'verify',
|
|
439
441
|
'audit', // deprecation alias for `verify`
|
|
442
|
+
'pause',
|
|
443
|
+
'unpause',
|
|
440
444
|
];
|
|
441
445
|
if (moduleCommands.includes(args[1] || '')) {
|
|
442
446
|
const db = getDb();
|
package/src/cli/fuel-gauge.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { stdout } from 'node:process';
|
|
13
|
-
import
|
|
13
|
+
import { log } from '@celilo/cli-display';
|
|
14
14
|
import { getActiveDisplay } from './prompts';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -173,7 +173,7 @@ export class FuelGauge {
|
|
|
173
173
|
if (!this.running) return;
|
|
174
174
|
|
|
175
175
|
this.cleanup();
|
|
176
|
-
|
|
176
|
+
log.info(`${this.title} (backgrounded)`);
|
|
177
177
|
|
|
178
178
|
if (this.onBackground) {
|
|
179
179
|
this.onBackground();
|
|
@@ -208,9 +208,9 @@ export class FuelGauge {
|
|
|
208
208
|
this.cleanup();
|
|
209
209
|
|
|
210
210
|
if (success) {
|
|
211
|
-
|
|
211
|
+
log.success(this.title);
|
|
212
212
|
} else {
|
|
213
|
-
|
|
213
|
+
log.error(this.title);
|
|
214
214
|
console.log('');
|
|
215
215
|
console.log(colors.dim('Last output:'));
|
|
216
216
|
const errorLines = this.outputLines.slice(-this.errorDisplayLines);
|