@celilo/cli 1.6.0 → 1.8.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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +3 -1
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +238 -2
  11. package/src/capabilities/validation.ts +67 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-remove.ts +34 -2
  14. package/src/cli/commands/module-update.test.ts +149 -2
  15. package/src/cli/commands/module-update.ts +113 -25
  16. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  17. package/src/cli/commands/service-set-credentials.ts +115 -0
  18. package/src/cli/commands/system-migrate.ts +6 -4
  19. package/src/cli/completion.ts +16 -1
  20. package/src/cli/index.ts +9 -0
  21. package/src/db/client.ts +10 -8
  22. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  23. package/src/db/migrate.test.ts +147 -0
  24. package/src/db/migrate.ts +69 -1
  25. package/src/db/schema.ts +21 -4
  26. package/src/hooks/capability-loader.test.ts +55 -0
  27. package/src/hooks/capability-loader.ts +16 -1
  28. package/src/manifest/template-validator.test.ts +47 -0
  29. package/src/manifest/template-validator.ts +18 -1
  30. package/src/module/import.ts +39 -6
  31. package/src/policy/capability-shape-baseline.ts +88 -0
  32. package/src/policy/capability-shape-drift.test.ts +162 -0
  33. package/src/policy/capability-shape.ts +117 -0
  34. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  35. package/src/policy/module-business-baseline.ts +32 -18
  36. package/src/services/alerting/monitors.ts +54 -2
  37. package/src/services/alerting/sweep-runner.ts +38 -1
  38. package/src/services/capability-table-rows.test.ts +191 -0
  39. package/src/services/capability-table-rows.ts +103 -0
  40. package/src/services/consumer-cleanup.ts +18 -10
  41. package/src/services/container-service.test.ts +34 -0
  42. package/src/services/container-service.ts +44 -0
  43. package/src/services/deployed-systems.test.ts +101 -0
  44. package/src/services/deployed-systems.ts +43 -11
  45. package/src/services/dns-internal-records.test.ts +72 -1
  46. package/src/services/dns-provider-backfill.ts +30 -0
  47. package/src/services/fleet-checks.test.ts +26 -0
  48. package/src/services/fleet-checks.ts +11 -1
  49. package/src/services/module-deploy.ts +88 -41
  50. package/src/services/module-validator/capability-versions.test.ts +6 -1
  51. package/src/services/port-forwards.test.ts +6 -2
  52. package/src/services/port-forwards.ts +0 -11
  53. package/src/services/provider-arrival.test.ts +241 -0
  54. package/src/services/provider-arrival.ts +213 -0
  55. package/src/services/trusted-sources.ts +0 -5
  56. package/src/templates/generator.test.ts +35 -0
  57. package/src/templates/generator.ts +29 -1
  58. package/src/variables/context.test.ts +63 -0
  59. package/src/variables/context.ts +85 -12
  60. package/src/variables/declarative-derivation.test.ts +47 -8
  61. package/src/variables/declarative-derivation.ts +6 -4
  62. package/src/variables/lxc-nameserver.test.ts +144 -0
  63. package/src/services/public-web-republish.test.ts +0 -189
  64. package/src/services/public-web-republish.ts +0 -84
@@ -19,6 +19,7 @@ import { allocateResources, getAllocation } from '../ipam/allocator';
19
19
  import { type ModuleManifest, getDeclaredSystems, getSingularSystemSpec } from '../manifest/schema';
20
20
  import { decryptSecret } from '../secrets/encryption';
21
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
22
+ import { resolveMachineIdentityAddress } from '../services/deployed-systems';
22
23
  import { upsertModuleConfig } from '../services/module-config';
23
24
  import { resolveComputedFields } from './computed/evaluate';
24
25
  import { containsComputedMarker } from './computed/marker';
@@ -419,10 +420,17 @@ async function assembleResolutionContext(
419
420
  `Machine '${infraSelection.machineId}' for module '${moduleId}' has no ipAddress`,
420
421
  );
421
422
  }
