@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.
- package/CELILO_CORE_MODULES.md +3 -1
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/MODULE_PRIMITIVES.md +6 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +238 -2
- package/src/capabilities/validation.ts +67 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +149 -2
- package/src/cli/commands/module-update.ts +113 -25
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +10 -8
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +21 -4
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/module/import.ts +39 -6
- package/src/policy/capability-shape-baseline.ts +88 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +117 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +32 -18
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.ts +18 -10
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-internal-records.test.ts +72 -1
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +88 -41
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +6 -2
- package/src/services/port-forwards.ts +0 -11
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +85 -12
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
package/src/variables/context.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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('
|
|
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: '
|
|
706
|
+
type: 'integer',
|
|
707
707
|
required: false,
|
|
708
|
-
source: '
|
|
709
|
-
derive_from: '$
|
|
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: {
|
|
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
|
-
|
|
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
|
|
190
|
-
//
|
|
191
|
-
// the config
|
|
192
|
-
|
|
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
|
-
}
|