@celilo/cli 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -1,6 +1,7 @@
1
1
  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
+ import { eq } from 'drizzle-orm';
4
5
  import { type DbClient, createDbClient } from '../db/client';
5
6
  import { capabilities, moduleConfigs, modules, systemConfig } from '../db/schema';
6
7
  import { buildResolutionContext } from './context';
@@ -67,6 +68,71 @@ describe('lxc_nameserver composition', () => {
67
68
  .run();
68
69
  }
69
70
 
71
+ /** Put the consumer in `zone` instead of the default `app`. */
72
+ function placeConsumerIn(zone: string): void {
73
+ db.update(modules)
74
+ .set({ manifestData: { requires: { system: { zone } } } })
75
+ .where(eq(modules.id, 'consumer'))
76
+ .run();
77
+ }
78
+
79
+ /**
80
+ * A dns_internal provider that advertises a zone-routable endpoint AND
81
+ * declares a base-module aspect, the way `knot-unbound-internal` does. The
82
+ * aspect's `applicable_zones` is what decides whether a system's ongoing DNS
83
+ * has an owner at all (design D5d).
84
+ */
85
+ function registerManagedPrimary(opts: {
86
+ ip: string;
87
+ internalIp?: string;
88
+ aspectZones: string[];
89
+ }): void {
90
+ db.insert(modules)
91
+ .values({
92
+ id: 'dns-provider',
93
+ name: 'dns-provider',
94
+ version: '1.0.0',
95
+ manifestData: {},
96
+ sourcePath: '/tmp/dns',
97
+ })
98
+ .run();
99
+ db.insert(capabilities)
100
+ .values({
101
+ moduleId: 'dns-provider',
102
+ capabilityName: 'dns_internal',
103
+ version: '1.0.0',
104
+ data: {
105
+ server: { ip: opts.ip, internal_ip: opts.internalIp },
106
+ // The provider DECLARES what its aspect covers, rather than core
107
+ // finding the module row and reading its manifest. A manifest test
108
+ // holds this list and `base_module_aspect.applicable_zones` together.
109
+ aspect: { covered_zones: opts.aspectZones },
110
+ },
111
+ })
112
+ .run();
113
+ }
114
+
115
+ /** The secondary publishes its OWN capability, never a field on the primary's. */
116
+ function registerSecondary(opts: { ip: string; internalIp?: string }): void {
117
+ db.insert(modules)
118
+ .values({
119
+ id: 'dns-secondary',
120
+ name: 'dns-secondary',
121
+ version: '1.0.0',
122
+ manifestData: {},
123
+ sourcePath: '/tmp/dns2',
124
+ })
125
+ .run();
126
+ db.insert(capabilities)
127
+ .values({
128
+ moduleId: 'dns-secondary',
129
+ capabilityName: 'dns_internal_secondary',
130
+ version: '1.0.0',
131
+ data: { server: { ip: opts.ip, internal_ip: opts.internalIp } },
132
+ })
133
+ .run();
134
+ }
135
+
70
136
  test('internal resolver first (CIDR stripped) + public fallback', async () => {
71
137
  registerInternalDns('192.168.0.151/24');
72
138
  const ctx = await buildResolutionContext('consumer', db);
@@ -83,4 +149,82 @@ describe('lxc_nameserver composition', () => {
83
149
  const ctx = await buildResolutionContext('consumer', db);
84
150
  expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.20.30 1.1.1.1 1.0.0.1 8.8.8.8');
85
151
  });
152
+
153
+ /**
154
+ * Terraform injects `lifecycle { ignore_changes = [nameserver] }` into every
155
+ * `proxmox_lxc`, so it cannot correct the birth value afterwards even in
156
+ * principle. `lxc-dns-at-birth` splits ownership accordingly: terraform owns
157
+ * birth DNS, the base-module aspect owns ongoing DNS. A zone the aspect does
158
+ * not cover therefore has NO owner for ongoing DNS — not a late one, none —
159
+ * and the birth list is that system's permanent configuration.
160
+ *
161
+ * That is why dropping the public resolvers is a TIGHTENING in an
162
+ * aspect-covered zone and an OUTRIGHT BREAK outside one, from the same edit
163
+ * (design D5d). These assert on the composed CONFIGURATION rather than on a
164
+ * lookup result, deliberately: a resolution assertion passes whenever the
165
+ * internal resolver happens to be up, which is nearly always, so it would
166
+ * flake in the direction that reads as harness noise. The production symptom
167
+ * is a correct-looking answer from the wrong view, and this string is the
168
+ * only place that is ever visible.
169
+ */
170
+ describe('a managed primary/secondary pair (design D5, D5d, D5e)', () => {
171
+ /**
172
+ * Both resolvers are reached the SAME way, and that is a consequence of
173
+ * placement rather than a convenience. Design D5e puts the secondary in
174
+ * `dmz` alongside the primary, so from dmz/app/secure both answer at their
175
+ * own zone addresses, and from `internal` — which cannot route into the dmz
176
+ * subnet — both answer at an internal-subnet address the firewall DNATs.
177
+ *
178
+ * This is worth stating because the symmetry is exactly what a reader
179
+ * should check rather than assume. Jeremy's version placed the secondary in
180
+ * `internal` and therefore selected its endpoints the other way round
181
+ * (native address for internal clients, a dmz ingress for everyone else).
182
+ * Under that placement this symmetric selection would be WRONG, and these
183
+ * tests would still pass, because the same premise would be in the test and
184
+ * in the code. If the secondary ever moves out of `dmz`, this block is what
185
+ * has to change first.
186
+ */
187
+ test('an aspect-covered zone drops the public resolvers', async () => {
188
+ registerManagedPrimary({ ip: '10.0.10.5', aspectZones: ['dmz', 'app', 'secure'] });
189
+ registerSecondary({ ip: '10.0.10.6' });
190
+ const ctx = await buildResolutionContext('consumer', db);
191
+ expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.10.5 10.0.10.6');
192
+ });
193
+
194
+ test('a zone NO aspect covers keeps them, because nothing will ever rewrite it', async () => {
195
+ placeConsumerIn('external');
196
+ registerManagedPrimary({ ip: '10.0.10.5', aspectZones: ['dmz', 'app', 'secure'] });
197
+ registerSecondary({ ip: '10.0.10.6' });
198
+ const ctx = await buildResolutionContext('consumer', db);
199
+ // An `external` VPS sits outside the perimeter with no route to a
200
+ // dmz-resident resolver. Handing it two unreachable addresses and nothing
201
+ // else does not tighten anything; it takes DNS away. So the list stays
202
+ // what it has always been here — the primary, then the public resolvers
203
+ // that are the only ones it can actually reach. The secondary is not
204
+ // added either: a second unreachable address costs another timeout.
205
+ expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.10.5 1.1.1.1 1.0.0.1 8.8.8.8');
206
+ });
207
+
208
+ test('an internal-zone system takes the zone-routable endpoints', async () => {
209
+ placeConsumerIn('internal');
210
+ registerManagedPrimary({
211
+ ip: '10.0.10.5',
212
+ internalIp: '192.168.0.5',
213
+ aspectZones: ['dmz', 'app', 'secure', 'internal'],
214
+ });
215
+ registerSecondary({ ip: '10.0.10.6', internalIp: '192.168.0.6' });
216
+ const ctx = await buildResolutionContext('consumer', db);
217
+ // `internal` cannot route into the dmz subnet, so it uses the ingress
218
+ // addresses the firewall DNATs — not the resolvers' own zone addresses.
219
+ expect(ctx.selfConfig.lxc_nameserver).toBe('192.168.0.5 192.168.0.6');
220
+ });
221
+
222
+ test('a primary with no secondary keeps the public fallback', async () => {
223
+ registerManagedPrimary({ ip: '10.0.10.5', aspectZones: ['dmz', 'app', 'secure'] });
224
+ const ctx = await buildResolutionContext('consumer', db);
225
+ // Removing the fallback and shipping a secondary are ONE decision (D5a).
226
+ // Without a secondary, a resolver redeploy would blank fleet DNS.
227
+ expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.10.5 1.1.1.1 1.0.0.1 8.8.8.8');
228
+ });
229
+ });
86
230
  });
@@ -1,80 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { unlink } from 'node:fs/promises';
3
- import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
4
- import { type DbClient, createDbClient, findMigrationsFolder } from '../db/client';
5
-
6
- // `findMigrationsFolder` is imported from db/client rather than redefined here.
7
- // This file used to carry its own copy, and the copy had drifted: it listed
8
- // only CWD-RELATIVE candidates (`./drizzle`, `process.cwd()/drizzle`, …) and was
9
- // missing the `join(currentDir, '../../drizzle')` fallback that resolves
10
- // relative to the source file.
11
- //
12
- // The effect was invisible from `apps/celilo` and total from the repo root —
13
- // which is where CLAUDE.md says to run the gate. Every suite using
14
- // setupTestDatabase threw here, left `db` unassigned, and then failed again in
15
- // its own afterEach on `db.$client`, so one missing path candidate presented as
16
- // hundreds of unrelated-looking assertion failures (celilo: 668 across the
17
- // repo, 341 in apps/celilo/src alone).
18
-
19
- /**
20
- * Setup test database with real migrations
21
- *
22
- * This helper ensures tests use the same schema as production by running
23
- * actual migrations instead of manual CREATE TABLE statements.
24
- *
25
- * Benefits:
26
- * - Single source of truth for schema
27
- * - Tests automatically use latest schema
28
- * - Validates migration correctness
29
- *
30
- * @param testDbPath - Path to test database file
31
- * @returns Database client instance
32
- */
33
- export async function setupTestDatabase(testDbPath: string) {
34
- // Create database client
35
- const db = createDbClient({ path: testDbPath });
36
-
37
- // Find and run migrations
38
- const migrationsFolder = findMigrationsFolder();
39
- await migrate(db, { migrationsFolder });
40
-
41
- return db;
42
- }
43
-
44
- /**
45
- * Cleanup test database
46
- *
47
- * @param db - Database client to close
48
- * @param testDbPath - Path to test database file to delete
49
- */
50
- export async function cleanupTestDatabase(db: DbClient, testDbPath: string) {
51
- // Close database connection
52
- db.$client.close();
53
-
54
- // Delete test database file
55
- if (existsSync(testDbPath)) {
56
- await unlink(testDbPath);
57
- }
58
-
59
- // Also delete WAL and SHM files if they exist
60
- const walPath = `${testDbPath}-wal`;
61
- const shmPath = `${testDbPath}-shm`;
62
-
63
- if (existsSync(walPath)) {
64
- await unlink(walPath);
65
- }
66
-
67
- if (existsSync(shmPath)) {
68
- await unlink(shmPath);
69
- }
70
- }
71
-
72
- /**
73
- * Generate unique test database path
74
- *
75
- * @param prefix - Optional prefix for the database file (defaults to 'test-celilo')
76
- * @returns Unique database file path
77
- */
78
- export function generateTestDbPath(prefix = 'test-celilo'): string {
79
- return `./${prefix}-${Date.now()}-${Math.random()}.db`;
80
- }