@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,12 @@
1
+ CREATE TABLE `module_operations` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `module_id` text NOT NULL,
4
+ `operation` text NOT NULL,
5
+ `status` text DEFAULT 'in_progress' NOT NULL,
6
+ `pid` integer NOT NULL,
7
+ `started_at` integer DEFAULT (unixepoch()) NOT NULL,
8
+ `completed_at` integer,
9
+ `error_message` text
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE INDEX `module_operations_status_idx` ON `module_operations` (`status`, `operation`);
@@ -0,0 +1,15 @@
1
+ CREATE TABLE `aspect_approvals` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `module_id` text NOT NULL,
4
+ `version` text NOT NULL,
5
+ `scope_hash` text NOT NULL,
6
+ `approved_at` integer DEFAULT (unixepoch()) NOT NULL,
7
+ `approver` text,
8
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE UNIQUE INDEX `aspect_approvals_module_version` ON `aspect_approvals` (`module_id`,`version`);
12
+ --> statement-breakpoint
13
+ ALTER TABLE `machines` ADD `api_only` integer DEFAULT false NOT NULL;
14
+ --> statement-breakpoint
15
+ ALTER TABLE `module_infrastructure` ADD `api_only` integer DEFAULT false NOT NULL;
@@ -0,0 +1,17 @@
1
+ CREATE TABLE `module_systems` (
2
+ `module_id` text NOT NULL,
3
+ `name` text NOT NULL,
4
+ `hostname` text NOT NULL,
5
+ `ipv4_address` text NOT NULL,
6
+ `zone` text NOT NULL,
7
+ `infra_type` text NOT NULL,
8
+ `machine_id` text,
9
+ `service_id` text,
10
+ `vmid` integer,
11
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
12
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
13
+ PRIMARY KEY(`module_id`, `name`),
14
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
15
+ FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE no action,
16
+ FOREIGN KEY (`service_id`) REFERENCES `container_services`(`id`) ON UPDATE no action ON DELETE no action
17
+ );
@@ -36,6 +36,27 @@
36
36
  "when": 1777680000000,
37
37
  "tag": "0004_caddy_hostname_list",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1779408000000,
44
+ "tag": "0005_module_operations",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1779840000000,
51
+ "tag": "0006_base_module_aspects",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1780459200000,
58
+ "tag": "0007_module_systems",
59
+ "breakpoints": true
39
60
  }
40
61
  ]
41
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.30",
3
+ "version": "0.4.0-alpha.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,8 +53,8 @@
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
55
  "@celilo/capabilities": "^0.1.10",
56
- "@celilo/cli-display": "^0.1.9",
57
- "@celilo/event-bus": "^0.1.4",
56
+ "@celilo/cli-display": "0.1.9-alpha.0",
57
+ "@celilo/event-bus": "0.1.5-alpha.0",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -75,5 +75,6 @@
75
75
  "ink-testing-library": "^4.0.0",
76
76
  "typescript": "^5.9.3",
77
77
  "zod-to-json-schema": "^3.25.2"
78
- }
78
+ },
79
+ "gitHead": "fb33d637f8fe812c110648612bb038ec8f944631"
79
80
  }
@@ -13,89 +13,75 @@
13
13
  "network.dmz.subnet": {
14
14
  "type": "string",
15
15
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
16
- "default": "10.0.10.0/24",
17
- "description": "DMZ subnet CIDR"
16
+ "description": "DMZ subnet CIDR (not defaulted — appears only when a firewall module provides this zone; see v2/NETWORK_CONFIG_TO_FIREWALL.md)"
18
17
  },
19
18
  "network.dmz.gateway": {
20
19
  "type": "string",
21
20
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
22
- "default": "10.0.10.1",
23
- "description": "DMZ gateway IP address"
21
+ "description": "DMZ gateway IP address (not defaulted)"
24
22
  },
25
23
  "network.dmz.vlan": {
26
24
  "type": "integer",
27
25
  "minimum": 1,
28
26
  "maximum": 4094,
29
- "default": 10,
30
- "description": "VLAN tag for DMZ zone (public-facing services)"
27
+ "description": "VLAN tag for DMZ zone (public-facing services; not defaulted)"
31
28
  },
32
29
  "network.app.subnet": {
33
30
  "type": "string",
34
31
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
35
- "default": "10.0.20.0/24",
36
- "description": "App subnet CIDR"
32
+ "description": "App subnet CIDR (not defaulted — appears only when a firewall module provides this zone)"
37
33
  },
