@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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pause planning + execution.
|
|
3
|
+
*
|
|
4
|
+
* The planner is pure, so the interesting cases (ordering, refusals, walking
|
|
5
|
+
* through already-done members) need no database at all. Execution is tested
|
|
6
|
+
* against fake deps — the point is the state machine, not Proxmox.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdtempSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { eq } from 'drizzle-orm';
|
|
14
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
15
|
+
import { ipAllocations, moduleConfigs, modules, secrets } from '../db/schema';
|
|
16
|
+
import type { ModuleState } from '../db/schema';
|
|
17
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
18
|
+
import {
|
|
19
|
+
type ModuleSnapshot,
|
|
20
|
+
type PauseDeps,
|
|
21
|
+
type PausePlan,
|
|
22
|
+
PauseRefusedError,
|
|
23
|
+
actedOn,
|
|
24
|
+
executePause,
|
|
25
|
+
executeUnpause,
|
|
26
|
+
formatPausedDuration,
|
|
27
|
+
planPause,
|
|
28
|
+
planUnpause,
|
|
29
|
+
} from './module-pause';
|
|
30
|
+
|
|
31
|
+
function makeManifest(
|
|
32
|
+
id: string,
|
|
33
|
+
opts: { provides?: string[]; requires?: string[]; optional?: string[] } = {},
|
|
34
|
+
): ModuleManifest {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
name: id,
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
celilo_contract: '1.0',
|
|
40
|
+
provides: {
|
|
41
|
+
capabilities: (opts.provides ?? []).map((name) => ({
|
|
42
|
+
name,
|
|
43
|
+
version: '1.0.0',
|
|
44
|
+
data: {},
|
|
45
|
+
functions: [],
|
|
46
|
+
})),
|
|
47
|
+
},
|
|
48
|
+
requires: { capabilities: (opts.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
|
|
49
|
+
optional: opts.optional
|
|
50
|
+
? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) }
|
|
51
|
+
: undefined,
|
|
52
|
+
} as unknown as ModuleManifest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function snap(
|
|
56
|
+
id: string,
|
|
57
|
+
state: ModuleState,
|
|
58
|
+
opts: { provides?: string[]; requires?: string[]; optional?: string[] } = {},
|
|
59
|
+
): ModuleSnapshot {
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
state,
|
|
63
|
+
pausedAt: state === 'PAUSED' ? new Date('2026-08-01T00:00:00Z') : null,
|
|
64
|
+
pauseReason: state === 'PAUSED' ? 'swapping the edge router' : null,
|
|
65
|
+
manifest: makeManifest(id, opts),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The real shape of the greenwave → axon problem: a firewall provider with a
|
|
71
|
+
* chain of consumers, one of which reaches it only via `optional`.
|
|
72
|
+
*
|
|
73
|
+
* greenwave ──provides firewall──> caddy (requires) ──provides public_web──> website
|
|
74
|
+
* technitium (OPTIONAL dhcp_server)
|
|
75
|
+
*/
|
|
76
|
+
function fleet(states: Partial<Record<string, ModuleState>> = {}): ModuleSnapshot[] {
|
|
77
|
+
const state = (id: string, fallback: ModuleState = 'INSTALLED') => states[id] ?? fallback;
|
|
78
|
+
return [
|
|
79
|
+
snap('greenwave', state('greenwave'), { provides: ['firewall', 'dhcp_server'] }),
|
|
80
|
+
snap('caddy', state('caddy'), { provides: ['public_web'], requires: ['firewall'] }),
|
|
81
|
+
snap('website', state('website'), { requires: ['public_web'] }),
|
|
82
|
+
snap('technitium', state('technitium'), { optional: ['dhcp_server'] }),
|
|
83
|
+
snap('unrelated', state('unrelated')),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('planPause — ordering', () => {
|
|
88
|
+
test('a plain pause is single-module, whatever depends on it', () => {
|
|
89
|
+
const plan = planPause({ moduleId: 'greenwave', fleet: fleet(), cascade: false });
|
|
90
|
+
expect(plan.steps.map((s) => s.moduleId)).toEqual(['greenwave']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('cascade pause covers the transitive consumers and pauses them FIRST', () => {
|
|
94
|
+
const plan = planPause({ moduleId: 'greenwave', fleet: fleet(), cascade: true });
|
|
95
|
+
const order = plan.steps.map((s) => s.moduleId);
|
|
96
|
+
|
|
97
|
+
expect(new Set(order)).toEqual(new Set(['greenwave', 'caddy', 'website', 'technitium']));
|
|
98
|
+
expect(order).not.toContain('unrelated');
|
|
99
|
+
|
|
100
|
+
// Consumers before the provider they depend on — the ordering IS the
|
|
101
|
+
// feature, so assert relative position rather than membership.
|
|
102
|
+
expect(order.indexOf('website')).toBeLessThan(order.indexOf('caddy'));
|
|
103
|
+
expect(order.indexOf('caddy')).toBeLessThan(order.indexOf('greenwave'));
|
|
104
|
+
expect(order.indexOf('technitium')).toBeLessThan(order.indexOf('greenwave'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('cascade unpause runs the same set in the opposite order — providers first', () => {
|
|
108
|
+
const paused = fleet({
|
|
109
|
+
greenwave: 'PAUSED',
|
|
110
|
+
caddy: 'PAUSED',
|
|
111
|
+
website: 'PAUSED',
|
|
112
|
+
technitium: 'PAUSED',
|
|
113
|
+
});
|
|
114
|
+
const order = planUnpause({ moduleId: 'greenwave', fleet: paused, cascade: true }).steps.map(
|
|
115
|
+
(s) => s.moduleId,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(order.indexOf('greenwave')).toBeLessThan(order.indexOf('caddy'));
|
|
119
|
+
expect(order.indexOf('caddy')).toBeLessThan(order.indexOf('website'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('an `optional` consumer is in the cascade — the graph counts it as an edge', () => {
|
|
123
|
+
const plan = planPause({ moduleId: 'greenwave', fleet: fleet(), cascade: true });
|
|
124
|
+
expect(plan.steps.map((s) => s.moduleId)).toContain('technitium');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('planPause — legal source states (task 3.7)', () => {
|
|
129
|
+
test.each([['INSTALLED'], ['VERIFIED'], ['ERROR']] as const)(
|
|
130
|
+
'%s is pausable',
|
|
131
|
+
(state: ModuleState) => {
|
|
132
|
+
const plan = planPause({
|
|
133
|
+
moduleId: 'greenwave',
|
|
134
|
+
fleet: fleet({ greenwave: state }),
|
|
135
|
+
cascade: false,
|
|
136
|
+
});
|
|
137
|
+
expect(plan.steps[0]).toEqual({ moduleId: 'greenwave', disposition: 'act' });
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
test('a never-deployed module is refused, and the message says why', () => {
|
|
142
|
+
expect(() =>
|
|
143
|
+
planPause({ moduleId: 'greenwave', fleet: fleet({ greenwave: 'IMPORTED' }), cascade: false }),
|
|
144
|
+
).toThrow(/never been deployed/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a module mid-transition is refused with a DIFFERENT message', () => {
|
|
148
|
+
expect(() =>
|
|
149
|
+
planPause({
|
|
150
|
+
moduleId: 'greenwave',
|
|
151
|
+
fleet: fleet({ greenwave: 'DEPLOYING' }),
|
|
152
|
+
cascade: false,
|
|
153
|
+
}),
|
|
154
|
+
).toThrow(/strand/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an in-flight operation refuses the pause and names the operation', () => {
|
|
158
|
+
expect(() =>
|
|
159
|
+
planPause({
|
|
160
|
+
moduleId: 'greenwave',
|
|
161
|
+
fleet: fleet(),
|
|
162
|
+
cascade: false,
|
|
163
|
+
inFlight: new Map([['greenwave', 'backup of greenwave (pid 4242)']]),
|
|
164
|
+
}),
|
|
165
|
+
).toThrow(/backup of greenwave \(pid 4242\)/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a cascade is refused WHOLE when any member cannot be paused', () => {
|
|
169
|
+
// caddy is mid-deploy. Pausing the rest and stopping would leave the
|
|
170
|
+
// operator to work out which half happened.
|
|
171
|
+
expect(() =>
|
|
172
|
+
planPause({ moduleId: 'greenwave', fleet: fleet({ caddy: 'DEPLOYING' }), cascade: true }),
|
|
173
|
+
).toThrow(PauseRefusedError);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('an unknown module is refused rather than silently planning nothing', () => {
|
|
177
|
+
expect(() => planPause({ moduleId: 'nope', fleet: fleet(), cascade: false })).toThrow(
|
|
178
|
+
/Module not found/,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('a cascade walks THROUGH members already in the target condition (task 4.7)', () => {
|
|
184
|
+
// The failure the operator specifically called out: `unpause --cascade caddy`
|
|
185
|
+
// when caddy is already unpaused must still reach the modules beyond it.
|
|
186
|
+
// A cascade that halted at the first already-done member would leave the
|
|
187
|
+
// consumers paused forever, and a half-finished cascade unresumable.
|
|
188
|
+
test('unpause --cascade on an ALREADY-UNPAUSED module still reaches its consumers', () => {
|
|
189
|
+
const plan = planUnpause({
|
|
190
|
+
moduleId: 'caddy',
|
|
191
|
+
fleet: fleet({ caddy: 'INSTALLED', website: 'PAUSED' }),
|
|
192
|
+
cascade: true,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const ids = plan.steps.map((s) => s.moduleId);
|
|
196
|
+
expect(ids).toContain('caddy');
|
|
197
|
+
expect(ids).toContain('website');
|
|
198
|
+
|
|
199
|
+
expect(plan.steps.find((s) => s.moduleId === 'caddy')?.disposition).toBe('skip_already');
|
|
200
|
+
expect(plan.steps.find((s) => s.moduleId === 'website')?.disposition).toBe('act');
|
|
201
|
+
expect(actedOn(plan)).toEqual(['website']);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('pause --cascade over a partly paused set plans only the outstanding work', () => {
|
|
205
|
+
const plan = planPause({
|
|
206
|
+
moduleId: 'greenwave',
|
|
207
|
+
fleet: fleet({ website: 'PAUSED', technitium: 'PAUSED' }),
|
|
208
|
+
cascade: true,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(actedOn(plan).sort()).toEqual(['caddy', 'greenwave']);
|
|
212
|
+
// Still PRESENT in the plan — the report tells the operator what was
|
|
213
|
+
// already done rather than pretending it was not in scope.
|
|
214
|
+
expect(plan.steps.map((s) => s.moduleId)).toContain('website');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('an already-paused member does not trip the source-state refusal', () => {
|
|
218
|
+
// PAUSED is not in PAUSABLE_STATES, so a naive validator would refuse the
|
|
219
|
+
// whole cascade the second time it ran — i.e. exactly when resuming.
|
|
220
|
+
expect(() =>
|
|
221
|
+
planPause({ moduleId: 'greenwave', fleet: fleet({ caddy: 'PAUSED' }), cascade: true }),
|
|
222
|
+
).not.toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Execution
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Execution runs against a REAL isolated SQLite database rather than a fake
|
|
232
|
+
* drizzle chain. A hand-rolled fake cannot honour a `where` clause, so it
|
|
233
|
+
* silently answered the wrong row for the read-before-redeploy that preserves
|
|
234
|
+
* the original `pausedAt` — the fake would have hidden precisely the bug that
|
|
235
|
+
* read exists to prevent. Everything else (deploy, bus, Proxmox) stays injected.
|
|
236
|
+
*/
|
|
237
|
+
interface FakeState {
|
|
238
|
+
unsubscribed: string[];
|
|
239
|
+
resubscribed: string[];
|
|
240
|
+
redeployed: string[];
|
|
241
|
+
operations: Array<{ moduleId: string; kind: string; outcome: 'completed' | 'failed' }>;
|
|
242
|
+
infraStopped: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function makeDb(): DbClient {
|
|
246
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-pause-'));
|
|
247
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
248
|
+
return createDbClient({ path: process.env.CELILO_DB_PATH });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function seed(db: DbClient, initial: Record<string, ModuleState>): void {
|
|
252
|
+
for (const [id, state] of Object.entries(initial)) {
|
|
253
|
+
db.insert(modules)
|
|
254
|
+
.values({
|
|
255
|
+
id,
|
|
256
|
+
name: id,
|
|
257
|
+
version: '1.0.0',
|
|
258
|
+
state,
|
|
259
|
+
manifestData: makeManifest(id) as unknown as Record<string, unknown>,
|
|
260
|
+
sourcePath: `/tmp/${id}`,
|
|
261
|
+
pausedAt: state === 'PAUSED' ? new Date('2026-08-01T00:00:00Z') : null,
|
|
262
|
+
pauseReason: state === 'PAUSED' ? 'original reason' : null,
|
|
263
|
+
})
|
|
264
|
+
.run();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function readRow(db: DbClient, id: string) {
|
|
269
|
+
return db.select().from(modules).where(eq(modules.id, id)).get();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function fakeDeps(
|
|
273
|
+
initial: Record<string, ModuleState>,
|
|
274
|
+
overrides: { failRedeployOf?: string } = {},
|
|
275
|
+
): { deps: PauseDeps; state: FakeState; db: DbClient } {
|
|
276
|
+
const db = makeDb();
|
|
277
|
+
seed(db, initial);
|
|
278
|
+
|
|
279
|
+
const state: FakeState = {
|
|
280
|
+
unsubscribed: [],
|
|
281
|
+
resubscribed: [],
|
|
282
|
+
redeployed: [],
|
|
283
|
+
operations: [],
|
|
284
|
+
infraStopped: [],
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const deps: PauseDeps = {
|
|
288
|
+
db,
|
|
289
|
+
unsubscribe: (id) => state.unsubscribed.push(id),
|
|
290
|
+
resubscribe: (id) => state.resubscribed.push(id),
|
|
291
|
+
redeploy: async (id) => {
|
|
292
|
+
state.redeployed.push(id);
|
|
293
|
+
if (overrides.failRedeployOf === id) {
|
|
294
|
+
// A real failed deploy leaves `state` somewhere else entirely — often
|
|
295
|
+
// ERROR. Model that, so the test proves the executor RESTORES PAUSED
|
|
296
|
+
// rather than merely never having left it.
|
|
297
|
+
db.update(modules).set({ state: 'ERROR' }).where(eq(modules.id, id)).run();
|
|
298
|
+
return { success: false, error: 'bad config' };
|
|
299
|
+
}
|
|
300
|
+
db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, id)).run();
|
|
301
|
+
return { success: true };
|
|
302
|
+
},
|
|
303
|
+
stopInfrastructure: async (id) => {
|
|
304
|
+
state.infraStopped.push(id);
|
|
305
|
+
return { stopped: true, detail: `stopped ${id}` };
|
|
306
|
+
},
|
|
307
|
+
startOperation: (moduleId, kind) => {
|
|
308
|
+
state.operations.push({ moduleId, kind, outcome: 'completed' });
|
|
309
|
+
return `${moduleId}:${kind}`;
|
|
310
|
+
},
|
|
311
|
+
completeOperation: () => {},
|
|
312
|
+
failOperation: (operationId) => {
|
|
313
|
+
const entry = state.operations.find((o) => `${o.moduleId}:${o.kind}` === operationId);
|
|
314
|
+
if (entry) entry.outcome = 'failed';
|
|
315
|
+
},
|
|
316
|
+
now: () => new Date('2026-08-12T00:00:00Z'),
|
|
317
|
+
log: () => {},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
return { deps, state, db };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function planFor(action: 'pause' | 'unpause', ids: string[], stopInfra = false): PausePlan {
|
|
324
|
+
return {
|
|
325
|
+
action,
|
|
326
|
+
requested: ids[0],
|
|
327
|
+
cascade: ids.length > 1,
|
|
328
|
+
stopInfra,
|
|
329
|
+
steps: ids.map((id) => ({ moduleId: id, disposition: 'act' as const })),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
describe('executePause', () => {
|
|
334
|
+
test('pauses, quiesces, and records the reason', async () => {
|
|
335
|
+
const { deps, state, db } = fakeDeps({ greenwave: 'INSTALLED' });
|
|
336
|
+
const report = await executePause(planFor('pause', ['greenwave']), deps, 'edge router swap');
|
|
337
|
+
|
|
338
|
+
expect(report.success).toBe(true);
|
|
339
|
+
expect(readRow(db, 'greenwave')?.state).toBe('PAUSED');
|
|
340
|
+
expect(readRow(db, 'greenwave')?.pauseReason).toBe('edge router swap');
|
|
341
|
+
expect(state.unsubscribed).toEqual(['greenwave']);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('does NOT stop infrastructure by default (design D2)', async () => {
|
|
345
|
+
const { deps, state } = fakeDeps({ caddy: 'INSTALLED' });
|
|
346
|
+
await executePause(planFor('pause', ['caddy']), deps, null);
|
|
347
|
+
// Pausing caddy to swap the FIREWALL must not take every website down.
|
|
348
|
+
expect(state.infraStopped).toEqual([]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('--stop-infra stops it', async () => {
|
|
352
|
+
const { deps, state } = fakeDeps({ caddy: 'INSTALLED' });
|
|
353
|
+
await executePause(planFor('pause', ['caddy'], true), deps, null);
|
|
354
|
+
expect(state.infraStopped).toEqual(['caddy']);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('re-pausing preserves the ORIGINAL pausedAt and reason (task 3.5)', async () => {
|
|
358
|
+
const { deps, db } = fakeDeps({ greenwave: 'PAUSED' });
|
|
359
|
+
const plan: PausePlan = {
|
|
360
|
+
action: 'pause',
|
|
361
|
+
requested: 'greenwave',
|
|
362
|
+
cascade: false,
|
|
363
|
+
stopInfra: false,
|
|
364
|
+
steps: [{ moduleId: 'greenwave', disposition: 'skip_already', note: 'already paused' }],
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const report = await executePause(plan, deps, 'a NEW reason');
|
|
368
|
+
|
|
369
|
+
expect(report.success).toBe(true);
|
|
370
|
+
// The age must keep measuring the real outage, not reset on every retry.
|
|
371
|
+
expect(readRow(db, 'greenwave')?.pausedAt).toEqual(new Date('2026-08-01T00:00:00Z'));
|
|
372
|
+
expect(readRow(db, 'greenwave')?.pauseReason).toBe('original reason');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('executeUnpause', () => {
|
|
377
|
+
test('redeploys, clears the pause, and re-arms the subscriptions', async () => {
|
|
378
|
+
const { deps, state, db } = fakeDeps({ greenwave: 'PAUSED' });
|
|
379
|
+
const report = await executeUnpause(planFor('unpause', ['greenwave']), deps);
|
|
380
|
+
|
|
381
|
+
expect(report.success).toBe(true);
|
|
382
|
+
expect(state.redeployed).toEqual(['greenwave']);
|
|
383
|
+
expect(readRow(db, 'greenwave')?.state).toBe('INSTALLED');
|
|
384
|
+
expect(readRow(db, 'greenwave')?.pausedAt).toBeNull();
|
|
385
|
+
// Deploy does not register subscriptions — without this the module comes
|
|
386
|
+
// back deployed but permanently deaf.
|
|
387
|
+
expect(state.resubscribed).toEqual(['greenwave']);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('a failed redeploy leaves the module PAUSED and quiesced (task 3.6)', async () => {
|
|
391
|
+
const { deps, state, db } = fakeDeps({ caddy: 'PAUSED' }, { failRedeployOf: 'caddy' });
|
|
392
|
+
const report = await executeUnpause(planFor('unpause', ['caddy']), deps);
|
|
393
|
+
|
|
394
|
+
expect(report.success).toBe(false);
|
|
395
|
+
// The fake deploy moved it to ERROR; the executor must put it back.
|
|
396
|
+
expect(readRow(db, 'caddy')?.state).toBe('PAUSED');
|
|
397
|
+
expect(readRow(db, 'caddy')?.pausedAt).toEqual(new Date('2026-08-01T00:00:00Z'));
|
|
398
|
+
expect(state.unsubscribed).toContain('caddy');
|
|
399
|
+
expect(state.resubscribed).toEqual([]);
|
|
400
|
+
expect(state.operations.find((o) => o.kind === 'unpause')?.outcome).toBe('failed');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('a failed provider stops the cascade and leaves consumers paused, not mis-bound', async () => {
|
|
404
|
+
const { deps, state, db } = fakeDeps(
|
|
405
|
+
{ greenwave: 'PAUSED', caddy: 'PAUSED', website: 'PAUSED' },
|
|
406
|
+
{ failRedeployOf: 'greenwave' },
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const report = await executeUnpause(
|
|
410
|
+
planFor('unpause', ['greenwave', 'caddy', 'website']),
|
|
411
|
+
deps,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(report.success).toBe(false);
|
|
415
|
+
// Redeploying a consumer against a provider that is not there is the exact
|
|
416
|
+
// mis-binding this design exists to prevent.
|
|
417
|
+
expect(state.redeployed).toEqual(['greenwave']);
|
|
418
|
+
expect(readRow(db, 'caddy')?.state).toBe('PAUSED');
|
|
419
|
+
expect(readRow(db, 'website')?.state).toBe('PAUSED');
|
|
420
|
+
expect(report.outcomes.filter((o) => o.result === 'skipped')).toHaveLength(2);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('an already-unpaused member is skipped WITHOUT a redeploy (task 4.8)', async () => {
|
|
424
|
+
const { deps, state, db } = fakeDeps({ caddy: 'INSTALLED', website: 'PAUSED' });
|
|
425
|
+
const plan: PausePlan = {
|
|
426
|
+
action: 'unpause',
|
|
427
|
+
requested: 'caddy',
|
|
428
|
+
cascade: true,
|
|
429
|
+
stopInfra: false,
|
|
430
|
+
steps: [
|
|
431
|
+
{ moduleId: 'caddy', disposition: 'skip_already', note: 'not paused' },
|
|
432
|
+
{ moduleId: 'website', disposition: 'act' },
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const report = await executeUnpause(plan, deps);
|
|
437
|
+
|
|
438
|
+
expect(report.success).toBe(true);
|
|
439
|
+
expect(state.redeployed).toEqual(['website']);
|
|
440
|
+
expect(readRow(db, 'website')?.state).toBe('INSTALLED');
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('formatPausedDuration', () => {
|
|
445
|
+
const now = new Date('2026-08-12T12:00:00Z');
|
|
446
|
+
|
|
447
|
+
test.each([
|
|
448
|
+
[new Date('2026-08-12T11:59:30Z'), 'just now'],
|
|
449
|
+
[new Date('2026-08-12T11:15:00Z'), '45m'],
|
|
450
|
+
[new Date('2026-08-12T09:00:00Z'), '3h'],
|
|
451
|
+
[new Date('2026-08-09T12:00:00Z'), '3d'],
|
|
452
|
+
])('%s → %s', (pausedAt, expected) => {
|
|
453
|
+
expect(formatPausedDuration(pausedAt, now)).toBe(expected);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('a null timestamp reads as unknown rather than as zero', () => {
|
|
457
|
+
// Rendering "just now" for a missing timestamp would make an old pause look
|
|
458
|
+
// fresh, which is the one direction that matters.
|
|
459
|
+
expect(formatPausedDuration(null, now)).toBe('unknown');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('a pause/unpause round trip preserves everything (task 1.4)', () => {
|
|
464
|
+
// Pausing is NOT a partial uninstall. If a round trip lost config, secrets, or
|
|
465
|
+
// the IPAM allocation, the provider swap would come back with a different IP
|
|
466
|
+
// and the whole exercise would be worse than the removal it replaced.
|
|
467
|
+
test('config, secret and IPAM/VMID rows are byte-identical before and after', async () => {
|
|
468
|
+
const { deps, db } = fakeDeps({ caddy: 'INSTALLED' });
|
|
469
|
+
|
|
470
|
+
db.insert(moduleConfigs)
|
|
471
|
+
.values({
|
|
472
|
+
moduleId: 'caddy',
|
|
473
|
+
key: 'domain',
|
|
474
|
+
value: 'example.test',
|
|
475
|
+
valueJson: '"example.test"',
|
|
476
|
+
})
|
|
477
|
+
.run();
|
|
478
|
+
db.insert(secrets)
|
|
479
|
+
.values({
|
|
480
|
+
moduleId: 'caddy',
|
|
481
|
+
name: 'api_key',
|
|
482
|
+
encryptedValue: 'ciphertext',
|
|
483
|
+
iv: 'iv-value',
|
|
484
|
+
authTag: 'tag-value',
|
|
485
|
+
})
|
|
486
|
+
.run();
|
|
487
|
+
db.insert(ipAllocations)
|
|
488
|
+
.values({ moduleId: 'caddy', vmid: 231, containerIp: '10.0.20.31/24', zone: 'dmz' })
|
|
489
|
+
.run();
|
|
490
|
+
|
|
491
|
+
const before = {
|
|
492
|
+
configs: db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, 'caddy')).all(),
|
|
493
|
+
secrets: db.select().from(secrets).where(eq(secrets.moduleId, 'caddy')).all(),
|
|
494
|
+
ipam: db.select().from(ipAllocations).where(eq(ipAllocations.moduleId, 'caddy')).all(),
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
await executePause(planFor('pause', ['caddy']), deps, 'edge router swap');
|
|
498
|
+
expect(readRow(db, 'caddy')?.state).toBe('PAUSED');
|
|
499
|
+
|
|
500
|
+
await executeUnpause(planFor('unpause', ['caddy']), deps);
|
|
501
|
+
expect(readRow(db, 'caddy')?.state).toBe('INSTALLED');
|
|
502
|
+
|
|
503
|
+
expect(
|
|
504
|
+
db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, 'caddy')).all(),
|
|
505
|
+
).toEqual(before.configs);
|
|
506
|
+
expect(db.select().from(secrets).where(eq(secrets.moduleId, 'caddy')).all()).toEqual(
|
|
507
|
+
before.secrets,
|
|
508
|
+
);
|
|
509
|
+
// The allocation in particular: releasing it would hand the address to the
|
|
510
|
+
// next module and make the unpause land somewhere else entirely.
|
|
511
|
+
expect(
|
|
512
|
+
db.select().from(ipAllocations).where(eq(ipAllocations.moduleId, 'caddy')).all(),
|
|
513
|
+
).toEqual(before.ipam);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('the pause itself does not run on_uninstall', async () => {
|
|
517
|
+
// Pausing is not a partial uninstall: withdrawing the module's cross-module
|
|
518
|
+
// state is exactly what must NOT happen, or the consumers it registered for
|
|
519
|
+
// would be torn down by a pause meant to protect them.
|
|
520
|
+
const { deps, state } = fakeDeps({ caddy: 'INSTALLED' });
|
|
521
|
+
await executePause(planFor('pause', ['caddy']), deps, null);
|
|
522
|
+
|
|
523
|
+
expect(state.operations.map((o) => o.kind)).toEqual(['pause']);
|
|
524
|
+
expect(state.redeployed).toEqual([]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe('a cascade skips undeployed members instead of refusing (found by e2e)', () => {
|
|
529
|
+
// Regression: `pause --cascade greenwave` refused outright because
|
|
530
|
+
// `technitium` was IMPORTED — swept in from the manifest graph, never
|
|
531
|
+
// deployed. An undeployed module is not bound to the provider and has nothing
|
|
532
|
+
// to quiesce, so blocking on it wedges exactly the migration this feature
|
|
533
|
+
// exists to enable: one stray imported module stops the swap.
|
|
534
|
+
test('an IMPORTED consumer is skipped, and the rest of the cascade proceeds', () => {
|
|
535
|
+
const plan = planPause({
|
|
536
|
+
moduleId: 'greenwave',
|
|
537
|
+
fleet: fleet({ technitium: 'IMPORTED' }),
|
|
538
|
+
cascade: true,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const technitium = plan.steps.find((s) => s.moduleId === 'technitium');
|
|
542
|
+
expect(technitium?.disposition).toBe('skip_undeployed');
|
|
543
|
+
expect(actedOn(plan).sort()).toEqual(['caddy', 'greenwave', 'website']);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test.each([['IMPORTED'], ['VALIDATED'], ['CONFIGURED']] as const)(
|
|
547
|
+
'a %s consumer does not block the cascade',
|
|
548
|
+
(state: ModuleState) => {
|
|
549
|
+
expect(() =>
|
|
550
|
+
planPause({ moduleId: 'greenwave', fleet: fleet({ technitium: state }), cascade: true }),
|
|
551
|
+
).not.toThrow();
|
|
552
|
+
},
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
test('but naming an undeployed module DIRECTLY is still refused', () => {
|
|
556
|
+
// The operator asked for something that cannot happen; that is worth saying.
|
|
557
|
+
expect(() =>
|
|
558
|
+
planPause({
|
|
559
|
+
moduleId: 'technitium',
|
|
560
|
+
fleet: fleet({ technitium: 'IMPORTED' }),
|
|
561
|
+
cascade: false,
|
|
562
|
+
}),
|
|
563
|
+
).toThrow(/never been deployed/);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('an in-flight cascade member still refuses — it WILL be bound', () => {
|
|
567
|
+
// Distinct from undeployed: a module mid-deploy is on its way to being
|
|
568
|
+
// bound to the provider, so pausing around it would strand the transition.
|
|
569
|
+
expect(() =>
|
|
570
|
+
planPause({ moduleId: 'greenwave', fleet: fleet({ caddy: 'DEPLOYING' }), cascade: true }),
|
|
571
|
+
).toThrow(/strand/);
|
|
572
|
+
});
|
|
573
|
+
});
|