@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,175 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
3
|
+
import {
|
|
4
|
+
type DependentCandidate,
|
|
5
|
+
describeRemovalRefusal,
|
|
6
|
+
findRemovalBlockers,
|
|
7
|
+
} from './remove-guard';
|
|
8
|
+
|
|
9
|
+
function manifest(
|
|
10
|
+
id: string,
|
|
11
|
+
opts: { requires?: string[]; optional?: string[] } = {},
|
|
12
|
+
): ModuleManifest {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
name: id,
|
|
16
|
+
version: '1.0.0',
|
|
17
|
+
celilo_contract: '1.0',
|
|
18
|
+
provides: { capabilities: [] },
|
|
19
|
+
requires: { capabilities: (opts.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
|
|
20
|
+
optional: opts.optional
|
|
21
|
+
? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) }
|
|
22
|
+
: undefined,
|
|
23
|
+
} as unknown as ModuleManifest;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function candidate(
|
|
27
|
+
id: string,
|
|
28
|
+
opts: { requires?: string[]; optional?: string[]; paused?: boolean; deployed?: boolean } = {},
|
|
29
|
+
): DependentCandidate {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
manifest: manifest(id, opts),
|
|
33
|
+
paused: opts.paused ?? false,
|
|
34
|
+
deployed: opts.deployed ?? true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What `greenwave` provides, and the consumers that wedged its removal. */
|
|
39
|
+
const GREENWAVE_CAPABILITIES = ['firewall', 'dhcp_server'];
|
|
40
|
+
|
|
41
|
+
describe('findRemovalBlockers — the guard that wedged the greenwave swap', () => {
|
|
42
|
+
test('a live consumer blocks removal', () => {
|
|
43
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
44
|
+
candidate('caddy', { requires: ['firewall'] }),
|
|
45
|
+
]);
|
|
46
|
+
expect(blockers).toEqual([{ moduleId: 'caddy', capability: 'firewall', kind: 'requires' }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a PAUSED consumer does not block (task 5.1)', () => {
|
|
50
|
+
// Sound because unpause cannot return it to service without a redeploy, and
|
|
51
|
+
// a redeploy re-resolves its capabilities against whatever is present then.
|
|
52
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
53
|
+
candidate('caddy', { requires: ['firewall'], paused: true }),
|
|
54
|
+
]);
|
|
55
|
+
expect(blockers).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a PARTIALLY paused dependent set still blocks, naming only the live ones', () => {
|
|
59
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
60
|
+
candidate('caddy', { requires: ['firewall'], paused: true }),
|
|
61
|
+
candidate('authentik', { requires: ['firewall'] }),
|
|
62
|
+
]);
|
|
63
|
+
expect(blockers.map((b) => b.moduleId)).toEqual(['authentik']);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('findRemovalBlockers — the optional-dependency regression (task 5.4)', () => {
|
|
68
|
+
// technitium consumes dhcp_server under `optional:`, so the old guard —
|
|
69
|
+
// which read `requires` alone — let a greenwave removal orphan its
|
|
70
|
+
// DHCP-handed-out DNS silently while correctly blocking on caddy.
|
|
71
|
+
test('an optional consumer NOW blocks, where it used to be invisible', () => {
|
|
72
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
73
|
+
candidate('technitium', { optional: ['dhcp_server'] }),
|
|
74
|
+
]);
|
|
75
|
+
expect(blockers).toEqual([
|
|
76
|
+
{ moduleId: 'technitium', capability: 'dhcp_server', kind: 'optional' },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('and stops blocking once paused', () => {
|
|
81
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
82
|
+
candidate('technitium', { optional: ['dhcp_server'], paused: true }),
|
|
83
|
+
]);
|
|
84
|
+
expect(blockers).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a module depending BOTH ways is reported once, as the stronger claim', () => {
|
|
88
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
89
|
+
candidate('both', { requires: ['firewall'], optional: ['dhcp_server'] }),
|
|
90
|
+
]);
|
|
91
|
+
expect(blockers).toHaveLength(1);
|
|
92
|
+
expect(blockers[0].kind).toBe('requires');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('findRemovalBlockers — deliberate non-goals', () => {
|
|
97
|
+
test('another provider existing does NOT by itself permit removal (celilo#683, task 5.5)', () => {
|
|
98
|
+
// `axon` provides the same capabilities as `greenwave`. The guard still
|
|
99
|
+
// refuses, because celilo does not reason about provider substitutability —
|
|
100
|
+
// pausing is the mechanism this change relies on instead.
|
|
101
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
102
|
+
candidate('axon'),
|
|
103
|
+
candidate('caddy', { requires: ['firewall'] }),
|
|
104
|
+
]);
|
|
105
|
+
expect(blockers.map((b) => b.moduleId)).toEqual(['caddy']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a module consuming an unrelated capability is not a dependent', () => {
|
|
109
|
+
expect(
|
|
110
|
+
findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
111
|
+
candidate('blog', { requires: ['public_web'] }),
|
|
112
|
+
]),
|
|
113
|
+
).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a provider of nothing blocks nobody', () => {
|
|
117
|
+
expect(findRemovalBlockers([], [candidate('caddy', { requires: ['firewall'] })])).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('describeRemovalRefusal', () => {
|
|
122
|
+
test('names each blocker AND which declaration makes it one (task 5.3)', () => {
|
|
123
|
+
const message = describeRemovalRefusal('greenwave', [
|
|
124
|
+
{ moduleId: 'caddy', capability: 'firewall', kind: 'requires' },
|
|
125
|
+
{ moduleId: 'technitium', capability: 'dhcp_server', kind: 'optional' },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// The operator needs to tell a hard prerequisite from an optional consumer
|
|
129
|
+
// to judge what pausing it implies.
|
|
130
|
+
expect(message).toContain("caddy — requires 'firewall'");
|
|
131
|
+
expect(message).toContain("technitium — optional 'dhcp_server'");
|
|
132
|
+
// And it must point at the way out, since that is the whole change.
|
|
133
|
+
expect(message).toContain('celilo module pause --cascade greenwave');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('an undeployed dependent does not block (guard/cascade agreement)', () => {
|
|
138
|
+
// The spec says removal is refused for another INSTALLED module. An imported
|
|
139
|
+
// module has never resolved the capability and cannot be orphaned by the
|
|
140
|
+
// provider going away.
|
|
141
|
+
//
|
|
142
|
+
// This is load-bearing for D3's guard/cascade agreement, not a nicety.
|
|
143
|
+
// `pause --cascade` SKIPS undeployed members — pausing needs a settled
|
|
144
|
+
// deployed state and there is nothing to quiesce. If the guard still counted
|
|
145
|
+
// them, the operator would pause the whole cascade and the removal would
|
|
146
|
+
// still refuse, naming a module that pause is structurally unable to act on.
|
|
147
|
+
// That is a dead end: no sequence of commands gets the operator out of it.
|
|
148
|
+
test('an IMPORTED consumer is not a blocker', () => {
|
|
149
|
+
expect(
|
|
150
|
+
findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
151
|
+
candidate('technitium', { optional: ['dhcp_server'], deployed: false }),
|
|
152
|
+
]),
|
|
153
|
+
).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('but a DEPLOYED one still is', () => {
|
|
157
|
+
expect(
|
|
158
|
+
findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
159
|
+
candidate('technitium', { optional: ['dhcp_server'], deployed: true }),
|
|
160
|
+
]).map((b) => b.moduleId),
|
|
161
|
+
).toEqual(['technitium']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('the guard and the cascade agree on every combination', () => {
|
|
165
|
+
// Whatever pause can act on, the guard must count; whatever pause skips,
|
|
166
|
+
// the guard must ignore. Enumerated so a future change to either side
|
|
167
|
+
// cannot silently break the pairing.
|
|
168
|
+
const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [
|
|
169
|
+
candidate('live', { requires: ['firewall'] }),
|
|
170
|
+
candidate('paused', { requires: ['firewall'], paused: true }),
|
|
171
|
+
candidate('imported', { requires: ['firewall'], deployed: false }),
|
|
172
|
+
]);
|
|
173
|
+
expect(blockers.map((b) => b.moduleId)).toEqual(['live']);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dependency guard on `module remove`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `cli/commands/module-remove.ts` so it can be unit-tested
|
|
5
|
+
* without a database, and so the two behaviour changes it carries are visible
|
|
6
|
+
* in one place (openspec/changes/module-pause-lifecycle, design D3):
|
|
7
|
+
*
|
|
8
|
+
* 1. A PAUSED module is not a dependent. Sound rather than a loosening: a
|
|
9
|
+
* paused module cannot return to service without a redeploy, and that
|
|
10
|
+
* redeploy re-resolves its capabilities against whatever providers exist
|
|
11
|
+
* at the time. The invariant is "paused implies not currently bound, and
|
|
12
|
+
* guaranteed to rebind before going live".
|
|
13
|
+
*
|
|
14
|
+
* 2. A dependent is one that declares the capability under `requires` OR
|
|
15
|
+
* `optional`, which is what `services/update/dep-graph.ts` already means
|
|
16
|
+
* by an edge. The guard used to consider `requires` alone, and the two
|
|
17
|
+
* definitions disagreeing was silently harmful: `technitium` consumes
|
|
18
|
+
* `dhcp_server` under `optional:`, so removing its provider orphaned its
|
|
19
|
+
* DHCP-handed-out DNS without a word while correctly blocking on `caddy`.
|
|
20
|
+
* The guard and the cascade MUST agree on the set, or `pause --cascade`
|
|
21
|
+
* pauses a set the guard still rejects.
|
|
22
|
+
*
|
|
23
|
+
* Note (2) makes the guard STRICTER for optional consumers while (1) makes it
|
|
24
|
+
* looser for paused ones. Both directions are intended.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
28
|
+
|
|
29
|
+
/** How a module came to depend on the capability — reported, not just counted. */
|
|
30
|
+
export type DependencyKind = 'requires' | 'optional';
|
|
31
|
+
|
|
32
|
+
export interface Blocker {
|
|
33
|
+
moduleId: string;
|
|
34
|
+
capability: string;
|
|
35
|
+
kind: DependencyKind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DependentCandidate {
|
|
39
|
+
id: string;
|
|
40
|
+
manifest: ModuleManifest;
|
|
41
|
+
paused: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Has this module actually been deployed? The spec says removal is refused
|
|
44
|
+
* when another **installed** module depends on the capability, and an
|
|
45
|
+
* imported-but-never-deployed module is not installed: it has never resolved
|
|
46
|
+
* the capability, holds no state derived from it, and cannot be orphaned by
|
|
47
|
+
* the provider going away.
|
|
48
|
+
*
|
|
49
|
+
* This is not a nicety — it is required for the guard and the cascade to
|
|
50
|
+
* agree (design D3). `pause --cascade` skips undeployed members, because
|
|
51
|
+
* pausing requires a settled deployed state and there is nothing to quiesce.
|
|
52
|
+
* If the guard still counted them, the operator would pause the full cascade
|
|
53
|
+
* and the removal would STILL refuse, naming a module that pause cannot act
|
|
54
|
+
* on — a dead end with no way forward.
|
|
55
|
+
*/
|
|
56
|
+
deployed: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Modules that block removal of a provider of `providedCapabilities`.
|
|
61
|
+
*
|
|
62
|
+
* Pure. Paused candidates are excluded; a candidate depending via both
|
|
63
|
+
* `requires` and `optional` is reported once, as `requires` (the stronger
|
|
64
|
+
* claim, and the one whose removal consequence is worse).
|
|
65
|
+
*/
|
|
66
|
+
export function findRemovalBlockers(
|
|
67
|
+
providedCapabilities: Iterable<string>,
|
|
68
|
+
candidates: DependentCandidate[],
|
|
69
|
+
): Blocker[] {
|
|
70
|
+
const provided = new Set(providedCapabilities);
|
|
71
|
+
if (provided.size === 0) return [];
|
|
72
|
+
|
|
73
|
+
const blockers: Blocker[] = [];
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
if (candidate.paused) continue;
|
|
76
|
+
if (!candidate.deployed) continue;
|
|
77
|
+
|
|
78
|
+
const required = (candidate.manifest.requires?.capabilities ?? []).map((c) => c.name);
|
|
79
|
+
const optional = (candidate.manifest.optional?.capabilities ?? []).map((c) => c.name);
|
|
80
|
+
|
|
81
|
+
const hit =
|
|
82
|
+
required.find((name) => provided.has(name)) ?? optional.find((name) => provided.has(name));
|
|
83
|
+
if (!hit) continue;
|
|
84
|
+
|
|
85
|
+
blockers.push({
|
|
86
|
+
moduleId: candidate.id,
|
|
87
|
+
capability: hit,
|
|
88
|
+
kind: required.includes(hit) ? 'requires' : 'optional',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return blockers.sort((a, b) => a.moduleId.localeCompare(b.moduleId));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The refusal an operator reads. Names each blocker AND which declaration makes
|
|
97
|
+
* it one, so a hard prerequisite is distinguishable from an optional consumer —
|
|
98
|
+
* the operator needs that to judge what pausing it implies.
|
|
99
|
+
*/
|
|
100
|
+
export function describeRemovalRefusal(moduleId: string, blockers: Blocker[]): string {
|
|
101
|
+
const lines = blockers.map((b) => ` • ${b.moduleId} — ${b.kind} '${b.capability}'`);
|
|
102
|
+
return [
|
|
103
|
+
`Cannot remove '${moduleId}': the following installed modules depend on its capabilities:`,
|
|
104
|
+
...lines,
|
|
105
|
+
'',
|
|
106
|
+
'Pause them first so they rebind on their next deploy:',
|
|
107
|
+
` celilo module pause --cascade ${moduleId}`,
|
|
108
|
+
].join('\n');
|
|
109
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal-responder: when a deploy runs on a TTY, this subscribes
|
|
3
3
|
* to `config.required.*`, `secret.required.*`, and `ensure.required.*`
|
|
4
|
-
* events and prompts the operator
|
|
5
|
-
* on the bus.
|
|
4
|
+
* events and prompts the operator on the terminal for each one,
|
|
5
|
+
* replying on the bus.
|
|
6
6
|
*
|
|
7
7
|
* Just one of several responder shapes — the bus query/reply path is
|
|
8
8
|
* a race, and the terminal is one racer. Other racers (Claude
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { hostname } from 'node:os';
|
|
29
|
+
import { confirm, isCancel, multiselect, select } from '@celilo/cli-display';
|
|
29
30
|
import { type Bus, openBus } from '@celilo/event-bus';
|
|
30
31
|
import { defineEvents } from '@celilo/event-bus';
|
|
31
|
-
import * as p from '@clack/prompts';
|
|
32
32
|
import { log, promptPassword, promptText } from '../cli/prompts';
|
|
33
33
|
import { getEventBusPath } from '../config/paths';
|
|
34
34
|
import { getDb } from '../db/client';
|
|
@@ -61,7 +61,7 @@ export interface TerminalResponderHandle {
|
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Register transient subscriptions on the three interview event
|
|
64
|
-
* families. For each event, prompt the operator
|
|
64
|
+
* families. For each event, prompt the operator on the terminal, optionally
|
|
65
65
|
* inject the secret value out-of-band, and reply on the bus.
|
|
66
66
|
*
|
|
67
67
|
* Returns a handle the caller can close() when the deploy completes.
|
|
@@ -103,9 +103,9 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
103
103
|
|
|
104
104
|
if (payload.options && payload.options.length > 0) {
|
|
105
105
|
// Multi-select prompt for vars with options[] declared in the
|
|
106
|
-
// manifest.
|
|
107
|
-
//
|
|
108
|
-
const selected = await
|
|
106
|
+
// manifest. multiselect returns an array of selected values
|
|
107
|
+
// directly — no JSON-typing to coerce.
|
|
108
|
+
const selected = await multiselect({
|
|
109
109
|
message,
|
|
110
110
|
options: payload.options.map((opt) => ({
|
|
111
111
|
value: opt.value,
|
|
@@ -114,7 +114,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
114
114
|
})),
|
|
115
115
|
required: payload.required,
|
|
116
116
|
});
|
|
117
|
-
if (
|
|
117
|
+
if (isCancel(selected)) {
|
|
118
118
|
log.warn(
|
|
119
119
|
`Terminal responder: cancelled prompt for ${payload.module}.${payload.key}; no reply emitted`,
|
|
120
120
|
);
|
|
@@ -308,11 +308,11 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
308
308
|
let value: unknown;
|
|
309
309
|
|
|
310
310
|
if (payload.kind === 'confirm') {
|
|
311
|
-
const answer = await
|
|
311
|
+
const answer = await confirm({
|
|
312
312
|
message,
|
|
313
313
|
initialValue: payload.defaultValue === 'true',
|
|
314
314
|
});
|
|
315
|
-
if (
|
|
315
|
+
if (isCancel(answer)) {
|
|
316
316
|
log.warn(
|
|
317
317
|
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
318
318
|
);
|
|
@@ -320,7 +320,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
320
320
|
}
|
|
321
321
|
value = answer;
|
|
322
322
|
} else if (payload.kind === 'select') {
|
|
323
|
-
const answer = await
|
|
323
|
+
const answer = await select({
|
|
324
324
|
message,
|
|
325
325
|
options: (payload.options ?? []).map((opt) => ({
|
|
326
326
|
value: opt.value,
|
|
@@ -329,7 +329,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
329
329
|
})),
|
|
330
330
|
initialValue: payload.defaultValue,
|
|
331
331
|
});
|
|
332
|
-
if (
|
|
332
|
+
if (isCancel(answer)) {
|
|
333
333
|
log.warn(
|
|
334
334
|
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
335
335
|
);
|
|
@@ -337,7 +337,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
337
337
|
}
|
|
338
338
|
value = answer;
|
|
339
339
|
} else if (payload.kind === 'multiselect') {
|
|
340
|
-
const answer = await
|
|
340
|
+
const answer = await multiselect({
|
|
341
341
|
message,
|
|
342
342
|
options: (payload.options ?? []).map((opt) => ({
|
|
343
343
|
value: opt.value,
|
|
@@ -346,7 +346,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
346
346
|
})),
|
|
347
347
|
required: payload.required,
|
|
348
348
|
});
|
|
349
|
-
if (
|
|
349
|
+
if (isCancel(answer)) {
|
|
350
350
|
log.warn(
|
|
351
351
|
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
352
352
|
);
|
|
@@ -624,8 +624,8 @@ function describeTypeHint(type: ConfigRequiredPayload['type']): string | null {
|
|
|
624
624
|
}
|
|
625
625
|
|
|
626
626
|
function coerceValue(raw: string | undefined, type: ConfigRequiredPayload['type']): unknown {
|
|
627
|
-
//
|
|
628
|
-
// that up as an error so validate sees it and the responder can
|
|
627
|
+
// The prompt layer yields undefined when the user cancels (Ctrl+C).
|
|
628
|
+
// Bubble that up as an error so validate sees it and the responder can
|
|
629
629
|
// skip the reply rather than emit a malformed one.
|
|
630
630
|
if (raw === undefined) {
|
|
631
631
|
throw new Error('Cancelled');
|
|
@@ -80,13 +80,42 @@ describe('buildModuleGraph', () => {
|
|
|
80
80
|
expect(g.edges.get('weird')?.size).toBe(0);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// Previously asserted "first declared provider wins". That was the behaviour,
|
|
84
|
+
// and it was a defect: `capability-loader.ts` resolves a `firewall` consumer
|
|
85
|
+
// through `buildFirewallChain()`, which wires a chain across EVERY provider —
|
|
86
|
+
// the edge device that owns egress plus the downstream layers. First-wins made
|
|
87
|
+
// the others invisible, so `module pause --cascade greenwave` omitted `caddy`
|
|
88
|
+
// even though caddy requires `firewall` and greenwave provides it, and the
|
|
89
|
+
// remove guard (which reads all providers) then refused the removal the
|
|
90
|
+
// cascade was supposed to enable.
|
|
91
|
+
test('a consumer depends on EVERY provider of a capability, not just the first', () => {
|
|
84
92
|
const g = buildModuleGraph([
|
|
85
|
-
makeManifest('
|
|
86
|
-
makeManifest('
|
|
93
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
94
|
+
makeManifest('greenwave', { provides: ['firewall'] }),
|
|
95
|
+
makeManifest('caddy', { requires: ['firewall'] }),
|
|
96
|
+
]);
|
|
97
|
+
expect([...(g.edges.get('caddy') ?? [])].sort()).toEqual(['greenwave', 'iptables']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('and every provider sees that consumer in its reverse index', () => {
|
|
101
|
+
// This is the half `transitiveConsumers` walks, so it is what decides
|
|
102
|
+
// whether a cascade reaches the module at all.
|
|
103
|
+
const g = buildModuleGraph([
|
|
104
|
+
makeManifest('iptables', { provides: ['firewall'] }),
|
|
105
|
+
makeManifest('greenwave', { provides: ['firewall'] }),
|
|
87
106
|
makeManifest('caddy', { requires: ['firewall'] }),
|
|
88
107
|
]);
|
|
89
|
-
expect([...(g.
|
|
108
|
+
expect([...(g.reverseEdges.get('greenwave') ?? [])]).toEqual(['caddy']);
|
|
109
|
+
expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('an optional consumer also edges to every provider', () => {
|
|
113
|
+
const g = buildModuleGraph([
|
|
114
|
+
makeManifest('greenwave', { provides: ['dhcp_server'] }),
|
|
115
|
+
makeManifest('router2', { provides: ['dhcp_server'] }),
|
|
116
|
+
makeManifest('technitium', { optional: ['dhcp_server'] }),
|
|
117
|
+
]);
|
|
118
|
+
expect([...(g.edges.get('technitium') ?? [])].sort()).toEqual(['greenwave', 'router2']);
|
|
90
119
|
});
|
|
91
120
|
});
|
|
92
121
|
|
|
@@ -45,15 +45,36 @@ export interface ModuleGraph {
|
|
|
45
45
|
reverseEdges: Map<ModuleId, Set<ModuleId>>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Internal: index capability name → EVERY module providing it.
|
|
50
|
+
*
|
|
51
|
+
* This used to keep only the first provider, on the reasoning that "one edge per
|
|
52
|
+
* capability is enough for graph-walking". It is not, and the discrepancy is
|
|
53
|
+
* not theoretical: `firewall` is provided by BOTH `iptables` and `greenwave`,
|
|
54
|
+
* and `capability-loader.ts` resolves a consumer's `requires: firewall` through
|
|
55
|
+
* `buildFirewallChain()`, which wires a chain across ALL providers — the edge
|
|
56
|
+
* device that owns egress plus the downstream layers. So a consumer genuinely
|
|
57
|
+
* depends on every one of them.
|
|
58
|
+
*
|
|
59
|
+
* With first-wins, whichever provider happened to be inserted first absorbed the
|
|
60
|
+
* edge and the others became invisible to the graph. `module pause --cascade
|
|
61
|
+
* greenwave` therefore omitted `caddy` entirely, even though caddy requires
|
|
62
|
+
* `firewall` and greenwave provides it — so the operator paused an incomplete
|
|
63
|
+
* set and `module remove` then refused, because the remove guard reads the
|
|
64
|
+
* capabilities table (all providers) and disagreed. That disagreement is exactly
|
|
65
|
+
* what openspec/changes/module-pause-lifecycle design D3 forbids.
|
|
66
|
+
*
|
|
67
|
+
* Keeping all providers makes the graph agree with how capabilities actually
|
|
68
|
+
* resolve. For `topologicalOrder` it means a consumer sorts after every provider
|
|
69
|
+
* of what it consumes, which is strictly more correct for `system update`.
|
|
70
|
+
*/
|
|
71
|
+
function indexProviders(modules: ModuleNode[]): Map<string, ModuleId[]> {
|
|
72
|
+
const index = new Map<string, ModuleId[]>();
|
|
51
73
|
for (const m of modules) {
|
|
52
74
|
for (const cap of m.provides) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!index.has(cap)) index.set(cap, m.id);
|
|
75
|
+
const existing = index.get(cap);
|
|
76
|
+
if (existing) existing.push(m.id);
|
|
77
|
+
else index.set(cap, [m.id]);
|
|
57
78
|
}
|
|
58
79
|
}
|
|
59
80
|
return index;
|
|
@@ -82,16 +103,17 @@ export function buildModuleGraph(manifests: ModuleManifest[]): ModuleGraph {
|
|
|
82
103
|
reverseEdges.set(node.id, reverseEdges.get(node.id) ?? new Set());
|
|
83
104
|
|
|
84
105
|
for (const cap of node.consumes) {
|
|
85
|
-
const providerId
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
for (const providerId of providerIndex.get(cap) ?? []) {
|
|
107
|
+
// Skip self-edges (a module that consumes its own capability —
|
|
108
|
+
// unusual but legal) and missing providers (deploy preflight
|
|
109
|
+
// handles those; graph just routes around them).
|
|
110
|
+
if (providerId === node.id) continue;
|
|
111
|
+
|
|
112
|
+
edges.get(node.id)?.add(providerId);
|
|
113
|
+
const rev = reverseEdges.get(providerId) ?? new Set();
|
|
114
|
+
rev.add(node.id);
|
|
115
|
+
reverseEdges.set(providerId, rev);
|
|
116
|
+
}
|
|
95
117
|
}
|
|
96
118
|
}
|
|
97
119
|
|
|
@@ -3,48 +3,11 @@
|
|
|
3
3
|
* Auto-detects network zone from IP address by matching against system subnets
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { subnetContains } from '@celilo/capabilities';
|
|
6
7
|
import { eq } from 'drizzle-orm';
|
|
7
8
|
import { getDb } from '../db/client';
|
|
8
9
|
import { type NetworkZone, systemConfig } from '../db/schema';
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
* Parse CIDR notation to get network address and prefix length
|
|
12
|
-
*/
|
|
13
|
-
function parseCIDR(cidr: string): { network: string; prefixLength: number } {
|
|
14
|
-
const [ip, prefix] = cidr.split('/');
|
|
15
|
-
return {
|
|
16
|
-
network: ip,
|
|
17
|
-
prefixLength: Number.parseInt(prefix, 10),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Convert IP address string to 32-bit integer
|
|
23
|
-
*/
|
|
24
|
-
function ipToInt(ip: string): number {
|
|
25
|
-
const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
|
|
26
|
-
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Check if an IP address is in a CIDR subnet
|
|
31
|
-
*/
|
|
32
|
-
function ipInSubnet(ip: string, cidr: string): boolean {
|
|
33
|
-
try {
|
|
34
|
-
const { network, prefixLength } = parseCIDR(cidr);
|
|
35
|
-
const ipInt = ipToInt(ip);
|
|
36
|
-
const networkInt = ipToInt(network);
|
|
37
|
-
|
|
38
|
-
// Create subnet mask
|
|
39
|
-
const mask = ~((1 << (32 - prefixLength)) - 1);
|
|
40
|
-
|
|
41
|
-
// Check if IP is in subnet
|
|
42
|
-
return (ipInt & mask) === (networkInt & mask);
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
11
|
/**
|
|
49
12
|
* Get system network configuration for a zone
|
|
50
13
|
*/
|
|
@@ -77,7 +40,7 @@ export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
|
|
|
77
40
|
|
|
78
41
|
for (const zone of zones) {
|
|
79
42
|
const subnet = await getZoneSubnet(zone);
|
|
80
|
-
if (subnet &&
|
|
43
|
+
if (subnet && subnetContains(subnet, ip)) {
|
|
81
44
|
return zone;
|
|
82
45
|
}
|
|
83
46
|
}
|
package/src/test-utils/cli.ts
CHANGED
|
@@ -6,13 +6,17 @@ import { execSync } from 'node:child_process';
|
|
|
6
6
|
* Disconnects stdin to ensure CLI cannot prompt for user input.
|
|
7
7
|
* Any attempt to read from stdin will immediately fail, preventing hanging tests.
|
|
8
8
|
*
|
|
9
|
-
* When command fails, throws error
|
|
9
|
+
* When command fails, throws an error carrying stderr — where the CLI writes
|
|
10
|
+
* its diagnostics. Both helpers used to concatenate stdout and stderr because
|
|
11
|
+
* `@clack/prompts` put error messages on stdout, which left no way to tell a
|
|
12
|
+
* result from a complaint about producing one (celilo#699). The streams are
|
|
13
|
+
* separate now and this reads only the one that carries diagnostics.
|
|
10
14
|
*
|
|
11
15
|
* @param cli - CLI command prefix (includes env vars and path)
|
|
12
16
|
* @param command - Command to execute (e.g., "system config get")
|
|
13
17
|
* @param options - Additional exec options
|
|
14
|
-
* @returns Command
|
|
15
|
-
* @throws Error with stderr message when command fails
|
|
18
|
+
* @returns Command stdout as string
|
|
19
|
+
* @throws Error with the stderr message when command fails
|
|
16
20
|
*/
|
|
17
21
|
export function runCli(cli: string, command: string, options: { encoding?: 'utf-8' } = {}): string {
|
|
18
22
|
try {
|
|
@@ -22,34 +26,31 @@ export function runCli(cli: string, command: string, options: { encoding?: 'utf-
|
|
|
22
26
|
timeout: 30000, // 30 second timeout to prevent tests hanging indefinitely
|
|
23
27
|
});
|
|
24
28
|
} catch (error: unknown) {
|
|
25
|
-
// CLI writes errors to stdout (via @clack/prompts), so capture both
|
|
26
|
-
const stdout = (error as { stdout?: Buffer }).stdout?.toString() || '';
|
|
27
29
|
const stderr = (error as { stderr?: Buffer }).stderr?.toString() || '';
|
|
28
|
-
|
|
29
|
-
const output = [stdout, stderr].filter(Boolean).join('\n') || (error as Error).message;
|
|
30
|
-
throw new Error(output);
|
|
30
|
+
throw new Error(stderr.trim() || (error as Error).message);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Execute CLI command expecting failure
|
|
36
|
-
* Returns
|
|
36
|
+
* Returns stderr for assertion — that is where the CLI writes diagnostics.
|
|
37
37
|
*
|
|
38
38
|
* Disconnects stdin to ensure CLI cannot prompt for user input.
|
|
39
39
|
*
|
|
40
40
|
* @param cli - CLI command prefix
|
|
41
41
|
* @param command - Command to execute
|
|
42
|
-
* @returns
|
|
42
|
+
* @returns stderr as string
|
|
43
43
|
* @throws Error if command succeeds (when it should fail)
|
|
44
44
|
*/
|
|
45
45
|
export function runCliExpectingFailure(cli: string, command: string): string {
|
|
46
46
|
try {
|
|
47
47
|
execSync(`${cli} ${command}`, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
|
-
throw new Error('Expected command to fail but it succeeded');
|
|
49
48
|
} catch (error: unknown) {
|
|
50
|
-
// CLI writes errors to stdout (via @clack/prompts), so capture both
|
|
51
|
-
const stdout = (error as { stdout?: Buffer }).stdout?.toString() || '';
|
|
52
49
|
const stderr = (error as { stderr?: Buffer }).stderr?.toString() || '';
|
|
53
|
-
return
|
|
50
|
+
return stderr.trim() || (error as Error).message;
|
|
54
51
|
}
|
|
52
|
+
// Previously thrown from inside the `try`, where the catch below swallowed it
|
|
53
|
+
// and RETURNED the message — so a command that wrongly succeeded read as a
|
|
54
|
+
// passing assertion about its own failure.
|
|
55
|
+
throw new Error('Expected command to fail but it succeeded');
|
|
55
56
|
}
|
|
@@ -31,3 +31,29 @@ export function skipIntegration(
|
|
|
31
31
|
if (req.platform && process.platform !== req.platform) return true;
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Quarantine a test that FAILS ON THE CI RUNNER for a reason we have not yet
|
|
37
|
+
* explained, while keeping it running for developers locally.
|
|
38
|
+
*
|
|
39
|
+
* This is the `cele2e-ci-unsafe` device (see `--ci-safe` in the cele2e runner)
|
|
40
|
+
* applied to the integration suite, and it is deliberately uncomfortable to
|
|
41
|
+
* use: the caller must pass the tracking issue, and the whole point is that the
|
|
42
|
+
* skip is legible in the log rather than silent.
|
|
43
|
+
*
|
|
44
|
+
* Use this ONLY when the failure is not explained by a missing tool — reach for
|
|
45
|
+
* `skipIntegration({ tools: [...] })` first, because "which tool" is a real
|
|
46
|
+
* diagnosis and "quarantined" is an admission that we do not have one yet.
|
|
47
|
+
*
|
|
48
|
+
* The alternative is worse in both directions: leaving the test in makes a gate
|
|
49
|
+
* permanently red, which trains everyone to ignore it — the same disease as a
|
|
50
|
+
* gate that cannot fail. Deleting it loses the coverage and the evidence.
|
|
51
|
+
*
|
|
52
|
+
* Keyed on `GITHUB_ACTIONS`, which the Forgejo runner sets (it is what makes
|
|
53
|
+
* `bun test` emit `::error` annotations there).
|
|
54
|
+
*
|
|
55
|
+
* Usage: test.skipIf(quarantinedInCi('celilo#713'))('...', ...)
|
|
56
|
+
*/
|
|
57
|
+
export function quarantinedInCi(_trackingIssue: string): boolean {
|
|
58
|
+
return process.env.GITHUB_ACTIONS === 'true';
|
|
59
|
+
}
|