38
34
  "network.app.gateway": {
39
35
  "type": "string",
40
36
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
41
- "default": "10.0.20.1",
42
- "description": "App gateway IP address"
37
+ "description": "App gateway IP address (not defaulted)"
43
38
  },
44
39
  "network.app.vlan": {
45
40
  "type": "integer",
46
41
  "minimum": 1,
47
42
  "maximum": 4094,
48
- "default": 20,
49
- "description": "VLAN tag for app zone (internal application services)"
43
+ "description": "VLAN tag for app zone (internal application services; not defaulted)"
50
44
  },
51
45
  "network.secure.subnet": {
52
46
  "type": "string",
53
47
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
54
- "default": "10.0.30.0/24",
55
- "description": "Secure subnet CIDR"
48
+ "description": "Secure subnet CIDR (not defaulted — appears only when a firewall module provides this zone)"
56
49
  },
57
50
  "network.secure.gateway": {
58
51
  "type": "string",
59
52
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
60
- "default": "10.0.30.1",
61
- "description": "Secure gateway IP address"
53
+ "description": "Secure gateway IP address (not defaulted)"
62
54
  },
63
55
  "network.secure.vlan": {
64
56
  "type": "integer",
65
57
  "minimum": 1,
66
58
  "maximum": 4094,
67
- "default": 30,
68
- "description": "VLAN tag for secure zone (authentication, databases)"
59
+ "description": "VLAN tag for secure zone (authentication, databases; not defaulted)"
69
60
  },
70
61
  "network.internal.subnet": {
71
62
  "type": "string",
72
63
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
73
- "default": "192.168.0.0/24",
74
- "description": "Internal subnet CIDR (home devices, trusted)"
64
+ "description": "Internal subnet CIDR (home devices, trusted; not defaulted — discovered from the management box's primary interface at celilo-mgmt install)"
75
65
  },
76
66
  "network.internal.gateway": {
77
67
  "type": "string",
78
68
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
79
- "default": "192.168.0.1",
80
- "description": "Internal gateway IP address"
69
+ "description": "Internal gateway IP address (not defaulted — discovered from the default route)"
81
70
  },
82
71
  "network.internal.vlan": {
83
72
  "type": "integer",
84
73
  "minimum": 1,
85
74
  "maximum": 4094,
86
- "default": 192,
87
- "description": "VLAN tag for internal zone"
75
+ "description": "VLAN tag for internal zone (not defaulted; internal is untagged)"
88
76
  },
89
77
  "dns.primary": {
90
78
  "type": "string",
91
79
  "pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
92
- "default": "1.1.1.1",
93
- "description": "Primary DNS server IP address"
80
+ "description": "Primary DNS server IP address (not defaulted — discovered from the host resolver at celilo-mgmt install; repointed at a dns_internal provider when one deploys)"
94
81
  },
95
82
  "dns.fallback": {
96
83
  "type": "string",
97
- "default": "1.0.0.1,8.8.8.8",
98
- "description": "Fallback DNS servers (comma-separated IP addresses)"
84
+ "description": "Fallback DNS servers (comma-separated IP addresses; not defaulted — discovered, with 1.1.1.1 as last resort)"
99
85
  },
100
86
  "ssh.public_key": {
101
87
  "type": "string",
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { rm } from 'node:fs/promises';
4
4
  import { type DbClient, createDbClient } from '../db/client';
5
- import { moduleConfigs, systemConfig } from '../db/schema';
5
+ import { systemConfig } from '../db/schema';
6
+ import { upsertModuleConfig } from '../services/module-config';
6
7
  import {
7
8
  buildHostVars,
8
9
  buildSystemVars,
@@ -93,6 +94,28 @@ describe('generateHostsIni', () => {
93
94
  `machine-host ansible_host=192.168.1.100 ansible_user=ubuntu ansible_ssh_private_key_file=${keyPath}`,
94
95
  );
95
96
  });
97
+
98
+ test('emits ansible_connection=local for a local machine (no SSH host/key)', () => {
99
+ // The management box deploying to itself uses Ansible's local
100
+ // connection (v2/CELILO_BOOTSTRAP_VIRTUAL_PACKAGE.md).
101
+ const hosts: InventoryHost[] = [
102
+ {
103
+ hostname: 'celilo-mgr',
104
+ ansibleHost: '127.0.0.1',
105
+ ansibleUser: 'root',
106
+ groups: ['celilo-mgmt'],
107
+ local: true,
108
+ // even if a key path were present, local hosts must ignore it
109
+ ansibleSshPrivateKeyFile: '/tmp/should-not-appear.key',
110
+ },
111
+ ];
112
+
113
+ const result = generateHostsIni(hosts);
114
+
115
+ expect(result).toContain('celilo-mgr ansible_connection=local');
116
+ expect(result).not.toContain('ansible_host=127.0.0.1');
117
+ expect(result).not.toContain('should-not-appear');
118
+ });
96
119
  });
