@celilo/cli 1.7.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 +1 -0
- package/CELILO_SUBSYSTEMS.md +5 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/schema.ts +21 -4
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/module/import.ts +19 -1
- 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 -7
- 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 +13 -7
- package/src/services/dns-internal-records.test.ts +72 -1
- 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/trusted-sources.ts +0 -5
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE consumer-removal rule, driven by the declarations
|
|
3
|
+
* (openspec/changes/capability-owned-tables task 2.4).
|
|
4
|
+
*
|
|
5
|
+
* Before this, "the row dies with its consumer" had five implementations for one
|
|
6
|
+
* rule, and two of them were functions core imported BY NAME from
|
|
7
|
+
* `consumer-cleanup.ts` — the file whose whole purpose is deleting exactly that
|
|
8
|
+
* kind of per-capability special-casing.
|
|
9
|
+
*
|
|
10
|
+
* The property worth protecting is not "port forwards get deleted". It is that
|
|
11
|
+
* core clears EVERY declared table without naming any of them, so declaring a
|
|
12
|
+
* new one is sufficient to have it cleaned up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { allDeclaredTables } from '@celilo/capabilities';
|
|
20
|
+
import type { DbClient } from '../db/client';
|
|
21
|
+
import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema';
|
|
22
|
+
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
23
|
+
import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
|
|
24
|
+
|
|
25
|
+
const FW = '192.168.0.254';
|
|
26
|
+
|
|
27
|
+
describe('planClaimedRowDeletion', () => {
|
|
28
|
+
/**
|
|
29
|
+
* The anti-regression that matters. If someone declares a table and this plan
|
|
30
|
+
* does not grow, their rows outlive the consumer silently — which is the whole
|
|
31
|
+
* failure the declaration exists to make impossible.
|
|
32
|
+
*/
|
|
33
|
+
it('covers every declared table, so declaring one is enough to have it cleared', () => {
|
|
34
|
+
const planned = new Set(planClaimedRowDeletion().map((t) => t.table));
|
|
35
|
+
const declared = allDeclaredTables().map((d) => d.declaration.table);
|
|
36
|
+
|
|
37
|
+
expect(declared.length).toBeGreaterThan(0);
|
|
38
|
+
for (const table of declared) expect(planned.has(table)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("carries each table's OWN claim column, which is spelled three ways", () => {
|
|
42
|
+
const byTable = new Map(planClaimedRowDeletion().map((t) => [t.table, t.claimColumn]));
|
|
43
|
+
|
|
44
|
+
expect(byTable.get('web_routes')).toBe('module_id');
|
|
45
|
+
expect(byTable.get('port_forwards')).toBe('registered_by');
|
|
46
|
+
expect(byTable.get('trusted_sources')).toBe('registered_by');
|
|
47
|
+
expect(byTable.get('dns_internal_records')).toBe('consumer_module_id');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('deleteClaimedRows', () => {
|
|
52
|
+
let dir: string;
|
|
53
|
+
let db: DbClient;
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
dir = mkdtempSync(join(tmpdir(), 'claimed-'));
|
|
57
|
+
const dbPath = join(dir, 'celilo.db');
|
|
58
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
59
|
+
db = await setupTestDatabase(dbPath);
|
|
60
|
+
|
|
61
|
+
for (const id of ['caddy', 'forgejo', 'technitium']) {
|
|
62
|
+
db.insert(modules)
|
|
63
|
+
.values({
|
|
64
|
+
id,
|
|
65
|
+
name: id,
|
|
66
|
+
version: '1.0.0',
|
|
67
|
+
state: 'VERIFIED',
|
|
68
|
+
sourcePath: `/tmp/${id}`,
|
|
69
|
+
manifestData: {},
|
|
70
|
+
})
|
|
71
|
+
.run();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
db.$client.close();
|
|
77
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function seed(consumer: string, hostname: string, port: number, subnet: string) {
|
|
82
|
+
db.insert(webRoutes)
|
|
83
|
+
.values({
|
|
84
|
+
slug: `${consumer}-slug`,
|
|
85
|
+
moduleId: consumer,
|
|
86
|
+
type: 'reverse_proxy',
|
|
87
|
+
path: `/${consumer}`,
|
|
88
|
+
hostname,
|
|
89
|
+
})
|
|
90
|
+
.run();
|
|
91
|
+
db.insert(portForwards)
|
|
92
|
+
.values({
|
|
93
|
+
firewallIp: FW,
|
|
94
|
+
internalIp: '10.0.20.5',
|
|
95
|
+
port,
|
|
96
|
+
protocol: 'TCP',
|
|
97
|
+
registeredBy: consumer,
|
|
98
|
+
})
|
|
99
|
+
.run();
|
|
100
|
+
db.insert(trustedSources).values({ firewallIp: FW, subnet, registeredBy: consumer }).run();
|
|
101
|
+
db.insert(dnsInternalRecords)
|
|
102
|
+
.values({
|
|
103
|
+
providerModuleId: 'technitium',
|
|
104
|
+
consumerModuleId: consumer,
|
|
105
|
+
host: hostname,
|
|
106
|
+
ip: '10.0.20.5',
|
|
107
|
+
})
|
|
108
|
+
.run();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('clears the departing consumer from all four declared tables at once', () => {
|
|
112
|
+
seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
|
|
113
|
+
|
|
114
|
+
const cleared = deleteClaimedRows(db, 'caddy');
|
|
115
|
+
|
|
116
|
+
expect(cleared.map((c) => c.table).sort()).toEqual([
|
|
117
|
+
'dns_internal_records',
|
|
118
|
+
'port_forwards',
|
|
119
|
+
'trusted_sources',
|
|
120
|
+
'web_routes',
|
|
121
|
+
]);
|
|
122
|
+
expect(db.select().from(webRoutes).all()).toHaveLength(0);
|
|
123
|
+
expect(db.select().from(portForwards).all()).toHaveLength(0);
|
|
124
|
+
expect(db.select().from(trustedSources).all()).toHaveLength(0);
|
|
125
|
+
expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The refcount case, the bug most likely to ship silently. Two consumers each
|
|
130
|
+
* hold their own row, and one leaving must not withdraw what the other still
|
|
131
|
+
* needs.
|
|
132
|
+
*/
|
|
133
|
+
it("leaves another consumer's rows untouched", () => {
|
|
134
|
+
seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
|
|
135
|
+
seed('forgejo', 'b.example.org', 2222, '10.2.0.0/24');
|
|
136
|
+
|
|
137
|
+
deleteClaimedRows(db, 'caddy');
|
|
138
|
+
|
|
139
|
+
expect(
|
|
140
|
+
db
|
|
141
|
+
.select()
|
|
142
|
+
.from(webRoutes)
|
|
143
|
+
.all()
|
|
144
|
+
.map((r) => r.moduleId),
|
|
145
|
+
).toEqual(['forgejo']);
|
|
146
|
+
expect(
|
|
147
|
+
db
|
|
148
|
+
.select()
|
|
149
|
+
.from(portForwards)
|
|
150
|
+
.all()
|
|
151
|
+
.map((r) => r.registeredBy),
|
|
152
|
+
).toEqual(['forgejo']);
|
|
153
|
+
expect(
|
|
154
|
+
db
|
|
155
|
+
.select()
|
|
156
|
+
.from(trustedSources)
|
|
157
|
+
.all()
|
|
158
|
+
.map((r) => r.registeredBy),
|
|
159
|
+
).toEqual(['forgejo']);
|
|
160
|
+
expect(
|
|
161
|
+
db
|
|
162
|
+
.select()
|
|
163
|
+
.from(dnsInternalRecords)
|
|
164
|
+
.all()
|
|
165
|
+
.map((r) => r.consumerModuleId),
|
|
166
|
+
).toEqual(['forgejo']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `dns_internal_records` is claimed by its CONSUMER, never its provider. The
|
|
171
|
+
* declaration cannot express anything else, which is what makes celilo#1010
|
|
172
|
+
* unrepresentable — but the runtime rule has to agree, or the declaration is
|
|
173
|
+
* decoration.
|
|
174
|
+
*/
|
|
175
|
+
it('does not treat the PROVIDER as the claimant of a dns_internal record', () => {
|
|
176
|
+
seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
|
|
177
|
+
|
|
178
|
+
deleteClaimedRows(db, 'technitium');
|
|
179
|
+
|
|
180
|
+
expect(db.select().from(dnsInternalRecords).all()).toHaveLength(1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('is a no-op for a module that claimed nothing', () => {
|
|
184
|
+
seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
|
|
185
|
+
|
|
186
|
+
deleteClaimedRows(db, 'forgejo');
|
|
187
|
+
|
|
188
|
+
expect(db.select().from(webRoutes).all()).toHaveLength(1);
|
|
189
|
+
expect(db.select().from(portForwards).all()).toHaveLength(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acting on every capability-declared table without naming any of them
|
|
3
|
+
* (openspec/changes/capability-owned-tables, design D3).
|
|
4
|
+
*
|
|
5
|
+
* "This row dies with its consumer" had five implementations for one rule: an FK
|
|
6
|
+
* cascade for `web_routes`, two functions core imported BY NAME from
|
|
7
|
+
* `consumer-cleanup.ts` for `port_forwards` and `trusted_sources`, a second
|
|
8
|
+
* table pruned on read for `dns_registrations`, a cascade on both provider and
|
|
9
|
+
* consumer for `dns_internal_records`, and a hook rewriting a JSON blob for the
|
|
10
|
+
* rest. `consumer-cleanup.ts` exists specifically to delete that kind of
|
|
11
|
+
* per-capability special-casing. It succeeded for the hook dispatch and then
|
|
12
|
+
* grew two hardcoded imports for the rows, because the rows had no generic rule.
|
|
13
|
+
*
|
|
14
|
+
* This is the generic rule. The declaration names the claim column, so core no
|
|
15
|
+
* longer has to know that three tables spell it `module_id`, `registered_by` and
|
|
16
|
+
* `consumer_module_id`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CAPABILITY_DECLARED_TABLES, allDeclaredTables } from '@celilo/capabilities';
|
|
20
|
+
import { type Column, eq, is } from 'drizzle-orm';
|
|
21
|
+
import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
|
|
22
|
+
import type { DbClient } from '../db/client';
|
|
23
|
+
import * as dbSchema from '../db/schema';
|
|
24
|
+
|
|
25
|
+
export interface ClaimedRowTarget {
|
|
26
|
+
capability: string;
|
|
27
|
+
/** Physical table name. */
|
|
28
|
+
table: string;
|
|
29
|
+
/** The column holding the consumer's module id. */
|
|
30
|
+
claimColumn: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Physical table name → the drizzle table object, for the tables celilo can
|
|
35
|
+
* actually act on. Built by walking the schema rather than by a hand-written
|
|
36
|
+
* map, so a table cannot be declared and then silently unreachable.
|
|
37
|
+
*/
|
|
38
|
+
function schemaTables(): Map<string, SQLiteTable> {
|
|
39
|
+
const out = new Map<string, SQLiteTable>();
|
|
40
|
+
for (const value of Object.values(dbSchema)) {
|
|
41
|
+
if (!is(value, SQLiteTable)) continue;
|
|
42
|
+
out.set(getTableConfig(value).name, value);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a consumer's removal must clear. Pure — the list is a property of the
|
|
49
|
+
* declarations, so it is worth being able to assert without a database.
|
|
50
|
+
*
|
|
51
|
+
* A declaration naming a table that does not exist is skipped here rather than
|
|
52
|
+
* throwing. `policy/capability-shape-drift.test.ts` Scan A is what fails on
|
|
53
|
+
* that, loudly and at build time; a removal is the wrong moment to discover it,
|
|
54
|
+
* and refusing to clear the OTHER tables because one declaration is wrong would
|
|
55
|
+
* strand real rows.
|
|
56
|
+
*/
|
|
57
|
+
export function planClaimedRowDeletion(): ClaimedRowTarget[] {
|
|
58
|
+
const tables = schemaTables();
|
|
59
|
+
return allDeclaredTables()
|
|
60
|
+
.filter(({ declaration }) => tables.has(declaration.table))
|
|
61
|
+
.map(({ capability, declaration }) => ({
|
|
62
|
+
capability,
|
|
63
|
+
table: declaration.table,
|
|
64
|
+
claimColumn: declaration.claim.column,
|
|
65
|
+
}))
|
|
66
|
+
.sort((a, b) => a.table.localeCompare(b.table));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Delete every declared row this consumer claimed.
|
|
71
|
+
*
|
|
72
|
+
* Called AFTER every provider has been told the consumer is going (its D4): a
|
|
73
|
+
* provider's `on_consumer_removed` is a full converge, and converging from a set
|
|
74
|
+
* the rows had already been removed from would withdraw more than the departing
|
|
75
|
+
* consumer's share.
|
|
76
|
+
*
|
|
77
|
+
* `web_routes` and `dns_internal_records` also carry an FK cascade, so their
|
|
78
|
+
* rows would go anyway once the `modules` row is deleted. Doing it here as well
|
|
79
|
+
* is deliberate and not merely harmless: it makes the ORDER explicit rather than
|
|
80
|
+
* a consequence of when some other statement happens to run, and it is the same
|
|
81
|
+
* point in the sequence at which `port_forwards` and `trusted_sources` were
|
|
82
|
+
* already being cleared by hand.
|
|
83
|
+
*/
|
|
84
|
+
export function deleteClaimedRows(db: DbClient, consumer: string): ClaimedRowTarget[] {
|
|
85
|
+
const tables = schemaTables();
|
|
86
|
+
const cleared: ClaimedRowTarget[] = [];
|
|
87
|
+
for (const target of planClaimedRowDeletion()) {
|
|
88
|
+
const table = tables.get(target.table);
|
|
89
|
+
if (!table) continue;
|
|
90
|
+
const column = getTableConfig(table).columns.find((c) => c.name === target.claimColumn);
|
|
91
|
+
if (!column) continue;
|
|
92
|
+
db.delete(table)
|
|
93
|
+
.where(eq(column as Column, consumer))
|
|
94
|
+
.run();
|
|
95
|
+
cleared.push(target);
|
|
96
|
+
}
|
|
97
|
+
return cleared;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Capability names that declare at least one table — for logs and reports. */
|
|
101
|
+
export function capabilitiesWithDeclaredTables(): string[] {
|
|
102
|
+
return Object.keys(CAPABILITY_DECLARED_TABLES).sort();
|
|
103
|
+
}
|
|
@@ -23,8 +23,7 @@ import { capabilities, modules } from '../db/schema';
|
|
|
23
23
|
import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook';
|
|
24
24
|
import type { HookLogger } from '../hooks/types';
|
|
25
25
|
import type { ModuleManifest } from '../manifest/schema';
|
|
26
|
-
import {
|
|
27
|
-
import { deleteTrustedSourcesForModule } from './trusted-sources';
|
|
26
|
+
import { deleteClaimedRows } from './capability-table-rows';
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
29
|
* States in which a module has never resolved a capability and therefore holds
|
|
@@ -213,11 +212,18 @@ export async function runConsumerCleanup(
|
|
|
213
212
|
);
|
|
214
213
|
}
|
|
215
214
|
|
|
216
|
-
// The rows, once every provider has converged without them (D4).
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
deleteTrustedSourcesForModule
|
|
215
|
+
// The rows, once every provider has converged without them (D4).
|
|
216
|
+
//
|
|
217
|
+
// Driven by the capability declarations rather than by name. This used to be
|
|
218
|
+
// two hardcoded imports, `deletePortForwardsForModule` and
|
|
219
|
+
// `deleteTrustedSourcesForModule`, which is the shape of the whole problem
|
|
220
|
+
// openspec/changes/capability-owned-tables addresses: this file exists to
|
|
221
|
+
// delete per-capability special-casing, it succeeded for the hook dispatch,
|
|
222
|
+
// and then grew two capability-named calls for the rows because the rows had
|
|
223
|
+
// no generic rule. Now they have one, and core no longer has to know that the
|
|
224
|
+
// claim column is spelled `module_id` on one table, `registered_by` on two and
|
|
225
|
+
// `consumer_module_id` on a fourth.
|
|
226
|
+
deleteClaimedRows(db, consumer);
|
|
221
227
|
|
|
222
228
|
return result;
|
|
223
229
|
}
|
|
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import type { DnsRecordRequest } from '@celilo/capabilities';
|
|
6
6
|
import type { ViewOverride } from '@celilo/capabilities';
|
|
7
|
+
import { eq } from 'drizzle-orm';
|
|
7
8
|
import type { DbClient } from '../db/client';
|
|
8
|
-
import { modules } from '../db/schema';
|
|
9
|
+
import { dnsInternalRecords, modules } from '../db/schema';
|
|
9
10
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
10
11
|
import {
|
|
11
12
|
listDnsInternalRecords,
|
|
@@ -197,3 +198,73 @@ describe('dns-internal-records ledger', () => {
|
|
|
197
198
|
});
|
|
198
199
|
});
|
|
199
200
|
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* celilo#1010 — swapping the internal DNS provider must not delete the ledger.
|
|
204
|
+
*
|
|
205
|
+
* `dns_internal_records` used to cascade on `provider_module_id` as well as on
|
|
206
|
+
* the consumer, so removing `technitium` to install `knot-unbound-internal` took
|
|
207
|
+
* the fleet's entire internal DNS ledger with it, `zone_routable_ip` view
|
|
208
|
+
* overrides included — the durable desired state the resolver's split-horizon
|
|
209
|
+
* config is reconciled from. `web_routes` cascades on its consumer only, and the
|
|
210
|
+
* two tables' docblocks claimed to be siblings, so the divergence read as intent
|
|
211
|
+
* and was not.
|
|
212
|
+
*
|
|
213
|
+
* The claim on a capability-owned table is the CONSUMER
|
|
214
|
+
* (openspec/changes/capability-owned-tables D3/D8). Migration 0027 makes the
|
|
215
|
+
* table agree with the declaration, which cannot express anything else.
|
|
216
|
+
*/
|
|
217
|
+
describe('provider cascade (celilo#1010)', () => {
|
|
218
|
+
let dir: string;
|
|
219
|
+
let db: DbClient;
|
|
220
|
+
|
|
221
|
+
beforeEach(async () => {
|
|
222
|
+
dir = mkdtempSync(join(tmpdir(), 'dns-cascade-'));
|
|
223
|
+
const dbPath = join(dir, 'celilo.db');
|
|
224
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
225
|
+
db = await setupTestDatabase(dbPath);
|
|
226
|
+
for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) {
|
|
227
|
+
db.insert(modules)
|
|
228
|
+
.values({
|
|
229
|
+
id,
|
|
230
|
+
name: id,
|
|
231
|
+
version: '1.0.0',
|
|
232
|
+
state: 'VERIFIED',
|
|
233
|
+
sourcePath: `/tmp/${id}`,
|
|
234
|
+
manifestData: {},
|
|
235
|
+
})
|
|
236
|
+
.run();
|
|
237
|
+
}
|
|
238
|
+
db.insert(dnsInternalRecords)
|
|
239
|
+
.values({
|
|
240
|
+
providerModuleId: 'technitium',
|
|
241
|
+
consumerModuleId: 'caddy',
|
|
242
|
+
host: 'auth.example.org',
|
|
243
|
+
ip: '192.168.0.253',
|
|
244
|
+
zoneRoutableIp: '10.0.10.14',
|
|
245
|
+
})
|
|
246
|
+
.run();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
db.$client.close();
|
|
251
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
252
|
+
rmSync(dir, { recursive: true, force: true });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('keeps the ledger when the PROVIDER is removed, view overrides included', () => {
|
|
256
|
+
db.delete(modules).where(eq(modules.id, 'technitium')).run();
|
|
257
|
+
|
|
258
|
+
const left = db.select().from(dnsInternalRecords).all();
|
|
259
|
+
expect(left).toHaveLength(1);
|
|
260
|
+
// The override is the part whose loss is silent: the resolver keeps
|
|
261
|
+
// answering, just with the wrong address for in-zone clients.
|
|
262
|
+
expect(left[0]?.zoneRoutableIp).toBe('10.0.10.14');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('still dies with its CONSUMER, which is the rule that did not change', () => {
|
|
266
|
+
db.delete(modules).where(eq(modules.id, 'caddy')).run();
|
|
267
|
+
|
|
268
|
+
expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
|
|
8
9
|
import { validateCapabilityVersions } from './capability-versions';
|
|
9
10
|
|
|
10
11
|
describe('validateCapabilityVersions', () => {
|
|
@@ -31,7 +32,11 @@ describe('validateCapabilityVersions', () => {
|
|
|
31
32
|
expect(errors).toHaveLength(1);
|
|
32
33
|
expect(errors[0]).toContain('provides[public_web]');
|
|
33
34
|
expect(errors[0]).toContain('1.0.0');
|
|
34
|
-
|
|
35
|
+
// Read from the registry, not pinned to today's number. The assertion is
|
|
36
|
+
// "the message names the RUNTIME version so the author knows what to move
|
|
37
|
+
// to"; a literal here turns a behaviour test into a version tracker that
|
|
38
|
+
// goes red on every legitimate bump, which is what happened at 3.1.0 → 3.2.0.
|
|
39
|
+
expect(errors[0]).toContain(CAPABILITY_CONTRACT_VERSIONS.public_web);
|
|
35
40
|
});
|
|
36
41
|
|
|
37
42
|
test('error when provides[X].version is newer than runtime', () => {
|
|
@@ -7,7 +7,8 @@ import { eq } from 'drizzle-orm';
|
|
|
7
7
|
import type { DbClient } from '../db/client';
|
|
8
8
|
import { portForwards } from '../db/schema';
|
|
9
9
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
10
|
-
import {
|
|
10
|
+
import { deleteClaimedRows } from './capability-table-rows';
|
|
11
|
+
import { buildPortForwardStore } from './port-forwards';
|
|
11
12
|
|
|
12
13
|
const FW = '192.168.0.254';
|
|
13
14
|
const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' };
|
|
@@ -113,7 +114,10 @@ describe('port-forward store', () => {
|
|
|
113
114
|
other.replace(FW, { ...CADDY, description: 'also 443' }, [443]);
|
|
114
115
|
expect(store.list(FW)).toHaveLength(2);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
// Through the GENERIC declaration-driven path, which replaced
|
|
118
|
+
// `deletePortForwardsForModule`. The property is unchanged; what changed is
|
|
119
|
+
// that core no longer names this capability's table to clear it.
|
|
120
|
+
deleteClaimedRows(db, 'caddy');
|
|
117
121
|
|
|
118
122
|
const left = store.list(FW);
|
|
119
123
|
expect(left).toHaveLength(1);
|
|
@@ -99,14 +99,3 @@ export function buildPortForwardStore(db: DbClient, registeredBy: string): PortF
|
|
|
99
99
|
},
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Drop every forward a departing module owns, across every firewall.
|
|
105
|
-
*
|
|
106
|
-
* `registered_by` is plain text rather than a FK, so unlike `web_routes` these
|
|
107
|
-
* rows do NOT die with the `modules` row — core deletes them explicitly, AFTER
|
|
108
|
-
* the provider has converged without them (D4).
|
|
109
|
-
*/
|
|
110
|
-
export function deletePortForwardsForModule(db: DbClient, moduleId: string): void {
|
|
111
|
-
db.delete(portForwards).where(eq(portForwards.registeredBy, moduleId)).run();
|
|
112
|
-
}
|
|
@@ -68,11 +68,6 @@ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): Tru
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
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
|
-
|
|
76
71
|
/** Where a trusted subnet came from — reach into every tier must be attributable. */
|
|
77
72
|
export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override';
|
|
78
73
|
|
package/src/variables/context.ts
CHANGED
|
@@ -593,16 +593,81 @@ async function assembleResolutionContext(
|
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
595
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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(' ');
|
|
606
671
|
}
|
|
607
672
|
}
|
|
608
673
|
|