@celilo/cli 0.23.0 → 0.24.1
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 +2 -2
- package/CELILO_SUBSYSTEMS.md +27 -7
- package/package.json +6 -5
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +70 -3
- package/src/cli/commands/module-config.ts +262 -28
- package/src/cli/commands/module-status.ts +155 -12
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +35 -9
- package/src/cli/index.ts +59 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/sweep-runner.test.ts +51 -3
- package/src/services/alerting/sweep-runner.ts +30 -7
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +33 -13
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- package/src/variables/declarative-derivation.ts +13 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo firewall interface list [<hostname>]`
|
|
3
|
+
*
|
|
4
|
+
* The interface classification, on demand, with no side effects.
|
|
5
|
+
*
|
|
6
|
+
* A converge already refuses or isolates on what it finds — but only when it
|
|
7
|
+
* runs, and only in the middle of a deploy's output. An operator about to
|
|
8
|
+
* onboard a firewall, or wondering why one refused, needs to be able to ASK.
|
|
9
|
+
* The condition that produced `fw-keeper.sh` was not that celilo lacked the
|
|
10
|
+
* information; it was that celilo never said it.
|
|
11
|
+
*
|
|
12
|
+
* Read-only by construction: it reads the stored interface table and the
|
|
13
|
+
* declarations, and classifies in memory. It never touches the box, so it is
|
|
14
|
+
* safe to run against a firewall that is currently refusing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { classifyInterfaces, isPubliclyRoutable } from '@celilo/capabilities';
|
|
18
|
+
import { getDb } from '../../db/client';
|
|
19
|
+
import { listFirewallIps, readDeclaredNetworks } from '../../hooks/capability-loader';
|
|
20
|
+
import { listMachines } from '../../services/machine-pool';
|
|
21
|
+
import { celiloIntro } from '../prompts';
|
|
22
|
+
import type { CommandResult } from '../types';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every network a subnet is declared for — THE same reader the converge uses.
|
|
26
|
+
*
|
|
27
|
+
* This command's whole value is telling an operator what the next converge will
|
|
28
|
+
* do, so reading declarations a second way is not a duplication smell, it is a
|
|
29
|
+
* correctness bug: this file walked `NETWORK_ZONES` and so could not see
|
|
30
|
+
* `network.control-plane-vpn.subnet`. It would have reported `wg0` as ALIEN —
|
|
31
|
+
* "this will be isolated" — about an interface the converge attributes and
|
|
32
|
+
* leaves alone. The operator's most likely response to that reading is to go
|
|
33
|
+
* and remove their own admin VPN.
|
|
34
|
+
*/
|
|
35
|
+
async function declaredZones(): Promise<Array<{ zone: string; subnet: string }>> {
|
|
36
|
+
return readDeclaredNetworks(getDb());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One line per interface, explaining the role rather than just naming it. */
|
|
40
|
+
function describe(role: string, zone: string | undefined, ip: string): string {
|
|
41
|
+
if (role === 'zone') return `zone:${zone}`;
|
|
42
|
+
if (role === 'external') return 'external — the WAN edge';
|
|
43
|
+
return isPubliclyRoutable(ip)
|
|
44
|
+
? 'ALIEN — publicly routable but no declared zone claims it'
|
|
45
|
+
: 'ALIEN — no declared subnet contains it';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleFirewallInterfaceList(
|
|
49
|
+
args: string[],
|
|
50
|
+
_flags: Record<string, boolean | string> = {},
|
|
51
|
+
): Promise<CommandResult> {
|
|
52
|
+
celiloIntro('Firewall interfaces');
|
|
53
|
+
|
|
54
|
+
const wanted = args[0];
|
|
55
|
+
const machines = await listMachines();
|
|
56
|
+
// A firewall is a machine a firewall provider MANAGES. `role === 'router'` is
|
|
57
|
+
// kept as a second way in, but it cannot be the only one: the role is decided
|
|
58
|
+
// by `machine add` from the zones declared at that moment, and the normal
|
|
59
|
+
// order is to add the machine and THEN deploy iptables, whose `on_install`
|
|
60
|
+
// writes the zone subnets. So a working firewall is recorded as a plain host,
|
|
61
|
+
// and this command — whose entire purpose is to report on firewalls — answered
|
|
62
|
+
// "No firewalls in the machine pool" on a fleet that had one.
|
|
63
|
+
//
|
|
64
|
+
// Named explicitly, the hostname wins, so an operator can inspect any box.
|
|
65
|
+
const firewallIps = new Set(await listFirewallIps(getDb()));
|
|
66
|
+
const targets = wanted
|
|
67
|
+
? machines.filter((m) => m.hostname === wanted)
|
|
68
|
+
: machines.filter((m) => firewallIps.has(m.ipAddress) || m.role === 'router');
|
|
69
|
+
|
|
70
|
+
if (targets.length === 0) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
error: wanted
|
|
74
|
+
? `No machine named "${wanted}". Run \`celilo machine list\` to see the pool.`
|
|
75
|
+
: 'No firewalls in the machine pool. celilo looks for a machine managed by a firewall provider, or one it classified as a router.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const zones = await declaredZones();
|
|
80
|
+
if (zones.length === 0) {
|
|
81
|
+
console.log(
|
|
82
|
+
'No zone subnets are declared, so every interface will read as unaccounted for.\n' +
|
|
83
|
+
'Declare them with: celilo system config set network.<zone>.subnet <cidr>\n',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let alienTotal = 0;
|
|
88
|
+
for (const machine of targets) {
|
|
89
|
+
console.log(`\n${machine.hostname} (${machine.ipAddress})`);
|
|
90
|
+
|
|
91
|
+
const interfaces = (machine.interfaces ?? []).map((i) => ({
|
|
92
|
+
name: i.name,
|
|
93
|
+
ip: i.ipAddress,
|
|
94
|
+
}));
|
|
95
|
+
if (interfaces.length === 0) {
|
|
96
|
+
console.log(' no interfaces recorded — re-run `celilo machine add` to detect them');
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const c of classifyInterfaces(interfaces, zones)) {
|
|
101
|
+
if (c.role === 'alien') alienTotal += 1;
|
|
102
|
+
console.log(` ${c.name.padEnd(8)} ${c.ip.padEnd(16)} ${describe(c.role, c.zone, c.ip)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('');
|
|
107
|
+
if (alienTotal > 0) {
|
|
108
|
+
// Say what will HAPPEN, not merely what was found — the answer differs by
|
|
109
|
+
// whether celilo has a baseline for the box, and that is the thing an
|
|
110
|
+
// operator most needs to know before the next converge.
|
|
111
|
+
const consequence = [
|
|
112
|
+
'On a firewall celilo has not yet converged cleanly, the next converge will REFUSE and change nothing.',
|
|
113
|
+
'On one with a recorded baseline, an interface that appeared since will be isolated.',
|
|
114
|
+
'Resolve either by declaring a zone: celilo system config set network.<zone>.subnet <cidr>',
|
|
115
|
+
].join('\n');
|
|
116
|
+
console.log(`${alienTotal} interface(s) celilo cannot attribute.\n${consequence}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
success: true,
|
|
121
|
+
message: alienTotal === 0 ? 'every interface accounted for' : `${alienTotal} unaccounted`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { isPubliclyRoutable } from '@celilo/capabilities';
|
|
9
10
|
import { getDb } from '../../db/client';
|
|
10
11
|
import type { NetworkZone } from '../../db/schema';
|
|
11
12
|
import { askText, withInterviewSession } from '../../services/bus-interview';
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
detectNetworkInterfacesLocal,
|
|
17
18
|
testSshConnection,
|
|
18
19
|
} from '../../services/machine-detector';
|
|
20
|
+
import { describeInterfaceZone } from '../../services/machine-detector';
|
|
19
21
|
import { addMachine, getMachineByIp } from '../../services/machine-pool';
|
|
20
22
|
import { loadExistingConfiguration } from '../../services/system-init';
|
|
21
23
|
import { detectZoneFromIp } from '../../services/zone-detector';
|
|
@@ -242,8 +244,34 @@ export async function handleMachineAdd(
|
|
|
242
244
|
console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
|
|
243
245
|
|
|
244
246
|
// Zone: explicit override, else infer from IP.
|
|
247
|
+
//
|
|
248
|
+
// `detectZoneFromIp` answers containment only, and now says `'unknown'`
|
|
249
|
+
// rather than claiming `external` when nothing matches. Resolving that
|
|
250
|
+
// is a SECOND question — is this address one the internet can route to?
|
|
251
|
+
// — and the two were conflated before, which is how a private address in
|
|
252
|
+
// no declared subnet got labelled as facing the internet.
|
|
245
253
|
console.log('Detecting network zone...');
|
|
246
|
-
|
|
254
|
+
if (zoneOverride) {
|
|
255
|
+
zone = zoneOverride;
|
|
256
|
+
} else {
|
|
257
|
+
const detected = await detectZoneFromIp(ipAddress);
|
|
258
|
+
if (detected !== 'unknown') {
|
|
259
|
+
zone = detected;
|
|
260
|
+
} else if (isPubliclyRoutable(ipAddress)) {
|
|
261
|
+
// No declared subnet contains it and the internet can route to it:
|
|
262
|
+
// that is what `external` means — a cloud/VPS box.
|
|
263
|
+
zone = 'external';
|
|
264
|
+
} else {
|
|
265
|
+
// A private address in no declared subnet is UN-ZONEABLE, not
|
|
266
|
+
// external. Guessing here is the original defect; ask instead.
|
|
267
|
+
const fix =
|
|
268
|
+
'Fix: pass --zone <zone>, or declare the subnet with `celilo system config set network.<zone>.subnet <cidr>` and retry.';
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
error: `Cannot infer a zone for ${ipAddress}: it is not publicly routable and no declared network.<zone>.subnet contains it.\n${fix}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
247
275
|
console.log(`✓ Zone: ${zone}\n`);
|
|
248
276
|
|
|
249
277
|
// Detect network interfaces and classify machine
|
|
@@ -254,7 +282,7 @@ export async function handleMachineAdd(
|
|
|
254
282
|
|
|
255
283
|
console.log(`✓ Role: ${role}`);
|
|
256
284
|
for (const iface of interfaces) {
|
|
257
|
-
console.log(` ${iface.name}: ${iface.ipAddress} (${iface
|
|
285
|
+
console.log(` ${iface.name}: ${iface.ipAddress} (${describeInterfaceZone(iface)})`);
|
|
258
286
|
}
|
|
259
287
|
console.log('');
|
|
260
288
|
}
|
|
@@ -14,7 +14,9 @@ import { modules } from '../../db/schema';
|
|
|
14
14
|
import { resolveDeployPosture } from '../../services/deploy-posture';
|
|
15
15
|
import {
|
|
16
16
|
FRAMEWORK_CONFIG_KEYS,
|
|
17
|
+
handleModuleConfigGet,
|
|
17
18
|
handleModuleConfigSet,
|
|
19
|
+
handleModuleConfigUnset,
|
|
18
20
|
validateFrameworkConfigValue,
|
|
19
21
|
} from './module-config';
|
|
20
22
|
import { pickUpgradePolicy } from './module-upgrade';
|
|
@@ -55,7 +57,12 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
55
57
|
const result = await handleModuleConfigSet(['testmod', 'vmid', '203']);
|
|
56
58
|
expect(result.success).toBe(false);
|
|
57
59
|
if (!result.success) {
|
|
58
|
-
|
|
60
|
+
// The refusal now names the source rather than a bespoke
|
|
61
|
+
// "infrastructure-managed" phrase, because `infrastructure` stopped being
|
|
62
|
+
// the only refused source — every non-`user` source is refused, and each
|
|
63
|
+
// gets guidance aimed at its own upstream.
|
|
64
|
+
expect(result.error).toContain('derived by celilo (source: infrastructure)');
|
|
65
|
+
expect(result.error).toContain('not operator-settable');
|
|
59
66
|
}
|
|
60
67
|
});
|
|
61
68
|
|
|
@@ -112,6 +119,30 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
112
119
|
expect(result.success).toBe(false);
|
|
113
120
|
});
|
|
114
121
|
|
|
122
|
+
test('a per-module policy key is settable on a module whose manifest never mentions it', async () => {
|
|
123
|
+
expect((await handleModuleConfigSet(['testmod', 'backup_schedule', '6h'])).success).toBe(true);
|
|
124
|
+
const read = await handleModuleConfigGet(['testmod', 'backup_schedule']);
|
|
125
|
+
expect(read.success).toBe(true);
|
|
126
|
+
if (read.success) expect(read.message).toContain('6h');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('unset removes the override so the module follows its manifest again', async () => {
|
|
130
|
+
await handleModuleConfigSet(['testmod', 'backup_schedule', 'weekly']);
|
|
131
|
+
|
|
132
|
+
const unset = await handleModuleConfigUnset(['testmod', 'backup_schedule']);
|
|
133
|
+
expect(unset.success).toBe(true);
|
|
134
|
+
|
|
135
|
+
expect((await handleModuleConfigGet(['testmod', 'backup_schedule'])).success).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('unsetting a key that was never set reports it and still succeeds', async () => {
|
|
139
|
+
// `unset` states a desired end state. Failing on an already-clean one makes
|
|
140
|
+
// it unusable from any script that cannot check first.
|
|
141
|
+
const result = await handleModuleConfigUnset(['testmod', 'backup_schedule']);
|
|
142
|
+
expect(result.success).toBe(true);
|
|
143
|
+
if (result.success) expect(result.message).toContain('No override set');
|
|
144
|
+
});
|
|
145
|
+
|
|
115
146
|
test('the valid-keys hint advertises the celilo-managed keys', async () => {
|
|
116
147
|
const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
|
|
117
148
|
expect(result.success).toBe(false);
|
|
@@ -127,8 +158,19 @@ describe('validateFrameworkConfigValue (pure)', () => {
|
|
|
127
158
|
expect(validateFrameworkConfigValue('app_port', 'anything')).toBeNull();
|
|
128
159
|
});
|
|
129
160
|
|
|
130
|
-
test('accepts
|
|
131
|
-
|
|
161
|
+
test('every framework key accepts at least its documented values', () => {
|
|
162
|
+
const documented: Record<string, string[]> = {
|
|
163
|
+
auto_upgrade: ['true', 'false'],
|
|
164
|
+
upgrade_policy: ['by-semver', 'always-safe', 'always-fast'],
|
|
165
|
+
backup_schedule: ['hourly', 'daily', 'weekly', 'monthly', 'manual', '6h'],
|
|
166
|
+
health_check_interval: ['15m', '1h', 'daily', 'manual'],
|
|
167
|
+
backup_retention_count: ['1', '3', '30'],
|
|
168
|
+
backup_retention_max_age_days: ['1', '30', '365'],
|
|
169
|
+
};
|
|
170
|
+
// Every key must be covered, so adding one without deciding what it accepts
|
|
171
|
+
// fails here rather than shipping unvalidated.
|
|
172
|
+
expect(Object.keys(documented).sort()).toEqual(Object.keys(FRAMEWORK_CONFIG_KEYS).sort());
|
|
173
|
+
for (const [key, values] of Object.entries(documented)) {
|
|
132
174
|
for (const v of values) expect(validateFrameworkConfigValue(key, v)).toBeNull();
|
|
133
175
|
}
|
|
134
176
|
});
|
|
@@ -136,6 +178,31 @@ describe('validateFrameworkConfigValue (pure)', () => {
|
|
|
136
178
|
test('rejects an unlisted value', () => {
|
|
137
179
|
expect(validateFrameworkConfigValue('upgrade_policy', 'always_safe')).toContain('Allowed');
|
|
138
180
|
});
|
|
181
|
+
|
|
182
|
+
test('rejects a cadence the sweep that would serve it cannot run', () => {
|
|
183
|
+
// The backup sweep rides an hourly tick; accepting `5m` would leave the
|
|
184
|
+
// operator believing they configured something that can never happen.
|
|
185
|
+
expect(validateFrameworkConfigValue('backup_schedule', '5m')).toContain('hourly');
|
|
186
|
+
// The alerting sweep ticks every five minutes, so the same value is fine there.
|
|
187
|
+
expect(validateFrameworkConfigValue('health_check_interval', '5m')).toBeNull();
|
|
188
|
+
expect(validateFrameworkConfigValue('health_check_interval', '1m')).toContain('Allowed');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('a retention of zero or below is refused rather than read as "keep nothing"', () => {
|
|
192
|
+
// A `0` read as a bound would delete every backup the module has.
|
|
193
|
+
for (const bad of ['0', '-1', '2.5', 'lots']) {
|
|
194
|
+
expect(validateFrameworkConfigValue('backup_retention_count', bad)).toContain('1 or greater');
|
|
195
|
+
expect(validateFrameworkConfigValue('backup_retention_max_age_days', bad)).toContain(
|
|
196
|
+
'1 or greater',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a misspelled cadence is refused, and says it was not coerced', () => {
|
|
202
|
+
const error = validateFrameworkConfigValue('backup_schedule', 'dailyy');
|
|
203
|
+
expect(error).toContain('named period');
|
|
204
|
+
expect(error).toContain('Rejected rather than coerced');
|
|
205
|
+
});
|
|
139
206
|
});
|
|
140
207
|
|
|
141
208
|
// The point of the whole control: with always-safe set, a PATCH upgrade — which
|
|
@@ -3,14 +3,38 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { z } from 'zod';
|
|
6
7
|
import { getDb } from '../../db/client';
|
|
7
8
|
import { modules } from '../../db/schema';
|
|
9
|
+
import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
|
|
8
10
|
import {
|
|
11
|
+
HEALTH_CHECK_INTERVAL_CONFIG_KEY,
|
|
12
|
+
reconcileModuleWatchState,
|
|
13
|
+
} from '../../services/alerting/health-cadence';
|
|
14
|
+
import {
|
|
15
|
+
BACKUP_RETENTION_COUNT_CONFIG_KEY,
|
|
16
|
+
BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY,
|
|
17
|
+
} from '../../services/backup-retention';
|
|
18
|
+
import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
|
|
19
|
+
import {
|
|
20
|
+
BACKUP_CADENCE_FLOOR_MINUTES,
|
|
21
|
+
MONITOR_INTERVAL_FLOOR_MINUTES,
|
|
22
|
+
cadenceSchema,
|
|
23
|
+
} from '../../services/cadence';
|
|
24
|
+
import {
|
|
25
|
+
declaredVariables,
|
|
26
|
+
describeDerivedSource,
|
|
27
|
+
explainNotSettable,
|
|
28
|
+
isDerivedVariable,
|
|
29
|
+
} from '../../services/config-provenance';
|
|
30
|
+
import {
|
|
31
|
+
deleteModuleConfig,
|
|
9
32
|
formatConfigValue,
|
|
10
33
|
getAllModuleConfigValues,
|
|
11
34
|
getModuleConfigValue,
|
|
12
35
|
setModuleConfigValue,
|
|
13
36
|
} from '../../services/module-config';
|
|
37
|
+
import { readResolutionContext } from '../../variables/context';
|
|
14
38
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
15
39
|
import type { CommandResult } from '../types';
|
|
16
40
|
|
|
@@ -18,6 +42,12 @@ import type { CommandResult } from '../types';
|
|
|
18
42
|
* Operator keys that EVERY module accepts, whether or not its manifest declares
|
|
19
43
|
* them, with their permitted values.
|
|
20
44
|
*
|
|
45
|
+
* This is also where a module's per-module POLICY lives: how often to back it
|
|
46
|
+
* up, how often to health-check it. A manifest states those as the author's
|
|
47
|
+
* suggestion about a fleet they have never seen; the row an operator writes
|
|
48
|
+
* here wins, and every reader resolves the two at read time so a corrected
|
|
49
|
+
* manifest still reaches installs that have not overridden it.
|
|
50
|
+
*
|
|
21
51
|
* These describe how celilo TREATS a module (its CD policy), not how the module
|
|
22
52
|
* configures itself, so gating them on `variables.owns` had it backwards: it
|
|
23
53
|
* required each module author to opt into being manageable. The failure was
|
|
@@ -34,9 +64,69 @@ import type { CommandResult } from '../types';
|
|
|
34
64
|
* floor while nothing changed. A safety control that fails open on a typo is
|
|
35
65
|
* worse than no control.
|
|
36
66
|
*/
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
export interface FrameworkConfigKey {
|
|
68
|
+
/** Accepts the value the operator typed, or explains what it should be. */
|
|
69
|
+
schema: z.ZodTypeAny;
|
|
70
|
+
/**
|
|
71
|
+
* Why this key is refused rather than coerced. Carried per key rather than
|
|
72
|
+
* derived from the schema's error: the substance is what a WRONG value would
|
|
73
|
+
* silently do, and no validator knows that.
|
|
74
|
+
*/
|
|
75
|
+
why: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const REFUSED_NOT_COERCED_UPGRADE =
|
|
79
|
+
'Rejected rather than coerced: an unrecognized value silently falls back to the PERMISSIVE default (upgrade_policy → by-semver, which skips the pre-deploy backup on a patch), so a typo would look like it took effect.';
|
|
80
|
+
|
|
81
|
+
const REFUSED_NOT_COERCED_CADENCE =
|
|
82
|
+
'Rejected rather than coerced: an unrecognized cadence falls back to the manifest\'s suggestion, so a typo would leave the module on the cadence you meant to change — visibly "set", and doing nothing.';
|
|
83
|
+
|
|
84
|
+
const REFUSED_NOT_COERCED_RETENTION =
|
|
85
|
+
'Rejected rather than coerced: an unrecognized retention value falls back to the manifest, so a typo would leave the module keeping a different number of backups than you asked for — and the direction that goes wrong deletes data.';
|
|
86
|
+
|
|
87
|
+
/** An enum whose rejection message reads as an operator instruction, not a type error. */
|
|
88
|
+
function oneOf(values: readonly [string, ...string[]]): z.ZodTypeAny {
|
|
89
|
+
return z.enum(values, { errorMap: () => ({ message: `Allowed: ${values.join(', ')}` }) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A whole number of things, at least one. Zero is refused rather than read as
|
|
94
|
+
* "keep nothing": a retention of 0 would delete every backup the module has.
|
|
95
|
+
*/
|
|
96
|
+
function positiveInteger(what: string): z.ZodTypeAny {
|
|
97
|
+
return z.string().superRefine((value, ctx) => {
|
|
98
|
+
const parsed = Number(value);
|
|
99
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
100
|
+
ctx.addIssue({
|
|
101
|
+
code: z.ZodIssueCode.custom,
|
|
102
|
+
message: `Allowed: a whole number of ${what}, 1 or greater. Unset the key to keep everything.`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const FRAMEWORK_CONFIG_KEYS: Record<string, FrameworkConfigKey> = {
|
|
109
|
+
auto_upgrade: { schema: oneOf(['true', 'false']), why: REFUSED_NOT_COERCED_UPGRADE },
|
|
110
|
+
upgrade_policy: {
|
|
111
|
+
schema: oneOf(['by-semver', 'always-safe', 'always-fast']),
|
|
112
|
+
why: REFUSED_NOT_COERCED_UPGRADE,
|
|
113
|
+
},
|
|
114
|
+
[BACKUP_SCHEDULE_CONFIG_KEY]: {
|
|
115
|
+
schema: cadenceSchema({ floorMinutes: BACKUP_CADENCE_FLOOR_MINUTES }),
|
|
116
|
+
why: REFUSED_NOT_COERCED_CADENCE,
|
|
117
|
+
},
|
|
118
|
+
[HEALTH_CHECK_INTERVAL_CONFIG_KEY]: {
|
|
119
|
+
schema: cadenceSchema({ floorMinutes: MONITOR_INTERVAL_FLOOR_MINUTES }),
|
|
120
|
+
why: REFUSED_NOT_COERCED_CADENCE,
|
|
121
|
+
},
|
|
122
|
+
[BACKUP_RETENTION_COUNT_CONFIG_KEY]: {
|
|
123
|
+
schema: positiveInteger('copies to keep'),
|
|
124
|
+
why: REFUSED_NOT_COERCED_RETENTION,
|
|
125
|
+
},
|
|
126
|
+
[BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY]: {
|
|
127
|
+
schema: positiveInteger('days to keep a backup'),
|
|
128
|
+
why: REFUSED_NOT_COERCED_RETENTION,
|
|
129
|
+
},
|
|
40
130
|
};
|
|
41
131
|
|
|
42
132
|
/**
|
|
@@ -44,10 +134,12 @@ export const FRAMEWORK_CONFIG_KEYS: Record<string, readonly string[]> = {
|
|
|
44
134
|
* or null when the key is not a framework key or the value is permitted.
|
|
45
135
|
*/
|
|
46
136
|
export function validateFrameworkConfigValue(key: string, value: string): string | null {
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
49
|
-
|
|
50
|
-
|
|
137
|
+
const framework = FRAMEWORK_CONFIG_KEYS[key];
|
|
138
|
+
if (!framework) return null;
|
|
139
|
+
const result = framework.schema.safeParse(value);
|
|
140
|
+
if (result.success) return null;
|
|
141
|
+
const explanation = result.error.issues.map((issue) => issue.message).join('\n');
|
|
142
|
+
return `Invalid value '${value}' for '${key}'.\n\n${explanation}\n\n${framework.why}`;
|
|
51
143
|
}
|
|
52
144
|
|
|
53
145
|
/**
|
|
@@ -96,17 +188,14 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
96
188
|
const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
|
|
97
189
|
|
|
98
190
|
// Validate key against manifest
|
|
99
|
-
const manifest = module.manifestData as
|
|
100
|
-
const
|
|
101
|
-
| { owns?: Array<{ name: string; required?: boolean; default?: string; source?: string }> }
|
|
102
|
-
| undefined;
|
|
103
|
-
const declaredVars = variables?.owns || [];
|
|
191
|
+
const manifest = module.manifestData as ModuleManifest;
|
|
192
|
+
const declaredVars = manifest.variables?.owns ?? [];
|
|
104
193
|
|
|
105
194
|
// Check if key is declared in manifest
|
|
106
195
|
const declaredVar = declaredVars.find((v) => v.name === key);
|
|
107
196
|
if (!declaredVar && !isFrameworkKey) {
|
|
108
197
|
const settableKeys = declaredVars
|
|
109
|
-
.filter((v) => v
|
|
198
|
+
.filter((v) => !isDerivedVariable(v))
|
|
110
199
|
.map((v) => v.name)
|
|
111
200
|
.join(', ');
|
|
112
201
|
const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
|
|
@@ -116,25 +205,37 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
116
205
|
};
|
|
117
206
|
}
|
|
118
207
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
208
|
+
// Refuse EVERY derived source, not just `infrastructure`.
|
|
209
|
+
//
|
|
210
|
+
// ISS-0069 established the principle — refuse at SET time rather than
|
|
211
|
+
// accepting-then-silently-overriding at deploy — and then applied it to one
|
|
212
|
+
// source out of four. So `celilo module config set authentik auth_url …`
|
|
213
|
+
// (a `capability`-sourced value) reported success, wrote the row, and was
|
|
214
|
+
// discarded on the next deploy. A command that says "Set config for authentik"
|
|
215
|
+
// and changes nothing is worse than one that refuses.
|
|
216
|
+
//
|
|
217
|
+
// No counter-example survived review of a good reason to pin a derived value:
|
|
218
|
+
// a derived value computes the right answer from one source of truth, so if
|
|
219
|
+
// the answer is wrong the source is wrong, and fixing the source fixes every
|
|
220
|
+
// consumer at once while pinning one module hides the divergence.
|
|
221
|
+
if (declaredVar && isDerivedVariable(declaredVar)) {
|
|
125
222
|
return {
|
|
126
223
|
success: false,
|
|
127
|
-
error:
|
|
224
|
+
error: explainNotSettable(moduleId, declaredVar),
|
|
128
225
|
};
|
|
129
226
|
}
|
|
130
227
|
|
|
131
228
|
// Set config value using service (handles primitive and complex types)
|
|
132
229
|
try {
|
|
133
230
|
await setModuleConfigValue(moduleId, key, value);
|
|
231
|
+
const resolved = settlingWatchState(db, moduleId, key);
|
|
134
232
|
|
|
135
233
|
return {
|
|
136
234
|
success: true,
|
|
137
|
-
message:
|
|
235
|
+
message:
|
|
236
|
+
resolved > 0
|
|
237
|
+
? `Set config for ${moduleId}: ${key} (resolved ${resolved} alert(s) — nothing will report on this module again until it is watched)`
|
|
238
|
+
: `Set config for ${moduleId}: ${key}`,
|
|
138
239
|
};
|
|
139
240
|
} catch (error) {
|
|
140
241
|
return {
|
|
@@ -144,6 +245,69 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
144
245
|
}
|
|
145
246
|
}
|
|
146
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Handle module config unset command
|
|
250
|
+
*
|
|
251
|
+
* Usage: celilo module config unset <module-id> <key>
|
|
252
|
+
*
|
|
253
|
+
* Removing an override is what returns a module to following its manifest.
|
|
254
|
+
* Without it, an operator who once set a cadence could never go back to the
|
|
255
|
+
* author's suggestion, and every later correction would stop reaching them —
|
|
256
|
+
* the exact failure read-time resolution exists to prevent, aimed at the
|
|
257
|
+
* operators who engaged with the feature.
|
|
258
|
+
*
|
|
259
|
+
* @param args - Command arguments
|
|
260
|
+
* @returns Command result
|
|
261
|
+
*/
|
|
262
|
+
export async function handleModuleConfigUnset(args: string[]): Promise<CommandResult> {
|
|
263
|
+
const error = validateRequiredArgs(args, 2);
|
|
264
|
+
if (error) {
|
|
265
|
+
return {
|
|
266
|
+
success: false,
|
|
267
|
+
error: `${error}\n\nUsage: celilo module config unset <module-id> <key>`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const moduleId = getArg(args, 0);
|
|
272
|
+
const key = getArg(args, 1);
|
|
273
|
+
|
|
274
|
+
if (!moduleId || !key) {
|
|
275
|
+
return { success: false, error: 'Module ID and key are required' };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const db = getDb();
|
|
279
|
+
|
|
280
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
281
|
+
if (!module) {
|
|
282
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Unsetting a key that was never set SUCCEEDS. `unset` states a desired end
|
|
286
|
+
// state, and failing on an already-clean one makes it unusable from any
|
|
287
|
+
// script that cannot check first.
|
|
288
|
+
if (!getModuleConfigValue(moduleId, key, db)) {
|
|
289
|
+
return { success: true, message: `No override set for ${moduleId}: ${key}` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
deleteModuleConfig(db, moduleId, key);
|
|
293
|
+
settlingWatchState(db, moduleId, key);
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
message: `Unset config for ${moduleId}: ${key} (now follows the module's manifest)`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A module that has just stopped being watched may still own live alerts from
|
|
302
|
+
* its last scheduled runs, and nothing will ever report on them again. Resolve
|
|
303
|
+
* them here rather than leaving them firing with no action able to clear them.
|
|
304
|
+
* No-op for every other key.
|
|
305
|
+
*/
|
|
306
|
+
function settlingWatchState(db: ReturnType<typeof getDb>, moduleId: string, key: string): number {
|
|
307
|
+
if (key !== HEALTH_CHECK_INTERVAL_CONFIG_KEY) return 0;
|
|
308
|
+
return reconcileModuleWatchState(db, moduleId, new Date());
|
|
309
|
+
}
|
|
310
|
+
|
|
147
311
|
/**
|
|
148
312
|
* Handle module config get command
|
|
149
313
|
*
|
|
@@ -203,10 +367,24 @@ export async function handleModuleConfigGet(args: string[]): Promise<CommandResu
|
|
|
203
367
|
};
|
|
204
368
|
}
|
|
205
369
|
|
|
206
|
-
// Get all config for module
|
|
207
|
-
|
|
370
|
+
// Get all config for module, split by who owns each value.
|
|
371
|
+
//
|
|
372
|
+
// Printing every row flat presented a value celilo computed as if the
|
|
373
|
+
// operator had chosen it, which is how a derived value gets "corrected" by
|
|
374
|
+
// hand and silently reverted. The derived section is also read from the
|
|
375
|
+
// resolution context rather than the config rows, so it shows what celilo
|
|
376
|
+
// computes RIGHT NOW — including values that were never written down, the
|
|
377
|
+
// absence that caused the 2026-08-14 DNS outage.
|
|
378
|
+
const declared = declaredVariables(module.manifestData as ModuleManifest);
|
|
379
|
+
const stored = getAllModuleConfigValues(moduleId);
|
|
380
|
+
const userConfigs = stored.filter((c) => {
|
|
381
|
+
const variable = declared.get(c.key);
|
|
382
|
+
return !variable || !isDerivedVariable(variable);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const derivedValues = await resolveDerivedForDisplay(moduleId, declared, db);
|
|
208
386
|
|
|
209
|
-
if (
|
|
387
|
+
if (userConfigs.length === 0 && derivedValues.length === 0) {
|
|
210
388
|
return {
|
|
211
389
|
success: true,
|
|
212
390
|
message: `No configuration set for ${moduleId}`,
|
|
@@ -214,14 +392,70 @@ export async function handleModuleConfigGet(args: string[]): Promise<CommandResu
|
|
|
214
392
|
}
|
|
215
393
|
|
|
216
394
|
const lines = [`Configuration for ${moduleId}:`, ''];
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
395
|
+
if (userConfigs.length > 0) {
|
|
396
|
+
for (const config of userConfigs) {
|
|
397
|
+
lines.push(`${config.key} = ${formatConfigValue(config)}`);
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
lines.push('(nothing set by you)');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (derivedValues.length > 0) {
|
|
404
|
+
lines.push('', 'Derived by celilo (not settable — fix the source instead):', '');
|
|
405
|
+
for (const derived of derivedValues) {
|
|
406
|
+
lines.push(`${derived.key} = ${derived.value}`);
|
|
407
|
+
lines.push(` ${describeDerivedSource(derived.variable)}`);
|
|
408
|
+
}
|
|
220
409
|
}
|
|
221
410
|
|
|
222
411
|
return {
|
|
223
412
|
success: true,
|
|
224
413
|
message: lines.join('\n'),
|
|
225
|
-
data:
|
|
414
|
+
data: {
|
|
415
|
+
config: userConfigs.map((c) => ({ key: c.key, value: c.value })),
|
|
416
|
+
derived: derivedValues.map((d) => ({
|
|
417
|
+
key: d.key,
|
|
418
|
+
value: d.value,
|
|
419
|
+
source: d.variable.source,
|
|
420
|
+
})),
|
|
421
|
+
},
|
|
226
422
|
};
|
|
227
423
|
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The current value of each derived variable, for display only.
|
|
427
|
+
*
|
|
428
|
+
* Uses the side-effect-free resolution context: reading a config must not seed
|
|
429
|
+
* rows or allocate addresses. A module whose derives cannot resolve yet (an
|
|
430
|
+
* undeployed provider, an unset system key) reports nothing derived rather than
|
|
431
|
+
* failing the whole command — `get` is how an operator diagnoses that state, so
|
|
432
|
+
* it has to survive it.
|
|
433
|
+
*/
|
|
434
|
+
async function resolveDerivedForDisplay(
|
|
435
|
+
moduleId: string,
|
|
436
|
+
declared: Map<string, VariableDeclare>,
|
|
437
|
+
db: ReturnType<typeof getDb>,
|
|
438
|
+
): Promise<Array<{ key: string; value: string; variable: VariableDeclare }>> {
|
|
439
|
+
const derivedVars = [...declared.values()].filter(isDerivedVariable);
|
|
440
|
+
if (derivedVars.length === 0) return [];
|
|
441
|
+
|
|
442
|
+
let selfConfig: Record<string, string>;
|
|
443
|
+
try {
|
|
444
|
+
selfConfig = (await readResolutionContext(moduleId, db)).selfConfig;
|
|
445
|
+
} catch {
|
|
446
|
+
// Reported as "not computed yet" below rather than as an error, so the
|
|
447
|
+
// command still shows the operator their own config.
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const resolved: Array<{ key: string; value: string; variable: VariableDeclare }> = [];
|
|
452
|
+
for (const variable of derivedVars) {
|
|
453
|
+
const value = selfConfig[variable.name];
|
|
454
|
+
resolved.push({
|
|
455
|
+
key: variable.name,
|
|
456
|
+
value: value ?? '(not computed yet)',
|
|
457
|
+
variable,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return resolved;
|
|
461
|
+
}
|