@celilo/cli 0.3.30 → 0.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +6 -5
  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,154 @@
1
+ /**
2
+ * Module-operations tracking — used by `checkInFlight()` to refuse a
3
+ * backup or restore while another module operation (deploy, uninstall,
4
+ * backup, restore) is active.
5
+ *
6
+ * Lifecycle:
7
+ * const opId = startOperation('homebridge', 'deploy');
8
+ * try {
9
+ * ...work...
10
+ * completeOperation(opId);
11
+ * } catch (err) {
12
+ * failOperation(opId, err);
13
+ * throw err;
14
+ * }
15
+ *
16
+ * Rows with status='in_progress' whose pid is no longer alive are
17
+ * treated as abandoned (the process crashed before the completion
18
+ * update landed) and ignored by `checkInFlight()`. This keeps a single
19
+ * Ctrl-C from wedging the system, at the cost of leaving stale rows in
20
+ * the table; a future cleanup command can sweep them.
21
+ */
22
+
23
+ import { randomUUID } from 'node:crypto';
24
+ import { eq } from 'drizzle-orm';
25
+ import { getDb } from '../db/client';
26
+ import { type ModuleOperation, type ModuleOperationKind, moduleOperations } from '../db/schema';
27
+
28
+ /**
29
+ * Insert an in-progress row for an operation. Returns the operation id
30
+ * that must be passed to completeOperation/failOperation.
31
+ */
32
+ export function startOperation(moduleId: string, operation: ModuleOperationKind): string {
33
+ const db = getDb();
34
+ const id = randomUUID();
35
+ db.insert(moduleOperations)
36
+ .values({
37
+ id,
38
+ moduleId,
39
+ operation,
40
+ status: 'in_progress',
41
+ pid: process.pid,
42
+ })
43
+ .run();
44
+ return id;
45
+ }
46
+
47
+ export function completeOperation(operationId: string): void {
48
+ const db = getDb();
49
+ db.update(moduleOperations)
50
+ .set({ status: 'completed', completedAt: new Date() })
51
+ .where(eq(moduleOperations.id, operationId))
52
+ .run();
53
+ }
54
+
55
+ export function failOperation(operationId: string, error: unknown): void {
56
+ const db = getDb();
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ db.update(moduleOperations)
59
+ .set({ status: 'failed', completedAt: new Date(), errorMessage: message })
60
+ .where(eq(moduleOperations.id, operationId))
61
+ .run();
62
+ }
63
+
64
+ /**
65
+ * True if the OS still has a process with the given pid. `kill(pid, 0)`
66
+ * sends no signal but throws ESRCH if the process is gone — the standard
67
+ * idiom for liveness on POSIX.
68
+ */
69
+ export function isPidAlive(pid: number): boolean {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ export interface InFlightConflict {
79
+ operation: ModuleOperation;
80
+ /** A short, operator-readable description: "deploy of homebridge (pid 12345)". */
81
+ describe: string;
82
+ }
83
+
84
+ /**
85
+ * Returns rows that genuinely look in-flight: status='in_progress' AND
86
+ * the originating process is still alive. Abandoned rows (process gone)
87
+ * are excluded so a stale Ctrl-C doesn't block future operations.
88
+ *
89
+ * @param excludeOperationId - operation id to exclude from the check
90
+ * (so an operation doesn't see itself as a conflict).
91
+ */
92
+ export function checkInFlight(excludeOperationId?: string): InFlightConflict[] {
93
+ const db = getDb();
94
+ const rows = db
95
+ .select()
96
+ .from(moduleOperations)
97
+ .where(eq(moduleOperations.status, 'in_progress'))
98
+ .all();
99
+
100
+ const conflicts: InFlightConflict[] = [];
101
+ for (const row of rows) {
102
+ if (excludeOperationId && row.id === excludeOperationId) continue;
103
+ if (!isPidAlive(row.pid)) continue;
104
+ conflicts.push({
105
+ operation: row,
106
+ describe: `${row.operation} of ${row.moduleId} (pid ${row.pid})`,
107
+ });
108
+ }
109
+ return conflicts;
110
+ }
111
+
112
+ /**
113
+ * Throws an InFlightError when conflicts exist. Returned error message
114
+ * is operator-readable: it names the conflicting operation(s) and the
115
+ * suggested retry.
116
+ */
117
+ export class InFlightError extends Error {
118
+ constructor(public readonly conflicts: InFlightConflict[]) {
119
+ const list = conflicts.map((c) => ` • ${c.describe}`).join('\n');
120
+ super(
121
+ `Cannot start: another module operation is in progress.\n${list}\nWait for it to complete (or fail) and re-run.`,
122
+ );
123
+ this.name = 'InFlightError';
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Throws InFlightError if a conflicting operation is in flight.
129
+ * Call this BEFORE startOperation() at the entry of backup/restore
130
+ * service functions.
131
+ */
132
+ export function refuseIfInFlight(excludeOperationId?: string): void {
133
+ const conflicts = checkInFlight(excludeOperationId);
134
+ if (conflicts.length > 0) {
135
+ throw new InFlightError(conflicts);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Restricted variant: refuse if any operation matching the predicate is
141
+ * in-flight. Used by callers that want to allow some concurrent ops
142
+ * (e.g. multiple backups across modules) but not others. Today's spec
143
+ * doesn't need this — refuseIfInFlight() suffices — but the hook is
144
+ * here for the future.
145
+ */
146
+ export function refuseIfInFlightMatching(
147
+ predicate: (op: ModuleOperation) => boolean,
148
+ excludeOperationId?: string,
149
+ ): void {
150
+ const conflicts = checkInFlight(excludeOperationId).filter((c) => predicate(c.operation));
151
+ if (conflicts.length > 0) {
152
+ throw new InFlightError(conflicts);
153
+ }
154
+ }
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { defineEvents, openBus } from '@celilo/event-bus';
6
+ import { ModuleSubscriptionSchema } from '../manifest/schema';
6
7
  import type { ModuleManifest } from '../manifest/schema';
7
8
  import {
8
9
  registerModuleSubscriptions,
@@ -69,6 +70,63 @@ describe('resolveSubscription', () => {
69
70
  expect(resolved.maxAttempts).toBe(5);
70
71
  expect(resolved.timeoutMs).toBe(90000);
71
72
  });
73
+
74
+ it('synthesizes a `celilo events run-hook` handler for a hook subscription', () => {
75
+ const resolved = resolveSubscription(
76
+ {
77
+ name: 'dns-register-system',
78
+ pattern: 'system.created.*',
79
+ hook: 'on_system_event',
80
+ hook_inputs: { op: 'register' },
81
+ },
82
+ 'technitium',
83
+ '/var/lib/celilo/modules/technitium',
84
+ );
85
+ // The runner re-reads the manifest by (module, sub-name); the handler
86
+ // carries exactly those two identifiers and nothing module-path-specific.
87
+ expect(resolved.handler).toBe('celilo events run-hook technitium dns-register-system');
88
+ expect(resolved.name).toBe('technitium.dns-register-system');
89
+ expect(resolved.pattern).toBe('system.created.*');
90
+ });
91
+ });
92
+
93
+ describe('ModuleSubscriptionSchema handler/hook validation', () => {
94
+ const base = { name: 'a', pattern: 'x' };
95
+
96
+ it('accepts a handler-only subscription', () => {
97
+ expect(ModuleSubscriptionSchema.safeParse({ ...base, handler: 'echo' }).success).toBe(true);
98
+ });
99
+
100
+ it('accepts a hook-only subscription, with or without hook_inputs', () => {
101
+ expect(ModuleSubscriptionSchema.safeParse({ ...base, hook: 'on_system_event' }).success).toBe(
102
+ true,
103
+ );
104
+ expect(
105
+ ModuleSubscriptionSchema.safeParse({
106
+ ...base,
107
+ hook: 'on_system_event',
108
+ hook_inputs: { op: 'register' },
109
+ }).success,
110
+ ).toBe(true);
111
+ });
112
+
113
+ it('rejects declaring both handler and hook', () => {
114
+ expect(
115
+ ModuleSubscriptionSchema.safeParse({ ...base, handler: 'echo', hook: 'on_system_event' })
116
+ .success,
117
+ ).toBe(false);
118
+ });
119
+
120
+ it('rejects declaring neither handler nor hook', () => {
121
+ expect(ModuleSubscriptionSchema.safeParse(base).success).toBe(false);
122
+ });
123
+
124
+ it('rejects hook_inputs without a hook', () => {
125
+ expect(
126
+ ModuleSubscriptionSchema.safeParse({ ...base, handler: 'echo', hook_inputs: { op: 'x' } })
127
+ .success,
128
+ ).toBe(false);
129
+ });
72
130
  });
73
131
 
74
132
  describe('register / unregister roundtrip', () => {
@@ -6,6 +6,8 @@
6
6
  * Substitutions performed at subscribe time:
7
7
  * - `$self` in `pattern` → the module's id
8
8
  * - `${MODULE_PATH}` in `handler` → the module's installed targetPath
9
+ * - a `hook:` subscription → a synthesized `celilo events run-hook
10
+ * <module> <sub-name>` handler (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md)
9
11
  *
10
12
  * The bus subscriber's name is namespaced as `<module-id>.<sub-name>`
11
13
  * so two modules can declare a subscription named `smoke` without
@@ -44,13 +46,34 @@ export function resolveSubscription(
44
46
  return {
45
47
  name: scopedName(moduleId, sub.name),
46
48
  pattern: substituteSelf(sub.pattern, moduleId),
47
- handler: substituteModulePath(sub.handler, modulePath),
49
+ handler: resolveHandler(sub, moduleId, modulePath),
48
50
  maxAttempts: sub.max_attempts,
49
51
  timeoutMs: sub.timeout_ms,
50
52
  registeredBy: moduleId,
51
53
  };
52
54
  }
53
55
 
56
+ /**
57
+ * The bus handler string for a subscription. A `hook:` subscription becomes a
58
+ * synthesized `celilo events run-hook <module> <sub-name>` invocation — the
59
+ * generic runner re-reads this module's manifest by (module, sub-name) to find
60
+ * the hook + its `hook_inputs`, then runs it in a fault-isolated subprocess
61
+ * with backend access. A `handler:` subscription is the literal command with
62
+ * `${MODULE_PATH}` resolved. The schema guarantees exactly one is set; the
63
+ * final throw is defense-in-depth (no surprises).
64
+ */
65
+ function resolveHandler(sub: ModuleSubscription, moduleId: string, modulePath: string): string {
66
+ if (sub.hook) {
67
+ return `celilo events run-hook ${moduleId} ${sub.name}`;
68
+ }
69
+ if (sub.handler) {
70
+ return substituteModulePath(sub.handler, modulePath);
71
+ }
72
+ throw new Error(
73
+ `subscription '${sub.name}' on module '${moduleId}' declares neither 'handler' nor 'hook'`,
74
+ );
75
+ }
76
+
54
77
  /**
55
78
  * Register all of a module's subscriptions on the bus. Idempotent —
56
79
  * re-running with the same manifest updates existing rows in place.
@@ -67,13 +67,13 @@ describe('generateModuleTypes', () => {
67
67
  const out = generateModuleTypes(baseManifest({ id: 'empty', name: 'Empty Module' }));
68
68
  expect(out).toContain('// Generated from manifest.yml');
69
69
  expect(out).toContain('Do not edit by hand');
70
- expect(out).toContain('export interface EmptyConfig {');
70
+ expect(out).toContain('export type EmptyConfig = {');
71
71
  expect(out).toContain('(No variables declared — module has no typed config surface)');
72
72
  });
73
73
 
74
- test('produces the right interface name from a kebab-case module ID', () => {
74
+ test('produces the right type-alias name from a kebab-case module ID', () => {
75
75
  const out = generateModuleTypes(baseManifest({ id: 'dns-external', name: 'DNS External' }));
76
- expect(out).toContain('export interface DnsExternalConfig {');
76
+ expect(out).toContain('export type DnsExternalConfig = {');
77
77
  });
78
78
 
79
79
  test('renders required fields as non-optional', () => {
@@ -139,8 +139,13 @@ export function generateModuleTypes(manifest: ModuleManifest): string {
139
139
  lines.push(' * Fields are derived from `variables.owns` and `variables.imports` in the');
140
140
  lines.push(' * module manifest. Optional fields (marked with `?`) correspond to variables');
141
141
  lines.push(' * that are neither `required: true` nor have a `default:` value.');
142
+ lines.push(' *');
143
+ lines.push(' * Emitted as a `type` alias rather than an `interface` so it satisfies the');
144
+ lines.push(' * `Record<string, unknown>` constraint on `defineHook<Config>`: TypeScript');
145
+ lines.push(' * gives type aliases an implicit index signature but withholds one from');
146
+ lines.push(' * interfaces (which can be declaration-merged). See v2/issues.');
142
147
  lines.push(' */');
143
- lines.push(`export interface ${typeName} {`);
148
+ lines.push(`export type ${typeName} = {`);
144
149
 
145
150
  const ownsFields: string[] = [];
146
151
  const importsFields: string[] = [];
@@ -182,7 +187,7 @@ export function generateModuleTypes(manifest: ModuleManifest): string {
182
187
  lines.push(' // (No variables declared — module has no typed config surface)');
183
188
  }
184
189
 
185
- lines.push('}');
190
+ lines.push('};');
186
191
  lines.push('');
187
192
 
188
193
  return lines.join('\n');
@@ -0,0 +1,333 @@
1
+ /**
2
+ * SC5 unit tests — Proxmox reconciliation planning.
3
+ *
4
+ * Covers:
5
+ * - planProxmoxReconcile: empty-block, no-systems-in-zone,
6
+ * non-Proxmox provider skip, template resolution from
7
+ * module config, $capability: resolution.
8
+ *
9
+ * executeProxmoxReconcile is currently observation-only (logs
10
+ * warnings); its log surface is tested by a single smoke test
11
+ * that just verifies the function doesn't throw on an empty plan
12
+ * and on a populated one.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { closeDb, getDb } from '../db/client';
21
+ import { runMigrations } from '../db/migrate';
22
+ import {
23
+ capabilities,
24
+ containerServices,
25
+ ipAllocations,
26
+ moduleInfrastructure,
27
+ modules,
28
+ } from '../db/schema';
29
+ import type { BaseModuleAspect } from '../manifest/schema';
30
+ import { upsertModuleConfig } from './module-config';
31
+ import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile';
32
+
33
+ const PROVIDER_MODULE_ID = 'knot-unbound-internal';
34
+
35
+ function seedProviderModule(opts: { configs?: Record<string, string> } = {}) {
36
+ const db = getDb();
37
+ db.insert(modules)
38
+ .values({
39
+ id: PROVIDER_MODULE_ID,
40
+ name: PROVIDER_MODULE_ID,
41
+ version: '1.0.0',
42
+ manifestData: {
43
+ id: PROVIDER_MODULE_ID,
44
+ name: PROVIDER_MODULE_ID,
45
+ version: '1.0.0',
46
+ celilo_contract: '1.0',
47
+ },
48
+ sourcePath: `/tmp/${PROVIDER_MODULE_ID}`,
49
+ })
50
+ .run();
51
+ for (const [key, value] of Object.entries(opts.configs ?? {})) {
52
+ upsertModuleConfig(db, PROVIDER_MODULE_ID, key, value);
53
+ }
54
+ }
55
+
56
+ function seedProxmoxService(serviceId = 'proxmox-home-lab') {
57
+ const id = randomUUID();
58
+ getDb()
59
+ .insert(containerServices)
60
+ .values({
61
+ id,
62
+ serviceId,
63
+ name: serviceId,
64
+ providerName: 'proxmox',
65
+ zones: ['dmz', 'app', 'secure', 'internal'],
66
+ apiCredentialsEncrypted: '{}',
67
+ providerConfig: {},
68
+ verified: true,
69
+ })
70
+ .run();
71
+ return id;
72
+ }
73
+
74
+ function seedDigitalOceanService(serviceId = 'do-vps') {
75
+ const id = randomUUID();
76
+ getDb()
77
+ .insert(containerServices)
78
+ .values({
79
+ id,
80
+ serviceId,
81
+ name: serviceId,
82
+ providerName: 'digitalocean',
83
+ zones: ['external'],
84
+ apiCredentialsEncrypted: '{}',
85
+ providerConfig: {},
86
+ verified: true,
87
+ })
88
+ .run();
89
+ return id;
90
+ }
91
+
92
+ function seedContainerLxc(opts: {
93
+ moduleId: string;
94
+ serviceUuid: string;
95
+ vmid: number;
96
+ containerIp: string;
97
+ zone: 'dmz' | 'app' | 'secure' | 'internal';
98
+ }) {
99
+ const db = getDb();
100
+ db.insert(modules)
101
+ .values({
102
+ id: opts.moduleId,
103
+ name: opts.moduleId,
104
+ version: '1.0.0',
105
+ manifestData: {
106
+ id: opts.moduleId,
107
+ name: opts.moduleId,
108
+ version: '1.0.0',
109
+ celilo_contract: '1.0',
110
+ },
111
+ sourcePath: `/tmp/${opts.moduleId}`,
112
+ })
113
+ .run();
114
+ db.insert(moduleInfrastructure)
115
+ .values({
116
+ id: randomUUID(),
117
+ moduleId: opts.moduleId,
118
+ infrastructureType: 'container_service',
119
+ machineId: null,
120
+ serviceId: opts.serviceUuid,
121
+ containerMetadata: { vmid: opts.vmid },
122
+ })
123
+ .run();
124
+ db.insert(ipAllocations)
125
+ .values({
126
+ moduleId: opts.moduleId,
127
+ vmid: opts.vmid,
128
+ containerIp: opts.containerIp,
129
+ zone: opts.zone,
130
+ })
131
+ .run();
132
+ }
133
+
134
+ const aspectWithReconcile: BaseModuleAspect = {
135
+ ansible_role: 'dns-client-config',
136
+ applicable_zones: ['app', 'secure'],
137
+ triggers: ['on_install'],
138
+ proxmox_reconcile: {
139
+ tfvars: {
140
+ nameserver: '$self:target_ip',
141
+ },
142
+ },
143
+ };
144
+
145
+ describe('proxmox-reconcile', () => {
146
+ let dir: string;
147
+
148
+ beforeEach(async () => {
149
+ dir = mkdtempSync(join(tmpdir(), 'celilo-proxmox-reconcile-test-'));
150
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
151
+ await runMigrations(process.env.CELILO_DB_PATH);
152
+ });
153
+
154
+ afterEach(() => {
155
+ closeDb();
156
+ process.env.CELILO_DB_PATH = undefined;
157
+ try {
158
+ rmSync(dir, { recursive: true, force: true });
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ });
163
+
164
+ describe('planProxmoxReconcile', () => {
165
+ it('returns an empty plan when the aspect has no proxmox_reconcile block', async () => {
166
+ seedProviderModule();
167
+ const aspect: BaseModuleAspect = {
168
+ ansible_role: 'noop',
169
+ applicable_zones: ['app'],
170
+ triggers: ['on_install'],
171
+ };
172
+ const plan = await planProxmoxReconcile({
173
+ aspect,
174
+ providerModuleId: PROVIDER_MODULE_ID,
175
+ db: getDb(),
176
+ });
177
+ expect(plan.actions).toEqual([]);
178
+ expect(plan.skipped).toEqual([]);
179
+ });
180
+
181
+ it('returns an empty plan when no container_service systems match the zones', async () => {
182
+ seedProviderModule({ configs: { target_ip: '192.168.0.10' } });
183
+ const plan = await planProxmoxReconcile({
184
+ aspect: aspectWithReconcile,
185
+ providerModuleId: PROVIDER_MODULE_ID,
186
+ db: getDb(),
187
+ });
188
+ expect(plan.actions).toEqual([]);
189
+ });
190
+
191
+ it('emits one action per Proxmox LXC in scope, with tfvars resolved', async () => {
192
+ seedProviderModule({ configs: { target_ip: '192.168.0.10' } });
193
+ const proxmoxId = seedProxmoxService();
194
+ seedContainerLxc({
195
+ moduleId: 'forgejo',
196
+ serviceUuid: proxmoxId,
197
+ vmid: 142,
198
+ containerIp: '10.0.20.42/24',
199
+ zone: 'app',
200
+ });
201
+ seedContainerLxc({
202
+ moduleId: 'authentik',
203
+ serviceUuid: proxmoxId,
204
+ vmid: 130,
205
+ containerIp: '10.0.20.30/24',
206
+ zone: 'app',
207
+ });
208
+
209
+ const plan = await planProxmoxReconcile({
210
+ aspect: aspectWithReconcile,
211
+ providerModuleId: PROVIDER_MODULE_ID,
212
+ db: getDb(),
213
+ });
214
+
215
+ expect(plan.actions).toHaveLength(2);
216
+ const byModule = new Map(plan.actions.map((a) => [a.moduleId, a]));
217
+ expect(byModule.get('forgejo')?.tfvarUpdates).toEqual({ nameserver: '192.168.0.10' });
218
+ expect(byModule.get('authentik')?.tfvarUpdates).toEqual({ nameserver: '192.168.0.10' });
219
+ expect(plan.skipped).toEqual([]);
220
+ });
221
+
222
+ it('skips non-Proxmox provider systems with a clear reason', async () => {
223
+ seedProviderModule({ configs: { target_ip: '192.168.0.10' } });
224
+ const doId = seedDigitalOceanService();
225
+ seedContainerLxc({
226
+ moduleId: 'external-app',
227
+ serviceUuid: doId,
228
+ vmid: 0, // n/a for DO; the field is just required
229
+ containerIp: '10.0.20.50/24',
230
+ zone: 'app',
231
+ });
232
+
233
+ const plan = await planProxmoxReconcile({
234
+ aspect: aspectWithReconcile,
235
+ providerModuleId: PROVIDER_MODULE_ID,
236
+ db: getDb(),
237
+ });
238
+ expect(plan.actions).toEqual([]);
239
+ expect(plan.skipped).toHaveLength(1);
240
+ expect(plan.skipped[0].system.moduleId).toBe('external-app');
241
+ expect(plan.skipped[0].reason).toContain('non-proxmox');
242
+ });
243
+
244
+ it('resolves $capability: templates against the providing module', async () => {
245
+ // Provider registers a capability whose data is consumed by the
246
+ // tfvar template. Real-world example: a future host-firewall
247
+ // aspect could read $capability:public_web.internal_ip.
248
+ seedProviderModule();
249
+ getDb()
250
+ .insert(capabilities)
251
+ .values({
252
+ moduleId: PROVIDER_MODULE_ID,
253
+ capabilityName: 'dns_internal',
254
+ version: '1.0.0',
255
+ data: { server: { ip: '10.99.0.53' } },
256
+ })
257
+ .run();
258
+
259
+ const proxmoxId = seedProxmoxService();
260
+ seedContainerLxc({
261
+ moduleId: 'forgejo',
262
+ serviceUuid: proxmoxId,
263
+ vmid: 142,
264
+ containerIp: '10.0.20.42/24',
265
+ zone: 'app',
266
+ });
267
+
268
+ const aspect: BaseModuleAspect = {
269
+ ansible_role: 'dns-client-config',
270
+ applicable_zones: ['app'],
271
+ triggers: ['on_install'],
272
+ proxmox_reconcile: {
273
+ tfvars: { nameserver: '$capability:dns_internal.server.ip' },
274
+ },
275
+ };
276
+ const plan = await planProxmoxReconcile({
277
+ aspect,
278
+ providerModuleId: PROVIDER_MODULE_ID,
279
+ db: getDb(),
280
+ });
281
+ expect(plan.actions[0].tfvarUpdates).toEqual({ nameserver: '10.99.0.53' });
282
+ });
283
+
284
+ it('throws with a clear message when a template references a missing field', async () => {
285
+ seedProviderModule(); // no target_ip configured
286
+ const proxmoxId = seedProxmoxService();
287
+ seedContainerLxc({
288
+ moduleId: 'forgejo',
289
+ serviceUuid: proxmoxId,
290
+ vmid: 142,
291
+ containerIp: '10.0.20.42/24',
292
+ zone: 'app',
293
+ });
294
+ await expect(
295
+ planProxmoxReconcile({
296
+ aspect: aspectWithReconcile,
297
+ providerModuleId: PROVIDER_MODULE_ID,
298
+ db: getDb(),
299
+ }),
300
+ ).rejects.toThrow(/Cannot resolve \$self:target_ip/);
301
+ });
302
+ });
303
+
304
+ describe('executeProxmoxReconcile', () => {
305
+ it('is a safe no-op on an empty plan', () => {
306
+ expect(() => executeProxmoxReconcile({ actions: [], skipped: [] })).not.toThrow();
307
+ });
308
+
309
+ it('does not throw on a populated plan (currently observation-only)', () => {
310
+ const plan = {
311
+ actions: [
312
+ {
313
+ moduleId: 'forgejo',
314
+ serviceId: 'proxmox-home-lab',
315
+ tfvarUpdates: { nameserver: '192.168.0.10' },
316
+ containerSystem: {
317
+ infrastructureId: 'infra-1',
318
+ moduleId: 'forgejo',
319
+ serviceId: 'proxmox-home-lab',
320
+ providerName: 'proxmox' as const,
321
+ zone: 'app' as const,
322
+ containerIp: '10.0.20.42/24',
323
+ containerMetadata: { vmid: 142 },
324
+ apiOnly: false,
325
+ },
326
+ },
327
+ ],
328
+ skipped: [],
329
+ };
330
+ expect(() => executeProxmoxReconcile(plan)).not.toThrow();
331
+ });
332
+ });
333
+ });