@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
@@ -8,7 +8,7 @@ import type { DbClient } from '../db/client';
8
8
  import { systemConfig } from '../db/schema';
9
9
  import { setupTestDatabase } from '../test-utils/database';
10
10
  import {
11
- autoDetectSSHKey,
11
+ autoDetectSSHKeys,
12
12
  getDefaultConfiguration,
13
13
  initializeSystem,
14
14
  isSystemInitialized,
@@ -22,25 +22,35 @@ describe('System Init Service', () => {
22
22
  });
23
23
 
24
24
  describe('getDefaultConfiguration', () => {
25
- test('returns all default values from schema', async () => {
26
- const defaults = await getDefaultConfiguration();
25
+ test('returns default values from schema', () => {
26
+ const defaults = getDefaultConfiguration();
27
27
 
28
+ // network.bridge keeps its default (a container-service concept, not
29
+ // a zone address).
28
30
  expect(defaults['network.bridge']).toBe('vmbr0');
29
- expect(defaults['network.dmz.subnet']).toBe('10.0.10.0/24');
30
- expect(defaults['network.dmz.vlan']).toBe(10);
31
+ // Network/DNS addressing is no longer defaulted
32
+ // (v2/NETWORK_CONFIG_TO_FIREWALL.md): internal is discovered at
33
+ // celilo-mgmt install; dmz/app/secure appear only when a firewall
34
+ // module provides them.
35
+ expect(defaults['network.dmz.subnet']).toBeUndefined();
36
+ expect(defaults['network.dmz.vlan']).toBeUndefined();
37
+ expect(defaults['network.internal.subnet']).toBeUndefined();
38
+ expect(defaults['dns.primary']).toBeUndefined();
39
+ expect(defaults['dns.fallback']).toBeUndefined();
31
40
  // primary_domain was removed from system config in MANIFEST_V2 D9.
32
41
  expect(defaults.primary_domain).toBeUndefined();
33
42
  });
34
43
  });
35
44
 
36
- describe('autoDetectSSHKey', () => {
37
- test('returns null or string', () => {
38
- const key = autoDetectSSHKey();
45
+ describe('autoDetectSSHKeys', () => {
46
+ test('returns an array of detected keys', () => {
47
+ const keys = autoDetectSSHKeys();
39
48
 
40
- // Either null (no key found) or a non-empty string
41
- if (key !== null) {
42
- expect(typeof key).toBe('string');
43
- expect(key.length).toBeGreaterThan(0);
49
+ // Either empty (no key found) or entries with non-empty content.
50
+ expect(Array.isArray(keys)).toBe(true);
51
+ for (const key of keys) {
52
+ expect(typeof key.content).toBe('string');
53
+ expect(key.content.length).toBeGreaterThan(0);
44
54
  }
45
55
  });
46
56
  });
@@ -62,8 +72,14 @@ describe('System Init Service', () => {
62
72
  expect(networkBridge?.value).toBe('vmbr0');
63
73
  });
64
74
 
65
- test('computes gateway IPs from subnets', () => {
66
- initializeSystem(db);
75
+ test('computes gateway IPs from provided subnets', () => {
76
+ // Subnets are no longer defaulted, so the operator/firewall supplies
77
+ // them as overrides; the gateway is still auto-computed from the
78
+ // subnet network address.
79
+ initializeSystem(db, {
80
+ 'network.dmz.subnet': '10.0.10.0/24',
81
+ 'network.app.subnet': '10.0.20.0/24',
82
+ });
67
83
 
68
84
  // Check DMZ gateway was computed
69
85
  const dmzGateway = db
@@ -84,6 +100,30 @@ describe('System Init Service', () => {
84
100
  expect(appGateway?.value).toBe('10.0.20.1');
85
101
  });
86
102
 
103
+ test('does not seed network/DNS addressing by default', () => {
104
+ initializeSystem(db);
105
+
106
+ for (const key of [
107
+ 'network.dmz.subnet',
108
+ 'network.app.subnet',
109
+ 'network.secure.subnet',
110
+ 'network.internal.subnet',
111
+ 'dns.primary',
112
+ 'dns.fallback',
113
+ ]) {
114
+ const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
115
+ expect(row).toBeUndefined();
116
+ }
117
+
118
+ // network.bridge IS still seeded.
119
+ const bridge = db
120
+ .select()
121
+ .from(systemConfig)
122
+ .where(eq(systemConfig.key, 'network.bridge'))
123
+ .get();
124
+ expect(bridge?.value).toBe('vmbr0');
125
+ });
126
+
87
127
  test('applies overrides instead of defaults', () => {
88
128
  initializeSystem(db, {
89
129
  primary_domain: 'custom.com',
@@ -112,8 +152,9 @@ describe('System Init Service', () => {
112
152
  test('includes SSH key if auto-detected', () => {
113
153
  const config = initializeSystem(db);
114
154
 
115
- const detectedKey = autoDetectSSHKey();
116
- if (detectedKey) {
155
+ const detectedKeys = autoDetectSSHKeys();
156
+ if (detectedKeys.length > 0) {
157
+ const detectedKey = detectedKeys[0].content;
117
158
  expect(config['ssh.public_key']).toBe(detectedKey);
118
159
 
119
160
  const sshKey = db
@@ -140,14 +181,16 @@ describe('System Init Service', () => {
140
181
  expect(initialized).toBe(true);
141
182
  });
142
183
 
143
- test('returns true with partial configuration', async () => {
144
- // Critical keys are network.dmz.subnet and ssh.public_key (2 of 2 after
145
- // primary_domain was removed in D9).
184
+ test('keys off the sentinel, not config rows', async () => {
185
+ // Config rows present but no sentinel NOT initialized. Initialization
186
+ // is tracked by the explicit `system.initialized` marker
187
+ // (v2/NETWORK_CONFIG_TO_FIREWALL.md), not by any network row.
146
188
  db.insert(systemConfig).values({ key: 'network.dmz.subnet', value: '10.0.0.0/24' }).run();
147
189
  db.insert(systemConfig).values({ key: 'ssh.public_key', value: 'ssh-rsa AAAA...' }).run();
190
+ expect(isSystemInitialized(db)).toBe(false);
148
191
 
149
- const initialized = isSystemInitialized(db);
150
- expect(initialized).toBe(true);
192
+ db.insert(systemConfig).values({ key: 'system.initialized', value: 'true' }).run();
193
+ expect(isSystemInitialized(db)).toBe(true);
151
194
  });
152
195
  });
153
196
  });
@@ -127,12 +127,6 @@ export function autoDetectSSHKeys(): DetectedSSHKey[] {
127
127
  return keys;
128
128
  }
129
129
 
130
- /** @deprecated Use autoDetectSSHKeys() instead */
131
- export function autoDetectSSHKey(): string | null {
132
- const keys = autoDetectSSHKeys();
133
- return keys.length > 0 ? keys[0].content : null;
134
- }
135
-
136
130
  /**
137
131
  * Initialize system configuration with defaults
138
132
  *
@@ -165,9 +159,9 @@ export function initializeSystem(
165
159
 
166
160
  // Auto-detect SSH key if not provided
167
161
  if (!config['ssh.public_key']) {
168
- const detectedKey = autoDetectSSHKey();
169
- if (detectedKey) {
170
- config['ssh.public_key'] = detectedKey;
162
+ const detectedKeys = autoDetectSSHKeys();
163
+ if (detectedKeys.length > 0) {
164
+ config['ssh.public_key'] = detectedKeys[0].content;
171
165
  }
172
166
  }
173
167
 
@@ -204,6 +198,19 @@ export function initializeSystem(
204
198
  .run();
205
199
  }
206
200
 
201
+ // Explicit init marker. Network/DNS addressing is no longer seeded with
202
+ // defaults (v2/NETWORK_CONFIG_TO_FIREWALL.md), so we can't key
203
+ // "is this system initialized?" off a network row anymore. Write a
204
+ // dedicated sentinel instead. additionalProperties:true in the schema
205
+ // permits this non-schema key.
206
+ db.insert(systemConfig)
207
+ .values({ key: 'system.initialized', value: 'true' })
208
+ .onConflictDoUpdate({
209
+ target: systemConfig.key,
210
+ set: { value: 'true' },
211
+ })
212
+ .run();
213
+
207
214
  return config;
208
215
  }
209
216
 
@@ -228,26 +235,21 @@ export function loadExistingConfiguration(db: DbClient): Record<string, string>
228
235
  /**
229
236
  * Check if system is already initialized
230
237
  *
231
- * System is considered initialized if critical configuration exists:
232
- * - At least one network zone subnet
233
- * - ssh.public_key
238
+ * Keys off the explicit `system.initialized` sentinel written by
239
+ * initializeSystem(). Earlier this checked for a seeded network zone
240
+ * subnet, but network/DNS addressing is no longer defaulted
241
+ * (v2/NETWORK_CONFIG_TO_FIREWALL.md), so a fresh init leaves those
242
+ * rows absent.
234
243
  *
235
244
  * @param db - Database instance
236
- * @returns true if system appears to be initialized
245
+ * @returns true if initializeSystem has run against this database
237
246
  */
238
247
  export function isSystemInitialized(db: DbClient): boolean {
239
- const criticalKeys = ['network.dmz.subnet', 'ssh.public_key'];
240
-
241
- let foundKeys = 0;
242
-
243
- for (const key of criticalKeys) {
244
- const result = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
245
-
246
- if (result) {
247
- foundKeys++;
248
- }
249
- }
248
+ const result = db
249
+ .select()
250
+ .from(systemConfig)
251
+ .where(eq(systemConfig.key, 'system.initialized'))
252
+ .get();
250
253
 
251
- // Consider initialized if at least 2 of 3 critical keys exist
252
- return foundKeys >= 2;
254
+ return result?.value === 'true';
253
255
  }
@@ -3,7 +3,8 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { type DbClient, createDbClient } from '../db/client';
6
- import { capabilities, moduleConfigs } from '../db/schema';
6
+ import { capabilities } from '../db/schema';
7
+ import { upsertModuleConfig } from '../services/module-config';
7
8
  import {
8
9
  discoverTemplateFiles,
9
10
  generateTemplates,
@@ -319,12 +320,8 @@ describe('Template Generator', () => {
319
320
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`,
320
321
  );
321
322
 
322
- db.insert(moduleConfigs)
323
- .values([
324
- { moduleId: 'test-module', key: 'target_ip', value: '192.168.0.50' },
325
- { moduleId: 'test-module', key: 'hostname', value: 'test' },
326
- ])
327
- .run();
323
+ upsertModuleConfig(db, 'test-module', 'target_ip', '192.168.0.50');
324
+ upsertModuleConfig(db, 'test-module', 'hostname', 'test');
328
325
 
329
326
  // Insert system config
330
327
  db.$client.run(
@@ -375,9 +372,7 @@ resource "proxmox_lxc" "container" {
375
372
  db.$client.run(
376
373
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`,
377
374
  );
378
- db.insert(moduleConfigs)
379
- .values({ moduleId: 'test-module', key: 'name', value: 'test' })
380
- .run();
375
+ upsertModuleConfig(db, 'test-module', 'name', 'test');
381
376
 
382
377
  // Create templates
383
378
  await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true });
@@ -511,9 +506,7 @@ resource "proxmox_lxc" "container" {
511
506
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', ?)`,
512
507
  [manifestWithResources],
513
508
  );
514
- db.insert(moduleConfigs)
515
- .values({ moduleId: 'test-module', key: 'name', value: 'test' })
516
- .run();
509
+ upsertModuleConfig(db, 'test-module', 'name', 'test');
517
510
 
518
511
  // Insert dummy machine (required for foreign key)
519
512
  db.$client.run(
@@ -598,9 +591,7 @@ resource "proxmox_lxc" "container" {
598
591
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', ?)`,
599
592
  [manifestWithResources],
600
593
  );
601
- db.insert(moduleConfigs)
602
- .values({ moduleId: 'test-module', key: 'name', value: 'test' })
603
- .run();
594
+ upsertModuleConfig(db, 'test-module', 'name', 'test');
604
595
 
605
596
  // Insert dummy container service (required for foreign key)
606
597
  db.$client.run(
@@ -18,6 +18,7 @@ import {
18
18
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
19
19
  import { validateZoneRequirements } from '../manifest/validate';
20
20
  import { selectInfrastructure } from '../services/infrastructure-selector';
21
+ import { upsertModuleConfig } from '../services/module-config';
21
22
  import type { InfrastructureSelection } from '../types/infrastructure';
22
23
  import { convertSecretsToJinja } from '../variables/ansible-resolver';
23
24
  import { buildResolutionContext } from '../variables/context';
@@ -532,39 +533,11 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
532
533
  );
533
534
  }
534
535
 
535
- // Store allocation in config temporarily (will be available in context)
536
- // These are "virtual" config values - not stored in module_configs table
537
- // Insert VMID
538
- db.insert(moduleConfigs)
539
- .values({
540
- moduleId,
541
- key: '__ipam_vmid',
542
- value: String(allocation.vmid),
543
- valueJson: null,
544
- })
545
- .onConflictDoUpdate({
546
- target: [moduleConfigs.moduleId, moduleConfigs.key],
547
- set: {
548
- value: String(allocation.vmid),
549
- },
550
- })
551
- .run();
552
-
553
- // Insert container IP
554
- db.insert(moduleConfigs)
555
- .values({
556
- moduleId,
557
- key: '__ipam_container_ip',
558
- value: allocation.containerIp,
559
- valueJson: null,
560
- })
561
- .onConflictDoUpdate({
562
- target: [moduleConfigs.moduleId, moduleConfigs.key],
563
- set: {
564
- value: allocation.containerIp,
565
- },
566
- })
567
- .run();
536
+ // Store allocation in config temporarily (will be available in context).
537
+ // These are "virtual" config values (the __ipam_ prefix flags them as
538
+ // framework-internal). Typed: vmid is number, container_ip is string.
539
+ upsertModuleConfig(db, moduleId, '__ipam_vmid', allocation.vmid);
540
+ upsertModuleConfig(db, moduleId, '__ipam_container_ip', allocation.containerIp);
568
541
  } catch (error) {
569
542
  return {
570
543
  success: false,
@@ -598,18 +571,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
598
571
  ];
599
572
 
600
573
  for (const prop of infraProperties) {
601
- db.insert(moduleConfigs)
602
- .values({
603
- moduleId,
604
- key: `__infra_${prop.key}`,
605
- value: prop.value,
606
- valueJson: null,
607
- })
608
- .onConflictDoUpdate({
609
- target: [moduleConfigs.moduleId, moduleConfigs.key],
610
- set: { value: prop.value, updatedAt: new Date() },
611
- })
612
- .run();
574
+ upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
613
575
  }
614
576
 
615
577
  log.success(
@@ -677,8 +639,6 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
677
639
  const derivedValue = context.selfConfig[variable.name];
678
640
  if (derivedValue === undefined) continue;
679
641
 
680
- const stringValue =
681
- typeof derivedValue === 'string' ? derivedValue : JSON.stringify(derivedValue);
682
642
  const existing = db
683
643
  .select()
684
644
  .from(moduleConfigs)
@@ -686,14 +646,16 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
686
646
  .get();
687
647
 
688
648
  if (!existing) {
689
- db.insert(moduleConfigs)
690
- .values({
691
- moduleId,
692
- key: variable.name,
693
- value: typeof derivedValue === 'string' ? stringValue : '',
694
- valueJson: typeof derivedValue !== 'string' ? stringValue : null,
695
- })
696
- .run();
649
+ // Insert-only (not upsert) — derived values only seed on first
650
+ // generate; subsequent runs preserve whatever the user has set.
651
+ // Routes through upsertModuleConfig anyway because the helper
652
+ // is the only path that maintains the valueJson invariant.
653
+ upsertModuleConfig(
654
+ db,
655
+ moduleId,
656
+ variable.name,
657
+ derivedValue as string | number | boolean | unknown[] | Record<string, unknown>,
658
+ );
697
659
  }
698
660
  }
699
661
  }
@@ -787,33 +749,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
787
749
 
788
750
  if (value !== undefined) {
789
751
  // Store in module_configs so it appears in host_vars
790
- const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
791
- const existing = db
792
- .select()
793
- .from(moduleConfigs)
794
- .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, imp.name)))
795
- .get();
796
-
797
- const isString = typeof value === 'string';
798
- if (existing) {
799
- db.update(moduleConfigs)
800
- .set({
801
- value: isString ? stringValue : '',
802
- valueJson: isString ? null : stringValue,
803
- updatedAt: new Date(),
804
- })
805
- .where(eq(moduleConfigs.id, existing.id))
806
- .run();
807
- } else {
808
- db.insert(moduleConfigs)
809
- .values({
810
- moduleId,
811
- key: imp.name,
812
- value: isString ? stringValue : '',
813
- valueJson: isString ? null : stringValue,
814
- })
815
- .run();
816
- }
752
+ upsertModuleConfig(
753
+ db,
754
+ moduleId,
755
+ imp.name,
756
+ value as string | number | boolean | unknown[] | Record<string, unknown>,
757
+ );
817
758
 
818
759
  // Redact sensitive values in logs
819
760
  const isSensitive =
@@ -1000,21 +941,10 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
1000
941
  ),
1001
942
  )
1002
943
  .get();
1003
- if (existingTasksPath) {
1004
- db.update(moduleConfigs)
1005
- .set({ value: tasksPathValue, updatedAt: new Date() })
1006
- .where(eq(moduleConfigs.id, existingTasksPath.id))
1007
- .run();
1008
- } else {
1009
- db.insert(moduleConfigs)
1010
- .values({
1011
- moduleId,
1012
- key: 'registrar_tasks_path',
1013
- value: tasksPathValue,
1014
- valueJson: null,
1015
- })
1016
- .run();
1017
- }
944
+ upsertModuleConfig(db, moduleId, 'registrar_tasks_path', tasksPathValue);
945
+ // (existingTasksPath select above is now redundant — left for
946
+ // the broader refactor; upsertModuleConfig is idempotent.)
947
+ void existingTasksPath;
1018
948
  }
1019
949
  }
1020
950
  }
@@ -1024,24 +954,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
1024
954
  // Modules with build artifacts (e.g., compiled binaries, static assets) need
1025
955
  // this path so Ansible can copy them to the target machine.
1026
956
  if (manifest.build?.artifacts && manifest.build.artifacts.length > 0) {
1027
- const existing = db
1028
- .select()
1029
- .from(moduleConfigs)
1030
- .where(
1031
- and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'build_artifacts_dir')),
1032
- )
1033
- .get();
1034
-
1035
- if (!existing) {
1036
- db.insert(moduleConfigs)
1037
- .values({ moduleId, key: 'build_artifacts_dir', value: modulePath })
1038
- .run();
1039
- } else {
1040
- db.update(moduleConfigs)
1041
- .set({ value: modulePath, updatedAt: new Date() })
1042
- .where(eq(moduleConfigs.id, existing.id))
1043
- .run();
1044
- }
957
+ upsertModuleConfig(db, moduleId, 'build_artifacts_dir', modulePath);
1045
958
  }
1046
959
 
1047
960
  // Execution: Generate Ansible inventory structure
@@ -67,8 +67,11 @@ export async function setupIntegrationTest(): Promise<IntegrationTestContext> {
67
67
  // the same path so subprocess + responder share the bus DB.
68
68
  const busDbPath = join(dataDir, 'events.db');
69
69
 
70
- // CLI with isolated environment
71
- const cli = `CELILO_DB_PATH="${dbPath}" CELILO_DATA_DIR="${dataDir}" bun run src/cli/index.ts`;
70
+ // CLI with isolated environment. CELILO_SUPPRESS_DEPRECATION silences
71
+ // the legacy-path banners (e.g. on `celilo system init`) so they
72
+ // don't pollute test output — the banners are an operator-UX concern,
73
+ // not a CI signal.
74
+ const cli = `CELILO_DB_PATH="${dbPath}" CELILO_DATA_DIR="${dataDir}" CELILO_SUPPRESS_DEPRECATION=1 bun run src/cli/index.ts`;
72
75
 
73
76
  // Note: Migrations are auto-run by createDbClient() in setupTestDatabaseFile()
74
77
 
@@ -87,6 +87,14 @@ export interface Machine {
87
87
  assignedModuleIds: string[];
88
88
  /** Module ID this machine is earmarked for, or null/undefined */
89
89
  earmarkedModule?: string | null;
90
+ /**
91
+ * Appliance / API-only machines (e.g., greenwave) where celilo has
92
+ * no shell access. Base-module aspects skip these — see
93
+ * v2/CELILO_BASE.md D8. Optional on the type because most call
94
+ * sites don't care; the DB column defaults to false so the
95
+ * effective value is well-defined regardless.
96
+ */
97
+ apiOnly?: boolean;
90
98
  createdAt: Date;
91
99
  updatedAt: Date;
92
100
  }