@celilo/cli 0.24.0 → 0.25.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 +2 -2
- package/CELILO_SUBSYSTEMS.md +3 -1
- package/package.json +1 -1
- package/schemas/system_config.json +10 -5
- package/src/cli/commands/module-config.test.ts +6 -1
- package/src/cli/commands/module-config.ts +103 -20
- package/src/cli/commands/module-status.ts +31 -12
- package/src/cli/commands/system-apply-config-equivalence.test.ts +46 -8
- package/src/cli/commands/system-apply-config.test.ts +57 -1
- package/src/cli/commands/system-apply-config.ts +43 -0
- package/src/cli/commands/system-discover-network.ts +35 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +9 -0
- package/src/hooks/capability-loader.ts +33 -17
- package/src/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/manifest/network-requirement-schema.test.ts +141 -0
- package/src/manifest/schema.ts +124 -0
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -0
- package/src/services/module-deploy.ts +31 -3
- package/src/services/network-discovery.test.ts +198 -0
- package/src/services/network-discovery.ts +164 -0
- package/src/services/network-ensure.test.ts +324 -0
- package/src/services/network-ensure.ts +260 -0
- package/src/services/system-init.ts +14 -3
- package/src/test-utils/bus-responder.ts +14 -1
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- package/src/variables/declarative-derivation.ts +13 -2
package/src/manifest/schema.ts
CHANGED
|
@@ -354,6 +354,68 @@ export const SystemResourceSchema = z.object({
|
|
|
354
354
|
zone: z.enum(NETWORK_ZONES).describe('Required security zone for this module'),
|
|
355
355
|
});
|
|
356
356
|
|
|
357
|
+
/**
|
|
358
|
+
* A network a module depends on, declared under `requires.networks`
|
|
359
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
360
|
+
*
|
|
361
|
+
* It names the network and stops there. celilo owns the network namespace: the
|
|
362
|
+
* range belongs to the operator's topology, not to whichever module happens to
|
|
363
|
+
* arrive first and need it. A module that carried the value here would be
|
|
364
|
+
* defining the network by another route, which is the authority this change
|
|
365
|
+
* reverses — so `.strict()` is load-bearing rather than tidiness, and its
|
|
366
|
+
* message says why.
|
|
367
|
+
*
|
|
368
|
+
* When the named network has no `network.<name>.subnet` in system config, the
|
|
369
|
+
* deploy asks for one BEFORE anything is generated or any hook runs
|
|
370
|
+
* ([[services/network-ensure.ts]]).
|
|
371
|
+
*
|
|
372
|
+
* ── Two forms, because some requirements are not knowable at authoring time ──
|
|
373
|
+
*
|
|
374
|
+
* `name:` is a literal — `wireguard` always needs `control-plane-vpn`, and says
|
|
375
|
+
* so once. `from:` names a config array whose VALUES are the network names, for
|
|
376
|
+
* a module whose set is decided per install: a firewall requires the networks it
|
|
377
|
+
* has legs on, and which legs it has is a property of the box it lands on.
|
|
378
|
+
*
|
|
379
|
+
* `from:` is not a loophole in "a requirement carries no value". It still names
|
|
380
|
+
* networks and still carries none — it just names them indirectly. What it fixes
|
|
381
|
+
* is the split that `firewall-interface-classification` §3 already diagnosed from
|
|
382
|
+
* the harness side: the legs a firewall has and the networks it declares were two
|
|
383
|
+
* hand-maintained lists, and every one of the ~20 call sites that had to keep
|
|
384
|
+
* them in step got it wrong. A leg whose network has no declared subnet
|
|
385
|
+
* classifies `alien`, so under-declaring is not cosmetic — it is how an interface
|
|
386
|
+
* gets isolated. One list makes that structurally impossible.
|
|
387
|
+
*/
|
|
388
|
+
export const NetworkRequirementSchema = z
|
|
389
|
+
.object({
|
|
390
|
+
name: z
|
|
391
|
+
.string()
|
|
392
|
+
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'network name must be kebab-case')
|
|
393
|
+
.describe('Name of a network this module needs defined before it deploys')
|
|
394
|
+
.optional(),
|
|
395
|
+
from: z
|
|
396
|
+
.string()
|
|
397
|
+
.regex(
|
|
398
|
+
/^\$self:[a-zA-Z_][a-zA-Z0-9_]*$/,
|
|
399
|
+
'from must reference one of this module\'s own config values, e.g. "$self:zones"',
|
|
400
|
+
)
|
|
401
|
+
.describe('A config array whose values are network names, e.g. "$self:zones"')
|
|
402
|
+
.optional(),
|
|
403
|
+
})
|
|
404
|
+
.strict(
|
|
405
|
+
'A network requirement NAMES networks; it never carries their values. celilo owns ' +
|
|
406
|
+
'the network namespace — the range is supplied before the deploy runs — so a ' +
|
|
407
|
+
'requirement takes `name:` (a literal) or `from:` (a $self: config array of names) ' +
|
|
408
|
+
'and nothing else. Read the range with `source: system` / ' +
|
|
409
|
+
'`derive_from: "$system:network.<name>.subnet"`.',
|
|
410
|
+
)
|
|
411
|
+
.refine((requirement) => Boolean(requirement.name) !== Boolean(requirement.from), {
|
|
412
|
+
message:
|
|
413
|
+
'A network requirement declares exactly one of `name:` (a literal network) or ' +
|
|
414
|
+
'`from:` (a $self: config array whose values are network names)',
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
export type NetworkRequirement = z.infer<typeof NetworkRequirementSchema>;
|
|
418
|
+
|
|
357
419
|
/**
|
|
358
420
|
* One system a module deploys (openspec/specs/module-systems-addressing/spec.md). A module
|
|
359
421
|
* declares 0..N of these under `requires.systems`. `name` is the stable
|
|
@@ -505,6 +567,11 @@ export const ModuleManifestSchema = z
|
|
|
505
567
|
* `systems` gets one host per entry, each addressable as `$infra:<name>`.
|
|
506
568
|
*/
|
|
507
569
|
systems: z.array(SystemDeclarationSchema).optional(),
|
|
570
|
+
/**
|
|
571
|
+
* Networks that must be DEFINED in celilo's own config before this
|
|
572
|
+
* module deploys. Names only — see `NetworkRequirementSchema`.
|
|
573
|
+
*/
|
|
574
|
+
networks: z.array(NetworkRequirementSchema).optional(),
|
|
508
575
|
/**
|
|
509
576
|
* Singular form — sugar for a single unnamed system. Normalized to one
|
|
510
577
|
* `systems` entry (name `main`) by `getDeclaredSystems`. Optional because
|
|
@@ -857,3 +924,60 @@ export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[
|
|
|
857
924
|
export function getSingularSystemSpec(manifest: ModuleManifest): SystemResource | undefined {
|
|
858
925
|
return manifest.requires?.system;
|
|
859
926
|
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The network names a module requires
|
|
930
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
931
|
+
* Deduplicated and in declaration order. Empty for the modules that depend on no
|
|
932
|
+
* network, which is most of them.
|
|
933
|
+
*
|
|
934
|
+
* `from:` entries are resolved against `config` — the module's own values, as
|
|
935
|
+
* answered by the config interview. A module whose required set is decided per
|
|
936
|
+
* install (a firewall requires the networks it has legs on) therefore needs its
|
|
937
|
+
* config populated before this is meaningful, which is why the ensure runs after
|
|
938
|
+
* the config interview and before generation.
|
|
939
|
+
*
|
|
940
|
+
* `external` is dropped wherever it appears. It is the RESIDUAL — an interface is
|
|
941
|
+
* `external` because it is publicly routable and matched no declared zone, never
|
|
942
|
+
* because it is contained in a subnet — so giving it one would reintroduce the
|
|
943
|
+
* overload that `readDeclaredNetworks` and `deployFirewall` both already exclude.
|
|
944
|
+
*/
|
|
945
|
+
export function getRequiredNetworkNames(
|
|
946
|
+
manifest: ModuleManifest,
|
|
947
|
+
config: Record<string, unknown> = {},
|
|
948
|
+
): string[] {
|
|
949
|
+
const names: string[] = [];
|
|
950
|
+
for (const requirement of manifest.requires?.networks ?? []) {
|
|
951
|
+
if (requirement.name) {
|
|
952
|
+
names.push(requirement.name);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (!requirement.from) continue;
|
|
956
|
+
const key = requirement.from.slice('$self:'.length);
|
|
957
|
+
names.push(...asNetworkNameArray(config[key]));
|
|
958
|
+
}
|
|
959
|
+
return [...new Set(names)].filter((name) => name !== 'external');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Narrow a `from:`-referenced config value to network names.
|
|
964
|
+
*
|
|
965
|
+
* Tolerates the JSON-string form as well as a real array: an array config value
|
|
966
|
+
* arrives as either depending on how it was written, and a requirement that
|
|
967
|
+
* silently resolved to nothing would leave a leg undeclared — which is the
|
|
968
|
+
* failure this whole mechanism exists to prevent.
|
|
969
|
+
*/
|
|
970
|
+
function asNetworkNameArray(raw: unknown): string[] {
|
|
971
|
+
const value =
|
|
972
|
+
typeof raw === 'string' && raw.trim().startsWith('[')
|
|
973
|
+
? (() => {
|
|
974
|
+
try {
|
|
975
|
+
return JSON.parse(raw) as unknown;
|
|
976
|
+
} catch {
|
|
977
|
+
return undefined;
|
|
978
|
+
}
|
|
979
|
+
})()
|
|
980
|
+
: raw;
|
|
981
|
+
if (!Array.isArray(value)) return [];
|
|
982
|
+
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
|
983
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a config value — the operator, or celilo.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing case here is the one that looks like a special case and is
|
|
5
|
+
* not: `derive_from` does NOT mean derived. Getting that backwards refuses an
|
|
6
|
+
* operator's edit to their own config, and a migration written on the same test
|
|
7
|
+
* would delete the row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import type { VariableDeclare } from '../manifest/schema';
|
|
12
|
+
import {
|
|
13
|
+
declaredVariables,
|
|
14
|
+
describeDerivedSource,
|
|
15
|
+
explainNotSettable,
|
|
16
|
+
isDerivedVariable,
|
|
17
|
+
} from './config-provenance';
|
|
18
|
+
|
|
19
|
+
function variable(overrides: Partial<VariableDeclare> & { name: string }): VariableDeclare {
|
|
20
|
+
return {
|
|
21
|
+
type: 'string',
|
|
22
|
+
required: false,
|
|
23
|
+
source: 'user',
|
|
24
|
+
...overrides,
|
|
25
|
+
} as VariableDeclare;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('isDerivedVariable', () => {
|
|
29
|
+
test('a user-sourced variable is the operator’s', () => {
|
|
30
|
+
expect(isDerivedVariable(variable({ name: 'acme_email', source: 'user' }))).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test.each(['capability', 'system', 'infrastructure', 'terraform'] as const)(
|
|
34
|
+
'a %s-sourced variable is celilo’s',
|
|
35
|
+
(source) => {
|
|
36
|
+
expect(isDerivedVariable(variable({ name: 'x', source }))).toBe(true);
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
test('a variable with NO declared source reads as the operator’s', () => {
|
|
41
|
+
// The manifest schema requires `source`, so this only happens for a
|
|
42
|
+
// malformed or pre-schema manifest already sitting in `manifest_data`. The
|
|
43
|
+
// question is which way to be wrong: guessing "derived" refuses an
|
|
44
|
+
// operator's `set` with a message insisting celilo owns a value nothing
|
|
45
|
+
// computes, which they cannot act on.
|
|
46
|
+
const noSource = {
|
|
47
|
+
name: 'app_port',
|
|
48
|
+
type: 'integer',
|
|
49
|
+
required: false,
|
|
50
|
+
} as unknown as VariableDeclare;
|
|
51
|
+
|
|
52
|
+
expect(isDerivedVariable(noSource)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a user-sourced variable WITH a derive_from is still the operator’s', () => {
|
|
56
|
+
// iptables: `firewall_ip`, `source: user`, `derive_from: $machine:ipAddress`.
|
|
57
|
+
// `$machine:` derives are answered by the config interview — they seed a
|
|
58
|
+
// default the operator confirms — so the row is operator config. Classing
|
|
59
|
+
// it as derived would refuse an operator correcting their own firewall
|
|
60
|
+
// address, and deleting it on the same test would blind the trusted-sources
|
|
61
|
+
// audit in services/firewall-reach.ts, which reads exactly this row.
|
|
62
|
+
const firewallIp = variable({
|
|
63
|
+
name: 'firewall_ip',
|
|
64
|
+
source: 'user',
|
|
65
|
+
required: true,
|
|
66
|
+
derive_from: '$machine:ipAddress',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(isDerivedVariable(firewallIp)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('declaredVariables', () => {
|
|
74
|
+
test('indexes a manifest’s owned variables by name', () => {
|
|
75
|
+
const declared = declaredVariables({
|
|
76
|
+
variables: {
|
|
77
|
+
owns: [variable({ name: 'hostname' }), variable({ name: 'vpn_subnet', source: 'system' })],
|
|
78
|
+
},
|
|
79
|
+
} as never);
|
|
80
|
+
|
|
81
|
+
expect([...declared.keys()].sort()).toEqual(['hostname', 'vpn_subnet']);
|
|
82
|
+
expect(declared.get('vpn_subnet')?.source).toBe('system');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a manifest declaring nothing yields an empty index, not a throw', () => {
|
|
86
|
+
expect(declaredVariables({} as never).size).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('explainNotSettable', () => {
|
|
91
|
+
test('a capability-sourced value points at the provider, not at this module', () => {
|
|
92
|
+
// The live footgun: `celilo module config set authentik auth_url …`
|
|
93
|
+
// reported success, wrote the row, and was discarded on the next deploy.
|
|
94
|
+
const message = explainNotSettable(
|
|
95
|
+
'authentik',
|
|
96
|
+
variable({
|
|
97
|
+
name: 'auth_url',
|
|
98
|
+
source: 'capability',
|
|
99
|
+
derive_from: '$capability:authentication.url',
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(message).toContain('not operator-settable');
|
|
104
|
+
expect(message).toContain('source: capability');
|
|
105
|
+
// Actionable: the only way to change a derived value is to fix its source.
|
|
106
|
+
expect(message).toContain('provider');
|
|
107
|
+
expect(message).toContain('redeploy');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a system-sourced value names the system key to set', () => {
|
|
111
|
+
const message = explainNotSettable(
|
|
112
|
+
'technitium',
|
|
113
|
+
variable({
|
|
114
|
+
name: 'vpn_subnet',
|
|
115
|
+
source: 'system',
|
|
116
|
+
derive_from: '$system:network.control-plane-vpn.subnet',
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// The operator should be able to copy the fix out of the error. The
|
|
121
|
+
// `$system:` prefix is stripped so the key is the one `system config set`
|
|
122
|
+
// actually takes.
|
|
123
|
+
expect(message).toContain('celilo system config set network.control-plane-vpn.subnet');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an infrastructure-sourced value keeps the placement guidance', () => {
|
|
127
|
+
const message = explainNotSettable(
|
|
128
|
+
'caddy',
|
|
129
|
+
variable({ name: 'vmid', source: 'infrastructure' }),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(message).toContain('IPAM');
|
|
133
|
+
expect(message).toContain('celilo proxmox migrate');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('describeDerivedSource', () => {
|
|
138
|
+
test('names the upstream, and the template when there is one', () => {
|
|
139
|
+
expect(
|
|
140
|
+
describeDerivedSource(
|
|
141
|
+
variable({
|
|
142
|
+
name: 'dmz_subnet',
|
|
143
|
+
source: 'system',
|
|
144
|
+
derive_from: '$system:network.dmz.subnet',
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
).toBe('from system config ($system:network.dmz.subnet)');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('degrades to the upstream alone when no template is declared', () => {
|
|
151
|
+
expect(describeDerivedSource(variable({ name: 'vmid', source: 'infrastructure' }))).toContain(
|
|
152
|
+
'infrastructure celilo selected',
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a module-config value: the operator, or celilo.
|
|
3
|
+
*
|
|
4
|
+
* A module's manifest declares a `source` for every variable it owns. `user`
|
|
5
|
+
* means the operator supplies it. Every other source — `capability`, `system`,
|
|
6
|
+
* `infrastructure`, `terraform` — means celilo computes it from somewhere else:
|
|
7
|
+
* another module's published capability data, system config, the selected
|
|
8
|
+
* infrastructure, a Terraform output.
|
|
9
|
+
*
|
|
10
|
+
* That distinction has to be shared rather than re-derived per command, because
|
|
11
|
+
* it was previously spelled differently in each place and the disagreements were
|
|
12
|
+
* silent. `module config set` refused only `source: infrastructure`, so setting
|
|
13
|
+
* a `capability`- or `system`-sourced value REPORTED SUCCESS, wrote the row, and
|
|
14
|
+
* was then discarded on the next deploy —
|
|
15
|
+
* `celilo module config set authentik auth_url …` being the live example. And
|
|
16
|
+
* `module config get` printed every row flat, so a value celilo derived was
|
|
17
|
+
* indistinguishable from one the operator had chosen.
|
|
18
|
+
*
|
|
19
|
+
* ## `derive_from` does not mean derived
|
|
20
|
+
*
|
|
21
|
+
* The tempting shortcut is "it has a `derive_from` template, so celilo computes
|
|
22
|
+
* it". That is wrong, and expensively so. `iptables` declares:
|
|
23
|
+
*
|
|
24
|
+
* - name: firewall_ip
|
|
25
|
+
* source: user
|
|
26
|
+
* derive_from: "$machine:ipAddress"
|
|
27
|
+
*
|
|
28
|
+
* `$machine:` derivations are answered by the config interview — they seed a
|
|
29
|
+
* default the operator confirms — not by template resolution. The row is
|
|
30
|
+
* operator config. Treating it as derived would refuse an operator's attempt to
|
|
31
|
+
* correct their own firewall address, and a migration that deleted rows on the
|
|
32
|
+
* same test would blind the trusted-sources audit that reads it.
|
|
33
|
+
*
|
|
34
|
+
* `source` is the authority. Nothing else is.
|
|
35
|
+
*/
|
|
36
|
+
import type { ModuleManifest, VariableDeclare } from '../manifest/schema';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does celilo compute this variable, rather than the operator supply it?
|
|
40
|
+
*
|
|
41
|
+
* An ABSENT source reads as the operator's. The manifest schema requires
|
|
42
|
+
* `source`, so absent means a malformed or pre-schema manifest sitting in
|
|
43
|
+
* `modules.manifest_data` — and for those the question is which way to be
|
|
44
|
+
* wrong. Guessing "derived" refuses an operator's attempt to set their own
|
|
45
|
+
* config with a message insisting celilo owns a value nothing computes, which
|
|
46
|
+
* is unanswerable. Guessing "user" preserves what celilo did before this
|
|
47
|
+
* predicate existed, when only `infrastructure` was refused.
|
|
48
|
+
*/
|
|
49
|
+
export function isDerivedVariable(variable: Pick<VariableDeclare, 'source'>): boolean {
|
|
50
|
+
return variable.source !== undefined && variable.source !== 'user';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every variable a module's manifest declares, indexed by name. */
|
|
54
|
+
export function declaredVariables(manifest: ModuleManifest): Map<string, VariableDeclare> {
|
|
55
|
+
return new Map((manifest.variables?.owns ?? []).map((variable) => [variable.name, variable]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A one-line explanation of where a derived value comes from, for output an
|
|
60
|
+
* operator reads. Says which upstream to go fix, since fixing the source is the
|
|
61
|
+
* only way to change a derived value.
|
|
62
|
+
*/
|
|
63
|
+
export function describeDerivedSource(variable: VariableDeclare): string {
|
|
64
|
+
switch (variable.source) {
|
|
65
|
+
case 'capability':
|
|
66
|
+
return variable.derive_from
|
|
67
|
+
? `from another module's capability data (${variable.derive_from})`
|
|
68
|
+
: "from another module's capability data";
|
|
69
|
+
case 'system':
|
|
70
|
+
return variable.derive_from
|
|
71
|
+
? `from system config (${variable.derive_from})`
|
|
72
|
+
: 'from system config';
|
|
73
|
+
case 'infrastructure':
|
|
74
|
+
return 'from the infrastructure celilo selected for this module';
|
|
75
|
+
case 'terraform':
|
|
76
|
+
return 'from a Terraform output, at deploy time';
|
|
77
|
+
default:
|
|
78
|
+
return `computed by celilo (source: ${variable.source})`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why `module config set` refuses this variable, and what to do instead.
|
|
84
|
+
* Actionable per source: a derived value is only wrong because its upstream is
|
|
85
|
+
* wrong, and fixing the upstream fixes every consumer at once, where pinning one
|
|
86
|
+
* module hides the divergence.
|
|
87
|
+
*/
|
|
88
|
+
export function explainNotSettable(moduleId: string, variable: VariableDeclare): string {
|
|
89
|
+
const header = `'${variable.name}' is derived by celilo (source: ${variable.source}) — not operator-settable.`;
|
|
90
|
+
const origin = `It is computed ${describeDerivedSource(variable)}, so a value set here would be overwritten the next time ${moduleId} is generated.`;
|
|
91
|
+
|
|
92
|
+
switch (variable.source) {
|
|
93
|
+
case 'capability':
|
|
94
|
+
return `${header}\n${origin}\n • Fix it at the provider: change the config of the module that publishes this capability, then redeploy it.\n • 'celilo module config get ${moduleId}' shows the value celilo currently computes.`;
|
|
95
|
+
case 'system':
|
|
96
|
+
return `${header}\n${origin}\n • Fix it at the source: 'celilo system config set ${variable.derive_from?.replace(/^\$\{?system:/, '').replace(/\}$/, '') ?? '<key>'} <value>'.\n • That corrects every module deriving from it at once, rather than pinning this one.`;
|
|
97
|
+
case 'infrastructure':
|
|
98
|
+
return `${header}\n${origin}\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate'.\n • vmid / IP: auto-allocated by IPAM.`;
|
|
99
|
+
case 'terraform':
|
|
100
|
+
return `${header}\n${origin}\n • It is read back from Terraform outputs after the deploy creates the resource.`;
|
|
101
|
+
default:
|
|
102
|
+
return `${header}\n${origin}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -38,6 +38,7 @@ import { getModuleSystems } from './deployed-systems';
|
|
|
38
38
|
import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
|
|
39
39
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
40
40
|
import { findMachineForModule } from './machine-pool';
|
|
41
|
+
import { ensureRequiredNetworks } from './network-ensure';
|
|
41
42
|
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
42
43
|
import { republishStaticWebConsumers } from './public-web-republish';
|
|
43
44
|
import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
|
|
@@ -367,6 +368,32 @@ async function deployModuleImpl(
|
|
|
367
368
|
return { success: false, error: E2E_CONFLICT_MESSAGE, phases };
|
|
368
369
|
}
|
|
369
370
|
|
|
371
|
+
// Every network this module REQUIRES must be defined before ANYTHING else
|
|
372
|
+
// (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
373
|
+
//
|
|
374
|
+
// First, not merely "before the hooks". `validateAndPrepareDeployment`
|
|
375
|
+
// generates templates itself when no variable is missing, and generation is
|
|
376
|
+
// where `$system:` derivations resolve and get persisted. A module reading
|
|
377
|
+
// `network.<name>.subnet` would derive it BEFORE the network existed, get
|
|
378
|
+
// nothing, and — because generation does not run twice — never get it. The
|
|
379
|
+
// hook would then fail on an unset value the deploy had just been told to
|
|
380
|
+
// supply. Ensuring the network up front is what makes "the value is
|
|
381
|
+
// available before any hook runs" true rather than nearly true.
|
|
382
|
+
const declaringModule = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
383
|
+
if (declaringModule?.manifestData) {
|
|
384
|
+
const networksEnsured = await ensureRequiredNetworks(
|
|
385
|
+
moduleId,
|
|
386
|
+
declaringModule.manifestData as ModuleManifest,
|
|
387
|
+
db,
|
|
388
|
+
);
|
|
389
|
+
if (!networksEnsured.success) {
|
|
390
|
+
return { success: false, error: networksEnsured.error, phases };
|
|
391
|
+
}
|
|
392
|
+
for (const line of networksEnsured.applied) {
|
|
393
|
+
log.success(`network defined: ${line}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
370
397
|
const validation = await validateAndPrepareDeployment(moduleId, db);
|
|
371
398
|
phases.validation = validation.success;
|
|
372
399
|
phases.autoGenerated = validation.autoGenerated;
|
|
@@ -517,9 +544,10 @@ async function deployModuleImpl(
|
|
|
517
544
|
// The bus-mediated interview has fired and any responders have
|
|
518
545
|
// answered; the operator can inspect the encrypted store and
|
|
519
546
|
// module config to confirm what landed without spinning up
|
|
520
|
-
// terraform / ansible / actual hooks.
|
|
521
|
-
//
|
|
522
|
-
//
|
|
547
|
+
// terraform / ansible / actual hooks. The required-network
|
|
548
|
+
// interview above HAS run by this point; the cross-module
|
|
549
|
+
// `ensure` events fire later from hook execution, so those are
|
|
550
|
+
// NOT exercised here — that requires a real run.
|
|
523
551
|
if (options.stopAfterInterview) {
|
|
524
552
|
log.info('--stop-after-interview: exiting before infrastructure phase.');
|
|
525
553
|
return {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo discovering the network of the box it is installed on.
|
|
3
|
+
*
|
|
4
|
+
* These tests moved here with the code, from
|
|
5
|
+
* `modules/celilo-mgmt/scripts/discovery.test.ts`. They were always claims about
|
|
6
|
+
* CELILO — that it reflects whatever network it finds and bakes in no range of
|
|
7
|
+
* its own — being made in a module's test file because that is where the code
|
|
8
|
+
* happened to live
|
|
9
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md).
|
|
10
|
+
*
|
|
11
|
+
* The headline test feeds a RANDOM RFC1918 /24 and asserts celilo uses exactly
|
|
12
|
+
* that: the product-level guarantee that no specific address range is baked in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { eq } from 'drizzle-orm';
|
|
17
|
+
import type { DbClient } from '../db/client';
|
|
18
|
+
import { systemConfig } from '../db/schema';
|
|
19
|
+
import { setupTestDatabase } from '../test-utils/database';
|
|
20
|
+
import {
|
|
21
|
+
discoverAndRecordNetwork,
|
|
22
|
+
discoveredNetworkKeys,
|
|
23
|
+
parseInternalNetwork,
|
|
24
|
+
} from './network-discovery';
|
|
25
|
+
|
|
26
|
+
/** Build an `ip route` output for a given /24 base, gateway, and host IP. */
|
|
27
|
+
function ipRouteFor(base: string, gateway: string, hostIp: string, dev = 'eth0'): string {
|
|
28
|
+
return [
|
|
29
|
+
`default via ${gateway} dev ${dev} proto dhcp src ${hostIp} metric 100`,
|
|
30
|
+
`${base}/24 dev ${dev} proto kernel scope link src ${hostIp} metric 100`,
|
|
31
|
+
'',
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Pick a random RFC1918 /24 base + a host IP + gateway within it. */
|
|
36
|
+
function randomRfc1918Slash24(): { base: string; gateway: string; hostIp: string } {
|
|
37
|
+
const r = Math.random();
|
|
38
|
+
let a: number;
|
|
39
|
+
let b: number;
|
|
40
|
+
if (r < 0.34) {
|
|
41
|
+
a = 10;
|
|
42
|
+
b = Math.floor(Math.random() * 256);
|
|
43
|
+
} else if (r < 0.67) {
|
|
44
|
+
a = 172;
|
|
45
|
+
b = 16 + Math.floor(Math.random() * 16); // 172.16–172.31
|
|
46
|
+
} else {
|
|
47
|
+
a = 192;
|
|
48
|
+
b = 168;
|
|
49
|
+
}
|
|
50
|
+
const c = Math.floor(Math.random() * 256);
|
|
51
|
+
return {
|
|
52
|
+
base: `${a}.${b}.${c}.0`,
|
|
53
|
+
gateway: `${a}.${b}.${c}.1`,
|
|
54
|
+
hostIp: `${a}.${b}.${c}.${10 + Math.floor(Math.random() * 200)}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('parseInternalNetwork', () => {
|
|
59
|
+
test('uses whatever /24 the host is actually on — no hard-coded range', () => {
|
|
60
|
+
for (let i = 0; i < 50; i++) {
|
|
61
|
+
const { base, gateway, hostIp } = randomRfc1918Slash24();
|
|
62
|
+
const result = parseInternalNetwork(ipRouteFor(base, gateway, hostIp));
|
|
63
|
+
expect(result).toEqual({ subnet: `${base}/24`, gateway });
|
|
64
|
+
// Guard against any latent assumption of the old default.
|
|
65
|
+
if (base !== '192.168.0.0') {
|
|
66
|
+
expect(result?.subnet).not.toBe('192.168.0.0/24');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('parses a typical dhcp default route + connected subnet', () => {
|
|
72
|
+
const out = ipRouteFor('10.37.42.0', '10.37.42.1', '10.37.42.50');
|
|
73
|
+
expect(parseInternalNetwork(out)).toEqual({ subnet: '10.37.42.0/24', gateway: '10.37.42.1' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('honors a non-/24 prefix length', () => {
|
|
77
|
+
const out = [
|
|
78
|
+
'default via 172.20.0.1 dev ens3',
|
|
79
|
+
'172.20.0.0/16 dev ens3 proto kernel scope link src 172.20.5.9',
|
|
80
|
+
].join('\n');
|
|
81
|
+
expect(parseInternalNetwork(out)).toEqual({ subnet: '172.20.0.0/16', gateway: '172.20.0.1' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('returns null when there is no default route', () => {
|
|
85
|
+
expect(parseInternalNetwork('10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.5')).toBe(
|
|
86
|
+
null,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('returns null when the connected subnet route is missing', () => {
|
|
91
|
+
expect(parseInternalNetwork('default via 10.0.0.1 dev eth0')).toBe(null);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('discoveredNetworkKeys (issue #300 redeploy idempotency)', () => {
|
|
96
|
+
const internal = { subnet: '192.168.1.0/24', gateway: '192.168.1.1' };
|
|
97
|
+
|
|
98
|
+
test('writes discovered subnet+gateway on first install (unset)', () => {
|
|
99
|
+
expect(discoveredNetworkKeys(internal, false)).toEqual({
|
|
100
|
+
'network.internal.subnet': '192.168.1.0/24',
|
|
101
|
+
'network.internal.gateway': '192.168.1.1',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('preserves an already-set subnet on redeploy (no override)', () => {
|
|
106
|
+
// The regression: a redeploy on a box off the internal network discovers the
|
|
107
|
+
// WRONG subnet — it must not clobber the operator's value.
|
|
108
|
+
expect(discoveredNetworkKeys(internal, true)).toEqual({});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('box IS on the configured internal network → nothing to record', () => {
|
|
112
|
+
expect(discoveredNetworkKeys(internal, true, { internalSubnet: '192.168.1.0/24' })).toEqual({});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('box is OFF the internal network → records it as secure-mgmt, not discarded', () => {
|
|
116
|
+
// #300 discarded this value to protect `internal`. Correct, but it left celilo
|
|
117
|
+
// blind to its own network: untrusted by the firewall, and absent from the
|
|
118
|
+
// resolver's split-horizon views (NOERROR/0-records → public DNS → hairpin).
|
|
119
|
+
const mgmtBox = { subnet: '10.0.120.0/24', gateway: '10.0.120.1' };
|
|
120
|
+
expect(discoveredNetworkKeys(mgmtBox, true, { internalSubnet: '192.168.0.0/24' })).toEqual({
|
|
121
|
+
'network.secure-mgmt.subnet': '10.0.120.0/24',
|
|
122
|
+
'network.secure-mgmt.gateway': '10.0.120.1',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('never clobbers an already-set secure-mgmt subnet', () => {
|
|
127
|
+
const mgmtBox = { subnet: '10.0.120.0/24', gateway: '10.0.120.1' };
|
|
128
|
+
expect(
|
|
129
|
+
discoveredNetworkKeys(mgmtBox, true, {
|
|
130
|
+
internalSubnet: '192.168.0.0/24',
|
|
131
|
+
secureMgmtAlreadySet: true,
|
|
132
|
+
}),
|
|
133
|
+
).toEqual({});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('internal set but its value unreadable → stays conservative, writes nothing', () => {
|
|
137
|
+
// Without knowing the configured subnet we cannot tell whether this box is on
|
|
138
|
+
// it; guessing wrong would mislabel the LAN. Preserve #300's behaviour.
|
|
139
|
+
expect(discoveredNetworkKeys(internal, true, {})).toEqual({});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('writes nothing when discovery failed and nothing is set', () => {
|
|
143
|
+
expect(discoveredNetworkKeys(null, false)).toEqual({});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('discoverAndRecordNetwork', () => {
|
|
148
|
+
let db: DbClient;
|
|
149
|
+
|
|
150
|
+
beforeEach(async () => {
|
|
151
|
+
db = await setupTestDatabase();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function read(key: string): string | undefined {
|
|
155
|
+
return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test('records the discovered network on a box that has none', () => {
|
|
159
|
+
const result = discoverAndRecordNetwork(db, () =>
|
|
160
|
+
ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20'),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(result.applied).toHaveLength(2);
|
|
164
|
+
expect(read('network.internal.subnet')).toBe('10.31.7.0/24');
|
|
165
|
+
expect(read('network.internal.gateway')).toBe('10.31.7.1');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a redeploy on the same box changes nothing and says so', () => {
|
|
169
|
+
const routes = () => ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20');
|
|
170
|
+
discoverAndRecordNetwork(db, routes);
|
|
171
|
+
const second = discoverAndRecordNetwork(db, routes);
|
|
172
|
+
|
|
173
|
+
expect(second.applied).toEqual([]);
|
|
174
|
+
expect(second.skipped).toContain('already accounts for');
|
|
175
|
+
expect(read('network.internal.subnet')).toBe('10.31.7.0/24');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a box off the internal LAN records its own network as secure-mgmt', () => {
|
|
179
|
+
db.insert(systemConfig)
|
|
180
|
+
.values({ key: 'network.internal.subnet', value: '192.168.0.0/24' })
|
|
181
|
+
.run();
|
|
182
|
+
|
|
183
|
+
discoverAndRecordNetwork(db, () => ipRouteFor('10.0.120.0', '10.0.120.1', '10.0.120.10'));
|
|
184
|
+
|
|
185
|
+
// The operator's internal value is untouched, and celilo is no longer blind
|
|
186
|
+
// to the network its own control plane occupies.
|
|
187
|
+
expect(read('network.internal.subnet')).toBe('192.168.0.0/24');
|
|
188
|
+
expect(read('network.secure-mgmt.subnet')).toBe('10.0.120.0/24');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('an unreadable routing table is reported, not guessed at', () => {
|
|
192
|
+
const result = discoverAndRecordNetwork(db, () => null);
|
|
193
|
+
|
|
194
|
+
expect(result.applied).toEqual([]);
|
|
195
|
+
expect(result.skipped).toContain('ip route');
|
|
196
|
+
expect(read('network.internal.subnet')).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
});
|