@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.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.
Files changed (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. 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
- const context = await buildResolutionContext('auto-module', db);
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
- // Verify allocation persisted to database
455
- const allocations = await db.select().from(ipAllocations).all();
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
- // Verify config persisted to database
462
- const configs = await db
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
- const vmidConfig = configs.find((c) => c.key === 'vmid');
468
- const ipConfig = configs.find((c) => c.key === 'target_ip');
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
- const context = await buildResolutionContext('existing-alloc', db);
548
+ await buildResolutionContext('existing-alloc', db);
506
549
 
507
- // Should reuse existing allocation
508
- expect(context.selfConfig.vmid).toBe('2150');
509
- expect(context.selfConfig.target_ip).toBe('10.0.10.50/24');
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 = await db.select().from(ipAllocations).all();
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
- const context1 = await buildResolutionContext('module1', db);
630
- const context2 = await buildResolutionContext('module2', db);
682
+ await buildResolutionContext('module1', db);
683
+ await buildResolutionContext('module2', db);
631
684
 
632
- // Should have sequential VMIDs
633
- expect(context1.selfConfig.vmid).toBe('2100');
634
- expect(context2.selfConfig.vmid).toBe('2101');
635
-
636
- // Should have sequential IPs
637
- expect(context1.selfConfig.target_ip).toBe('10.0.10.10/24');
638
- expect(context2.selfConfig.target_ip).toBe('10.0.10.11/24');
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 = await db.select().from(ipAllocations).all();
701
+ const allocations = db.select().from(ipAllocations).all();
641
702
  expect(allocations).toHaveLength(2);
642
703
  });
643
704