423
+ const deployedZone = machineRow.zone ?? zone;
424
+ const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone);
425
+ if (!machineIdentity) {
426
+ throw new Error(
427
+ `Machine '${infraSelection.machineId}' for module '${moduleId}' has no non-loopback interface in zone '${deployedZone}'`,
428
+ );
429
+ }
422
430
  upsertDeployedSystem(db, moduleId, {
423
431
  name: decl.name,
424
432
  hostname,
425
- ipv4Address: machineRow.ipAddress,
433
+ ipv4Address: machineIdentity,
426
434
  // The machine's own zone, matching recordDeployedSystemForModule and
427
435
  // backfillModuleSystems. This is the THIRD place that decides a
428
436
  // deployed system's zone, and it ran last — so while the other two
@@ -430,7 +438,7 @@ async function assembleResolutionContext(
430
438
  // manifest's, and the control plane stayed recorded as `internal`.
431
439
  // `requires.system.zone` is the minimum used to SELECT a host; the
432
440
  // machine we selected is where the system actually is.
433
- zone: machineRow.zone ?? zone,
441
+ zone: deployedZone,
434
442
  infraType: 'machine',
435
443
  machineId: infraSelection.machineId,
436
444
  });
@@ -585,16 +593,81 @@ async function assembleResolutionContext(
585
593
  }
586
594
  }
587
595
  }
588
- const dnsInternal = capabilitiesMap.dns_internal as { server?: { ip?: unknown } } | undefined;
589
- // The dns_internal capability advertises the provider's address, which may
590
- // carry a CIDR suffix (technitium's server.ip resolves from target_ip,
591
- // e.g. "192.168.0.151/24"). A nameserver must be a bare IP — strip it.
592
- const rawInternalIp =
593
- typeof dnsInternal?.server?.ip === 'string' ? dnsInternal.server.ip : undefined;
594
- const internalIp = rawInternalIp ? rawInternalIp.split('/')[0] : undefined;
595
- const nameservers = internalIp ? [internalIp, ...publicDns] : publicDns;
596
- if (nameservers.length > 0) {
597
- selfConfig.lxc_nameserver = nameservers.join(' ');
596
+ type ResolverEndpoint = {
597
+ server?: { ip?: unknown; internal_ip?: unknown };
598
+ /** Zones the provider's base-module aspect owns ongoing DNS for (D5d). */
599
+ aspect?: { covered_zones?: unknown };
600
+ };
601
+ const dnsInternal = capabilitiesMap.dns_internal as ResolverEndpoint | undefined;
602
+ const dnsSecondary = capabilitiesMap.dns_internal_secondary as ResolverEndpoint | undefined;
603
+
604
+ // A nameserver must be a bare IP. The advertised address may carry a CIDR
605
+ // suffix (technitium's server.ip resolves from target_ip, e.g.
606
+ // "192.168.0.151/24"), so strip it.
607
+ const bareIp = (value: unknown): string | undefined =>
608
+ typeof value === 'string' && value.length > 0 ? value.split('/')[0] : undefined;
609
+
610
+ const targetZone = module?.manifestData
611
+ ? getSingularSystemSpec(module.manifestData as ModuleManifest)?.zone
612
+ : undefined;
613
+
614
+ // `internal` cannot route into the resolvers' own (dmz) subnet, so it uses
615
+ // the ingress addresses the firewall DNATs. Every other zone reaches them
616
+ // directly.
617
+ const endpointFor = (resolver: ResolverEndpoint | undefined): string | undefined =>
618
+ targetZone === 'internal'
619
+ ? (bareIp(resolver?.server?.internal_ip) ?? bareIp(resolver?.server?.ip))
620
+ : bareIp(resolver?.server?.ip);
621
+
622
+ const primaryIp = endpointFor(dnsInternal);
623
+ const secondaryIp = endpointFor(dnsSecondary);
624
+
625
+ // Whether the DEPLOYED provider's base-module aspect covers this system's
626
+ // zone, which is what decides if the public entries may go (design D5d).
627
+ //
628
+ // The predicate is aspect COVERAGE, not routing. Routing is why a zone goes
629
+ // uncovered — `external` and the planned `quarantine` hold systems that
630
+ // cannot reach a resolver inside the perimeter. Coverage is why the birth
631
+ // list is PERMANENT: terraform injects
632
+ // `lifecycle { ignore_changes = [nameserver] }`, so it cannot correct the
633
+ // value afterwards even in principle, and a zone no aspect covers has no
634
+ // owner for ongoing DNS at all. Keying on coverage therefore handles a
635
+ // future excluded zone with no change here, and fails safe on an accidental
636
+ // omission — where keeping the public entries is the lesser harm until the
637
+ // array is fixed.
638
+ // The provider DECLARES its coverage in the capability data. Core could
639
+ // instead find the provider's module row and read
640
+ // `base_module_aspect.applicable_zones` off its manifest, which would be
641
+ // one source of truth rather than two — but that means core naming a
642
+ // capability in order to find its provider, which is the pattern the
643
+ // module-business gate exists to stop, and its advice is exactly this: let
644
+ // the provider declare the behaviour. A manifest test holds the declared
645
+ // list and the aspect's own `applicable_zones` together so they cannot
646
+ // drift.
647
+ const aspectZones = dnsInternal?.aspect?.covered_zones;
648
+ const zoneIsAspectCovered =
649
+ targetZone !== undefined && Array.isArray(aspectZones) && aspectZones.includes(targetZone);
650
+
651
+ // Both halves are required. A pair with no aspect covering this zone is the
652
+ // `external` case: two addresses it cannot route to and nothing else, which
653
+ // does not tighten anything, it takes DNS away. A primary with no secondary
654
+ // is D5a: removing the fallback and shipping a secondary are ONE decision,
655
+ // because otherwise every resolver redeploy blanks fleet DNS.
656
+ const dropPublicResolvers =
657
+ primaryIp !== undefined && secondaryIp !== undefined && zoneIsAspectCovered;
658
+
659
+ // The uncovered branch is main's composition unchanged: the primary, then
660
+ // the public resolvers. The secondary is deliberately NOT added to it — in
661
+ // a zone that cannot route to the resolvers, a second unreachable address
662
+ // buys nothing but another timeout before the public entries answer.
663
+ const nameservers = dropPublicResolvers
664
+ ? [primaryIp, secondaryIp]
665
+ : primaryIp
666
+ ? [primaryIp, ...publicDns]
667
+ : publicDns;
668
+ const unique = [...new Set(nameservers)];
669
+ if (unique.length > 0) {
670
+ selfConfig.lxc_nameserver = unique.join(' ');
598
671
  }
599
672
  }
