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