@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,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module pause / unpause — taking a module out of celilo's control plane
|
|
3
|
+
* without uninstalling it, so a capability provider its consumers depend on can
|
|
4
|
+
* be removed and replaced.
|
|
5
|
+
*
|
|
6
|
+
* See openspec/changes/module-pause-lifecycle/. The shape of this file follows
|
|
7
|
+
* Rule 10.4: `planPause`/`planUnpause` are pure and produce an explicit ordered
|
|
8
|
+
* plan; `executePause`/`executeUnpause` perform the side effects. `--dry-run`
|
|
9
|
+
* renders the plan, which for a fleet-wide operation is the most valuable part
|
|
10
|
+
* of the feature.
|
|
11
|
+
*
|
|
12
|
+
* What makes pause cheap is that capability consumption is DEPLOY-time: every
|
|
13
|
+
* consumer of `firewall` / `dhcp_server` calls it from `on_install`, and
|
|
14
|
+
* nothing calls it while merely serving traffic. So a consumer only has to stop
|
|
15
|
+
* participating in the control plane — not stop running — for its provider to
|
|
16
|
+
* be replaced underneath it. That is why the data plane is left alone by
|
|
17
|
+
* default (design D2).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
21
|
+
import type { DbClient } from '../db/client';
|
|
22
|
+
import { IN_FLIGHT_STATES, type ModuleState, PAUSABLE_STATES, modules } from '../db/schema';
|
|
23
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
24
|
+
import {
|
|
25
|
+
DependencyCycleError,
|
|
26
|
+
type ModuleGraph,
|
|
27
|
+
buildModuleGraph,
|
|
28
|
+
topologicalOrder,
|
|
29
|
+
transitiveConsumers,
|
|
30
|
+
} from './update/dep-graph';
|
|
31
|
+
|
|
32
|
+
export type PauseAction = 'pause' | 'unpause';
|
|
33
|
+
|
|
34
|
+
/** The read-only view of a module the planner needs. No DB handle, no I/O. */
|
|
35
|
+
export interface ModuleSnapshot {
|
|
36
|
+
id: string;
|
|
37
|
+
state: ModuleState;
|
|
38
|
+
pausedAt: Date | null;
|
|
39
|
+
pauseReason: string | null;
|
|
40
|
+
manifest: ModuleManifest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `act` — the module is not yet in the target condition and will be changed.
|
|
45
|
+
*
|
|
46
|
+
* `skip_already` — it is already there. Crucially NOT an error and NOT a reason
|
|
47
|
+
* to halt: a cascade must walk THROUGH members already in the target condition
|
|
48
|
+
* to reach the ones beyond them, which is what makes a half-finished cascade
|
|
49
|
+
* resumable (design D5, task 4.7).
|
|
50
|
+
*
|
|
51
|
+
* `skip_undeployed` — swept in by `--cascade` but never deployed, so there is
|
|
52
|
+
* nothing to quiesce and nothing bound to the provider. Only ever applies to a
|
|
53
|
+
* module the operator did NOT name; naming an undeployed module directly is
|
|
54
|
+
* still refused, per the spec scenario "An undeployed module cannot be paused".
|
|
55
|
+
*/
|
|
56
|
+
export type StepDisposition = 'act' | 'skip_already' | 'skip_undeployed';
|
|
57
|
+
|
|
58
|
+
export interface PlanStep {
|
|
59
|
+
moduleId: string;
|
|
60
|
+
disposition: StepDisposition;
|
|
61
|
+
/** Present on `skip_already`, so the report explains itself. */
|
|
62
|
+
note?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PausePlan {
|
|
66
|
+
action: PauseAction;
|
|
67
|
+
/** The module the operator named. */
|
|
68
|
+
requested: string;
|
|
69
|
+
cascade: boolean;
|
|
70
|
+
/** Whether the module's infrastructure should also be stopped (pause only). */
|
|
71
|
+
stopInfra: boolean;
|
|
72
|
+
/** Ordered: cascade pause is consumers-first, cascade unpause providers-first. */
|
|
73
|
+
steps: PlanStep[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Modules a plan would actually change — what confirmation must name. */
|
|
77
|
+
export function actedOn(plan: PausePlan): string[] {
|
|
78
|
+
return plan.steps.filter((s) => s.disposition === 'act').map((s) => s.moduleId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class PauseRefusedError extends Error {
|
|
82
|
+
constructor(message: string) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'PauseRefusedError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PlanRequest {
|
|
89
|
+
moduleId: string;
|
|
90
|
+
/** Every module celilo knows about — the planner filters, the caller does not. */
|
|
91
|
+
fleet: ModuleSnapshot[];
|
|
92
|
+
cascade: boolean;
|
|
93
|
+
stopInfra?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* moduleId → operator-readable description of an operation currently in
|
|
96
|
+
* flight for it. Pause is refused for those (design, closed question 3).
|
|
97
|
+
*/
|
|
98
|
+
inFlight?: ReadonlyMap<string, string>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Has this module ever reached a deployed state? Anything settled-or-in-flight
|
|
103
|
+
* past CONFIGURED has something on a machine; the earlier states do not.
|
|
104
|
+
*/
|
|
105
|
+
function isDeployed(state: ModuleState): boolean {
|
|
106
|
+
return !(['IMPORTED', 'VALIDATED', 'CONFIGURED'] as readonly ModuleState[]).includes(state);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function snapshotById(fleet: ModuleSnapshot[]): Map<string, ModuleSnapshot> {
|
|
110
|
+
return new Map(fleet.map((m) => [m.id, m]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The ordered set a cascade covers: the named module plus every transitive
|
|
115
|
+
* consumer of it.
|
|
116
|
+
*
|
|
117
|
+
* Computed from the dependency GRAPH, never from which modules happen to be
|
|
118
|
+
* paused — otherwise an already-unpaused module would truncate the set and the
|
|
119
|
+
* cascade would stop at the first member that needed no work (task 4.7).
|
|
120
|
+
*/
|
|
121
|
+
function cascadeOrder(graph: ModuleGraph, moduleId: string, action: PauseAction): string[] {
|
|
122
|
+
const affected = new Set([moduleId, ...transitiveConsumers(graph, moduleId)]);
|
|
123
|
+
const providersFirst = topologicalOrder(graph).filter((id) => affected.has(id));
|
|
124
|
+
|
|
125
|
+
// Unpausing redeploys, and a redeploy resolves capabilities, so a provider
|
|
126
|
+
// must be live before any consumer redeploys. Pausing is the mirror: a
|
|
127
|
+
// consumer must stop depending before its provider goes quiet.
|
|
128
|
+
return action === 'unpause' ? providersFirst : providersFirst.slice().reverse();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildSteps(request: PlanRequest, action: PauseAction): PlanStep[] {
|
|
132
|
+
const byId = snapshotById(request.fleet);
|
|
133
|
+
const target = byId.get(request.moduleId);
|
|
134
|
+
if (!target) {
|
|
135
|
+
throw new PauseRefusedError(`Module not found: ${request.moduleId}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let ordered: string[];
|
|
139
|
+
if (request.cascade) {
|
|
140
|
+
try {
|
|
141
|
+
ordered = cascadeOrder(
|
|
142
|
+
buildModuleGraph(request.fleet.map((m) => m.manifest)),
|
|
143
|
+
request.moduleId,
|
|
144
|
+
action,
|
|
145
|
+
);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// A cycle means there is no safe order, so acting on part of the set
|
|
148
|
+
// would leave the fleet in a state no re-run can reason about. Refuse and
|
|
149
|
+
// name the cycle (task 4.3) rather than pausing a partial set.
|
|
150
|
+
if (err instanceof DependencyCycleError) {
|
|
151
|
+
throw new PauseRefusedError(
|
|
152
|
+
`Cannot ${action} with cascade: the affected modules declare a dependency cycle.\n ${err.cycle.join(' → ')} → ${err.cycle[0]}\nFix the manifests, or ${action} each module individually.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
ordered = [request.moduleId];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return ordered.map((id) => {
|
|
162
|
+
const snapshot = byId.get(id);
|
|
163
|
+
// A graph node with no DB row cannot happen (the graph is built from the
|
|
164
|
+
// fleet), but the map lookup is nullable and a silent skip would be worse
|
|
165
|
+
// than a loud one.
|
|
166
|
+
if (!snapshot) {
|
|
167
|
+
throw new PauseRefusedError(`Module '${id}' is in the dependency graph but has no record`);
|
|
168
|
+
}
|
|
169
|
+
const isPaused = snapshot.state === 'PAUSED';
|
|
170
|
+
const alreadyDone = action === 'pause' ? isPaused : !isPaused;
|
|
171
|
+
if (alreadyDone) {
|
|
172
|
+
return {
|
|
173
|
+
moduleId: id,
|
|
174
|
+
disposition: 'skip_already' as const,
|
|
175
|
+
note: action === 'pause' ? 'already paused' : 'not paused',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A cascade sweeps in every transitive consumer from the manifest graph,
|
|
180
|
+
// including ones that were imported but never deployed. Those are not bound
|
|
181
|
+
// to the provider and have nothing to quiesce, so refusing the whole cascade
|
|
182
|
+
// over them would wedge exactly the migration this feature exists to enable
|
|
183
|
+
// — one stray imported module would block the swap. Skip them instead.
|
|
184
|
+
//
|
|
185
|
+
// The module the operator NAMED is not eligible for this: asking to pause an
|
|
186
|
+
// undeployed module is a mistake worth reporting, and the spec says so.
|
|
187
|
+
const named = id === request.moduleId;
|
|
188
|
+
if (action === 'pause' && !named && !isDeployed(snapshot.state)) {
|
|
189
|
+
return {
|
|
190
|
+
moduleId: id,
|
|
191
|
+
disposition: 'skip_undeployed' as const,
|
|
192
|
+
note: `${snapshot.state} — never deployed, nothing to quiesce`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { moduleId: id, disposition: 'act' as const };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Why a module cannot be paused right now, or null if it can. Split out so the
|
|
202
|
+
* message names the specific case — "nothing deployed to quiesce" and "would
|
|
203
|
+
* strand the transition" are different problems with different fixes.
|
|
204
|
+
*/
|
|
205
|
+
function pauseRefusal(
|
|
206
|
+
snapshot: ModuleSnapshot,
|
|
207
|
+
inFlight: ReadonlyMap<string, string>,
|
|
208
|
+
): string | null {
|
|
209
|
+
const operation = inFlight.get(snapshot.id);
|
|
210
|
+
if (operation) {
|
|
211
|
+
return `'${snapshot.id}' has an operation in progress (${operation}). Wait for it to finish, or release it with "celilo module operations clear".`;
|
|
212
|
+
}
|
|
213
|
+
if ((PAUSABLE_STATES as readonly ModuleState[]).includes(snapshot.state)) return null;
|
|
214
|
+
if ((IN_FLIGHT_STATES as readonly ModuleState[]).includes(snapshot.state)) {
|
|
215
|
+
return `'${snapshot.id}' is ${snapshot.state} — pausing mid-transition would strand it. Wait for the transition to finish.`;
|
|
216
|
+
}
|
|
217
|
+
return `'${snapshot.id}' is ${snapshot.state} — it has never been deployed, so there is nothing to quiesce.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Plan a pause. Pure: no DB, no bus, no filesystem.
|
|
222
|
+
*
|
|
223
|
+
* Refuses the WHOLE plan when any module it would act on cannot be paused,
|
|
224
|
+
* rather than pausing a partial set and leaving the operator to work out which
|
|
225
|
+
* half happened.
|
|
226
|
+
*/
|
|
227
|
+
export function planPause(request: PlanRequest): PausePlan {
|
|
228
|
+
const steps = buildSteps(request, 'pause');
|
|
229
|
+
const byId = snapshotById(request.fleet);
|
|
230
|
+
const inFlight = request.inFlight ?? new Map<string, string>();
|
|
231
|
+
|
|
232
|
+
const refusals = steps
|
|
233
|
+
.filter((s) => s.disposition === 'act')
|
|
234
|
+
.map((s) => byId.get(s.moduleId))
|
|
235
|
+
.filter((s): s is ModuleSnapshot => s !== undefined)
|
|
236
|
+
.map((s) => pauseRefusal(s, inFlight))
|
|
237
|
+
.filter((r): r is string => r !== null);
|
|
238
|
+
|
|
239
|
+
if (refusals.length > 0) {
|
|
240
|
+
throw new PauseRefusedError(`Cannot pause:\n${refusals.map((r) => ` • ${r}`).join('\n')}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
action: 'pause',
|
|
245
|
+
requested: request.moduleId,
|
|
246
|
+
cascade: request.cascade,
|
|
247
|
+
stopInfra: request.stopInfra ?? false,
|
|
248
|
+
steps,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Plan an unpause. Pure.
|
|
254
|
+
*
|
|
255
|
+
* There is no source-state gate here: the only module an unpause acts on is one
|
|
256
|
+
* in `PAUSED`, which is settled by construction.
|
|
257
|
+
*/
|
|
258
|
+
export function planUnpause(request: PlanRequest): PausePlan {
|
|
259
|
+
return {
|
|
260
|
+
action: 'unpause',
|
|
261
|
+
requested: request.moduleId,
|
|
262
|
+
cascade: request.cascade,
|
|
263
|
+
stopInfra: false,
|
|
264
|
+
steps: buildSteps(request, 'unpause'),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Reading paused state
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export interface PausedModule {
|
|
273
|
+
id: string;
|
|
274
|
+
pausedAt: Date | null;
|
|
275
|
+
pauseReason: string | null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Every currently paused module. One indexed query (`modules_state_idx`) — it
|
|
280
|
+
* runs on every management-API response (design D7), so it has to stay cheap.
|
|
281
|
+
*/
|
|
282
|
+
export function listPausedModules(db: DbClient): PausedModule[] {
|
|
283
|
+
return db
|
|
284
|
+
.select({
|
|
285
|
+
id: modules.id,
|
|
286
|
+
pausedAt: modules.pausedAt,
|
|
287
|
+
pauseReason: modules.pauseReason,
|
|
288
|
+
})
|
|
289
|
+
.from(modules)
|
|
290
|
+
.where(eq(modules.state, 'PAUSED'))
|
|
291
|
+
.all();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** True if this module is paused. Used by the remove guard and hook dispatch. */
|
|
295
|
+
export function isModulePaused(db: DbClient, moduleId: string): boolean {
|
|
296
|
+
const row = db
|
|
297
|
+
.select({ state: modules.state })
|
|
298
|
+
.from(modules)
|
|
299
|
+
.where(eq(modules.id, moduleId))
|
|
300
|
+
.get();
|
|
301
|
+
return row?.state === 'PAUSED';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The subset of `moduleIds` that are paused. One query, for guard/plan use. */
|
|
305
|
+
export function pausedAmong(db: DbClient, moduleIds: string[]): Set<string> {
|
|
306
|
+
if (moduleIds.length === 0) return new Set();
|
|
307
|
+
const rows = db
|
|
308
|
+
.select({ id: modules.id, state: modules.state })
|
|
309
|
+
.from(modules)
|
|
310
|
+
.where(inArray(modules.id, moduleIds))
|
|
311
|
+
.all();
|
|
312
|
+
return new Set(rows.filter((r) => r.state === 'PAUSED').map((r) => r.id));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* How long a module has been paused, human-readable. One implementation so no
|
|
317
|
+
* call site formats the age by hand (task 1.3) — `module list`, `module
|
|
318
|
+
* status`, `system doctor` and the management-API warning all render the same
|
|
319
|
+
* string for the same pause.
|
|
320
|
+
*/
|
|
321
|
+
export function formatPausedDuration(pausedAt: Date | null, now: Date = new Date()): string {
|
|
322
|
+
if (!pausedAt) return 'unknown';
|
|
323
|
+
const ms = Math.max(0, now.getTime() - pausedAt.getTime());
|
|
324
|
+
const minutes = Math.floor(ms / 60_000);
|
|
325
|
+
if (minutes < 1) return 'just now';
|
|
326
|
+
if (minutes < 60) return `${minutes}m`;
|
|
327
|
+
const hours = Math.floor(minutes / 60);
|
|
328
|
+
if (hours < 24) return `${hours}h`;
|
|
329
|
+
return `${Math.floor(hours / 24)}d`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* What `--stop-infra` reports for a module hosted on a MACHINE (machine-pool
|
|
334
|
+
* sense): not applicable, by design (design D2, revised).
|
|
335
|
+
*
|
|
336
|
+
* Extracted so the semantics are testable rather than buried in a string. The
|
|
337
|
+
* wording is load-bearing: `--stop-infra` acts only on infrastructure celilo
|
|
338
|
+
* PROVISIONED, and a machine is operator-pre-provisioned — it may predate
|
|
339
|
+
* celilo and may run work celilo has never been told about. Saying celilo
|
|
340
|
+
* "cannot determine the service unit" would imply a capability gap where the
|
|
341
|
+
* truth is that this host is not celilo's to stop.
|
|
342
|
+
*/
|
|
343
|
+
export function describeMachineStopInfra(hostname: string, moduleId: string): string {
|
|
344
|
+
return `${hostname}: machine-hosted — not applicable. --stop-infra acts only on infrastructure celilo provisioned; this machine is operator-managed and may run more than '${moduleId}'.`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** `caddy (3d, "swapping the edge router")` — the shared one-line rendering. */
|
|
348
|
+
export function describePausedModule(module: PausedModule, now: Date = new Date()): string {
|
|
349
|
+
const age = formatPausedDuration(module.pausedAt, now);
|
|
350
|
+
return module.pauseReason
|
|
351
|
+
? `${module.id} (${age}, "${module.pauseReason}")`
|
|
352
|
+
: `${module.id} (${age})`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Execution
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The side-effecting collaborators, injected so the executor is testable
|
|
361
|
+
* without a Proxmox, a bus, or a real deploy (Rule 2.3).
|
|
362
|
+
*/
|
|
363
|
+
export interface PauseDeps {
|
|
364
|
+
db: DbClient;
|
|
365
|
+
/** Quiesce: drop the module's bus subscriptions so nothing is delivered. */
|
|
366
|
+
unsubscribe(moduleId: string): void;
|
|
367
|
+
/** Re-arm them after a successful unpause redeploy. */
|
|
368
|
+
resubscribe(moduleId: string): void;
|
|
369
|
+
/** Unpause's rebinding mechanism (design D4). Resolves false on failure. */
|
|
370
|
+
redeploy(moduleId: string): Promise<{ success: boolean; error?: string }>;
|
|
371
|
+
/** `--stop-infra`. Returns what it did, for the report. */
|
|
372
|
+
stopInfrastructure(moduleId: string): Promise<InfraStopOutcome>;
|
|
373
|
+
/** Progress + resumability substrate (`module_operations`). */
|
|
374
|
+
startOperation(moduleId: string, operation: 'pause' | 'unpause'): string;
|
|
375
|
+
completeOperation(operationId: string): void;
|
|
376
|
+
failOperation(operationId: string, error: unknown): void;
|
|
377
|
+
now(): Date;
|
|
378
|
+
log(message: string): void;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface InfraStopOutcome {
|
|
382
|
+
stopped: boolean;
|
|
383
|
+
/** Operator-readable: "stopped container vmid 231", "no infrastructure to stop". */
|
|
384
|
+
detail: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface StepOutcome {
|
|
388
|
+
moduleId: string;
|
|
389
|
+
/** `acted` changed it; `skipped` was already there; `failed` did not happen. */
|
|
390
|
+
result: 'acted' | 'skipped' | 'failed';
|
|
391
|
+
detail?: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface ExecutionReport {
|
|
395
|
+
action: PauseAction;
|
|
396
|
+
outcomes: StepOutcome[];
|
|
397
|
+
/** False if any step failed — the caller renders a non-zero result. */
|
|
398
|
+
success: boolean;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function markPaused(deps: PauseDeps, moduleId: string, reason: string | null): void {
|
|
402
|
+
deps.db
|
|
403
|
+
.update(modules)
|
|
404
|
+
.set({ state: 'PAUSED', pausedAt: deps.now(), pauseReason: reason, updatedAt: deps.now() })
|
|
405
|
+
.where(eq(modules.id, moduleId))
|
|
406
|
+
.run();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Pause every module the plan acts on, in the plan's order.
|
|
411
|
+
*
|
|
412
|
+
* A step that fails does NOT abort the rest: the remaining modules are
|
|
413
|
+
* independent, the half-paused state is durable and safe (paused modules are
|
|
414
|
+
* quiesced, not broken), and re-running the cascade completes the outstanding
|
|
415
|
+
* work. Aborting would leave a smaller done-set for no benefit.
|
|
416
|
+
*/
|
|
417
|
+
export async function executePause(
|
|
418
|
+
plan: PausePlan,
|
|
419
|
+
deps: PauseDeps,
|
|
420
|
+
reason: string | null,
|
|
421
|
+
): Promise<ExecutionReport> {
|
|
422
|
+
const outcomes: StepOutcome[] = [];
|
|
423
|
+
|
|
424
|
+
for (const step of plan.steps) {
|
|
425
|
+
if (step.disposition !== 'act') {
|
|
426
|
+
// Deliberately not a halt (task 4.7) and deliberately not a re-write:
|
|
427
|
+
// re-pausing preserves the ORIGINAL pausedAt/pauseReason, so the age
|
|
428
|
+
// keeps measuring the real outage rather than resetting on every retry.
|
|
429
|
+
outcomes.push({ moduleId: step.moduleId, result: 'skipped', detail: step.note });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const opId = deps.startOperation(step.moduleId, 'pause');
|
|
434
|
+
try {
|
|
435
|
+
deps.unsubscribe(step.moduleId);
|
|
436
|
+
markPaused(deps, step.moduleId, reason);
|
|
437
|
+
|
|
438
|
+
let detail = 'paused';
|
|
439
|
+
if (plan.stopInfra) {
|
|
440
|
+
const outcome = await deps.stopInfrastructure(step.moduleId);
|
|
441
|
+
detail = `paused; ${outcome.detail}`;
|
|
442
|
+
}
|
|
443
|
+
deps.completeOperation(opId);
|
|
444
|
+
deps.log(`${step.moduleId}: ${detail}`);
|
|
445
|
+
outcomes.push({ moduleId: step.moduleId, result: 'acted', detail });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
448
|
+
deps.failOperation(opId, err);
|
|
449
|
+
deps.log(`${step.moduleId}: pause failed — ${message}`);
|
|
450
|
+
outcomes.push({ moduleId: step.moduleId, result: 'failed', detail: message });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { action: 'pause', outcomes, success: outcomes.every((o) => o.result !== 'failed') };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Unpause every paused module the plan covers, providers first.
|
|
459
|
+
*
|
|
460
|
+
* A failure DOES stop the cascade here, unlike pause: the plan is ordered so
|
|
461
|
+
* providers come first, so continuing past a failed provider would redeploy its
|
|
462
|
+
* consumers against a provider that is not there — precisely the mis-binding
|
|
463
|
+
* the whole design exists to prevent. The unreached modules stay paused, which
|
|
464
|
+
* is safe and visible, and re-running completes the remainder.
|
|
465
|
+
*/
|
|
466
|
+
export async function executeUnpause(plan: PausePlan, deps: PauseDeps): Promise<ExecutionReport> {
|
|
467
|
+
const outcomes: StepOutcome[] = [];
|
|
468
|
+
|
|
469
|
+
for (const step of plan.steps) {
|
|
470
|
+
if (step.disposition !== 'act') {
|
|
471
|
+
// An already-unpaused member is skipped WITHOUT redeploying (task 4.8) —
|
|
472
|
+
// safe because a failed unpause leaves its module PAUSED, so there is no
|
|
473
|
+
// "unpaused but never redeployed" state to repair.
|
|
474
|
+
outcomes.push({ moduleId: step.moduleId, result: 'skipped', detail: step.note });
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Read the pause metadata BEFORE the redeploy overwrites `state`, so a
|
|
479
|
+
// failure can restore the module to the pause it was actually in rather
|
|
480
|
+
// than stamping a fresh one.
|
|
481
|
+
const previous = deps.db
|
|
482
|
+
.select({ pausedAt: modules.pausedAt, pauseReason: modules.pauseReason })
|
|
483
|
+
.from(modules)
|
|
484
|
+
.where(eq(modules.id, step.moduleId))
|
|
485
|
+
.get();
|
|
486
|
+
|
|
487
|
+
const opId = deps.startOperation(step.moduleId, 'unpause');
|
|
488
|
+
let deployResult: { success: boolean; error?: string };
|
|
489
|
+
try {
|
|
490
|
+
deployResult = await deps.redeploy(step.moduleId);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
deployResult = { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!deployResult.success) {
|
|
496
|
+
// The redeploy has already moved `state` off PAUSED (and possibly to
|
|
497
|
+
// ERROR). Put it back, keeping the original timestamp, and re-drop the
|
|
498
|
+
// subscriptions in case the deploy re-registered them: a module that is
|
|
499
|
+
// reported as paused must actually BE quiesced.
|
|
500
|
+
deps.db
|
|
501
|
+
.update(modules)
|
|
502
|
+
.set({
|
|
503
|
+
state: 'PAUSED',
|
|
504
|
+
pausedAt: previous?.pausedAt ?? deps.now(),
|
|
505
|
+
pauseReason: previous?.pauseReason ?? null,
|
|
506
|
+
updatedAt: deps.now(),
|
|
507
|
+
})
|
|
508
|
+
.where(eq(modules.id, step.moduleId))
|
|
509
|
+
.run();
|
|
510
|
+
deps.unsubscribe(step.moduleId);
|
|
511
|
+
deps.failOperation(opId, deployResult.error ?? 'redeploy failed');
|
|
512
|
+
|
|
513
|
+
const message = deployResult.error ?? 'redeploy failed';
|
|
514
|
+
deps.log(`${step.moduleId}: unpause failed — ${message}; left paused`);
|
|
515
|
+
outcomes.push({ moduleId: step.moduleId, result: 'failed', detail: message });
|
|
516
|
+
|
|
517
|
+
// Everything after this depends on it. Stop rather than mis-bind.
|
|
518
|
+
const unreached = plan.steps
|
|
519
|
+
.slice(plan.steps.indexOf(step) + 1)
|
|
520
|
+
.filter((s) => s.disposition === 'act');
|
|
521
|
+
for (const s of unreached) {
|
|
522
|
+
outcomes.push({
|
|
523
|
+
moduleId: s.moduleId,
|
|
524
|
+
result: 'skipped',
|
|
525
|
+
detail: 'left paused — a provider it depends on failed to unpause',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return { action: 'unpause', outcomes, success: false };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// The deploy set the live state; all that is left is to clear the pause.
|
|
532
|
+
deps.db
|
|
533
|
+
.update(modules)
|
|
534
|
+
.set({ pausedAt: null, pauseReason: null, updatedAt: deps.now() })
|
|
535
|
+
.where(eq(modules.id, step.moduleId))
|
|
536
|
+
.run();
|
|
537
|
+
deps.resubscribe(step.moduleId);
|
|
538
|
+
deps.completeOperation(opId);
|
|
539
|
+
deps.log(`${step.moduleId}: unpaused and redeployed`);
|
|
540
|
+
outcomes.push({ moduleId: step.moduleId, result: 'acted', detail: 'unpaused and redeployed' });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { action: 'unpause', outcomes, success: true };
|
|
544
|
+
}
|