@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.
- package/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +6 -5
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- 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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|