@celilo/cli 0.21.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_CORE_MODULES.md +5 -4
- package/CELILO_SUBSYSTEMS.md +34 -2
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -6
- 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.test.ts +66 -0
- package/src/cli/commands/events.ts +106 -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 +5 -0
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +49 -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/dns-registrations.ts +12 -0
- package/src/services/fleet-checks.test.ts +46 -0
- package/src/services/fleet-checks.ts +63 -6
- 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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* tail recent events
|
|
11
11
|
* list-subscribers persistent subscribers
|
|
12
12
|
* list-pending pending deliveries (subscriber fan-out — NOT questions)
|
|
13
|
+
* list-failed failed/abandoned deliveries + a TRUE total
|
|
13
14
|
* list-unanswered interview questions nobody has answered yet
|
|
14
15
|
* drain process pending deliveries once
|
|
15
16
|
* run long-running dispatcher (foreground; SIGINT to stop)
|
|
@@ -24,7 +25,9 @@
|
|
|
24
25
|
import { spawnSync } from 'node:child_process';
|
|
25
26
|
import {
|
|
26
27
|
BUS_VERSION,
|
|
28
|
+
type FailedBySubscriber,
|
|
27
29
|
defineEvents,
|
|
30
|
+
describeError,
|
|
28
31
|
drainOnce,
|
|
29
32
|
openBus,
|
|
30
33
|
recoverFromCrash,
|
|
@@ -52,7 +55,7 @@ import {
|
|
|
52
55
|
unitInstalledInAnyScope,
|
|
53
56
|
} from '../../services/events-daemon';
|
|
54
57
|
import { getArg, hasFlag } from '../parser';
|
|
55
|
-
import type { CommandResult } from '../types';
|
|
58
|
+
import type { CommandResult, CommandSuccess } from '../types';
|
|
56
59
|
|
|
57
60
|
const NO_SCHEMAS = defineEvents({});
|
|
58
61
|
|
|
@@ -60,10 +63,16 @@ function openCliBus() {
|
|
|
60
63
|
return openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Every `celilo events` JSON verb returns through here, so `rawOutput` is set
|
|
68
|
+
* once: without it the payload goes through the CLI's decorating renderer and
|
|
69
|
+
* reaches stdout prefixed and re-wrapped, and no longer parses.
|
|
70
|
+
*/
|
|
63
71
|
function jsonResult(data: unknown): CommandResult {
|
|
64
72
|
return {
|
|
65
73
|
success: true,
|
|
66
74
|
message: JSON.stringify(data, null, 2),
|
|
75
|
+
rawOutput: true,
|
|
67
76
|
data,
|
|
68
77
|
};
|
|
69
78
|
}
|
|
@@ -120,6 +129,26 @@ export async function handleEventsRunHook(args: string[]): Promise<CommandResult
|
|
|
120
129
|
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
121
130
|
if (!module) return { success: false, error: `Module not found: ${moduleId}` };
|
|
122
131
|
|
|
132
|
+
// Quiescence (openspec/changes/module-pause-lifecycle, task 2.1/2.2). Pausing
|
|
133
|
+
// drops the module's bus subscriptions, so ordinarily nothing reaches here at
|
|
134
|
+
// all; this is the second line, and it is load-bearing rather than belt-and-
|
|
135
|
+
// braces. The subscribers table lives in a DIFFERENT database (events.db) to
|
|
136
|
+
// the module state, so the two can disagree — `events resync-subscriptions`
|
|
137
|
+
// rebuilds subscribers from celilo.db, a restore starts events.db empty, and
|
|
138
|
+
// a hand-written row is always possible. Every one of those paths ends here,
|
|
139
|
+
// where the module's actual state is readable.
|
|
140
|
+
//
|
|
141
|
+
// Success, not failure: the event was delivered correctly and the module is
|
|
142
|
+
// deliberately not listening. Reporting a failure would retry it up to
|
|
143
|
+
// max_attempts and then surface as an alert about the pause the operator
|
|
144
|
+
// themselves took.
|
|
145
|
+
if (module.state === 'PAUSED') {
|
|
146
|
+
return {
|
|
147
|
+
success: true,
|
|
148
|
+
message: `Skipped ${moduleId}.${subName}: module is paused`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
const manifest = module.manifestData as ModuleManifest;
|
|
124
153
|
const sub = (manifest.subscriptions ?? []).find((s) => s.name === subName);
|
|
125
154
|
if (!sub) {
|
|
@@ -246,6 +275,75 @@ export async function handleEventsListPending(
|
|
|
246
275
|
}
|
|
247
276
|
}
|
|
248
277
|
|
|
278
|
+
/** One failed/abandoned delivery, as `events list-failed` reports it. */
|
|
279
|
+
export interface FailedDelivery {
|
|
280
|
+
eventId: number;
|
|
281
|
+
eventType: string | null;
|
|
282
|
+
subscriber: string | null;
|
|
283
|
+
status: string;
|
|
284
|
+
attempts: number;
|
|
285
|
+
finishedAt: number | null;
|
|
286
|
+
error: string | null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** What `events list-failed` returns: the true total, the shape, and a sample. */
|
|
290
|
+
export interface FailedDeliveryReport {
|
|
291
|
+
total: number;
|
|
292
|
+
bySubscriber: FailedBySubscriber[];
|
|
293
|
+
shown: number;
|
|
294
|
+
deliveries: FailedDelivery[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `celilo events list-failed` — what has failed or been abandoned, who it was
|
|
299
|
+
* for, and why.
|
|
300
|
+
*
|
|
301
|
+
* The instrument celilo#623 lacked: `failedDeliveries` had exactly one caller
|
|
302
|
+
* (the doctor) and no operator surface at all, so the only signal was a count
|
|
303
|
+
* that was really a LIMIT. `total` here is a real COUNT — `deliveries` is the
|
|
304
|
+
* sample, capped by `--limit`, and `shown` says so.
|
|
305
|
+
*
|
|
306
|
+
* This is NOT `list-pending`, which reads deliveries still queued.
|
|
307
|
+
*/
|
|
308
|
+
export async function handleEventsListFailed(
|
|
309
|
+
_args: string[],
|
|
310
|
+
flags: Record<string, string | boolean>,
|
|
311
|
+
): Promise<CommandResult> {
|
|
312
|
+
const bus = openCliBus();
|
|
313
|
+
try {
|
|
314
|
+
const subscriber = typeof flags.subscriber === 'string' ? flags.subscriber : undefined;
|
|
315
|
+
const limit = flags.limit ? Number(flags.limit) : 50;
|
|
316
|
+
const { total, bySubscriber } = bus.failedDeliveryTotals();
|
|
317
|
+
// Subscriber/event names live in other tables; the delivery row has ids.
|
|
318
|
+
const named = new Map(
|
|
319
|
+
bus.db
|
|
320
|
+
.query<{ id: number; name: string }, []>('SELECT id, name FROM subscribers')
|
|
321
|
+
.all()
|
|
322
|
+
.map((r) => [r.id, r.name]),
|
|
323
|
+
);
|
|
324
|
+
const deliveries: FailedDelivery[] = bus.failedDeliveries({ limit, subscriber }).map((d) => ({
|
|
325
|
+
eventId: d.eventId,
|
|
326
|
+
eventType: bus.getEvent(d.eventId)?.type ?? null,
|
|
327
|
+
subscriber: named.get(d.subscriberId) ?? null,
|
|
328
|
+
status: d.status,
|
|
329
|
+
attempts: d.attempts,
|
|
330
|
+
finishedAt: d.finishedAt,
|
|
331
|
+
error: describeError(d.lastError),
|
|
332
|
+
}));
|
|
333
|
+
const report: FailedDeliveryReport = {
|
|
334
|
+
total: subscriber
|
|
335
|
+
? (bySubscriber.find((s) => s.subscriber === subscriber)?.count ?? 0)
|
|
336
|
+
: total,
|
|
337
|
+
bySubscriber,
|
|
338
|
+
shown: deliveries.length,
|
|
339
|
+
deliveries,
|
|
340
|
+
};
|
|
341
|
+
return jsonResult(report);
|
|
342
|
+
} finally {
|
|
343
|
+
bus.close();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
249
347
|
/** One unanswered interview question, as `events list-unanswered` reports it. */
|
|
250
348
|
export interface UnansweredInterview {
|
|
251
349
|
eventId: number;
|
|
@@ -810,7 +908,7 @@ async function runProgrammaticResponder(
|
|
|
810
908
|
|
|
811
909
|
let stopping = false;
|
|
812
910
|
let stopReason: 'idle' | 'max-duration' | 'signal' = 'idle';
|
|
813
|
-
const buildSummary = ():
|
|
911
|
+
const buildSummary = (): CommandSuccess => {
|
|
814
912
|
if (!stopping) {
|
|
815
913
|
stopping = true;
|
|
816
914
|
handle.close();
|
|
@@ -821,7 +919,12 @@ async function runProgrammaticResponder(
|
|
|
821
919
|
answered: handle.answered(),
|
|
822
920
|
missed: handle.missed(),
|
|
823
921
|
};
|
|
824
|
-
return {
|
|
922
|
+
return {
|
|
923
|
+
success: true,
|
|
924
|
+
message: JSON.stringify(summary, null, 2),
|
|
925
|
+
rawOutput: true,
|
|
926
|
+
data: summary,
|
|
927
|
+
};
|
|
825
928
|
};
|
|
826
929
|
|
|
827
930
|
process.on('SIGINT', () => {
|
|
@@ -71,8 +71,8 @@ export async function handleModuleDeploy(
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Success message is emitted by deployModule via the active ProgressDisplay,
|
|
74
|
-
// so we return an empty message
|
|
75
|
-
// a duplicate line in a different style.
|
|
74
|
+
// so we return an empty message — index.ts exits without writing anything
|
|
75
|
+
// more, rather than printing a duplicate line in a different style.
|
|
76
76
|
return {
|
|
77
77
|
success: true,
|
|
78
78
|
message: '',
|
|
@@ -143,9 +143,9 @@ export async function handleAspectApprovalAfterImport(args: {
|
|
|
143
143
|
`celilo will run the '${aspect.ansible_role}' Ansible role on every non-api_only system`,
|
|
144
144
|
'in those zones.',
|
|
145
145
|
].join('\n');
|
|
146
|
-
//
|
|
147
|
-
// multi-line scope block is preserved verbatim —
|
|
148
|
-
//
|
|
146
|
+
// Written straight to stderr rather than through the prompt UI so the
|
|
147
|
+
// multi-line scope block is preserved verbatim — a prompt renderer owns
|
|
148
|
+
// its frame and reflows a multi-line message argument.
|
|
149
149
|
process.stderr.write(`\n${scopeMsg}\n\n`);
|
|
150
150
|
|
|
151
151
|
const accepted = await withInterviewSession(() =>
|
|
@@ -3,6 +3,7 @@ import { type DbClient, getDb } from '../../db/client';
|
|
|
3
3
|
import { modules, modules as modulesTable, monitors } from '../../db/schema';
|
|
4
4
|
import { moduleHealthCell } from '../../services/alerting/format';
|
|
5
5
|
import { loadAllLiveAlerts, summariseByModule } from '../../services/alerting/store';
|
|
6
|
+
import { formatPausedDuration } from '../../services/module-pause';
|
|
6
7
|
import { hasFlag } from '../parser';
|
|
7
8
|
import type { CommandResult } from '../types';
|
|
8
9
|
|
|
@@ -50,7 +51,17 @@ export async function handleModuleList(
|
|
|
50
51
|
for (const module of moduleRows) {
|
|
51
52
|
const observed = health.get(module.id);
|
|
52
53
|
const healthNote = observed ? ` [${observed}]` : '';
|
|
53
|
-
|
|
54
|
+
// A pause suppresses the alerting that would otherwise report this module
|
|
55
|
+
// as down, so the state alone is not enough — the AGE is what separates a
|
|
56
|
+
// maintenance window from an outage nobody remembers taking (design D7).
|
|
57
|
+
const stateCell =
|
|
58
|
+
module.state === 'PAUSED'
|
|
59
|
+
? `PAUSED (${formatPausedDuration(module.pausedAt)})`
|
|
60
|
+
: module.state;
|
|
61
|
+
lines.push(`${module.id} (v${module.version}) - ${stateCell}${healthNote}`);
|
|
62
|
+
if (module.state === 'PAUSED' && module.pauseReason) {
|
|
63
|
+
lines.push(` Paused: ${module.pauseReason}`);
|
|
64
|
+
}
|
|
54
65
|
if (module.description) {
|
|
55
66
|
lines.push(` ${module.description}`);
|
|
56
67
|
}
|
|
@@ -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
|
+
}
|