@celilo/cli 0.3.30 → 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 +5 -4
  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,191 @@
1
+ /**
2
+ * Integration test: computed capability fields end-to-end through the
3
+ * DB-backed resolver. Exercises registration folding a `computed:` entry
4
+ * into stored capability data, then resolving `$capability:X.field` by
5
+ * evaluating the DSL in the provider's context (typed secret lookup).
6
+ *
7
+ * Isolation: CELILO_DATA_DIR is pointed at a temp dir and a throwaway master
8
+ * key is written there, so this never touches the operator's real key/db.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { registerModuleCapabilities } from '../../capabilities/registration';
15
+ import { type DbClient, closeDb, createDbClient } from '../../db/client';
16
+ import type { ModuleManifest } from '../../manifest/schema';
17
+ import { encryptSecret } from '../../secrets/encryption';
18
+ import { generateMasterKey, writeMasterKey } from '../../secrets/master-key';
19
+ import { buildResolutionContext } from '../context';
20
+ import { parseVariables } from '../parser';
21
+ import { resolveVariable } from '../resolver';
22
+ import type { ResolutionContext } from '../types';
23
+ import { COMPUTED_MARKER_KEY } from './marker';
24
+
25
+ let testDir: string;
26
+ let originalDataDir: string | undefined;
27
+ let db: DbClient;
28
+
29
+ /** Fresh manifest per call — never share a mutable manifest across tests. */
30
+ function namecheapManifest(computedValue = 'keys(secret.ddns_passwords)'): ModuleManifest {
31
+ return {
32
+ celilo_contract: '1.0',
33
+ id: 'namecheap',
34
+ name: 'Namecheap',
35
+ version: '4.1.0',
36
+ description: 'test',
37
+ provides: {
38
+ capabilities: [
39
+ {
40
+ name: 'dns_registrar',
41
+ version: '4.1.0',
42
+ data: { provider: 'namecheap' },
43
+ computed: [{ name: 'domain_list', type: 'computed', value: computedValue }],
44
+ },
45
+ ],
46
+ },
47
+ } as unknown as ModuleManifest;
48
+ }
49
+
50
+ /** Build a resolution context whose capabilities map comes from the DB. */
51
+ function contextFromDb(moduleId: string): ResolutionContext {
52
+ const rows = db.$client.prepare('SELECT capability_name, data FROM capabilities').all() as Array<{
53
+ capability_name: string;
54
+ data: string;
55
+ }>;
56
+ const capMap: Record<string, Record<string, unknown>> = {};
57
+ for (const row of rows) {
58
+ capMap[row.capability_name] = JSON.parse(row.data);
59
+ }
60
+ return {
61
+ moduleId,
62
+ selfConfig: {},
63
+ systemConfig: {},
64
+ systemSecrets: {},
65
+ secrets: {},
66
+ capabilities: capMap,
67
+ };
68
+ }
69
+
70
+ beforeEach(async () => {
71
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-computed-'));
72
+ originalDataDir = process.env.CELILO_DATA_DIR;
73
+ process.env.CELILO_DATA_DIR = testDir;
74
+ await writeMasterKey(generateMasterKey());
75
+
76
+ db = createDbClient({ path: join(testDir, 'test.db') });
77
+
78
+ db.$client
79
+ .prepare(
80
+ 'INSERT INTO modules (id, name, version, state, manifest_data, source_path) VALUES (?, ?, ?, ?, ?, ?)',
81
+ )
82
+ .run('namecheap', 'Namecheap', '4.1.0', 'INSTALLED', '{}', '/tmp/namecheap');
83
+ db.$client
84
+ .prepare(
85
+ 'INSERT INTO modules (id, name, version, state, manifest_data, source_path) VALUES (?, ?, ?, ?, ?, ?)',
86
+ )
87
+ .run('technitium', 'Technitium', '1.0.0', 'INSTALLED', '{}', '/tmp/technitium');
88
+
89
+ // namecheap's ddns_passwords secret: a JSON map of domain -> password.
90
+ // Encrypt with the SAME master key the resolver will decrypt with (the one
91
+ // beforeEach wrote into CELILO_DATA_DIR).
92
+ const enc = encryptSecret(
93
+ JSON.stringify({ 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' }),
94
+ await loadTestMasterKey(),
95
+ );
96
+ db.$client
97
+ .prepare(
98
+ 'INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES (?, ?, ?, ?, ?)',
99
+ )
100
+ .run('namecheap', 'ddns_passwords', enc.encryptedValue, enc.iv, enc.authTag);
101
+ });
102
+
103
+ afterEach(() => {
104
+ closeDb();
105
+ process.env.CELILO_DATA_DIR = originalDataDir;
106
+ rmSync(testDir, { recursive: true, force: true });
107
+ });
108
+
109
+ /** Read the master key that beforeEach wrote into CELILO_DATA_DIR. */
110
+ async function loadTestMasterKey(): Promise<Buffer> {
111
+ const { readMasterKey } = await import('../../secrets/master-key');
112
+ return readMasterKey();
113
+ }
114
+
115
+ describe('computed capability fields — DB integration', () => {
116
+ test('registration folds a computed field into stored data as a marker', async () => {
117
+ await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client);
118
+
119
+ const row = db.$client
120
+ .prepare('SELECT data FROM capabilities WHERE capability_name = ?')
121
+ .get('dns_registrar') as { data: string };
122
+ const data = JSON.parse(row.data);
123
+ expect(data.provider).toBe('namecheap');
124
+ expect(data.domain_list).toEqual({ [COMPUTED_MARKER_KEY]: 'keys(secret.ddns_passwords)' });
125
+ });
126
+
127
+ test('resolver evaluates the computed field in the provider context', async () => {
128
+ await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client);
129
+
130
+ const ref = parseVariables('$capability:dns_registrar.domain_list')[0];
131
+ const result = await resolveVariable(ref, contextFromDb('technitium'), db);
132
+
133
+ expect(result.success).toBe(true);
134
+ if (result.success) {
135
+ // Non-scalar computed results serialize to JSON in the string path.
136
+ expect(JSON.parse(result.value)).toEqual(['lunacycle.net', 'celilo.computer']);
137
+ }
138
+ });
139
+
140
+ test('a static field alongside the computed one still resolves', async () => {
141
+ await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client);
142
+
143
+ const ref = parseVariables('$capability:dns_registrar.provider')[0];
144
+ const result = await resolveVariable(ref, contextFromDb('technitium'), db);
145
+ expect(result.success).toBe(true);
146
+ if (result.success) expect(result.value).toBe('namecheap');
147
+ });
148
+
149
+ test('a computed field referencing a missing secret yields a clear error', async () => {
150
+ await registerModuleCapabilities(
151
+ 'namecheap',
152
+ namecheapManifest('keys(secret.nope)'),
153
+ db.$client,
154
+ );
155
+
156
+ const ref = parseVariables('$capability:dns_registrar.domain_list')[0];
157
+ const result = await resolveVariable(ref, contextFromDb('technitium'), db);
158
+ expect(result.success).toBe(false);
159
+ if (!result.success) expect(result.error).toMatch(/could not be resolved|Failed to evaluate/);
160
+ });
161
+
162
+ // Gap B (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md step 3): buildResolutionContext
163
+ // must EAGERLY evaluate computed markers into the capabilities map, so the
164
+ // `variables.imports` path (which reads context.capabilities directly, not via
165
+ // resolveVariable) sees the real array.
166
+ test('buildResolutionContext pre-evaluates the computed field into the capabilities map', async () => {
167
+ await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client);
168
+
169
+ const ctx = await buildResolutionContext('technitium', db);
170
+ const reg = ctx.capabilities.dns_registrar as Record<string, unknown>;
171
+
172
+ // The real array, not the raw marker object.
173
+ expect(reg.domain_list).toEqual(['lunacycle.net', 'celilo.computer']);
174
+ expect(reg.provider).toBe('namecheap');
175
+ });
176
+
177
+ test('a provider whose computed field fails to evaluate does not break context build', async () => {
178
+ await registerModuleCapabilities(
179
+ 'namecheap',
180
+ namecheapManifest('keys(secret.nope)'),
181
+ db.$client,
182
+ );
183
+
184
+ // Build context for an unrelated consumer — must not throw despite the
185
+ // un-evaluable marker (best-effort: leaves the marker in place).
186
+ const ctx = await buildResolutionContext('technitium', db);
187
+ const reg = ctx.capabilities.dns_registrar as Record<string, unknown>;
188
+ expect(reg.provider).toBe('namecheap');
189
+ expect(reg.domain_list).toEqual({ [COMPUTED_MARKER_KEY]: 'keys(secret.nope)' });
190
+ });
191
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ComputedEvalError, type LookupFn, evaluateComputed } from './evaluate';
3
+ import { ComputedParseError, parseComputed } from './parse';
4
+
5
+ /**
6
+ * A lookup over a fixed fixture, mirroring how the resolver will map
7
+ * roots (self/system/secret/...) onto live data. Returns undefined for
8
+ * anything absent so the evaluator's missing-ref path is exercised.
9
+ */
10
+ function makeLookup(data: Record<string, unknown>): LookupFn {
11
+ return (root, path) => {
12
+ let current: unknown = data[root];
13
+ for (const seg of path) {
14
+ if (current && typeof current === 'object' && seg in (current as Record<string, unknown>)) {
15
+ current = (current as Record<string, unknown>)[seg];
16
+ } else {
17
+ return undefined;
18
+ }
19
+ }
20
+ return current;
21
+ };
22
+ }
23
+
24
+ const FIXTURE = {
25
+ secret: {
26
+ ddns_passwords: { 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' },
27
+ },
28
+ self: {
29
+ zone_names: { dmz: 'DMZ', app: 'App' },
30
+ upstreams: [{ ip: '1.1.1.1' }, { ip: '8.8.8.8' }],
31
+ primary_domains: ['a.net', 'b.net'],
32
+ extra_domains: ['b.net', 'c.net'],
33
+ hostname: 'dns-int',
34
+ zones_with_dupes: ['x', 'y', 'x', 'z', 'y'],
35
+ },
36
+ system: {
37
+ primary_domain: 'lunacycle.net',
38
+ },
39
+ };
40
+
41
+ function evalOk(expr: string): unknown {
42
+ return evaluateComputed(expr, makeLookup(FIXTURE));
43
+ }
44
+
45
+ describe('computed DSL — keys', () => {
46
+ test('keys of the ddns_passwords secret map (the domain_list case)', () => {
47
+ expect(evalOk('keys(secret.ddns_passwords)')).toEqual(['lunacycle.net', 'celilo.computer']);
48
+ });
49
+
50
+ test('keys of a non-secret object', () => {
51
+ expect(evalOk('keys(self.zone_names)')).toEqual(['dmz', 'app']);
52
+ });
53
+ });
54
+
55
+ describe('computed DSL — values', () => {
56
+ test('values of a NON-secret map is allowed', () => {
57
+ expect(evalOk('values(self.zone_names)')).toEqual(['DMZ', 'App']);
58
+ });
59
+
60
+ test('values of a secret map is REJECTED (projection rule)', () => {
61
+ expect(() => evalOk('values(secret.ddns_passwords)')).toThrow(ComputedEvalError);
62
+ expect(() => evalOk('values(secret.ddns_passwords)')).toThrow(/leak/i);
63
+ });
64
+
65
+ test('keys of a secret map is allowed (key names are non-sensitive)', () => {
66
+ expect(evalOk('keys(secret.ddns_passwords)')).toEqual(['lunacycle.net', 'celilo.computer']);
67
+ });
68
+ });
69
+
70
+ describe('computed DSL — map', () => {
71
+ test('projects a field from a list of objects', () => {
72
+ expect(evalOk('map(self.upstreams, ip)')).toEqual(['1.1.1.1', '8.8.8.8']);
73
+ });
74
+
75
+ test('errors when an element is not an object', () => {
76
+ expect(() => evalOk('map(self.primary_domains, ip)')).toThrow(ComputedEvalError);
77
+ });
78
+
79
+ test('errors when the field arg is not a bare identifier', () => {
80
+ expect(() => evalOk("map(self.upstreams, 'ip')")).toThrow(/bare field name/);
81
+ });
82
+ });
83
+
84
+ describe('computed DSL — concat + unique (nesting/chaining)', () => {
85
+ test('concat flattens multiple arrays', () => {
86
+ expect(evalOk('concat(self.primary_domains, self.extra_domains)')).toEqual([
87
+ 'a.net',
88
+ 'b.net',
89
+ 'b.net',
90
+ 'c.net',
91
+ ]);
92
+ });
93
+
94
+ test('unique dedupes', () => {
95
+ expect(evalOk('unique(self.zones_with_dupes)')).toEqual(['x', 'y', 'z']);
96
+ });
97
+
98
+ test('nested calls chain: unique(concat(...))', () => {
99
+ expect(evalOk('unique(concat(self.primary_domains, self.extra_domains))')).toEqual([
100
+ 'a.net',
101
+ 'b.net',
102
+ 'c.net',
103
+ ]);
104
+ });
105
+ });
106
+
107
+ describe('computed DSL — format', () => {
108
+ test('interpolates named parts', () => {
109
+ expect(evalOk("format('{host}.{zone}', host=self.hostname, zone=system.primary_domain)")).toBe(
110
+ 'dns-int.lunacycle.net',
111
+ );
112
+ });
113
+
114
+ test('errors when template references an unsupplied part', () => {
115
+ expect(() => evalOk("format('{host}.{missing}', host=self.hostname)")).toThrow(
116
+ /no such named argument/,
117
+ );
118
+ });
119
+
120
+ test('errors when a non-template arg is positional', () => {
121
+ expect(() => evalOk("format('{a}', self.hostname)")).toThrow(/must be named/);
122
+ });
123
+ });
124
+
125
+ describe('computed DSL — reference & arity errors', () => {
126
+ test('missing reference throws', () => {
127
+ expect(() => evalOk('keys(secret.nonexistent)')).toThrow(/could not be resolved/);
128
+ });
129
+
130
+ test('keys on a non-object throws', () => {
131
+ expect(() => evalOk('keys(self.hostname)')).toThrow(/expects an object/);
132
+ });
133
+
134
+ test('unknown function is rejected', () => {
135
+ expect(() => evalOk('frobnicate(self.hostname)')).toThrow(/Unknown function/);
136
+ });
137
+
138
+ test('wrong arity is rejected', () => {
139
+ expect(() => evalOk('keys(self.zone_names, self.upstreams)')).toThrow(/expects 1 argument/);
140
+ });
141
+
142
+ test('named arg to a function that does not take them is rejected', () => {
143
+ expect(() => evalOk('keys(x=self.zone_names)')).toThrow(/does not take named arguments/);
144
+ });
145
+ });
146
+
147
+ describe('computed DSL — parser', () => {
148
+ test('parses a simple call to a ref-arg AST', () => {
149
+ const ast = parseComputed('keys(secret.ddns_passwords)');
150
+ expect(ast).toEqual({
151
+ kind: 'call',
152
+ fn: 'keys',
153
+ args: [{ value: { kind: 'ref', root: 'secret', path: ['ddns_passwords'] } }],
154
+ });
155
+ });
156
+
157
+ test('parses nested calls', () => {
158
+ const ast = parseComputed('unique(concat(self.a, self.b))');
159
+ expect(ast.kind).toBe('call');
160
+ });
161
+
162
+ test('empty expression is a parse error', () => {
163
+ expect(() => parseComputed('')).toThrow(ComputedParseError);
164
+ });
165
+
166
+ test('unterminated string is a parse error', () => {
167
+ expect(() => parseComputed("format('{a}")).toThrow(/unterminated string/);
168
+ });
169
+
170
+ test('trailing input is a parse error', () => {
171
+ expect(() => parseComputed('keys(self.x) extra')).toThrow(/trailing input/);
172
+ });
173
+
174
+ test('unexpected character is a parse error', () => {
175
+ expect(() => parseComputed('keys(self.x) + 1')).toThrow(ComputedParseError);
176
+ });
177
+ });
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Evaluator for the computed-variable DSL.
3
+ *
4
+ * Pure: given an AST (from `parse.ts`) and a `lookup` callback that
5
+ * resolves a variable reference to a value, it produces the computed
6
+ * value. No DB, no I/O, no `ResolutionContext` coupling — the caller
7
+ * supplies `lookup`, which keeps this unit-testable and lets the resolver
8
+ * integration (a later step) decide how refs map onto live data.
9
+ *
10
+ * The function allow-list is the v1 set from
11
+ * v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md (D1.1):
12
+ * keys, values, map, concat, unique, format
13
+ *
14
+ * Secret-projection rule: `values(secret.*)` is rejected — it would leak
15
+ * secret values. `keys(secret.*)` is allowed (key names are non-sensitive).
16
+ */
17
+
18
+ import { asComputedExpression } from './marker';
19
+ import { type Arg, type Node, type RefNode, parseComputed } from './parse';
20
+
21
+ export class ComputedEvalError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = 'ComputedEvalError';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Resolves a variable reference to its value.
30
+ * `root` is e.g. "secret", `path` is the remaining segments.
31
+ * Returns `undefined` if the reference doesn't exist.
32
+ */
33
+ export type LookupFn = (root: string, path: string[]) => unknown;
34
+
35
+ const ALLOWED_FUNCTIONS = new Set(['keys', 'values', 'map', 'concat', 'unique', 'format']);
36
+
37
+ interface EvalState {
38
+ lookup: LookupFn;
39
+ }
40
+
41
+ function refLabel(node: RefNode): string {
42
+ return `${node.root}.${node.path.join('.')}`;
43
+ }
44
+
45
+ function evalNode(node: Node, state: EvalState): unknown {
46
+ switch (node.kind) {
47
+ case 'str':
48
+ return node.value;
49
+ case 'bare':
50
+ throw new ComputedEvalError(
51
+ `Unexpected identifier '${node.name}' — expected a function call, a quoted string, or a variable reference like self.x / secret.y`,
52
+ );
53
+ case 'ref': {
54
+ const value = state.lookup(node.root, node.path);
55
+ if (value === undefined) {
56
+ throw new ComputedEvalError(`Reference '${refLabel(node)}' could not be resolved`);
57
+ }
58
+ return value;
59
+ }
60
+ case 'call':
61
+ return evalCall(node.fn, node.args, state);
62
+ }
63
+ }
64
+
65
+ function asObject(value: unknown, fn: string): Record<string, unknown> {
66
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
67
+ throw new ComputedEvalError(`${fn}() expects an object/map argument`);
68
+ }
69
+ return value as Record<string, unknown>;
70
+ }
71
+
72
+ function asArray(value: unknown, fn: string): unknown[] {
73
+ if (!Array.isArray(value)) {
74
+ throw new ComputedEvalError(`${fn}() expects an array argument`);
75
+ }
76
+ return value;
77
+ }
78
+
79
+ /** A `secret`/`system_secret`-rooted ref argument, or null if not one. */
80
+ function secretRefArg(arg: Arg): RefNode | null {
81
+ if (
82
+ arg.value.kind === 'ref' &&
83
+ (arg.value.root === 'secret' || arg.value.root === 'system_secret')
84
+ ) {
85
+ return arg.value;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function evalCall(fn: string, args: Arg[], state: EvalState): unknown {
91
+ if (!ALLOWED_FUNCTIONS.has(fn)) {
92
+ throw new ComputedEvalError(
93
+ `Unknown function '${fn}'. Allowed: keys, values, map, concat, unique, format`,
94
+ );
95
+ }
96
+
97
+ switch (fn) {
98
+ case 'keys': {
99
+ requireArity(fn, args, 1);
100
+ requirePositional(fn, args);
101
+ const obj = asObject(evalNode(args[0].value, state), fn);
102
+ return Object.keys(obj);
103
+ }
104
+
105
+ case 'values': {
106
+ requireArity(fn, args, 1);
107
+ requirePositional(fn, args);
108
+ // Secret-projection rule: surfacing the VALUES of a secret map would
109
+ // leak secrets. Reject statically (before evaluation).
110
+ const secretRef = secretRefArg(args[0]);
111
+ if (secretRef) {
112
+ throw new ComputedEvalError(
113
+ `values(${refLabel(secretRef)}) is not allowed — exposing the values of a secret would leak it. Use keys() to surface its key names.`,
114
+ );
115
+ }
116
+ const obj = asObject(evalNode(args[0].value, state), fn);
117
+ return Object.values(obj);
118
+ }
119
+
120
+ case 'unique': {
121
+ requireArity(fn, args, 1);
122
+ requirePositional(fn, args);
123
+ const arr = asArray(evalNode(args[0].value, state), fn);
124
+ return dedupe(arr);
125
+ }
126
+
127
+ case 'concat': {
128
+ requirePositional(fn, args);
129
+ if (args.length === 0) {
130
+ throw new ComputedEvalError('concat() requires at least one argument');
131
+ }
132
+ const out: unknown[] = [];
133
+ for (const arg of args) {
134
+ const v = evalNode(arg.value, state);
135
+ if (Array.isArray(v)) out.push(...v);
136
+ else out.push(v);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ case 'map': {
142
+ // map(list, field) — field is a bare identifier naming the property.
143
+ requireArity(fn, args, 2);
144
+ requirePositional(fn, args);
145
+ const arr = asArray(evalNode(args[0].value, state), fn);
146
+ const fieldNode = args[1].value;
147
+ if (fieldNode.kind !== 'bare') {
148
+ throw new ComputedEvalError(
149
+ `map()'s second argument must be a bare field name (e.g. map(self.upstreams, ip))`,
150
+ );
151
+ }
152
+ const field = fieldNode.name;
153
+ return arr.map((item, idx) => {
154
+ if (item === null || typeof item !== 'object') {
155
+ throw new ComputedEvalError(
156
+ `map() element at index ${idx} is not an object; cannot project field '${field}'`,
157
+ );
158
+ }
159
+ return (item as Record<string, unknown>)[field];
160
+ });
161
+ }
162
+
163
+ case 'format': {
164
+ // format('{a}.{b}', a=..., b=...)
165
+ if (args.length < 1) {
166
+ throw new ComputedEvalError('format() requires a template string as its first argument');
167
+ }
168
+ if (args[0].name !== undefined) {
169
+ throw new ComputedEvalError("format()'s first argument (the template) must be positional");
170
+ }
171
+ const tmplVal = evalNode(args[0].value, state);
172
+ if (typeof tmplVal !== 'string') {
173
+ throw new ComputedEvalError("format()'s first argument must be a string template");
174
+ }
175
+ const parts: Record<string, string> = {};
176
+ for (let i = 1; i < args.length; i++) {
177
+ const arg = args[i];
178
+ if (!arg.name) {
179
+ throw new ComputedEvalError(
180
+ 'format() arguments after the template must be named (e.g. host=self.hostname)',
181
+ );
182
+ }
183
+ parts[arg.name] = stringifyScalar(
184
+ evalNode(arg.value, state),
185
+ `format() part '${arg.name}'`,
186
+ );
187
+ }
188
+ return tmplVal.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, key: string) => {
189
+ if (!(key in parts)) {
190
+ throw new ComputedEvalError(
191
+ `format() template references '{${key}}' but no such named argument was supplied`,
192
+ );
193
+ }
194
+ return parts[key];
195
+ });
196
+ }
197
+
198
+ default:
199
+ // Unreachable — guarded by ALLOWED_FUNCTIONS above.
200
+ throw new ComputedEvalError(`Unhandled function '${fn}'`);
201
+ }
202
+ }
203
+
204
+ function requireArity(fn: string, args: Arg[], n: number): void {
205
+ if (args.length !== n) {
206
+ throw new ComputedEvalError(`${fn}() expects ${n} argument(s), got ${args.length}`);
207
+ }
208
+ }
209
+
210
+ function requirePositional(fn: string, args: Arg[]): void {
211
+ for (const arg of args) {
212
+ if (arg.name !== undefined) {
213
+ throw new ComputedEvalError(`${fn}() does not take named arguments`);
214
+ }
215
+ }
216
+ }
217
+
218
+ function dedupe(arr: unknown[]): unknown[] {
219
+ const seen = new Set<string>();
220
+ const out: unknown[] = [];
221
+ for (const item of arr) {
222
+ // Key scalars by value; objects/arrays by JSON form. Good enough for
223
+ // the homogeneous primitive lists computed variables produce.
224
+ const key = typeof item === 'object' ? JSON.stringify(item) : `${typeof item}:${String(item)}`;
225
+ if (!seen.has(key)) {
226
+ seen.add(key);
227
+ out.push(item);
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ function stringifyScalar(value: unknown, what: string): string {
234
+ if (typeof value === 'string') return value;
235
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
236
+ throw new ComputedEvalError(`${what} must be a string, number, or boolean`);
237
+ }
238
+
239
+ /**
240
+ * Parse + evaluate a computed-variable expression against a lookup.
241
+ * Throws ComputedParseError / ComputedEvalError on failure.
242
+ */
243
+ export function evaluateComputed(expression: string, lookup: LookupFn): unknown {
244
+ const ast = parseComputed(expression);
245
+ return evalNode(ast, { lookup });
246
+ }
247
+
248
+ /**
249
+ * Recursively replace every computed marker in `value` with its evaluated
250
+ * result, using `lookup` (built over the PROVIDER's context). Non-marker
251
+ * values pass through unchanged; arrays and plain objects are walked. Pure —
252
+ * returns a new value, never mutates the input. Throws on the first marker
253
+ * that fails to evaluate (callers in eager paths catch + degrade).
254
+ */
255
+ export function resolveComputedFields(value: unknown, lookup: LookupFn): unknown {
256
+ const expr = asComputedExpression(value);
257
+ if (expr !== null) {
258
+ return evaluateComputed(expr, lookup);
259
+ }
260
+ if (Array.isArray(value)) {
261
+ return value.map((v) => resolveComputedFields(v, lookup));
262
+ }
263
+ if (value !== null && typeof value === 'object') {
264
+ const out: Record<string, unknown> = {};
265
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
266
+ out[k] = resolveComputedFields(v, lookup);
267
+ }
268
+ return out;
269
+ }
270
+ return value;
271
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Marker for a computed capability field embedded in stored capability data.
3
+ *
4
+ * At registration time, each `computed:` entry is folded into the capability's
5
+ * `data` JSON as `{ [name]: { [MARKER_KEY]: "<dsl expression>" } }`. At read
6
+ * time the resolver detects the marker and evaluates the expression in the
7
+ * PROVIDER's context (see resolver.ts capability case). This mirrors how
8
+ * static `$self:` refs already live verbatim in stored data and resolve lazily.
9
+ */
10
+
11
+ /** Property key that flags a value as a computed-field marker. */
12
+ export const COMPUTED_MARKER_KEY = '__celilo_computed__';
13
+
14
+ export interface ComputedMarker {
15
+ [COMPUTED_MARKER_KEY]: string;
16
+ }
17
+
18
+ /** Build a marker object for storage. */
19
+ export function computedMarker(expression: string): ComputedMarker {
20
+ return { [COMPUTED_MARKER_KEY]: expression };
21
+ }
22
+
23
+ /** Narrow an arbitrary value to a computed marker, returning its expression. */
24
+ export function asComputedExpression(value: unknown): string | null {
25
+ if (
26
+ value !== null &&
27
+ typeof value === 'object' &&
28
+ !Array.isArray(value) &&
29
+ COMPUTED_MARKER_KEY in (value as Record<string, unknown>)
30
+ ) {
31
+ const expr = (value as Record<string, unknown>)[COMPUTED_MARKER_KEY];
32
+ return typeof expr === 'string' ? expr : null;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Cheap recursive scan: does this value (or anything nested) contain a
39
+ * computed marker? Used to skip the (relatively expensive) provider-lookup
40
+ * build when a capability has no computed fields — the common case.
41
+ */
42
+ export function containsComputedMarker(value: unknown): boolean {
43
+ if (asComputedExpression(value) !== null) return true;
44
+ if (Array.isArray(value)) {
45
+ return value.some(containsComputedMarker);
46
+ }
47
+ if (value !== null && typeof value === 'object') {
48
+ for (const v of Object.values(value as Record<string, unknown>)) {
49
+ if (containsComputedMarker(v)) return true;
50
+ }
51
+ }
52
+ return false;
53
+ }