600
673
 
@@ -691,7 +691,7 @@ describe('applyDeclarativeDerivations', () => {
691
691
  expect(context.selfConfig.primary_domain).toBeUndefined();
692
692
  });
693
693
 
694
- test('skips non-string types (handled by config interview instead)', () => {
694
+ test('derives scalar numeric values from capability data', () => {
695
695
  const manifest: ModuleManifest = {
696
696
  celilo_contract: '1.0',
697
697
  id: 'test-module',
@@ -703,10 +703,10 @@ describe('applyDeclarativeDerivations', () => {
703
703
  owns: [
704
704
  {
705
705
  name: 'port',
706
- type: 'number',
706
+ type: 'integer',
707
707
  required: false,
708
- source: 'user',
709
- derive_from: '$system:port',
708
+ source: 'capability',
709
+ derive_from: '$capability:dns_internal.dns.knot_port',
710
710
  },
711
711
  ],
712
712
  imports: [],
@@ -716,16 +716,55 @@ describe('applyDeclarativeDerivations', () => {
716
716
  const context: ResolutionContext = {
717
717
  moduleId: 'test-module',
718
718
  selfConfig: {},
719
- systemConfig: { port: '8080' },
719
+ systemConfig: {},
720
720
  systemSecrets: {},
721
721
  secrets: {},
722
- capabilities: {},
722
+ capabilities: {
723
+ dns_internal: { dns: { knot_port: 5353 } },
724
+ },
725
+ };
726
+
727
+ applyDeclarativeDerivations(manifest, context);
728
+
729
+ expect(context.selfConfig.port).toBe('5353');
730
+ });
731
+
732
+ test('continues to skip structured derivations', () => {
733
+ const manifest: ModuleManifest = {
734
+ celilo_contract: '1.0',
735
+ id: 'test-module',
736
+ name: 'Test Module',
737
+ version: '1.0.0',
738
+ requires: { capabilities: [] },
739
+ provides: { capabilities: [] },
740
+ variables: {
741
+ owns: [
742
+ {
743
+ name: 'domains',
744
+ type: 'array',
745
+ required: false,
746
+ source: 'capability',
747
+ derive_from: '$capability:dns_internal.dns.managed_domains',
748
+ },
749
+ ],
750
+ imports: [],
751
+ },
752
+ };
753
+
754
+ const context: ResolutionContext = {
755
+ moduleId: 'test-module',
756
+ selfConfig: {},
757
+ systemConfig: {},
758
+ systemSecrets: {},
759
+ secrets: {},
760
+ capabilities: {
761
+ dns_internal: { dns: { managed_domains: ['home.arpa'] } },
762
+ },
723
763
  };
724
764
 
725
765
  applyDeclarativeDerivations(manifest, context);
726
766
 
727
- // Non-string variable is skipped — port remains unset
728
- expect(context.selfConfig.port).toBeUndefined();
767
+ expect(context.selfConfig.domains).toBeUndefined();
729
768
  });
730
769
  });
731
770
 
@@ -186,10 +186,12 @@ export function applyDeclarativeDerivations(
186
186
  continue;
187
187
  }
188
188
 
189
- // Declarative derivation only resolves string template expressions.
190
- // Non-string types (arrays, objects) with derive_from are handled by
191
- // the config interview system instead.
192
- if (variable.type !== 'string') {
189
+ // Declarative derivation resolves scalar template expressions. Numeric and
190
+ // boolean values flow through this resolution context as their string form
191
+ // and recover their declared type at the module-config / inventory boundary.
192
+ // Structured arrays and objects still belong to the typed config path: a
193
+ // template substitution would collapse them to a lossy string.
194
+ if (variable.type === 'array' || variable.type === 'object') {
193
195
  continue;
194
196
  }
195
197
 
@@ -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,189 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import type { DbClient } from '../db/client';
6
- import { modules, webRoutes } from '../db/schema';
7
- import type { ModuleManifest } from '../manifest/schema';
8
- import { setupTestDatabase } from '../test-utils/setup-test-db';
9
- import { providesPublicWeb, republishStaticWebConsumers } from './public-web-republish';
10
-
11
- const caddyManifest = {
12
- provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
13
- } as unknown as ModuleManifest;
14
-
15
- const plainManifest = { provides: { capabilities: [] } } as unknown as ModuleManifest;
16
-
17
- describe('republishStaticWebConsumers', () => {
18
- let dir: string;
19
- let db: DbClient;
20
-
21
- function seedModule(id: string) {
22
- db.insert(modules)
23
- .values({ id, name: id, version: '1.0.0', manifestData: {}, sourcePath: `/tmp/${id}` })
24
- .run();
25
- }
26
-
27
- function seedRoute(
28
- moduleId: string,
29
- slug: string,
30
- path: string,
31
- type: 'static' | 'reverse_proxy',
32
- ) {
33
- db.insert(webRoutes)
34
- .values({
35
- slug,
36
- moduleId,
37
- type,
38
- path,
39
- hostname: 'iamtheinternet.org',
40
- targetHost: type === 'reverse_proxy' ? '10.0.20.5' : null,
41
- targetPort: type === 'reverse_proxy' ? 8080 : null,
42
- })
43
- .run();
44
- }
45
-
46
- function recordingHook(failFor: string[] = []) {
47
- const calls: string[] = [];
48
- const runHook = (async (moduleId: string) => {
49
- calls.push(moduleId);
50
- return failFor.includes(moduleId)
51
- ? { success: false, outputs: {}, error: 'boom', duration: 1 }
52
- : { success: true, outputs: {}, duration: 1 };
53
- }) as unknown as Parameters<typeof republishStaticWebConsumers>[0]['runHook'];
54
- return { runHook, calls };
55
- }
56
-
57
- beforeEach(async () => {
58
- dir = mkdtempSync(join(tmpdir(), 'pwr-'));
59
- const dbPath = join(dir, 'celilo.db');
60
- process.env.CELILO_DB_PATH = dbPath;
61
- db = await setupTestDatabase(dbPath);
62
- });
63
-
64
- afterEach(() => {
65
- db.$client.close();
66
- process.env.CELILO_DB_PATH = undefined;
67
- try {
68
- rmSync(dir, { recursive: true, force: true });
69
- } catch {
70
- /* ignore */
71
- }
72
- });
73
-
74
- test('providesPublicWeb reads the manifest', () => {
75
- expect(providesPublicWeb(caddyManifest)).toBe(true);
76
- expect(providesPublicWeb(plainManifest)).toBe(false);
77
- });
78
-
79
- test('does not run for a module that is not a public_web provider', async () => {
80
- seedModule('hello-foo');
81
- seedRoute('hello-foo', 'foo', '/foo', 'static');
82
- const { runHook, calls } = recordingHook();
83
-
84
- const result = await republishStaticWebConsumers({
85
- providerModuleId: 'namecheap',
86
- manifest: plainManifest,
87
- db,
88
- runHook,
89
- });
90
-
91
- expect(result).toEqual({ ran: false, republished: [], failures: [] });
92
- expect(calls).toEqual([]);
93
- });
94
-
95
- test('re-runs on_install for every static consumer', async () => {
96
- seedModule('caddy');
97
- seedModule('hello-foo');
98
- seedModule('hello-bar');
99
- seedModule('celilo-website');
100
- seedRoute('hello-foo', 'foo', '/foo', 'static');
101
- seedRoute('hello-bar', 'bar', '/bar', 'static');
102
- seedRoute('celilo-website', 'celilo-website', '/', 'static');
103
- const { runHook, calls } = recordingHook();
104
-
105
- const result = await republishStaticWebConsumers({
106
- providerModuleId: 'caddy',
107
- manifest: caddyManifest,
108
- db,
109
- runHook,
110
- });
111
-
112
- expect(result.ran).toBe(true);
113
- expect(calls.sort()).toEqual(['celilo-website', 'hello-bar', 'hello-foo']);
114
- expect(result.republished.sort()).toEqual(['celilo-website', 'hello-bar', 'hello-foo']);
115
- expect(result.failures).toEqual([]);
116
- });
117
-
118
- test('skips reverse-proxy-only consumers (no assets on the provider host)', async () => {
119
- seedModule('caddy');
120
- seedModule('forgejo');
121
- seedModule('hello-foo');
122
- seedRoute('forgejo', 'git', '/git', 'reverse_proxy');
123
- seedRoute('hello-foo', 'foo', '/foo', 'static');
124
- const { runHook, calls } = recordingHook();
125
-
126
- await republishStaticWebConsumers({
127
- providerModuleId: 'caddy',
128
- manifest: caddyManifest,
129
- db,
130
- runHook,
131
- });
132
-
133
- expect(calls).toEqual(['hello-foo']);
134
- });
135
-
136
- test('never re-runs the provider itself', async () => {
137
- seedModule('caddy');
138
- // A provider that also owns a static route (a landing page of its own).
139
- seedRoute('caddy', 'caddy', '/', 'static');
140
- const { runHook, calls } = recordingHook();
141
-
142
- const result = await republishStaticWebConsumers({
143
- providerModuleId: 'caddy',
144
- manifest: caddyManifest,
145
- db,
146
- runHook,
147
- });
148
-
149
- expect(calls).toEqual([]);
150
- expect(result.republished).toEqual([]);
151
- });
152
-
153
- test('de-duplicates a consumer that owns several static routes', async () => {
154
- seedModule('caddy');
155
- seedModule('hello-foo');
156
- seedRoute('hello-foo', 'foo', '/foo', 'static');
157
- seedRoute('hello-foo', 'foo-docs', '/foo/docs', 'static');
158
- const { runHook, calls } = recordingHook();
159
-
160
- await republishStaticWebConsumers({
161
- providerModuleId: 'caddy',
162
- manifest: caddyManifest,
163
- db,
164
- runHook,
165
- });
166
-
167
- expect(calls).toEqual(['hello-foo']);
168
- });
169
-
170
- test('one consumer failing does not stop the others', async () => {
171
- seedModule('caddy');
172
- seedModule('hello-foo');
173
- seedModule('hello-bar');
174
- seedRoute('hello-foo', 'foo', '/foo', 'static');
175
- seedRoute('hello-bar', 'bar', '/bar', 'static');
176
- const { runHook, calls } = recordingHook(['hello-foo']);
177
-
178
- const result = await republishStaticWebConsumers({
179
- providerModuleId: 'caddy',
180
- manifest: caddyManifest,
181
- db,
182
- runHook,
183
- });
184
-
185
- expect(calls.sort()).toEqual(['hello-bar', 'hello-foo']);
186
- expect(result.republished).toEqual(['hello-bar']);
187
- expect(result.failures).toEqual([{ moduleId: 'hello-foo', error: 'boom' }]);
188
- });
189
- });
@@ -1,84 +0,0 @@
1
- /**
2
- * Restore consumer content after a public_web provider redeploy.
3
- *
4
- * A provider redeploy destroys the provider's host and rebuilds it, taking
5
- * `/srv/www` with it. The consumers' `web_routes` rows survive — they belong to
6
- * the consumer modules, not the provider — so the rebuilt provider renders
7
- * `root * /srv/www/<slug>` for directories that no longer exist and every
8
- * static site 404s. Nothing re-uploaded the assets; an operator had to redeploy
9
- * each consumer by hand and there was no signal telling them to.
10
- *
11
- * The fix is to do exactly what that operator did: after a provider deploy
12
- * lands, re-run `on_install` for every module holding a STATIC route.
13
- * `publishStaticSite` is idempotent (it upserts the route and re-tars the
14
- * source dir), so the re-run re-registers the route and refills the fresh
15
- * `/srv/www/<slug>`.
16
- *
17
- * Reverse-proxy consumers are skipped: they keep no state on the provider host,
18
- * so re-running them buys nothing and only widens the blast radius.
19
- *
20
- * ponytail: re-runs every static consumer unconditionally rather than probing
21
- * which /srv/www/<slug> directories are actually missing — O(static consumers)
22
- * per provider deploy, currently a handful. If a large fleet makes provider
23
- * deploys slow, probe the provider host first and re-run only the misses.
24
- */
25
-
26
- import { eq } from 'drizzle-orm';
27
- import type { DbClient } from '../db/client';
28
- import { webRoutes } from '../db/schema';
29
- import { createConsoleLogger } from '../hooks/logger';
30
- import { runNamedHook } from '../hooks/run-named-hook';
31
- import type { ModuleManifest } from '../manifest/schema';
32
-
33
- export interface RepublishFailure {
34
- moduleId: string;
35
- error: string;
36
- }
37
-
38
- export interface RepublishResult {
39
- /** False when the deployed module isn't a public_web provider. */
40
- ran: boolean;
41
- /** Consumer modules whose on_install was re-run successfully. */
42
- republished: string[];
43
- failures: RepublishFailure[];
44
- }
45
-
46
- export function providesPublicWeb(manifest: ModuleManifest): boolean {
47
- return (manifest.provides?.capabilities ?? []).some((c) => c.name === 'public_web');
48
- }
49
-
50
- export interface RepublishDeps {
51
- providerModuleId: string;
52
- manifest: ModuleManifest;
53
- db: DbClient;
54
- /** Injectable for tests. */
55
- runHook?: typeof runNamedHook;
56
- }
57
-
58
- export async function republishStaticWebConsumers(deps: RepublishDeps): Promise<RepublishResult> {
59
- const { providerModuleId, manifest, db, runHook = runNamedHook } = deps;
60
-
61
- if (!providesPublicWeb(manifest)) {
62
- return { ran: false, republished: [], failures: [] };
63
- }
64
-
65
- const staticRoutes = db.select().from(webRoutes).where(eq(webRoutes.type, 'static')).all();
66
- const consumerIds = [
67
- ...new Set(staticRoutes.map((r) => r.moduleId).filter((id) => id !== providerModuleId)),
68
- ];
69
-
70
- const republished: string[] = [];
71
- const failures: RepublishFailure[] = [];
72
-
73
- for (const moduleId of consumerIds) {
74
- const logger = createConsoleLogger(moduleId, 'on_install');
75
- const result = await runHook(moduleId, 'on_install', db, logger, {});
76
- if (result.success) {
77
- republished.push(moduleId);
78
- } else {
79
- failures.push({ moduleId, error: result.error ?? 'unknown error' });
80
- }
81
- }
82
-
83
- return { ran: true, republished, failures };
84
- }