@celilo/cli 0.3.30 → 0.4.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5 -4
- 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,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SC3 unit tests — covers the parts of aspect-runner that don't
|
|
3
|
+
* require live Ansible execution:
|
|
4
|
+
*
|
|
5
|
+
* - planAspectFanOut: zone filtering, api_only exclusion,
|
|
6
|
+
* excludeHostnames respect, mgmt-system inclusion.
|
|
7
|
+
* - materializeAspectAnsible: inventory + playbook generation,
|
|
8
|
+
* role-copy, error path when the role is missing.
|
|
9
|
+
*
|
|
10
|
+
* runAspectFanOut (end-to-end with Ansible) gets covered by the
|
|
11
|
+
* SC3 follow-up e2e test (modules/knot-unbound-internal/e2e/
|
|
12
|
+
* aspect-fanout.test.ts).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
16
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { closeDb, getDb } from '../db/client';
|
|
20
|
+
import { runMigrations } from '../db/migrate';
|
|
21
|
+
import { modules } from '../db/schema';
|
|
22
|
+
import type { BaseModuleAspect, ModuleManifest } from '../manifest/schema';
|
|
23
|
+
import { computeAspectScopeHash, recordAspectApproval } from './aspect-approvals';
|
|
24
|
+
import {
|
|
25
|
+
type AspectRunResult,
|
|
26
|
+
materializeAspectAnsible,
|
|
27
|
+
maybeRunAspectForTrigger,
|
|
28
|
+
planAspectFanOut,
|
|
29
|
+
} from './aspect-runner';
|
|
30
|
+
import { addMachine } from './machine-pool';
|
|
31
|
+
|
|
32
|
+
const baseAspect: BaseModuleAspect = {
|
|
33
|
+
ansible_role: 'dns-client-config',
|
|
34
|
+
applicable_zones: ['dmz', 'app', 'secure', 'internal'],
|
|
35
|
+
triggers: ['on_install'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function seedMachine(opts: {
|
|
39
|
+
hostname: string;
|
|
40
|
+
zone: 'dmz' | 'app' | 'secure' | 'internal';
|
|
41
|
+
ip: string;
|
|
42
|
+
apiOnly?: boolean;
|
|
43
|
+
}) {
|
|
44
|
+
const machine = await addMachine({
|
|
45
|
+
hostname: opts.hostname,
|
|
46
|
+
zone: opts.zone,
|
|
47
|
+
ipAddress: opts.ip,
|
|
48
|
+
sshUser: 'root',
|
|
49
|
+
sshKey: 'ssh-key-placeholder',
|
|
50
|
+
hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 5, arch: 'amd64' },
|
|
51
|
+
assignedModuleIds: [],
|
|
52
|
+
earmarkedModule: null,
|
|
53
|
+
});
|
|
54
|
+
if (opts.apiOnly) {
|
|
55
|
+
// The DB column defaults to false; flip directly since there's
|
|
56
|
+
// no setter API yet (D8 escape valve is set via the row, not
|
|
57
|
+
// via a public function in Phase 1).
|
|
58
|
+
const { getDb } = await import('../db/client');
|
|
59
|
+
const { machines } = await import('../db/schema');
|
|
60
|
+
const { eq } = await import('drizzle-orm');
|
|
61
|
+
await getDb().update(machines).set({ apiOnly: true }).where(eq(machines.id, machine.id));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('aspect-runner', () => {
|
|
66
|
+
let dir: string;
|
|
67
|
+
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-runner-test-'));
|
|
70
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
71
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
closeDb();
|
|
76
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
77
|
+
try {
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('planAspectFanOut', () => {
|
|
85
|
+
it('returns systems matching the aspect zones, skipping api_only', async () => {
|
|
86
|
+
await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
|
|
87
|
+
await seedMachine({ hostname: 'authentik', zone: 'app', ip: '10.0.20.30' });
|
|
88
|
+
await seedMachine({ hostname: 'celilo-mgmt', zone: 'secure', ip: '10.0.30.5' });
|
|
89
|
+
await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' });
|
|
90
|
+
// greenwave: appliance, must be skipped
|
|
91
|
+
await seedMachine({
|
|
92
|
+
hostname: 'greenwave',
|
|
93
|
+
zone: 'internal',
|
|
94
|
+
ip: '192.168.0.1',
|
|
95
|
+
apiOnly: true,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const plan = await planAspectFanOut(baseAspect);
|
|
99
|
+
const targetHostnames = plan.targetSystems.map((m) => m.hostname).sort();
|
|
100
|
+
expect(targetHostnames).toEqual(['authentik', 'caddy', 'celilo-mgmt', 'knot']);
|
|
101
|
+
expect(plan.skipped.map((s) => s.machine.hostname)).toEqual(['greenwave']);
|
|
102
|
+
expect(plan.skipped[0]?.reason).toBe('api_only');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('excludes hostnames the caller asks to skip', async () => {
|
|
106
|
+
await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' });
|
|
107
|
+
await seedMachine({ hostname: 'other', zone: 'internal', ip: '192.168.0.11' });
|
|
108
|
+
|
|
109
|
+
const plan = await planAspectFanOut(baseAspect, { excludeHostnames: ['knot'] });
|
|
110
|
+
expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['other']);
|
|
111
|
+
// excludeHostnames-removed systems aren't in `skipped` — the
|
|
112
|
+
// caller's expressing a deliberate "don't include this", not
|
|
113
|
+
// an exclusion the framework chose.
|
|
114
|
+
expect(plan.skipped).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns an empty plan when no machines match the zones', async () => {
|
|
118
|
+
// Only a dmz machine present; aspect targets nothing else.
|
|
119
|
+
await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
|
|
120
|
+
const onlySecure: BaseModuleAspect = { ...baseAspect, applicable_zones: ['secure'] };
|
|
121
|
+
const plan = await planAspectFanOut(onlySecure);
|
|
122
|
+
expect(plan.targetSystems).toEqual([]);
|
|
123
|
+
expect(plan.skipped).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('includes the management system (secure zone, no special casing)', async () => {
|
|
127
|
+
// Per CELILO_BASE.md D1 sub-section: mgmt is part of the fleet,
|
|
128
|
+
// not a special-case exclusion. As long as it's in a zone the
|
|
129
|
+
// aspect targets and isn't api_only, it gets the aspect.
|
|
130
|
+
await seedMachine({ hostname: 'celilo-mgmt', zone: 'secure', ip: '10.0.30.5' });
|
|
131
|
+
const plan = await planAspectFanOut({ ...baseAspect, applicable_zones: ['secure'] });
|
|
132
|
+
expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['celilo-mgmt']);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('materializeAspectAnsible', () => {
|
|
137
|
+
function makeFakeRole(moduleSourcePath: string, roleName: string) {
|
|
138
|
+
const taskFile = join(
|
|
139
|
+
moduleSourcePath,
|
|
140
|
+
'base-module-aspect',
|
|
141
|
+
'ansible',
|
|
142
|
+
'roles',
|
|
143
|
+
roleName,
|
|
144
|
+
'tasks',
|
|
145
|
+
'main.yml',
|
|
146
|
+
);
|
|
147
|
+
mkdirSync(join(taskFile, '..'), { recursive: true });
|
|
148
|
+
writeFileSync(taskFile, "---\n- name: noop\n ansible.builtin.debug:\n msg: 'ok'\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it('builds an inventory + playbook + role copy in a temp workspace', async () => {
|
|
152
|
+
await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
|
|
153
|
+
await seedMachine({ hostname: 'authentik', zone: 'app', ip: '10.0.20.30' });
|
|
154
|
+
|
|
155
|
+
const moduleSourcePath = join(dir, 'module-src');
|
|
156
|
+
makeFakeRole(moduleSourcePath, 'dns-client-config');
|
|
157
|
+
|
|
158
|
+
const plan = await planAspectFanOut(baseAspect);
|
|
159
|
+
const workDir = await materializeAspectAnsible({
|
|
160
|
+
aspect: baseAspect,
|
|
161
|
+
moduleSourcePath,
|
|
162
|
+
targetSystems: plan.targetSystems,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Inventory file lists both systems under the 'aspect_targets'
|
|
166
|
+
// group plus their per-zone groups.
|
|
167
|
+
const hostsIni = readFileSync(join(workDir, 'ansible', 'inventory', 'hosts.ini'), 'utf-8');
|
|
168
|
+
expect(hostsIni).toContain('[aspect_targets]');
|
|
169
|
+
expect(hostsIni).toContain('caddy ansible_host=10.0.10.10');
|
|
170
|
+
expect(hostsIni).toContain('authentik ansible_host=10.0.20.30');
|
|
171
|
+
expect(hostsIni).toContain('[dmz]');
|
|
172
|
+
expect(hostsIni).toContain('[app]');
|
|
173
|
+
|
|
174
|
+
// Per-host vars carry target_zone.
|
|
175
|
+
const caddyVars = readFileSync(
|
|
176
|
+
join(workDir, 'ansible', 'inventory', 'host_vars', 'caddy.yml'),
|
|
177
|
+
'utf-8',
|
|
178
|
+
);
|
|
179
|
+
expect(caddyVars).toContain('target_zone: dmz');
|
|
180
|
+
const authentikVars = readFileSync(
|
|
181
|
+
join(workDir, 'ansible', 'inventory', 'host_vars', 'authentik.yml'),
|
|
182
|
+
'utf-8',
|
|
183
|
+
);
|
|
184
|
+
expect(authentikVars).toContain('target_zone: app');
|
|
185
|
+
|
|
186
|
+
// Synthesized playbook invokes the named role against
|
|
187
|
+
// 'aspect_targets'.
|
|
188
|
+
const playbook = readFileSync(join(workDir, 'ansible', 'playbook.yml'), 'utf-8');
|
|
189
|
+
expect(playbook).toContain('hosts: aspect_targets');
|
|
190
|
+
expect(playbook).toContain('role: dns-client-config');
|
|
191
|
+
expect(playbook).toContain('become: true');
|
|
192
|
+
|
|
193
|
+
// Role files were copied across.
|
|
194
|
+
const copiedTask = readFileSync(
|
|
195
|
+
join(workDir, 'ansible', 'roles', 'dns-client-config', 'tasks', 'main.yml'),
|
|
196
|
+
'utf-8',
|
|
197
|
+
);
|
|
198
|
+
expect(copiedTask).toContain('name: noop');
|
|
199
|
+
|
|
200
|
+
// Cleanup (caller's responsibility per the contract).
|
|
201
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('throws a clear error when the aspect role directory is missing', async () => {
|
|
205
|
+
await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
|
|
206
|
+
const moduleSourcePath = join(dir, 'module-src-without-role');
|
|
207
|
+
mkdirSync(moduleSourcePath, { recursive: true });
|
|
208
|
+
const plan = await planAspectFanOut(baseAspect);
|
|
209
|
+
await expect(
|
|
210
|
+
materializeAspectAnsible({
|
|
211
|
+
aspect: baseAspect,
|
|
212
|
+
moduleSourcePath,
|
|
213
|
+
targetSystems: plan.targetSystems,
|
|
214
|
+
}),
|
|
215
|
+
).rejects.toThrow(/Aspect role not found/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('writes resolved ansible_vars to group_vars/all/aspect_vars.yml', async () => {
|
|
219
|
+
// Seed the providing module + its target_ip so the
|
|
220
|
+
// $self:target_ip template resolves.
|
|
221
|
+
const { moduleConfigs, modules } = await import('../db/schema');
|
|
222
|
+
getDb()
|
|
223
|
+
.insert(modules)
|
|
224
|
+
.values({
|
|
225
|
+
id: 'knot-unbound-internal',
|
|
226
|
+
name: 'knot-unbound-internal',
|
|
227
|
+
version: '1.0.0',
|
|
228
|
+
manifestData: {
|
|
229
|
+
id: 'knot-unbound-internal',
|
|
230
|
+
name: 'knot-unbound-internal',
|
|
231
|
+
version: '1.0.0',
|
|
232
|
+
celilo_contract: '1.0',
|
|
233
|
+
},
|
|
234
|
+
sourcePath: '/tmp/knot',
|
|
235
|
+
})
|
|
236
|
+
.run();
|
|
237
|
+
getDb()
|
|
238
|
+
.insert(moduleConfigs)
|
|
239
|
+
.values({ moduleId: 'knot-unbound-internal', key: 'target_ip', value: '192.168.0.10' })
|
|
240
|
+
.run();
|
|
241
|
+
|
|
242
|
+
await seedMachine({ hostname: 'app-host', zone: 'app', ip: '10.0.20.20' });
|
|
243
|
+
const moduleSourcePath = join(dir, 'module-src-with-vars');
|
|
244
|
+
makeFakeRole(moduleSourcePath, 'dns-client-config');
|
|
245
|
+
|
|
246
|
+
const aspectWithVars: BaseModuleAspect = {
|
|
247
|
+
...baseAspect,
|
|
248
|
+
ansible_vars: { knot_server_ip: '$self:target_ip' },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const plan = await planAspectFanOut(aspectWithVars);
|
|
252
|
+
const workDir = await materializeAspectAnsible({
|
|
253
|
+
aspect: aspectWithVars,
|
|
254
|
+
moduleSourcePath,
|
|
255
|
+
targetSystems: plan.targetSystems,
|
|
256
|
+
providerModuleId: 'knot-unbound-internal',
|
|
257
|
+
db: getDb(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const aspectVars = readFileSync(
|
|
261
|
+
join(workDir, 'ansible', 'inventory', 'group_vars', 'all', 'aspect_vars.yml'),
|
|
262
|
+
'utf-8',
|
|
263
|
+
);
|
|
264
|
+
expect(aspectVars).toContain('knot_server_ip: "192.168.0.10"');
|
|
265
|
+
|
|
266
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('skips group_vars file when the aspect has no ansible_vars', async () => {
|
|
270
|
+
await seedMachine({ hostname: 'app-host', zone: 'app', ip: '10.0.20.20' });
|
|
271
|
+
const moduleSourcePath = join(dir, 'module-src-no-vars');
|
|
272
|
+
makeFakeRole(moduleSourcePath, 'dns-client-config');
|
|
273
|
+
|
|
274
|
+
const plan = await planAspectFanOut(baseAspect);
|
|
275
|
+
const workDir = await materializeAspectAnsible({
|
|
276
|
+
aspect: baseAspect, // no ansible_vars
|
|
277
|
+
moduleSourcePath,
|
|
278
|
+
targetSystems: plan.targetSystems,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const groupVarsPath = join(
|
|
282
|
+
workDir,
|
|
283
|
+
'ansible',
|
|
284
|
+
'inventory',
|
|
285
|
+
'group_vars',
|
|
286
|
+
'all',
|
|
287
|
+
'aspect_vars.yml',
|
|
288
|
+
);
|
|
289
|
+
const { existsSync } = await import('node:fs');
|
|
290
|
+
expect(existsSync(groupVarsPath)).toBe(false);
|
|
291
|
+
|
|
292
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('maybeRunAspectForTrigger', () => {
|
|
297
|
+
const manifestWithAspect: ModuleManifest = {
|
|
298
|
+
celilo_contract: '1.0',
|
|
299
|
+
id: 'knot-unbound-internal',
|
|
300
|
+
name: 'Knot Unbound Internal',
|
|
301
|
+
version: '1.0.0',
|
|
302
|
+
base_module_aspect: {
|
|
303
|
+
ansible_role: 'dns-client-config',
|
|
304
|
+
applicable_zones: ['dmz', 'app', 'secure', 'internal'],
|
|
305
|
+
triggers: ['on_install'],
|
|
306
|
+
},
|
|
307
|
+
requires: { capabilities: [] },
|
|
308
|
+
provides: { capabilities: [] },
|
|
309
|
+
variables: { owns: [], imports: [] },
|
|
310
|
+
secrets: { declares: [] },
|
|
311
|
+
hooks: {},
|
|
312
|
+
} as unknown as ModuleManifest;
|
|
313
|
+
|
|
314
|
+
function seedModuleRow(opts: { id: string; version: string }) {
|
|
315
|
+
getDb()
|
|
316
|
+
.insert(modules)
|
|
317
|
+
.values({
|
|
318
|
+
id: opts.id,
|
|
319
|
+
name: opts.id,
|
|
320
|
+
version: opts.version,
|
|
321
|
+
manifestData: {
|
|
322
|
+
id: opts.id,
|
|
323
|
+
name: opts.id,
|
|
324
|
+
version: opts.version,
|
|
325
|
+
celilo_contract: '1.0',
|
|
326
|
+
},
|
|
327
|
+
sourcePath: `/tmp/${opts.id}`,
|
|
328
|
+
})
|
|
329
|
+
.run();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Recording fake runner — captures the args, returns the canned result. */
|
|
333
|
+
function makeFakeRunner(success = true) {
|
|
334
|
+
const calls: Array<{ moduleId: string; trigger: string; zones: string[] }> = [];
|
|
335
|
+
const fake = async (args: {
|
|
336
|
+
moduleId: string;
|
|
337
|
+
aspect: BaseModuleAspect;
|
|
338
|
+
moduleSourcePath: string;
|
|
339
|
+
options: { trigger: string };
|
|
340
|
+
db: unknown;
|
|
341
|
+
}): Promise<AspectRunResult> => {
|
|
342
|
+
calls.push({
|
|
343
|
+
moduleId: args.moduleId,
|
|
344
|
+
trigger: args.options.trigger,
|
|
345
|
+
zones: [...args.aspect.applicable_zones],
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
success,
|
|
349
|
+
output: success ? 'fake-output' : '',
|
|
350
|
+
error: success ? undefined : 'fake-error',
|
|
351
|
+
plan: { targetSystems: [], skipped: [] },
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
return { fake, calls };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
it('skips when the manifest has no base_module_aspect', async () => {
|
|
358
|
+
const manifest = { ...manifestWithAspect, base_module_aspect: undefined };
|
|
359
|
+
const { fake, calls } = makeFakeRunner();
|
|
360
|
+
const result = await maybeRunAspectForTrigger({
|
|
361
|
+
moduleId: 'knot-unbound-internal',
|
|
362
|
+
manifest: manifest as ModuleManifest,
|
|
363
|
+
trigger: 'on_install',
|
|
364
|
+
db: getDb(),
|
|
365
|
+
runner: fake,
|
|
366
|
+
});
|
|
367
|
+
expect(result.ran).toBe(false);
|
|
368
|
+
expect(result.reason).toBe('no_aspect');
|
|
369
|
+
expect(calls).toHaveLength(0);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("skips when the trigger isn't declared on the aspect", async () => {
|
|
373
|
+
seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
|
|
374
|
+
recordAspectApproval({
|
|
375
|
+
moduleId: 'knot-unbound-internal',
|
|
376
|
+
version: '1.0.0',
|
|
377
|
+
scopeHash: computeAspectScopeHash(
|
|
378
|
+
manifestWithAspect.base_module_aspect as BaseModuleAspect,
|
|
379
|
+
),
|
|
380
|
+
approver: null,
|
|
381
|
+
db: getDb(),
|
|
382
|
+
});
|
|
383
|
+
const { fake, calls } = makeFakeRunner();
|
|
384
|
+
const result = await maybeRunAspectForTrigger({
|
|
385
|
+
moduleId: 'knot-unbound-internal',
|
|
386
|
+
manifest: manifestWithAspect,
|
|
387
|
+
trigger: 'on_new_system_in_zone', // not in the declared triggers
|
|
388
|
+
db: getDb(),
|
|
389
|
+
runner: fake,
|
|
390
|
+
});
|
|
391
|
+
expect(result.ran).toBe(false);
|
|
392
|
+
expect(result.reason).toBe('trigger_not_declared');
|
|
393
|
+
expect(calls).toHaveLength(0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('skips with "no_approval" when the operator never approved', async () => {
|
|
397
|
+
seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
|
|
398
|
+
// No recordAspectApproval call.
|
|
399
|
+
const { fake, calls } = makeFakeRunner();
|
|
400
|
+
const result = await maybeRunAspectForTrigger({
|
|
401
|
+
moduleId: 'knot-unbound-internal',
|
|
402
|
+
manifest: manifestWithAspect,
|
|
403
|
+
trigger: 'on_install',
|
|
404
|
+
db: getDb(),
|
|
405
|
+
runner: fake,
|
|
406
|
+
});
|
|
407
|
+
expect(result.ran).toBe(false);
|
|
408
|
+
expect(result.reason).toBe('no_approval');
|
|
409
|
+
expect(calls).toHaveLength(0);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('skips with "scope_changed" when manifest scope diverged from approval', async () => {
|
|
413
|
+
seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
|
|
414
|
+
// Approve under a narrower scope than the manifest now declares.
|
|
415
|
+
const oldScope: BaseModuleAspect = {
|
|
416
|
+
ansible_role: 'dns-client-config',
|
|
417
|
+
applicable_zones: ['app'],
|
|
418
|
+
triggers: ['on_install'],
|
|
419
|
+
};
|
|
420
|
+
recordAspectApproval({
|
|
421
|
+
moduleId: 'knot-unbound-internal',
|
|
422
|
+
version: '1.0.0',
|
|
423
|
+
scopeHash: computeAspectScopeHash(oldScope),
|
|
424
|
+
approver: null,
|
|
425
|
+
db: getDb(),
|
|
426
|
+
});
|
|
427
|
+
const { fake, calls } = makeFakeRunner();
|
|
428
|
+
const result = await maybeRunAspectForTrigger({
|
|
429
|
+
moduleId: 'knot-unbound-internal',
|
|
430
|
+
manifest: manifestWithAspect,
|
|
431
|
+
trigger: 'on_install',
|
|
432
|
+
db: getDb(),
|
|
433
|
+
runner: fake,
|
|
434
|
+
});
|
|
435
|
+
expect(result.ran).toBe(false);
|
|
436
|
+
expect(result.reason).toBe('scope_changed');
|
|
437
|
+
expect(calls).toHaveLength(0);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('dispatches to the runner when everything checks out', async () => {
|
|
441
|
+
seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
|
|
442
|
+
recordAspectApproval({
|
|
443
|
+
moduleId: 'knot-unbound-internal',
|
|
444
|
+
version: '1.0.0',
|
|
445
|
+
scopeHash: computeAspectScopeHash(
|
|
446
|
+
manifestWithAspect.base_module_aspect as BaseModuleAspect,
|
|
447
|
+
),
|
|
448
|
+
approver: null,
|
|
449
|
+
db: getDb(),
|
|
450
|
+
});
|
|
451
|
+
const { fake, calls } = makeFakeRunner();
|
|
452
|
+
const result = await maybeRunAspectForTrigger({
|
|
453
|
+
moduleId: 'knot-unbound-internal',
|
|
454
|
+
manifest: manifestWithAspect,
|
|
455
|
+
trigger: 'on_install',
|
|
456
|
+
db: getDb(),
|
|
457
|
+
runner: fake,
|
|
458
|
+
});
|
|
459
|
+
expect(result.ran).toBe(true);
|
|
460
|
+
expect(result.success).toBe(true);
|
|
461
|
+
expect(calls).toHaveLength(1);
|
|
462
|
+
expect(calls[0]).toEqual({
|
|
463
|
+
moduleId: 'knot-unbound-internal',
|
|
464
|
+
trigger: 'on_install',
|
|
465
|
+
zones: ['dmz', 'app', 'secure', 'internal'],
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('returns {ran:true, success:false} when the runner fails (no rollback)', async () => {
|
|
470
|
+
seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
|
|
471
|
+
recordAspectApproval({
|
|
472
|
+
moduleId: 'knot-unbound-internal',
|
|
473
|
+
version: '1.0.0',
|
|
474
|
+
scopeHash: computeAspectScopeHash(
|
|
475
|
+
manifestWithAspect.base_module_aspect as BaseModuleAspect,
|
|
476
|
+
),
|
|
477
|
+
approver: null,
|
|
478
|
+
db: getDb(),
|
|
479
|
+
});
|
|
480
|
+
const { fake } = makeFakeRunner(false);
|
|
481
|
+
const result = await maybeRunAspectForTrigger({
|
|
482
|
+
moduleId: 'knot-unbound-internal',
|
|
483
|
+
manifest: manifestWithAspect,
|
|
484
|
+
trigger: 'on_install',
|
|
485
|
+
db: getDb(),
|
|
486
|
+
runner: fake,
|
|
487
|
+
});
|
|
488
|
+
expect(result.ran).toBe(true);
|
|
489
|
+
expect(result.success).toBe(false);
|
|
490
|
+
expect(result.runResult?.error).toBe('fake-error');
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
});
|