@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +6 -5
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the computed-variable DSL.
|
|
3
|
+
*
|
|
4
|
+
* Computed variables (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md, D1) are
|
|
5
|
+
* declared in a manifest with `type: computed` and a `value:` expression
|
|
6
|
+
* derived on access from other values, e.g.:
|
|
7
|
+
*
|
|
8
|
+
* value: "keys(secret.ddns_passwords)"
|
|
9
|
+
* value: "unique(concat(self.a, self.b))"
|
|
10
|
+
* value: "format('{host}.{zone}', host=self.hostname, zone=system.primary_domain)"
|
|
11
|
+
*
|
|
12
|
+
* This module turns that string into an AST. It is PURE — no evaluation,
|
|
13
|
+
* no lookups, no I/O. The grammar is deliberately tiny: a call over an
|
|
14
|
+
* allow-list of functions whose arguments are variable references, string
|
|
15
|
+
* literals, bare identifiers (field names), nested calls, or named args
|
|
16
|
+
* (for `format`). It is NOT a general expression language — there is no
|
|
17
|
+
* arithmetic, no control flow, no operators.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Roots a variable reference may start with (`secret.ddns_passwords`). */
|
|
21
|
+
export const REF_ROOTS = ['self', 'system', 'secret', 'system_secret', 'capability'] as const;
|
|
22
|
+
export type RefRoot = (typeof REF_ROOTS)[number];
|
|
23
|
+
|
|
24
|
+
const REF_ROOT_SET = new Set<string>(REF_ROOTS);
|
|
25
|
+
|
|
26
|
+
/** A function call: `keys(secret.ddns_passwords)`. */
|
|
27
|
+
export interface CallNode {
|
|
28
|
+
kind: 'call';
|
|
29
|
+
fn: string;
|
|
30
|
+
args: Arg[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A variable reference whose first segment is a known root. */
|
|
34
|
+
export interface RefNode {
|
|
35
|
+
kind: 'ref';
|
|
36
|
+
root: RefRoot;
|
|
37
|
+
/** Path segments after the root, e.g. `["ddns_passwords"]`. */
|
|
38
|
+
path: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A quoted string literal: `'{host}.{zone}'`. */
|
|
42
|
+
export interface StrNode {
|
|
43
|
+
kind: 'str';
|
|
44
|
+
value: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A bare (non-root) identifier, possibly dotted — used as a field-name
|
|
49
|
+
* argument, e.g. the `ip` in `map(self.upstreams, ip)`.
|
|
50
|
+
*/
|
|
51
|
+
export interface BareNode {
|
|
52
|
+
kind: 'bare';
|
|
53
|
+
name: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type Node = CallNode | RefNode | StrNode | BareNode;
|
|
57
|
+
|
|
58
|
+
/** A call argument — optionally named (`host=self.hostname`). */
|
|
59
|
+
export interface Arg {
|
|
60
|
+
/** Present only for named args (used by `format`). */
|
|
61
|
+
name?: string;
|
|
62
|
+
value: Node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ComputedParseError extends Error {
|
|
66
|
+
constructor(
|
|
67
|
+
message: string,
|
|
68
|
+
readonly position: number,
|
|
69
|
+
) {
|
|
70
|
+
super(`Computed expression parse error at ${position}: ${message}`);
|
|
71
|
+
this.name = 'ComputedParseError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type TokenType = 'ident' | 'string' | '(' | ')' | ',' | '=' | '.';
|
|
76
|
+
|
|
77
|
+
interface Token {
|
|
78
|
+
type: TokenType;
|
|
79
|
+
value: string;
|
|
80
|
+
pos: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const IDENT_START = /[a-zA-Z_]/;
|
|
84
|
+
const IDENT_CHAR = /[a-zA-Z0-9_]/;
|
|
85
|
+
|
|
86
|
+
function tokenize(input: string): Token[] {
|
|
87
|
+
const tokens: Token[] = [];
|
|
88
|
+
let i = 0;
|
|
89
|
+
|
|
90
|
+
while (i < input.length) {
|
|
91
|
+
const ch = input[i];
|
|
92
|
+
|
|
93
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (ch === '(' || ch === ')' || ch === ',' || ch === '=' || ch === '.') {
|
|
99
|
+
tokens.push({ type: ch, value: ch, pos: i });
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (ch === "'" || ch === '"') {
|
|
105
|
+
const quote = ch;
|
|
106
|
+
const start = i;
|
|
107
|
+
i++;
|
|
108
|
+
let value = '';
|
|
109
|
+
while (i < input.length && input[i] !== quote) {
|
|
110
|
+
value += input[i];
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
if (i >= input.length) {
|
|
114
|
+
throw new ComputedParseError('unterminated string literal', start);
|
|
115
|
+
}
|
|
116
|
+
i++; // closing quote
|
|
117
|
+
tokens.push({ type: 'string', value, pos: start });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (IDENT_START.test(ch)) {
|
|
122
|
+
const start = i;
|
|
123
|
+
let value = '';
|
|
124
|
+
while (i < input.length && IDENT_CHAR.test(input[i])) {
|
|
125
|
+
value += input[i];
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
tokens.push({ type: 'ident', value, pos: start });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new ComputedParseError(`unexpected character '${ch}'`, i);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class Parser {
|
|
139
|
+
private pos = 0;
|
|
140
|
+
|
|
141
|
+
constructor(private readonly tokens: Token[]) {}
|
|
142
|
+
|
|
143
|
+
parse(): Node {
|
|
144
|
+
const node = this.parseExpr();
|
|
145
|
+
if (this.pos < this.tokens.length) {
|
|
146
|
+
throw new ComputedParseError(
|
|
147
|
+
`unexpected trailing input '${this.tokens[this.pos].value}'`,
|
|
148
|
+
this.tokens[this.pos].pos,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return node;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private peek(): Token | undefined {
|
|
155
|
+
return this.tokens[this.pos];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private next(): Token {
|
|
159
|
+
const t = this.tokens[this.pos];
|
|
160
|
+
if (!t) {
|
|
161
|
+
throw new ComputedParseError('unexpected end of expression', -1);
|
|
162
|
+
}
|
|
163
|
+
this.pos++;
|
|
164
|
+
return t;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private expect(type: TokenType): Token {
|
|
168
|
+
const t = this.next();
|
|
169
|
+
if (t.type !== type) {
|
|
170
|
+
throw new ComputedParseError(`expected '${type}' but found '${t.value}'`, t.pos);
|
|
171
|
+
}
|
|
172
|
+
return t;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private parseExpr(): Node {
|
|
176
|
+
const t = this.peek();
|
|
177
|
+
if (!t) {
|
|
178
|
+
throw new ComputedParseError('expected an expression', -1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (t.type === 'string') {
|
|
182
|
+
this.next();
|
|
183
|
+
return { kind: 'str', value: t.value };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (t.type === 'ident') {
|
|
187
|
+
// Function call?
|
|
188
|
+
if (this.tokens[this.pos + 1]?.type === '(') {
|
|
189
|
+
return this.parseCall();
|
|
190
|
+
}
|
|
191
|
+
return this.parseRefOrBare();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw new ComputedParseError(`expected an expression but found '${t.value}'`, t.pos);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private parseCall(): CallNode {
|
|
198
|
+
const fnTok = this.expect('ident');
|
|
199
|
+
this.expect('(');
|
|
200
|
+
const args: Arg[] = [];
|
|
201
|
+
|
|
202
|
+
if (this.peek()?.type !== ')') {
|
|
203
|
+
for (;;) {
|
|
204
|
+
args.push(this.parseArg());
|
|
205
|
+
if (this.peek()?.type === ',') {
|
|
206
|
+
this.next();
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.expect(')');
|
|
214
|
+
return { kind: 'call', fn: fnTok.value, args };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private parseArg(): Arg {
|
|
218
|
+
// Named arg: IDENT '=' expr (used by format).
|
|
219
|
+
const t = this.peek();
|
|
220
|
+
if (t?.type === 'ident' && this.tokens[this.pos + 1]?.type === '=') {
|
|
221
|
+
const nameTok = this.next();
|
|
222
|
+
this.expect('=');
|
|
223
|
+
return { name: nameTok.value, value: this.parseExpr() };
|
|
224
|
+
}
|
|
225
|
+
return { value: this.parseExpr() };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private parseRefOrBare(): RefNode | BareNode {
|
|
229
|
+
const first = this.expect('ident');
|
|
230
|
+
const segments = [first.value];
|
|
231
|
+
|
|
232
|
+
while (this.peek()?.type === '.') {
|
|
233
|
+
this.next();
|
|
234
|
+
segments.push(this.expect('ident').value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (REF_ROOT_SET.has(segments[0]) && segments.length > 1) {
|
|
238
|
+
return {
|
|
239
|
+
kind: 'ref',
|
|
240
|
+
root: segments[0] as RefRoot,
|
|
241
|
+
path: segments.slice(1),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A known root with no path (`secret`) is meaningless; treat as bare so
|
|
246
|
+
// the evaluator can produce a clear "unknown identifier" style error in
|
|
247
|
+
// context rather than silently accepting it.
|
|
248
|
+
return { kind: 'bare', name: segments.join('.') };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a computed-variable `value:` expression into an AST.
|
|
254
|
+
* Throws {@link ComputedParseError} on malformed input.
|
|
255
|
+
*/
|
|
256
|
+
export function parseComputed(expression: string): Node {
|
|
257
|
+
const tokens = tokenize(expression);
|
|
258
|
+
if (tokens.length === 0) {
|
|
259
|
+
throw new ComputedParseError('empty expression', 0);
|
|
260
|
+
}
|
|
261
|
+
return new Parser(tokens).parse();
|
|
262
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a {@link LookupFn} for evaluating a computed capability field in its
|
|
3
|
+
* PROVIDER's context.
|
|
4
|
+
*
|
|
5
|
+
* A computed field's DSL refs (`secret.ddns_passwords`, `self.hostname`,
|
|
6
|
+
* `system.primary_domain`, …) name values in the *provider* module's
|
|
7
|
+
* namespace — not the consumer that reads
|
|
8
|
+
* `$capability:dns_registrar.domain_list`. So when the resolver hits a
|
|
9
|
+
* computed marker it builds this lookup over the provider and evaluates there
|
|
10
|
+
* (the operator-chosen "evaluate in provider context, lazily" model).
|
|
11
|
+
*
|
|
12
|
+
* Values are returned TYPED — `module_configs.value_json` and JSON-encoded
|
|
13
|
+
* secrets are parsed back into objects/arrays — so DSL functions like
|
|
14
|
+
* `keys(secret.ddns_passwords)` operate on a real map, not a string.
|
|
15
|
+
*
|
|
16
|
+
* The lookup is built eagerly (it must `await getOrCreateMasterKey()` to
|
|
17
|
+
* decrypt secrets, and {@link LookupFn} is synchronous), then returned as a
|
|
18
|
+
* sync closure over the loaded namespaces.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
import type { DbClient } from '../../db/client';
|
|
23
|
+
import { moduleConfigs, secrets, systemConfig, systemSecrets } from '../../db/schema';
|
|
24
|
+
import { decryptSecret } from '../../secrets/encryption';
|
|
25
|
+
import { getOrCreateMasterKey } from '../../secrets/master-key';
|
|
26
|
+
import { parseStoredConfigValue } from '../../services/module-config';
|
|
27
|
+
import type { LookupFn } from './evaluate';
|
|
28
|
+
|
|
29
|
+
/** Navigate a dotted path into a value; undefined if any segment is absent. */
|
|
30
|
+
function navigate(root: unknown, path: string[]): unknown {
|
|
31
|
+
let current = root;
|
|
32
|
+
for (const seg of path) {
|
|
33
|
+
if (current && typeof current === 'object' && seg in (current as Record<string, unknown>)) {
|
|
34
|
+
current = (current as Record<string, unknown>)[seg];
|
|
35
|
+
} else {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Best-effort recover a typed value from a stored string (JSON or scalar). */
|
|
43
|
+
function parseMaybeJson(raw: string): unknown {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a lookup over `providerModuleId`'s config, secrets, and system data.
|
|
53
|
+
* Eagerly loads every namespace (decrypting secrets with the master key) and
|
|
54
|
+
* returns a synchronous lookup closure.
|
|
55
|
+
*/
|
|
56
|
+
export async function buildProviderLookup(
|
|
57
|
+
providerModuleId: string,
|
|
58
|
+
db: DbClient,
|
|
59
|
+
): Promise<LookupFn> {
|
|
60
|
+
const masterKey = await getOrCreateMasterKey();
|
|
61
|
+
|
|
62
|
+
// self: the provider module's config, typed via value_json.
|
|
63
|
+
const selfMap: Record<string, unknown> = {};
|
|
64
|
+
for (const row of db
|
|
65
|
+
.select()
|
|
66
|
+
.from(moduleConfigs)
|
|
67
|
+
.where(eq(moduleConfigs.moduleId, providerModuleId))
|
|
68
|
+
.all()) {
|
|
69
|
+
try {
|
|
70
|
+
selfMap[row.key] = parseStoredConfigValue(row);
|
|
71
|
+
} catch {
|
|
72
|
+
// Pre-Defect-1 / unparseable row — fall back to the raw display value.
|
|
73
|
+
selfMap[row.key] = row.value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// secret: the provider module's decrypted secrets, JSON-parsed where possible.
|
|
78
|
+
const secretMap: Record<string, unknown> = {};
|
|
79
|
+
for (const row of db.select().from(secrets).where(eq(secrets.moduleId, providerModuleId)).all()) {
|
|
80
|
+
try {
|
|
81
|
+
const plaintext = decryptSecret(
|
|
82
|
+
{ encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
|
|
83
|
+
masterKey,
|
|
84
|
+
);
|
|
85
|
+
secretMap[row.name] = parseMaybeJson(plaintext);
|
|
86
|
+
} catch {
|
|
87
|
+
// Skip undecryptable secrets — a reference surfaces as "could not be
|
|
88
|
+
// resolved" from the evaluator.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// system: global config.
|
|
93
|
+
const systemMap: Record<string, unknown> = {};
|
|
94
|
+
for (const row of db.select().from(systemConfig).all()) {
|
|
95
|
+
systemMap[row.key] = parseMaybeJson(row.value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// system_secret: decrypted global secrets.
|
|
99
|
+
const systemSecretMap: Record<string, unknown> = {};
|
|
100
|
+
for (const row of db.select().from(systemSecrets).all()) {
|
|
101
|
+
try {
|
|
102
|
+
const plaintext = decryptSecret(
|
|
103
|
+
{ encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
|
|
104
|
+
masterKey,
|
|
105
|
+
);
|
|
106
|
+
systemSecretMap[row.key] = parseMaybeJson(plaintext);
|
|
107
|
+
} catch {
|
|
108
|
+
// skip undecryptable
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (root, path) => {
|
|
113
|
+
switch (root) {
|
|
114
|
+
case 'self':
|
|
115
|
+
return navigate(selfMap, path);
|
|
116
|
+
case 'secret':
|
|
117
|
+
return navigate(secretMap, path);
|
|
118
|
+
case 'system':
|
|
119
|
+
return navigate(systemMap, path);
|
|
120
|
+
case 'system_secret':
|
|
121
|
+
return navigate(systemSecretMap, path);
|
|
122
|
+
default:
|
|
123
|
+
// `capability` cross-refs from inside a computed field are not
|
|
124
|
+
// supported in v1 — they'd reintroduce the provider→provider coupling
|
|
125
|
+
// computed variables exist to avoid. Returns undefined → a clear
|
|
126
|
+
// "could not be resolved" error.
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -5,8 +5,11 @@ import { eq } from 'drizzle-orm';
|
|
|
5
5
|
import { type DbClient, createDbClient } from '../db/client';
|
|
6
6
|
import {
|
|
7
7
|
capabilities,
|
|
8
|
+
containerServices,
|
|
8
9
|
ipAllocations,
|
|
9
10
|
moduleConfigs,
|
|
11
|
+
moduleInfrastructure,
|
|
12
|
+
moduleSystems,
|
|
10
13
|
modules,
|
|
11
14
|
secrets,
|
|
12
15
|
systemConfig,
|
|
@@ -15,6 +18,38 @@ import { buildContextFromData, buildResolutionContext } from './context';
|
|
|
15
18
|
|
|
16
19
|
const TEST_DB_PATH = './test-context.db';
|
|
17
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Select a proxmox container_service for a module + give it a hostname, so the
|
|
23
|
+
* deployed-system recording in buildResolutionContext fires (IPAM allocates and
|
|
24
|
+
* records into module_systems). v2/MODULE_SYSTEMS_ADDRESSING.md.
|
|
25
|
+
*/
|
|
26
|
+
function setupProxmoxInfra(db: DbClient, moduleId: string, zone: string): void {
|
|
27
|
+
const serviceId = `svc-${moduleId}`;
|
|
28
|
+
db.insert(containerServices)
|
|
29
|
+
.values({
|
|
30
|
+
id: serviceId,
|
|
31
|
+
serviceId,
|
|
32
|
+
name: 'Test Proxmox',
|
|
33
|
+
providerName: 'proxmox',
|
|
34
|
+
zones: [zone] as Array<'internal' | 'dmz' | 'app' | 'secure' | 'external'>,
|
|
35
|
+
apiCredentialsEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
|
|
36
|
+
providerConfig: { default_target_node: 'pve', lxc_template: 't', storage: 's' },
|
|
37
|
+
verified: true,
|
|
38
|
+
})
|
|
39
|
+
.run();
|
|
40
|
+
db.insert(moduleInfrastructure)
|
|
41
|
+
.values({
|
|
42
|
+
id: `infra-${moduleId}`,
|
|
43
|
+
moduleId,
|
|
44
|
+
infrastructureType: 'container_service',
|
|
45
|
+
serviceId,
|
|
46
|
+
})
|
|
47
|
+
.run();
|
|
48
|
+
db.insert(moduleConfigs)
|
|
49
|
+
.values({ moduleId, key: 'hostname', value: moduleId, valueJson: JSON.stringify(moduleId) })
|
|
50
|
+
.run();
|
|
51
|
+
}
|
|
52
|
+
|
|
18
53
|
describe('Variable Context', () => {
|
|
19
54
|
let db: DbClient;
|
|
20
55
|
|
|
@@ -444,30 +479,36 @@ describe('Variable Context', () => {
|
|
|
444
479
|
},
|
|
445
480
|
})
|
|
446
481
|
.run();
|
|
482
|
+
setupProxmoxInfra(db, 'auto-module', 'dmz');
|
|
447
483
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
// Should have auto-allocated vmid and target_ip
|
|
451
|
-
expect(context.selfConfig.vmid).toBe('2100');
|
|
452
|
-
expect(context.selfConfig.target_ip).toBe('10.0.10.10/24');
|
|
484
|
+
await buildResolutionContext('auto-module', db);
|
|
453
485
|
|
|
454
|
-
//
|
|
455
|
-
const allocations =
|
|
486
|
+
// IPAM allocation persisted
|
|
487
|
+
const allocations = db.select().from(ipAllocations).all();
|
|
456
488
|
expect(allocations).toHaveLength(1);
|
|
457
489
|
expect(allocations[0].moduleId).toBe('auto-module');
|
|
458
490
|
expect(allocations[0].vmid).toBe(2100);
|
|
459
491
|
expect(allocations[0].containerIp).toBe('10.0.10.10/24');
|
|
460
492
|
|
|
461
|
-
//
|
|
462
|
-
const
|
|
493
|
+
// The system is recorded in module_systems (target_ip no longer in config).
|
|
494
|
+
const systems = db
|
|
495
|
+
.select()
|
|
496
|
+
.from(moduleSystems)
|
|
497
|
+
.where(eq(moduleSystems.moduleId, 'auto-module'))
|
|
498
|
+
.all();
|
|
499
|
+
expect(systems).toHaveLength(1);
|
|
500
|
+
expect(systems[0].name).toBe('main');
|
|
501
|
+
expect(systems[0].vmid).toBe(2100);
|
|
502
|
+
expect(systems[0].ipv4Address).toBe('10.0.10.10'); // CIDR-stripped
|
|
503
|
+
|
|
504
|
+
// target_ip / vmid are NOT written to module_configs anymore.
|
|
505
|
+
const configs = db
|
|
463
506
|
.select()
|
|
464
507
|
.from(moduleConfigs)
|
|
465
508
|
.where(eq(moduleConfigs.moduleId, 'auto-module'))
|
|
466
509
|
.all();
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
expect(vmidConfig?.value).toBe('2100');
|
|
470
|
-
expect(ipConfig?.value).toBe('10.0.10.10/24');
|
|
510
|
+
expect(configs.find((c) => c.key === 'target_ip')).toBeUndefined();
|
|
511
|
+
expect(configs.find((c) => c.key === 'vmid')).toBeUndefined();
|
|
471
512
|
});
|
|
472
513
|
|
|
473
514
|
test('should reuse existing allocation if already allocated', async () => {
|
|
@@ -488,9 +529,11 @@ describe('Variable Context', () => {
|
|
|
488
529
|
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
489
530
|
],
|
|
490
531
|
},
|
|
532
|
+
requires: { machine: { zone: 'dmz' } },
|
|
491
533
|
},
|
|
492
534
|
})
|
|
493
535
|
.run();
|
|
536
|
+
setupProxmoxInfra(db, 'existing-alloc', 'dmz');
|
|
494
537
|
|
|
495
538
|
// Pre-create allocation
|
|
496
539
|
db.insert(ipAllocations)
|
|
@@ -502,14 +545,20 @@ describe('Variable Context', () => {
|
|
|
502
545
|
})
|
|
503
546
|
.run();
|
|
504
547
|
|
|
505
|
-
|
|
548
|
+
await buildResolutionContext('existing-alloc', db);
|
|
506
549
|
|
|
507
|
-
// Should reuse existing allocation
|
|
508
|
-
|
|
509
|
-
|
|
550
|
+
// Should reuse the existing allocation when recording the system.
|
|
551
|
+
const systems = db
|
|
552
|
+
.select()
|
|
553
|
+
.from(moduleSystems)
|
|
554
|
+
.where(eq(moduleSystems.moduleId, 'existing-alloc'))
|
|
555
|
+
.all();
|
|
556
|
+
expect(systems).toHaveLength(1);
|
|
557
|
+
expect(systems[0].vmid).toBe(2150);
|
|
558
|
+
expect(systems[0].ipv4Address).toBe('10.0.10.50');
|
|
510
559
|
|
|
511
560
|
// Should not create duplicate allocation
|
|
512
|
-
const allocations =
|
|
561
|
+
const allocations = db.select().from(ipAllocations).all();
|
|
513
562
|
expect(allocations).toHaveLength(1);
|
|
514
563
|
});
|
|
515
564
|
|
|
@@ -600,9 +649,11 @@ describe('Variable Context', () => {
|
|
|
600
649
|
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
601
650
|
],
|
|
602
651
|
},
|
|
652
|
+
requires: { machine: { zone: 'dmz' } },
|
|
603
653
|
},
|
|
604
654
|
})
|
|
605
655
|
.run();
|
|
656
|
+
setupProxmoxInfra(db, 'module1', 'dmz');
|
|
606
657
|
|
|
607
658
|
// Create second module
|
|
608
659
|
db.insert(modules)
|
|
@@ -621,23 +672,33 @@ describe('Variable Context', () => {
|
|
|
621
672
|
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
622
673
|
],
|
|
623
674
|
},
|
|
675
|
+
requires: { machine: { zone: 'dmz' } },
|
|
624
676
|
},
|
|
625
677
|
})
|
|
626
678
|
.run();
|
|
679
|
+
setupProxmoxInfra(db, 'module2', 'dmz');
|
|
627
680
|
|
|
628
681
|
// Allocate for both
|
|
629
|
-
|
|
630
|
-
|
|
682
|
+
await buildResolutionContext('module1', db);
|
|
683
|
+
await buildResolutionContext('module2', db);
|
|
631
684
|
|
|
632
|
-
// Should have sequential VMIDs
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
685
|
+
// Should have recorded systems with sequential VMIDs + IPs.
|
|
686
|
+
const sys1 = db
|
|
687
|
+
.select()
|
|
688
|
+
.from(moduleSystems)
|
|
689
|
+
.where(eq(moduleSystems.moduleId, 'module1'))
|
|
690
|
+
.all();
|
|
691
|
+
const sys2 = db
|
|
692
|
+
.select()
|
|
693
|
+
.from(moduleSystems)
|
|
694
|
+
.where(eq(moduleSystems.moduleId, 'module2'))
|
|
695
|
+
.all();
|
|
696
|
+
expect(sys1[0].vmid).toBe(2100);
|
|
697
|
+
expect(sys2[0].vmid).toBe(2101);
|
|
698
|
+
expect(sys1[0].ipv4Address).toBe('10.0.10.10');
|
|
699
|
+
expect(sys2[0].ipv4Address).toBe('10.0.10.11');
|
|
639
700
|
|
|
640
|
-
const allocations =
|
|
701
|
+
const allocations = db.select().from(ipAllocations).all();
|
|
641
702
|
expect(allocations).toHaveLength(2);
|
|
642
703
|
});
|
|
643
704
|
|