@celilo/cli 0.27.0 → 1.1.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 +16 -0
- package/CELILO_SUBSYSTEMS.md +5 -2
- package/drizzle/0025_port_forward_owner.sql +29 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +2 -2
- package/src/cli/commands/module-remove.ts +26 -23
- package/src/cli/commands/system-audit.ts +5 -1
- package/src/cli/commands/system-update.ts +10 -2
- package/src/db/schema.ts +24 -6
- package/src/hooks/capability-loader.ts +59 -13
- package/src/hooks/define-hook.test.ts +0 -6
- package/src/hooks/executor.ts +2 -1
- package/src/hooks/types.ts +9 -17
- package/src/manifest/contracts/index.ts +20 -0
- package/src/manifest/contracts/v1.ts +33 -1
- package/src/manifest/schema.ts +48 -58
- package/src/services/audit/undeployed-modules.ts +18 -1
- package/src/services/consumer-cleanup.test.ts +347 -0
- package/src/services/consumer-cleanup.ts +244 -0
- package/src/services/module-validator/index.test.ts +9 -0
- package/src/services/port-forwards.test.ts +93 -40
- package/src/services/port-forwards.ts +74 -48
- package/src/services/trusted-sources.test.ts +52 -13
- package/src/services/trusted-sources.ts +25 -15
- package/src/templates/generator.ts +46 -28
- package/src/templates/{dns-ingress-ip.test.ts → ingress-ip.test.ts} +38 -22
- package/src/test-utils/cli-context.ts +15 -2
- package/src/services/web-route-cleanup.test.ts +0 -250
- package/src/services/web-route-cleanup.ts +0 -144
|
@@ -40,27 +40,63 @@ describe('trusted-source store', () => {
|
|
|
40
40
|
|
|
41
41
|
it('stamps registeredBy from the binding, not the caller', () => {
|
|
42
42
|
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
43
|
-
store.
|
|
43
|
+
store.replace(FW, { subnets: [VPN], description: 'admin VPN clients' });
|
|
44
44
|
|
|
45
45
|
expect(store.list(FW)).toEqual([
|
|
46
46
|
{ subnet: VPN, description: 'admin VPN clients', registeredBy: 'wireguard' },
|
|
47
47
|
]);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it('
|
|
50
|
+
it('replace is idempotent — declaring the same set twice yields one row', () => {
|
|
51
51
|
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
52
|
-
store.
|
|
53
|
-
store.
|
|
52
|
+
store.replace(FW, { subnets: [VPN], description: 'first' });
|
|
53
|
+
store.replace(FW, { subnets: [VPN], description: 'second' });
|
|
54
54
|
|
|
55
55
|
expect(store.list(FW)).toHaveLength(1);
|
|
56
56
|
expect(store.list(FW)[0].description).toBe('second');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// D5b: the set is DECLARED, so changing an admin VPN's client subnet revokes
|
|
60
|
+
// the old one's reach. It used to keep reaching every zone forever.
|
|
61
|
+
it('a subnet left out of a later declaration loses its reach', () => {
|
|
62
|
+
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
63
|
+
store.replace(FW, { subnets: [VPN, '10.9.9.0/24'], description: 'vpn' });
|
|
64
|
+
store.replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
expect(store.list(FW).map((s) => s.subnet)).toEqual([VPN]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('an empty declaration withdraws the consumer’s whole set', () => {
|
|
70
|
+
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
71
|
+
store.replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
72
|
+
store.replace(FW, { subnets: [], description: 'vpn' });
|
|
73
|
+
|
|
74
|
+
expect(store.list(FW)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// D5a, the refcount case: the owner is in the unique index, so two modules
|
|
78
|
+
// trusting the same subnet are two rows and one withdrawing does not revoke
|
|
79
|
+
// the other's reach.
|
|
80
|
+
it('two consumers trusting the same subnet are two rows; one withdrawing leaves the other', () => {
|
|
81
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
82
|
+
buildTrustedSourceStore(db, 'other').replace(FW, { subnets: [VPN], description: 'also vpn' });
|
|
83
|
+
expect(listTrustedSourcesFor(db, FW)).toHaveLength(2);
|
|
84
|
+
|
|
85
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [], description: 'vpn' });
|
|
86
|
+
|
|
87
|
+
expect(listTrustedSourcesFor(db, FW)).toEqual([
|
|
88
|
+
{ subnet: VPN, description: 'also vpn', registeredBy: 'other' },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('withdrawing on one firewall leaves other firewalls alone', () => {
|
|
93
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
94
|
+
buildTrustedSourceStore(db, 'other').replace('10.0.0.1', {
|
|
95
|
+
subnets: [VPN],
|
|
96
|
+
description: 'elsewhere',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [], description: 'vpn' });
|
|
64
100
|
|
|
65
101
|
expect(buildTrustedSourceStore(db, 'wireguard').list(FW)).toEqual([]);
|
|
66
102
|
expect(buildTrustedSourceStore(db, 'other').list('10.0.0.1')).toHaveLength(1);
|
|
@@ -69,9 +105,9 @@ describe('trusted-source store', () => {
|
|
|
69
105
|
it('reads one firewall’s registrations without a module binding', () => {
|
|
70
106
|
// Trust registered against one firewall is not trust granted by another —
|
|
71
107
|
// the read is scoped, and the reader needs no identity to look.
|
|
72
|
-
buildTrustedSourceStore(db, 'wireguard').
|
|
73
|
-
buildTrustedSourceStore(db, 'other').
|
|
74
|
-
|
|
108
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
109
|
+
buildTrustedSourceStore(db, 'other').replace('10.0.0.1', {
|
|
110
|
+
subnets: ['172.16.9.0/24'],
|
|
75
111
|
description: 'elsewhere',
|
|
76
112
|
});
|
|
77
113
|
|
|
@@ -80,7 +116,7 @@ describe('trusted-source store', () => {
|
|
|
80
116
|
});
|
|
81
117
|
|
|
82
118
|
it('reports every registration across firewalls, with who registered it', () => {
|
|
83
|
-
buildTrustedSourceStore(db, 'wireguard').
|
|
119
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
84
120
|
|
|
85
121
|
expect(listAllTrustedSources(db)).toEqual([
|
|
86
122
|
{ firewallIp: FW, subnet: VPN, description: 'vpn', registeredBy: 'wireguard' },
|
|
@@ -104,7 +140,10 @@ describe('the render input excludes registrations; the reporting view includes t
|
|
|
104
140
|
process.env.CELILO_DB_PATH = dbPath;
|
|
105
141
|
db = await setupTestDatabase(dbPath);
|
|
106
142
|
db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
|
|
107
|
-
buildTrustedSourceStore(db, 'wireguard').
|
|
143
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, {
|
|
144
|
+
subnets: [VPN],
|
|
145
|
+
description: 'admin VPN',
|
|
146
|
+
});
|
|
108
147
|
});
|
|
109
148
|
afterEach(() => {
|
|
110
149
|
db.$client.close();
|
|
@@ -38,31 +38,41 @@ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): Tru
|
|
|
38
38
|
}));
|
|
39
39
|
},
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
//
|
|
41
|
+
replace(firewallIp: string, source: RegisterTrustedSourceRequest): void {
|
|
42
|
+
// The consumer's COMPLETE set for this firewall (D5b), scoped to its own
|
|
43
|
+
// rows: a subnet it trusted before and omits now loses its reach, and a
|
|
44
|
+
// subnet another module also trusts keeps it. Changing an admin VPN's
|
|
45
|
+
// client subnet used to leave the old one reaching every zone forever.
|
|
43
46
|
db.delete(trustedSources)
|
|
44
47
|
.where(
|
|
45
|
-
and(
|
|
48
|
+
and(
|
|
49
|
+
eq(trustedSources.firewallIp, firewallIp),
|
|
50
|
+
eq(trustedSources.registeredBy, registeredBy),
|
|
51
|
+
),
|
|
46
52
|
)
|
|
47
53
|
.run();
|
|
48
|
-
db.insert(trustedSources)
|
|
49
|
-
.values({
|
|
50
|
-
firewallIp,
|
|
51
|
-
subnet: source.subnet,
|
|
52
|
-
description: source.description,
|
|
53
|
-
registeredBy,
|
|
54
|
-
})
|
|
55
|
-
.run();
|
|
56
|
-
},
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
if (source.subnets.length === 0) return;
|
|
56
|
+
|
|
57
|
+
db.insert(trustedSources)
|
|
58
|
+
.values(
|
|
59
|
+
source.subnets.map((subnet) => ({
|
|
60
|
+
firewallIp,
|
|
61
|
+
subnet,
|
|
62
|
+
description: source.description,
|
|
63
|
+
registeredBy,
|
|
64
|
+
})),
|
|
65
|
+
)
|
|
61
66
|
.run();
|
|
62
67
|
},
|
|
63
68
|
};
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/** Drop every trusted source a departing module owns, across every firewall. */
|
|
72
|
+
export function deleteTrustedSourcesForModule(db: DbClient, moduleId: string): void {
|
|
73
|
+
db.delete(trustedSources).where(eq(trustedSources.registeredBy, moduleId)).run();
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
/** Where a trusted subnet came from — reach into every tier must be attributable. */
|
|
67
77
|
export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override';
|
|
68
78
|
|
|
@@ -654,38 +654,58 @@ celilo module import modules/${moduleId}
|
|
|
654
654
|
* @returns Generation result
|
|
655
655
|
*/
|
|
656
656
|
/** Narrower than `GenerateResult`: this step produces no files, only an outcome. */
|
|
657
|
-
export type
|
|
657
|
+
export type IngressIpResult = { success: true } | { success: false; error: string };
|
|
658
658
|
|
|
659
659
|
/**
|
|
660
|
-
* Allocate-and-reserve
|
|
660
|
+
* Allocate-and-reserve a module's dedicated `internal`-subnet ingress IPs, ONCE
|
|
661
|
+
* (ISS-0156, celilo#879).
|
|
661
662
|
*
|
|
662
|
-
* A
|
|
663
|
-
* protected-zone query sources for split-horizon
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
* `
|
|
663
|
+
* A service can need to live in a PROTECTED zone — the `dns_internal` resolver
|
|
664
|
+
* sits in `dmz` so it can see protected-zone query sources for split-horizon,
|
|
665
|
+
* and `caddy-internal` sits there so systems in the segmented zones can reach
|
|
666
|
+
* it. `internal` devices have no route into the 10-net, so they reach such a
|
|
667
|
+
* service through a firewall DNAT on a dedicated `internal`-subnet address.
|
|
668
|
+
* That DNAT is internal-side only: it is NOT a public port-forward, and the two
|
|
669
|
+
* are routinely confused.
|
|
670
|
+
*
|
|
671
|
+
* A module opts in by declaring an infrastructure variable whose name ends in
|
|
672
|
+
* `ingress_ip` — `dns_ingress_ip` for the resolver's `:53`, `ingress_ip` for a
|
|
673
|
+
* private web ingress's `:80/:443`. The hook then passes the stored value to
|
|
674
|
+
* `firewall.exposeService({ ingressIp })`.
|
|
667
675
|
*
|
|
668
676
|
* **Idempotence is the whole point, and it is load-bearing.** `module generate`
|
|
669
677
|
* runs repeatedly over a module's life. Re-allocating here on the second run
|
|
670
|
-
* would move the address internal clients use to reach
|
|
671
|
-
* every command still reports success. That is why the stored value is
|
|
672
|
-
* rather than re-derived, and why this is a named function instead of a
|
|
673
|
-
* buried in `generateTemplates`: an invariant nothing can call is an
|
|
674
|
-
* nothing can test, and this one had no test at all.
|
|
678
|
+
* would move the address internal clients use to reach the service, every time
|
|
679
|
+
* — while every command still reports success. That is why the stored value is
|
|
680
|
+
* reused rather than re-derived, and why this is a named function instead of a
|
|
681
|
+
* branch buried in `generateTemplates`: an invariant nothing can call is an
|
|
682
|
+
* invariant nothing can test, and this one had no test at all.
|
|
675
683
|
*/
|
|
676
|
-
export async function
|
|
684
|
+
export async function ensureIngressIps(
|
|
677
685
|
moduleId: string,
|
|
678
686
|
manifest: ModuleManifest,
|
|
679
687
|
db: DbClient,
|
|
680
|
-
): Promise<
|
|
681
|
-
const
|
|
682
|
-
(v) => v.name
|
|
688
|
+
): Promise<IngressIpResult> {
|
|
689
|
+
const wanted = (manifest.variables?.owns ?? []).filter(
|
|
690
|
+
(v) => v.name.endsWith('ingress_ip') && v.source === 'infrastructure',
|
|
683
691
|
);
|
|
684
|
-
if (
|
|
692
|
+
if (wanted.length === 0) return { success: true };
|
|
693
|
+
|
|
694
|
+
for (const variable of wanted) {
|
|
695
|
+
const result = await allocateIngressIp(moduleId, variable.name, db);
|
|
696
|
+
if (!result.success) return result;
|
|
697
|
+
}
|
|
698
|
+
return { success: true };
|
|
699
|
+
}
|
|
685
700
|
|
|
686
|
-
|
|
701
|
+
async function allocateIngressIp(
|
|
702
|
+
moduleId: string,
|
|
703
|
+
variableName: string,
|
|
704
|
+
db: DbClient,
|
|
705
|
+
): Promise<IngressIpResult> {
|
|
706
|
+
const existing = getModuleConfigValue(moduleId, variableName, db)?.value;
|
|
687
707
|
if (typeof existing === 'string' && existing.length > 0) {
|
|
688
|
-
log.success(`Using existing
|
|
708
|
+
log.success(`Using existing ingress IP ${existing} (${variableName}) for ${moduleId}`);
|
|
689
709
|
return { success: true };
|
|
690
710
|
}
|
|
691
711
|
|
|
@@ -695,9 +715,7 @@ export async function ensureDnsIngressIp(
|
|
|
695
715
|
if (!subnetRow?.value) {
|
|
696
716
|
return {
|
|
697
717
|
success: false,
|
|
698
|
-
error:
|
|
699
|
-
'network.internal.subnet is not configured — required to allocate the ' +
|
|
700
|
-
'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
|
|
718
|
+
error: `network.internal.subnet is not configured — required to allocate the ${variableName} ingress IP (ISS-0156). Ensure the internal network is set up first.`,
|
|
701
719
|
};
|
|
702
720
|
}
|
|
703
721
|
|
|
@@ -705,14 +723,14 @@ export async function ensureDnsIngressIp(
|
|
|
705
723
|
const { stripCIDR } = await import('../ipam/subnet-parser');
|
|
706
724
|
try {
|
|
707
725
|
const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
|
|
708
|
-
await reserveIP(ip, 'internal', `
|
|
709
|
-
upsertModuleConfig(db, moduleId,
|
|
710
|
-
log.success(`Allocated
|
|
726
|
+
await reserveIP(ip, 'internal', `ingress:${moduleId}:${variableName}`, null, db);
|
|
727
|
+
upsertModuleConfig(db, moduleId, variableName, ip);
|
|
728
|
+
log.success(`Allocated ingress IP ${ip} (internal subnet, ${variableName}) for ${moduleId}`);
|
|
711
729
|
return { success: true };
|
|
712
730
|
} catch (error) {
|
|
713
731
|
return {
|
|
714
732
|
success: false,
|
|
715
|
-
error: `
|
|
733
|
+
error: `Ingress IP allocation failed for ${variableName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
716
734
|
};
|
|
717
735
|
}
|
|
718
736
|
}
|
|
@@ -879,8 +897,8 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
879
897
|
}
|
|
880
898
|
}
|
|
881
899
|
|
|
882
|
-
const
|
|
883
|
-
if (!
|
|
900
|
+
const ingress = await ensureIngressIps(moduleId, manifest, db);
|
|
901
|
+
if (!ingress.success) return ingress;
|
|
884
902
|
|
|
885
903
|
// Infrastructure Properties Resolution (Proxmox provider config)
|
|
886
904
|
// For Proxmox services, extract provider config and store as temporary values
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The ingress-IP allocate-and-reserve guard (ISS-0156, celilo#879).
|
|
3
3
|
*
|
|
4
4
|
* This invariant had NO test. Losing it is not a crash: `module generate`
|
|
5
5
|
* re-allocates a different address on every run, silently moving the address
|
|
6
|
-
* internal clients use to reach
|
|
7
|
-
* `module generate` runs repeatedly over a module's life, so "on the
|
|
8
|
-
* run" is the normal case, not an edge one.
|
|
6
|
+
* internal clients use to reach the service, while every command still reports
|
|
7
|
+
* success. `module generate` runs repeatedly over a module's life, so "on the
|
|
8
|
+
* second run" is the normal case, not an edge one.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
@@ -13,7 +13,7 @@ import type { DbClient } from '../db/client';
|
|
|
13
13
|
import type { ModuleManifest } from '../manifest/schema';
|
|
14
14
|
import { getModuleConfigValue } from '../services/module-config';
|
|
15
15
|
import { setupTestDatabase } from '../test-utils/database';
|
|
16
|
-
import {
|
|
16
|
+
import { ensureIngressIps } from './generator';
|
|
17
17
|
|
|
18
18
|
let db: DbClient;
|
|
19
19
|
|
|
@@ -27,12 +27,17 @@ const operatorSupplied = {
|
|
|
27
27
|
variables: { owns: [{ name: 'dns_ingress_ip', source: 'user_input' }] },
|
|
28
28
|
} as unknown as ModuleManifest;
|
|
29
29
|
|
|
30
|
+
/** How `caddy-internal` opts in — a web ingress rather than a DNS one. */
|
|
31
|
+
const wantsWebIngress = {
|
|
32
|
+
variables: { owns: [{ name: 'ingress_ip', source: 'infrastructure' }] },
|
|
33
|
+
} as unknown as ModuleManifest;
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
36
|
* Typed as `string | undefined` rather than `unknown`: every assertion here is
|
|
32
37
|
* about an address, and an untyped read pushes a cast onto each one.
|
|
33
38
|
*/
|
|
34
|
-
const storedIp = (moduleId: string): string | undefined => {
|
|
35
|
-
const value = getModuleConfigValue(moduleId,
|
|
39
|
+
const storedIp = (moduleId: string, variable = 'dns_ingress_ip'): string | undefined => {
|
|
40
|
+
const value = getModuleConfigValue(moduleId, variable, db)?.value;
|
|
36
41
|
return typeof value === 'string' ? value : undefined;
|
|
37
42
|
};
|
|
38
43
|
|
|
@@ -43,9 +48,9 @@ beforeEach(async () => {
|
|
|
43
48
|
.run('network.internal.subnet', '10.226.1.0/24');
|
|
44
49
|
});
|
|
45
50
|
|
|
46
|
-
describe('
|
|
51
|
+
describe('ensureIngressIps', () => {
|
|
47
52
|
test('allocates an address from the internal subnet on first generate', async () => {
|
|
48
|
-
const result = await
|
|
53
|
+
const result = await ensureIngressIps('technitium', wantsIngress, db);
|
|
49
54
|
|
|
50
55
|
expect(result.success).toBe(true);
|
|
51
56
|
expect(storedIp('technitium')).toMatch(/^10\.226\.1\.\d+$/);
|
|
@@ -54,21 +59,21 @@ describe('ensureDnsIngressIp', () => {
|
|
|
54
59
|
test('REUSES the same address on a second generate', async () => {
|
|
55
60
|
// The guard itself. Re-allocating here moves the resolver's DNAT ingress
|
|
56
61
|
// every time the module is regenerated, and nothing reports a problem.
|
|
57
|
-
await
|
|
62
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
58
63
|
const first = storedIp('technitium');
|
|
59
64
|
|
|
60
|
-
await
|
|
65
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
61
66
|
const second = storedIp('technitium');
|
|
62
67
|
|
|
63
68
|
expect(second).toBe(first);
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
test('stays stable across many generates, not just two', async () => {
|
|
67
|
-
await
|
|
72
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
68
73
|
const first = storedIp('technitium');
|
|
69
74
|
|
|
70
75
|
for (let i = 0; i < 5; i++) {
|
|
71
|
-
await
|
|
76
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
expect(storedIp('technitium')).toBe(first);
|
|
@@ -77,7 +82,7 @@ describe('ensureDnsIngressIp', () => {
|
|
|
77
82
|
test('RESERVES the address, so it is never handed out to something else', async () => {
|
|
78
83
|
// Allocation without reservation is the same bug one step later: a
|
|
79
84
|
// container gets the resolver's ingress address and DNS goes dark.
|
|
80
|
-
await
|
|
85
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
81
86
|
const ip = storedIp('technitium');
|
|
82
87
|
|
|
83
88
|
const reserved = db.$client
|
|
@@ -85,20 +90,20 @@ describe('ensureDnsIngressIp', () => {
|
|
|
85
90
|
.get(ip ?? '') as { ip_start: string; reason: string } | undefined;
|
|
86
91
|
|
|
87
92
|
expect(reserved?.ip_start).toBe(ip);
|
|
88
|
-
// The reason names the owner, so an operator reading the
|
|
89
|
-
// what an otherwise anonymous held address is for.
|
|
90
|
-
expect(reserved?.reason).toBe('
|
|
93
|
+
// The reason names the owner AND the variable, so an operator reading the
|
|
94
|
+
// table can tell what an otherwise anonymous held address is for.
|
|
95
|
+
expect(reserved?.reason).toBe('ingress:technitium:dns_ingress_ip');
|
|
91
96
|
});
|
|
92
97
|
|
|
93
98
|
test('two modules get two different addresses', async () => {
|
|
94
|
-
await
|
|
95
|
-
await
|
|
99
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
100
|
+
await ensureIngressIps('knot-unbound-internal', wantsIngress, db);
|
|
96
101
|
|
|
97
102
|
expect(storedIp('knot-unbound-internal')).not.toBe(storedIp('technitium'));
|
|
98
103
|
});
|
|
99
104
|
|
|
100
105
|
test('does nothing for a module that never asked for one', async () => {
|
|
101
|
-
const result = await
|
|
106
|
+
const result = await ensureIngressIps('caddy', {} as ModuleManifest, db);
|
|
102
107
|
|
|
103
108
|
expect(result.success).toBe(true);
|
|
104
109
|
expect(storedIp('caddy')).toBeUndefined();
|
|
@@ -107,7 +112,7 @@ describe('ensureDnsIngressIp', () => {
|
|
|
107
112
|
test('only `source: infrastructure` opts in', async () => {
|
|
108
113
|
// A same-named variable the operator supplies is theirs to set; allocating
|
|
109
114
|
// over it would overwrite an operator's deliberate choice.
|
|
110
|
-
await
|
|
115
|
+
await ensureIngressIps('technitium', operatorSupplied, db);
|
|
111
116
|
|
|
112
117
|
expect(storedIp('technitium')).toBeUndefined();
|
|
113
118
|
});
|
|
@@ -115,9 +120,20 @@ describe('ensureDnsIngressIp', () => {
|
|
|
115
120
|
test('fails with an actionable message when the internal subnet is unset', async () => {
|
|
116
121
|
db.$client.prepare('DELETE FROM system_config WHERE key = ?').run('network.internal.subnet');
|
|
117
122
|
|
|
118
|
-
const result = await
|
|
123
|
+
const result = await ensureIngressIps('technitium', wantsIngress, db);
|
|
119
124
|
|
|
120
125
|
expect(result.success).toBe(false);
|
|
121
126
|
expect(result.success === false && result.error).toContain('network.internal.subnet');
|
|
122
127
|
});
|
|
128
|
+
|
|
129
|
+
// celilo#879. The opt-in used to be the literal name `dns_ingress_ip`, so a
|
|
130
|
+
// dmz-resident WEB ingress had no way to ask for the same treatment — which
|
|
131
|
+
// is how `caddy-internal` came to be pinned into the `internal` zone with a
|
|
132
|
+
// manifest comment claiming a dmz ingress could not be reached from a LAN.
|
|
133
|
+
test('a `ingress_ip` variable opts in the same way, for a non-DNS ingress', async () => {
|
|
134
|
+
const result = await ensureIngressIps('caddy-internal', wantsWebIngress, db);
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(true);
|
|
137
|
+
expect(storedIp('caddy-internal', 'ingress_ip')).toMatch(/^10\.226\.1\.\d+$/);
|
|
138
|
+
});
|
|
123
139
|
});
|
|
@@ -351,9 +351,22 @@ export class CLIContext {
|
|
|
351
351
|
new Promise<CommandResponse>((resolve, reject) => {
|
|
352
352
|
this.pendingResponses.set(id, { resolve, reject });
|
|
353
353
|
}),
|
|
354
|
-
// Timeout promise
|
|
354
|
+
// Timeout promise. Name the command and the actual elapsed time, not
|
|
355
|
+
// just the budget — a bare "timed out after 30000ms" reads as a hang
|
|
356
|
+
// in the command under test, indistinguishable from a real deploy
|
|
357
|
+
// defect. `Date.now() - startTime` at fire time is normally ~= timeout,
|
|
358
|
+
// but under CI load the event loop can be too busy to run this
|
|
359
|
+
// callback promptly, so a MUCH larger elapsed-than-budget is itself a
|
|
360
|
+
// load signal, not a fluke to explain away (celilo#804).
|
|
355
361
|
new Promise<CommandResponse>((_, reject) =>
|
|
356
|
-
setTimeout(() =>
|
|
362
|
+
setTimeout(() => {
|
|
363
|
+
const elapsed = Date.now() - startTime;
|
|
364
|
+
reject(
|
|
365
|
+
new Error(
|
|
366
|
+
`Command #${id} "${command}" timed out after ${timeout}ms (elapsed ${elapsed}ms)`,
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}, timeout),
|
|
357
370
|
),
|
|
358
371
|
]);
|
|
359
372
|
|