97
120
 
98
121
  describe('generateHostVarsYaml', () => {
@@ -283,20 +306,14 @@ describe('Database integration', () => {
283
306
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`,
284
307
  );
285
308
 
286
- // Insert module config with proper schema
287
- db.insert(moduleConfigs)
288
- .values([
289
- { moduleId: 'homebridge', key: 'vmid', value: '2110', valueJson: null },
290
- { moduleId: 'homebridge', key: 'hostname', value: 'iot', valueJson: null },
291
- {
292
- moduleId: 'homebridge',
293
- key: 'target_ip',
294
- value: '192.168.0.110/24',
295
- valueJson: null,
296
- },
297
- { moduleId: 'homebridge', key: 'cores', value: '1', valueJson: null },
298
- ])
299
- .run();
309
+ // Insert module config via the typed-storage helper so
310
+ // parseStoredConfigValue can roundtrip them. Numbers stay numbers,
311
+ // strings stay strings, etc. (vmid=2110, cores=1 are integers in
312
+ // the manifest test that the type round-trips correctly.)
313
+ upsertModuleConfig(db, 'homebridge', 'vmid', 2110);
314
+ upsertModuleConfig(db, 'homebridge', 'hostname', 'iot');
315
+ upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.110/24');
316
+ upsertModuleConfig(db, 'homebridge', 'cores', 1);
300
317
 
301
318
  const vars = buildHostVars('homebridge', db);
302
319
 
@@ -311,12 +328,8 @@ describe('Database integration', () => {
311
328
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test', 'Test', '1.0.0', '/path', '{}')`,
312
329
  );
313
330
 
314
- db.insert(moduleConfigs)
315
- .values([
316
- { moduleId: 'test', key: 'network.interface', value: 'eth0', valueJson: null },
317
- { moduleId: 'test', key: 'network.ip', value: '192.168.0.1', valueJson: null },
318
- ])
319
- .run();
331
+ upsertModuleConfig(db, 'test', 'network.interface', 'eth0');
332
+ upsertModuleConfig(db, 'test', 'network.ip', '192.168.0.1');
320
333
 
321
334
  const vars = buildHostVars('test', db);
322
335
 
@@ -329,18 +342,9 @@ describe('Database integration', () => {
329
342
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test', 'Test', '1.0.0', '/path', '{}')`,
330
343
  );
331
344
 
332
- db.insert(moduleConfigs)
333
- .values([
334
- { moduleId: 'test', key: 'hostname', value: 'iot', valueJson: null },
335
- { moduleId: 'test', key: 'inventory.hostname', value: 'iot', valueJson: null },
336
- {
337
- moduleId: 'test',
338
- key: 'inventory.ansible_host',
339
- value: '192.168.0.110',
340
- valueJson: null,
341
- },
342
- ])
343
- .run();
345
+ upsertModuleConfig(db, 'test', 'hostname', 'iot');
346
+ upsertModuleConfig(db, 'test', 'inventory.hostname', 'iot');
347
+ upsertModuleConfig(db, 'test', 'inventory.ansible_host', '192.168.0.110');
344
348
 
345
349
  const vars = buildHostVars('test', db);
346
350
 
@@ -354,17 +358,9 @@ describe('Database integration', () => {
354
358
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns', 'DNS', '1.0.0', '/path', '{}')`,
355
359
  );
356
360
 
357
- // Complex array type - store in valueJson column
358
- db.insert(moduleConfigs)
359
- .values([
360
- {
361
- moduleId: 'dns',
362
- key: 'zone_records',
363
- value: '', // Empty for complex types
364
- valueJson: '[{"name":"ns1","type":"A","value":"188.166.157.2"}]',
365
- },
366
- ])
367
- .run();
361
+ upsertModuleConfig(db, 'dns', 'zone_records', [
362
+ { name: 'ns1', type: 'A', value: '188.166.157.2' },
363
+ ]);
368
364
 
369
365
  const vars = buildHostVars('dns', db);
370
366
 
@@ -402,12 +398,8 @@ describe('Database integration', () => {
402
398
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`,
403
399
  );
404
400
 
405
- db.insert(moduleConfigs)
406
- .values([
407
- { moduleId: 'homebridge', key: 'hostname', value: 'iot' },
408
- { moduleId: 'homebridge', key: 'target_ip', value: '192.168.0.110/24' },
409
- ])
410
- .run();
401
+ upsertModuleConfig(db, 'homebridge', 'hostname', 'iot');
402
+ upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.110/24');
411
403
 
412
404
  const host = extractInventoryHost('homebridge', db);
413
405
 
@@ -423,12 +415,8 @@ describe('Database integration', () => {
423
415
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-external', 'DNS External', '1.0.0', '/path', '{}')`,
424
416
  );
425
417
 
426
- db.insert(moduleConfigs)
427
- .values([
428
- { moduleId: 'dns-external', key: 'hostname', value: 'dns-ext' },
429
- { moduleId: 'dns-external', key: 'vps_ip', value: '188.166.157.2' },
430
- ])
431
- .run();
418
+ upsertModuleConfig(db, 'dns-external', 'hostname', 'dns-ext');
419
+ upsertModuleConfig(db, 'dns-external', 'vps_ip', '188.166.157.2');
432
420
 
433
421
  const host = extractInventoryHost('dns-external', db);
434
422
 
@@ -444,12 +432,8 @@ describe('Database integration', () => {
444
432
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test', 'Test', '1.0.0', '/path', '{}')`,
445
433
  );
446
434
 
447
- db.insert(moduleConfigs)
448
- .values([
449
- { moduleId: 'test', key: 'hostname', value: 'iot' },
450
- // Missing target_ip or vps_ip for ansible_host
451
- ])
452
- .run();
435
+ upsertModuleConfig(db, 'test', 'hostname', 'iot');
436
+ // Missing target_ip or vps_ip for ansible_host
453
437
 
454
438
  const host = extractInventoryHost('test', db);
455
439
 
@@ -4,6 +4,8 @@ import { eq } from 'drizzle-orm';
4
4
  import { stringify as stringifyYaml } from 'yaml';
5
5
  import type { DbClient } from '../db/client';
6
6
  import { machines, moduleConfigs, systemConfig } from '../db/schema';
7
+ import { getModuleSystems } from '../services/deployed-systems';
8
+ import { parseStoredConfigValue } from '../services/module-config';
7
9
  import { getTempKeyPath } from '../services/ssh-key-manager';
8
10
 
9
11
  /**
@@ -25,6 +27,12 @@ export interface InventoryHost {
25
27
  ansibleUser: string;
26
28
  groups: string[];
27
29
  ansibleSshPrivateKeyFile?: string;
30
+ /**
31
+ * The management box deploying to itself (127.0.0.1) uses Ansible's
32
+ * local connection — no SSH, no ansible_host/key. See
33
+ * v2/CELILO_BOOTSTRAP_VIRTUAL_PACKAGE.md.
34
+ */
35
+ local?: boolean;
28
36
  }
29
37
 
30
38
  /**
@@ -54,6 +62,11 @@ export function generateHostsIni(hosts: InventoryHost[]): string {
54
62
  for (const [group, groupHosts] of groupMap) {
55
63
  lines.push(`[${group}]`);
56
64
  for (const host of groupHosts) {
65
+ if (host.local) {
66
+ // Local connection: no SSH, no key — Ansible runs commands directly.
67
+ lines.push(`${host.hostname} ansible_connection=local`);
68
+ continue;
69
+ }
57
70
  let hostLine = `${host.hostname} ansible_host=${host.ansibleHost} ansible_user=${host.ansibleUser}`;
58
71
  if (host.ansibleSshPrivateKeyFile) {
59
72
  hostLine += ` ansible_ssh_private_key_file=${host.ansibleSshPrivateKeyFile}`;
@@ -175,27 +188,27 @@ export function buildHostVars(moduleId: string, db: DbClient): Record<string, un
175
188
  continue;
176
189
  }
177
190
 
178
- // Parse value from correct column
179
- let parsedValue: unknown;
180
- if (config.valueJson) {
181
- // Complex type stored in valueJson
182
- try {
183
- parsedValue = JSON.parse(config.valueJson);
184
- } catch (error) {
185
- throw new Error(
186
- `Failed to parse config value for ${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
187
- );
188
- }
189
- } else {
190
- // Primitive type stored in value
191
- parsedValue = parseConfigValue(config.value);
192
- }
191
+ // Use shared typed-storage parser — preserves number/boolean/string/
192
+ // complex types end-to-end. The two-column logic that used to live
193
+ // here (valueJson ? parse : parseConfigValue) was a casualty of
194
+ // Defect 1 and no longer makes sense — every row has valueJson
195
+ // populated as of the type-fidelity refactor.
196
+ const parsedValue = parseStoredConfigValue(config);
193
197
 
194
198
  // Store with underscore naming
195
199
  const key = config.key.replace(/\./g, '_');
196
200
  vars[key] = parsedValue;
197
201
  }
198
202
 
203
+ // The host's IP is sourced from the deployed-systems model now, not a
204
+ // `target_ip` config row (v2/MODULE_SYSTEMS_ADDRESSING.md). Emit it as the
205
+ // `target_ip` host_var so Ansible templates that reference `{{ target_ip }}`
206
+ // (zone files, DNS record tasks) keep working. Single-system modules have one.
207
+ const recordedSystems = getModuleSystems(moduleId, db);
208
+ if (recordedSystems[0]) {
209
+ vars.target_ip = recordedSystems[0].ipv4_address;
210
+ }
211
+
199
212
  // Sort keys alphabetically for deterministic YAML output
200
213
  return sortObjectKeys(vars);
201
214
  }
@@ -244,14 +257,21 @@ export function extractInventoryHost(moduleId: string, db: DbClient): InventoryH
244
257
 
245
258
  const moduleConfig: Record<string, string> = {};
246
259
  for (const config of configs) {
247
- // Parse value from correct column (same logic as buildHostVars)
248
- if (config.valueJson) {
249
- // Complex type stored in valueJson
250
- moduleConfig[config.key] = config.valueJson;
251
- } else {
252
- // Primitive type stored in value
253
- moduleConfig[config.key] = config.value;
254
- }
260
+ // Inventory derivation works in string form (hostnames, IPs, group
261
+ // names). Use the display-string `value` column — it's already the
262
+ // canonical human-readable form populated by upsertModuleConfig.
263
+ // For complex types `value` is the JSON-stringified form, which
264
+ // matches the legacy behavior here.
265
+ moduleConfig[config.key] = config.value;
266
+ }
267
+
268
+ // The host's IP now comes from the deployed-systems model, not a `target_ip`
269
+ // config row (v2/MODULE_SYSTEMS_ADDRESSING.md). Inject it so the
270
+ // ansible_host derivation below and the Ansible `{{ target_ip }}` host_var
271
+ // (zone files, DNS record tasks) resolve. Single-system modules have one.
272
+ const recordedSystems = getModuleSystems(moduleId, db);
273
+ if (recordedSystems[0]) {
274
+ moduleConfig.target_ip = recordedSystems[0].ipv4_address;
255
275
  }
256
276
 
257
277
  // Auto-derive inventory variables (same logic as context.ts)
@@ -373,13 +393,16 @@ export async function generateInventory(
373
393
  (c: typeof moduleConfigs.$inferSelect) => c.key === 'hostname',
374
394
  )?.value;
375
395
 
376
- // Build host definition from machine
396
+ // Build host definition from machine. The local box (127.0.0.1)
397
+ // uses Ansible's local connection — no SSH key/host.
398
+ const isLocal = machine.ipAddress === '127.0.0.1';
377
399
  host = {
378
400
  hostname: moduleHostname || machine.hostname,
379
401
  ansibleHost: machine.ipAddress,
380
402
  ansibleUser: machine.sshUser,
381
403
  groups: [moduleId], // Use module ID as group
382
- ansibleSshPrivateKeyFile: getTempKeyPath(machine.id),
404
+ local: isLocal,
405
+ ansibleSshPrivateKeyFile: isLocal ? undefined : getTempKeyPath(machine.id),
383
406
  };
384
407
  } else {
385
408
  // Container service or no infrastructure: use module config
@@ -9,6 +9,7 @@ import type { ModuleManifest } from '../manifest/schema';
9
9
  import { encryptSecret } from '../secrets/encryption';
10
10
  import type { EncryptedSecret } from '../secrets/encryption';
11
11
  import { getOrCreateMasterKey } from '../secrets/master-key';
12
+ import { computedMarker } from '../variables/computed/marker';
12
13
 
13
14
  export interface RegistrationResult {
14
15
  success: boolean;
@@ -45,7 +46,9 @@ export async function registerModuleCapabilities(
45
46
  }
46
47
 
47
48
  try {
48
- const _masterKey = await getOrCreateMasterKey();
49
+ // Ensure the master key exists (side effect); registration stores secret
50
+ // metadata only, so the key itself isn't used here.
51
+ await getOrCreateMasterKey();
49
52
 
50
53
  for (const capability of manifest.provides.capabilities) {
51
54
  // Build capability data by resolving $self: variables
@@ -104,16 +107,31 @@ export async function registerModuleCapabilities(
104
107
  * @returns Capability data with resolved variables
105
108
  */
106
109
  export function buildCapabilityData(
107
- capability: { data?: Record<string, unknown> },
110
+ capability: {
111
+ name?: string;
112
+ data?: Record<string, unknown>;
113
+ computed?: Array<{ name: string; value: string }>;
114
+ },
108
115
  _manifest: ModuleManifest,
109
116
  ): Record<string, unknown> {
110
- if (!capability.data) {
111
- return {};
117
+ // Static data is stored verbatim (any $self: refs are resolved lazily at
118
+ // read time by the variable resolver).
119
+ const data: Record<string, unknown> = capability.data ? structuredClone(capability.data) : {};
120
+
121
+ // Fold computed fields into the data namespace as markers. They resolve
122
+ // lazily in the PROVIDER's context at read time — see resolver.ts and
123
+ // src/variables/computed/. A computed name colliding with a static data
124
+ // key is a manifest error.
125
+ for (const field of capability.computed ?? []) {
126
+ if (field.name in data) {
127
+ throw new Error(
128
+ `Capability '${capability.name ?? '?'}': computed field '${field.name}' collides with a static data key`,
129
+ );
130
+ }
131
+ data[field.name] = computedMarker(field.value);
112
132
  }
113
133
 
114
- // Capability data is static (no $self: variables in well-known capabilities)
115
- // This function is here for future extensibility
116
- return structuredClone(capability.data);
134
+ return data;
117
135
  }
118
136
 
119
137
  /**
@@ -241,6 +241,36 @@ describe('Capability Access Validation', () => {
241
241
  expect(result.error).toContain('No module provides this capability');
242
242
  });
243
243
 
244
+ test('should skip framework-granted privileges (no provider required)', async () => {
245
+ // cross_module_read is a privilege (allow-listed to celilo-mgmt),
246
+ // not a provider-backed capability. validateCapabilityAccess must
247
+ // not demand a providing module for it — otherwise celilo-mgmt
248
+ // can't be imported (regression: v2/NETWORK_CONFIG_TO_FIREWALL.md).
249
+ const manifest: ModuleManifest = {
250
+ celilo_contract: '1.0',
251
+ id: 'celilo-mgmt',
252
+ name: 'Celilo Management Server',
253
+ version: '1.0.0',
254
+ description: 'Management server',
255
+ requires: {
256
+ capabilities: [{ name: 'cross_module_read', version: '^1.0' }],
257
+ },
258
+ provides: { capabilities: [] },
259
+ variables: { owns: [], imports: [] },
260
+ };
261
+
262
+ // DB must NOT be consulted for a privilege — fail loudly if it is.
263
+ const mockDb = {
264
+ prepare: () => {
265
+ throw new Error('getProviderManifest should not be called for a privilege');
266
+ },
267
+ } as unknown as Database;
268
+
269
+ const result = await validateCapabilityAccess(manifest, mockDb);
270
+
271
+ expect(result.success).toBe(true);
272
+ });
273
+
244
274
  test('should return success when capability has no secrets', async () => {
245
275
  const manifest: ModuleManifest = {
246
276
  celilo_contract: '1.0',
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { Database } from 'bun:sqlite';
7
7
  import type { ModuleManifest } from '../manifest/schema';
8
+ import { isPrivilegedCapability } from '../manifest/validate';
8
9
 
9
10
  export interface ValidationResult {
10
11
  success: boolean;
@@ -35,6 +36,13 @@ export async function validateCapabilityAccess(
35
36
 
36
37
  // Check each required capability
37
38
  for (const requiredCapability of manifest.requires.capabilities) {
39
+ // Framework-granted privileges (e.g. cross_module_read) are not
40
+ // provider-backed — they're gated separately by the allow-list in
41
+ // validatePrivilegedCapabilities. Don't demand a providing module.
42
+ if (isPrivilegedCapability(requiredCapability.name)) {
43
+ continue;
44
+ }
45
+
38
46
  // Get the provider module's manifest
39
47
  const providerManifest = getProviderManifest(requiredCapability.name, db);
40
48