@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/module/import.ts
CHANGED
|
@@ -300,7 +300,8 @@ export function moduleExists(moduleId: string, db = getDb()): boolean {
|
|
|
300
300
|
* Validate well-known capabilities
|
|
301
301
|
*
|
|
302
302
|
* Policy function - checks if module's well-known capabilities are valid:
|
|
303
|
-
* 1. No other module provides the same well-known capability
|
|
303
|
+
* 1. No other module provides the same well-known capability in an
|
|
304
|
+
* overlapping scope (zone-aware uniqueness)
|
|
304
305
|
* 2. Module's zone matches capability's required zone (zone enforcement)
|
|
305
306
|
*
|
|
306
307
|
* @param manifest - Module manifest
|
|
@@ -321,16 +322,30 @@ export async function validateWellKnownCapabilities(
|
|
|
321
322
|
|
|
322
323
|
const wellKnown = getWellKnownCapability(capability.name);
|
|
323
324
|
|
|
324
|
-
// Check 1: Capability uniqueness
|
|
325
|
+
// Check 1: Capability uniqueness within an overlapping scope. An explicit
|
|
326
|
+
// zone-scoped provider may coexist with a zone-agnostic fallback because
|
|
327
|
+
// lookup deterministically prefers the explicit match. Two agnostic
|
|
328
|
+
// providers, or two explicit providers sharing a zone, remain ambiguous.
|
|
325
329
|
const existingCapability = await db
|
|
326
330
|
.select()
|
|
327
331
|
.from(capabilities)
|
|
328
332
|
.where(eq(capabilities.capabilityName, capability.name))
|
|
329
333
|
.all();
|
|
330
334
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
335
|
+
const newZones = capability.zones ?? null;
|
|
336
|
+
const conflictingModule = existingCapability.find((candidate) => {
|
|
337
|
+
const existingZones = candidate.zones ?? null;
|
|
338
|
+
|
|
339
|
+
if (newZones === null || existingZones === null) {
|
|
340
|
+
return newZones === null && existingZones === null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return newZones.some((zone) => existingZones.includes(zone));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (conflictingModule) {
|
|
347
|
+
const scope = newZones ? ` zone(s) ${newZones.join(', ')}` : ' the zone-agnostic scope';
|
|
348
|
+
return `Well-known capability '${capability.name}' is already provided in${scope} by module '${conflictingModule.moduleId}'. Remove '${conflictingModule.moduleId}' or use a non-overlapping explicit zone scope before importing this module.`;
|
|
334
349
|
}
|
|
335
350
|
|
|
336
351
|
// Check 2: Zone enforcement - module must be in the correct zone
|
|
@@ -592,7 +607,25 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
592
607
|
// Execution: Validate capability access if module requires capabilities
|
|
593
608
|
if (manifest.requires?.capabilities && manifest.requires.capabilities.length > 0) {
|
|
594
609
|
const { validateCapabilityAccess } = await import('../capabilities/validation');
|
|
595
|
-
|
|
610
|
+
// Templates are where CLAUDE.md's Definition of Done tells module authors
|
|
611
|
+
// to put `$capability:` references, so the import-time gate has to see
|
|
612
|
+
// them (celilo#854, celilo#1027).
|
|
613
|
+
//
|
|
614
|
+
// This adds no parser. `validateModuleTemplates` above already reads and
|
|
615
|
+
// parses every `.tpl` on every import, roughly 25 lines before the gate
|
|
616
|
+
// runs — it was discarding the references it saw. So there is no new file
|
|
617
|
+
// walk, no new failure mode and no new ordering question: the data is
|
|
618
|
+
// already in scope and the gate simply was not looking at it.
|
|
619
|
+
//
|
|
620
|
+
// Fail-closed by that same ordering. An unreadable template makes
|
|
621
|
+
// `validateModuleTemplates` return success:false with no references, and
|
|
622
|
+
// the early return above fires BEFORE this check, so an empty reference
|
|
623
|
+
// set can never reach the gate as a silent pass.
|
|
624
|
+
const accessResult = await validateCapabilityAccess(
|
|
625
|
+
manifest,
|
|
626
|
+
db.$client,
|
|
627
|
+
templateValidation.capabilityReferences,
|
|
628
|
+
);
|
|
596
629
|
|
|
597
630
|
if (!accessResult.success) {
|
|
598
631
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expected shape hashes per capability — the fixture behind
|
|
3
|
+
* `capability-shape-drift.test.ts`.
|
|
4
|
+
*
|
|
5
|
+
* `CAPABILITY_CONTRACT_VERSIONS` is bumped BY HAND, so the whole scheme rests on
|
|
6
|
+
* a human remembering. `capability-contract.ts` used to claim that risk was
|
|
7
|
+
* "mitigated by a CI check that compares interface AST hashes against an
|
|
8
|
+
* expected fixture". No such check existed. It was designed in the retired
|
|
9
|
+
* `apps/celilo/designs/CELILO_UPDATE.md` and never built, and three design
|
|
10
|
+
* documents reasoned from it anyway (celilo#1042). This is that check.
|
|
11
|
+
*
|
|
12
|
+
* Changing a capability's interface OR its table declaration changes its hash.
|
|
13
|
+
* The test then fails until BOTH the contract version and the entry below move,
|
|
14
|
+
* in the same commit — which is the whole point, because the version is what
|
|
15
|
+
* `compareProviderToRuntime` and `celilo system audit` compare against.
|
|
16
|
+
*
|
|
17
|
+
* Comments and formatting do NOT change a hash. The interface is re-printed from
|
|
18
|
+
* its AST with comments removed before hashing, so a doc-only edit stays a patch
|
|
19
|
+
* (the bump rule `capability-contract.ts` already states).
|
|
20
|
+
*
|
|
21
|
+
* WHY THIS LIVES IN apps/celilo AND NOT NEXT TO THE CONTRACT. It needs the
|
|
22
|
+
* TypeScript compiler API to parse, and `db/schema.ts` to check a declaration
|
|
23
|
+
* against the real table. `@celilo/capabilities` is bundled into every module
|
|
24
|
+
* (celilo#173), so adding a `typescript` dependency there to serve a test would
|
|
25
|
+
* be paid for by every module on the fleet. apps/celilo already depends on both.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface CapabilityShape {
|
|
29
|
+
/** The contract version this hash was recorded at. */
|
|
30
|
+
readonly version: string;
|
|
31
|
+
/** sha256 of the printed interface plus the capability's table declaration. */
|
|
32
|
+
readonly hash: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>> = {
|
|
36
|
+
public_web: {
|
|
37
|
+
version: '3.2.0',
|
|
38
|
+
hash: 'a353bf184552f1ed82e5d97c9b4609dc243718c20051a9446d8dc7174561db50',
|
|
39
|
+
},
|
|
40
|
+
idp: {
|
|
41
|
+
version: '1.2.0',
|
|
42
|
+
hash: '99eee190ed670f53595554335f23e1745b872e3226dc9ffdb1d0bf6397d6013a',
|
|
43
|
+
},
|
|
44
|
+
dns_registrar: {
|
|
45
|
+
version: '4.0.0',
|
|
46
|
+
hash: 'f5d02196a3394ec71d865beb45825160103800baf632293a769a6e72dc76236a',
|
|
47
|
+
},
|
|
48
|
+
firewall: {
|
|
49
|
+
version: '1.1.0',
|
|
50
|
+
hash: '564a83ebf8ca937faf955afc4f7bf4278fe9ed01694f0237bfbc38e2eff38408',
|
|
51
|
+
},
|
|
52
|
+
source_forge: {
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
hash: '3bc797cf8c2990c61723fb680299f6a1d4b99ac42fd0371dd9dc64d05c588991',
|
|
55
|
+
},
|
|
56
|
+
registry_publish: {
|
|
57
|
+
version: '1.0.0',
|
|
58
|
+
hash: '693e7d08e9f105acd90dd00f8f9282ab3508def16e48237b47bc0ccba7885679',
|
|
59
|
+
},
|
|
60
|
+
dhcp_server: {
|
|
61
|
+
version: '1.0.0',
|
|
62
|
+
hash: '291a247d98bc11b18ab34d40b520bec679b95a031cbd674d513b313559fa3288',
|
|
63
|
+
},
|
|
64
|
+
dns_internal: {
|
|
65
|
+
version: '1.1.0',
|
|
66
|
+
hash: 'af72e861a23f6c81fe9a1a679121b7d3ea87ac5e8167efa20d5d6323010e5fef',
|
|
67
|
+
},
|
|
68
|
+
external_web: {
|
|
69
|
+
version: '1.0.0',
|
|
70
|
+
hash: '7396028ace76938f4e892e6ef8a53450183cdbcf8dff3ce767724bd9b2276dae',
|
|
71
|
+
},
|
|
72
|
+
notification: {
|
|
73
|
+
version: '1.0.0',
|
|
74
|
+
hash: '40629722b397a314d65a448a618d600fc8560c4aae0767dd7324d497d4cc9d41',
|
|
75
|
+
},
|
|
76
|
+
control_plane_vpn: {
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
|
|
79
|
+
},
|
|
80
|
+
private_web: {
|
|
81
|
+
version: '1.0.0',
|
|
82
|
+
hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
|
|
83
|
+
},
|
|
84
|
+
cross_module_read: {
|
|
85
|
+
version: '1.0.0',
|
|
86
|
+
hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate `capability-contract.ts` claimed for months and did not have.
|
|
3
|
+
*
|
|
4
|
+
* Two independent failures are caught here, and they are different enough to be
|
|
5
|
+
* worth naming separately.
|
|
6
|
+
*
|
|
7
|
+
* SCAN A — a declaration that disagrees with the real table. This is the failure
|
|
8
|
+
* mode `openspec/changes/capability-owned-tables` design D4 warns is *worse than
|
|
9
|
+
* today's*: a capability whose declared shape does not match the database, where
|
|
10
|
+
* the version check still passes, "because versions live in files and the table
|
|
11
|
+
* lives in the database". A missing migration at least says `no such table`.
|
|
12
|
+
* Nothing else in the tree compares the two.
|
|
13
|
+
*
|
|
14
|
+
* SCAN B — an interface or declaration that changed with no version bump.
|
|
15
|
+
* `CAPABILITY_CONTRACT_VERSIONS` is hand-maintained, and `compareProviderToRuntime`
|
|
16
|
+
* plus `celilo system audit` both trust it. A silently-changed shape makes both
|
|
17
|
+
* of them confidently wrong. Comments and formatting are excluded, so a doc-only
|
|
18
|
+
* edit stays a patch.
|
|
19
|
+
*
|
|
20
|
+
* The subject list is DERIVED from `CapabilityRegistry` rather than written out,
|
|
21
|
+
* so a new capability is covered the day it is added rather than the day someone
|
|
22
|
+
* remembers to add it here.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, expect, test } from 'bun:test';
|
|
26
|
+
import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
|
|
27
|
+
import { is } from 'drizzle-orm';
|
|
28
|
+
import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
|
|
29
|
+
import * as dbSchema from '../db/schema';
|
|
30
|
+
import { capabilitySubjects, shapeHash, tablesOf } from './capability-shape';
|
|
31
|
+
import { CAPABILITY_SHAPE_BASELINE } from './capability-shape-baseline';
|
|
32
|
+
|
|
33
|
+
/** Physical table name → the drizzle table the running code declares. */
|
|
34
|
+
function drizzleTables(): Map<string, ReturnType<typeof getTableConfig>> {
|
|
35
|
+
const out = new Map<string, ReturnType<typeof getTableConfig>>();
|
|
36
|
+
for (const value of Object.values(dbSchema)) {
|
|
37
|
+
if (!is(value, SQLiteTable)) continue;
|
|
38
|
+
const config = getTableConfig(value);
|
|
39
|
+
out.set(config.name, config);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DRIZZLE_TYPE_TO_DECLARED: Record<string, string> = {
|
|
45
|
+
SQLiteText: 'text',
|
|
46
|
+
SQLiteInteger: 'integer',
|
|
47
|
+
SQLiteBoolean: 'boolean',
|
|
48
|
+
SQLiteTimestamp: 'timestamp',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('Scan A — a declared table matches the real one', () => {
|
|
52
|
+
test('every declared table exists in db/schema.ts', async () => {
|
|
53
|
+
const tables = drizzleTables();
|
|
54
|
+
const missing: string[] = [];
|
|
55
|
+
for (const [capability, subject] of capabilitySubjects()) {
|
|
56
|
+
for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
|
|
57
|
+
if (!tables.has(decl.table)) missing.push(`${capability}.${key} → ${decl.table}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
expect(missing).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('every declared column exists on the real table, claim included', async () => {
|
|
64
|
+
const tables = drizzleTables();
|
|
65
|
+
const missing: string[] = [];
|
|
66
|
+
for (const [capability, subject] of capabilitySubjects()) {
|
|
67
|
+
for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
|
|
68
|
+
const config = tables.get(decl.table);
|
|
69
|
+
if (!config) continue;
|
|
70
|
+
const present = new Set(config.columns.map((c) => c.name));
|
|
71
|
+
for (const column of [decl.claim.column, ...Object.keys(decl.columns)]) {
|
|
72
|
+
if (!present.has(column)) missing.push(`${capability}.${key}: ${decl.table}.${column}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
expect(missing).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The half that catches a rename or a retype rather than an absence. A column
|
|
81
|
+
* declared `text` that is really an integer reads as present to the check
|
|
82
|
+
* above, and every consumer of the declaration would then be typed wrong.
|
|
83
|
+
*/
|
|
84
|
+
test('every declared column has the declared type and nullability', async () => {
|
|
85
|
+
const tables = drizzleTables();
|
|
86
|
+
const wrong: string[] = [];
|
|
87
|
+
for (const [capability, subject] of capabilitySubjects()) {
|
|
88
|
+
for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
|
|
89
|
+
const config = tables.get(decl.table);
|
|
90
|
+
if (!config) continue;
|
|
91
|
+
for (const [column, declared] of Object.entries(decl.columns)) {
|
|
92
|
+
const actual = config.columns.find((c) => c.name === column);
|
|
93
|
+
if (!actual) continue;
|
|
94
|
+
const base = DRIZZLE_TYPE_TO_DECLARED[actual.columnType] ?? actual.columnType;
|
|
95
|
+
const expected = declared.endsWith('?') ? declared.slice(0, -1) : declared;
|
|
96
|
+
const nullableDeclared = declared.endsWith('?');
|
|
97
|
+
if (base !== expected) {
|
|
98
|
+
wrong.push(
|
|
99
|
+
`${capability}.${key}: ${decl.table}.${column} is ${base}, declared ${expected}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (nullableDeclared === actual.notNull) {
|
|
103
|
+
wrong.push(
|
|
104
|
+
`${capability}.${key}: ${decl.table}.${column} nullability disagrees (declared ${declared}, notNull=${actual.notNull})`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
expect(wrong).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Scan B — a shape change carries a version bump', () => {
|
|
115
|
+
test('every capability in the registry has a baseline entry', async () => {
|
|
116
|
+
const missing = [...capabilitySubjects().keys()].filter(
|
|
117
|
+
(name) => !(name in CAPABILITY_SHAPE_BASELINE),
|
|
118
|
+
);
|
|
119
|
+
expect(missing).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The fixture records the version its hash was taken at. If the contract has
|
|
124
|
+
* moved since, the baseline describes a shape nobody re-measured — which is
|
|
125
|
+
* how a bump lands without the regenerate that is supposed to accompany it.
|
|
126
|
+
*
|
|
127
|
+
* This does NOT catch the reverse (regenerating without bumping), and cannot:
|
|
128
|
+
* telling a deliberate shape change from an accidental one needs history the
|
|
129
|
+
* gate does not have. That case shows in review as a hash moving while a
|
|
130
|
+
* version stands still, which is why the generator reads versions from the
|
|
131
|
+
* contract rather than inventing them.
|
|
132
|
+
*/
|
|
133
|
+
test('every baseline entry was taken at the CURRENT contract version', () => {
|
|
134
|
+
const versions = CAPABILITY_CONTRACT_VERSIONS as Record<string, string>;
|
|
135
|
+
const stale: string[] = [];
|
|
136
|
+
for (const capability of capabilitySubjects().keys()) {
|
|
137
|
+
const recorded = CAPABILITY_SHAPE_BASELINE[capability];
|
|
138
|
+
if (!recorded) continue;
|
|
139
|
+
if (recorded.version !== versions[capability]) {
|
|
140
|
+
stale.push(
|
|
141
|
+
`${capability}: baseline recorded at ${recorded.version}, contract is now ${versions[capability]}. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
expect(stale).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('no capability changed shape without moving its baseline', async () => {
|
|
149
|
+
const drifted: string[] = [];
|
|
150
|
+
for (const capability of capabilitySubjects().keys()) {
|
|
151
|
+
const recorded = CAPABILITY_SHAPE_BASELINE[capability];
|
|
152
|
+
if (!recorded) continue;
|
|
153
|
+
const actual = await shapeHash(capability);
|
|
154
|
+
if (actual !== recorded.hash) {
|
|
155
|
+
drifted.push(
|
|
156
|
+
`${capability}: shape changed. Bump CAPABILITY_CONTRACT_VERSIONS.${capability} (currently recorded at ${recorded.version}) and set its baseline hash to ${actual}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
expect(drifted).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a capability's SHAPE: its interface, and the tables it declares.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `capability-shape-drift.test.ts` so the gate and the tool that
|
|
5
|
+
* regenerates its fixture compute the same hash from the same code. Two
|
|
6
|
+
* implementations of "what is this capability's shape" would drift, and the one
|
|
7
|
+
* in the GENERATOR drifting is indistinguishable from the gate passing.
|
|
8
|
+
*
|
|
9
|
+
* Same split as `module-script-scan.ts` / `no-hand-built-ssh.test.ts` in this
|
|
10
|
+
* directory, for the same reason.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import type { CapabilityTableDeclaration, CapabilityTables } from '@celilo/capabilities';
|
|
17
|
+
import ts from 'typescript';
|
|
18
|
+
|
|
19
|
+
/** Walk up to the repo root (the dir holding both modules/ and apps/). */
|
|
20
|
+
export function repoRoot(): string {
|
|
21
|
+
let dir = import.meta.dir;
|
|
22
|
+
for (let i = 0; i < 8; i++) {
|
|
23
|
+
if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
|
|
24
|
+
dir = resolve(dir, '..');
|
|
25
|
+
}
|
|
26
|
+
throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CAPABILITIES_SRC = join(repoRoot(), 'packages', 'capabilities', 'src');
|
|
30
|
+
|
|
31
|
+
function parse(file: string): ts.SourceFile {
|
|
32
|
+
return ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* capability name → { interface type name, source file }, read out of
|
|
37
|
+
* `capability-registry.ts` itself.
|
|
38
|
+
*/
|
|
39
|
+
export function capabilitySubjects(): Map<string, { typeName: string; file: string }> {
|
|
40
|
+
const registryFile = join(CAPABILITIES_SRC, 'capability-registry.ts');
|
|
41
|
+
const source = parse(registryFile);
|
|
42
|
+
|
|
43
|
+
// `import type { XCapability } from './x'` → XCapability lives in ./x.ts
|
|
44
|
+
const fileOfType = new Map<string, string>();
|
|
45
|
+
for (const statement of source.statements) {
|
|
46
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
47
|
+
const bindings = statement.importClause?.namedBindings;
|
|
48
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
49
|
+
const specifier = (statement.moduleSpecifier as ts.StringLiteral).text;
|
|
50
|
+
for (const element of bindings.elements) {
|
|
51
|
+
fileOfType.set(
|
|
52
|
+
element.name.text,
|
|
53
|
+
join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const subjects = new Map<string, { typeName: string; file: string }>();
|
|
59
|
+
for (const statement of source.statements) {
|
|
60
|
+
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'CapabilityRegistry') {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
for (const member of statement.members) {
|
|
64
|
+
if (!ts.isPropertySignature(member) || !member.name || !member.type) continue;
|
|
65
|
+
const name = member.name.getText(source);
|
|
66
|
+
const typeName = member.type.getText(source);
|
|
67
|
+
const file = fileOfType.get(typeName);
|
|
68
|
+
if (file) subjects.set(name, { typeName, file });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return subjects;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The interface, re-printed from its AST with comments dropped. */
|
|
75
|
+
function printedInterface(file: string, typeName: string): string {
|
|
76
|
+
const source = parse(file);
|
|
77
|
+
const printer = ts.createPrinter({ removeComments: true });
|
|
78
|
+
for (const statement of source.statements) {
|
|
79
|
+
if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
|
|
80
|
+
return printer.printNode(ts.EmitHint.Unspecified, statement, source);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`${typeName} not found in ${file}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every `* satisfies CapabilityTables` const exported from a capability's module. */
|
|
87
|
+
export async function tablesOf(file: string): Promise<CapabilityTables> {
|
|
88
|
+
const loaded = (await import(file)) as Record<string, unknown>;
|
|
89
|
+
const out: Record<string, CapabilityTableDeclaration> = {};
|
|
90
|
+
for (const [exportName, value] of Object.entries(loaded)) {
|
|
91
|
+
if (!exportName.endsWith('_TABLES') || typeof value !== 'object' || value === null) continue;
|
|
92
|
+
for (const [key, decl] of Object.entries(value as Record<string, unknown>)) {
|
|
93
|
+
out[key] = decl as CapabilityTableDeclaration;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Order-independent so a reordered literal is not a shape change. */
|
|
100
|
+
function canonical(value: unknown): string {
|
|
101
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
102
|
+
if (value && typeof value === 'object') {
|
|
103
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
104
|
+
.map(([k, v]) => `${k}:${canonical(v)}`)
|
|
105
|
+
.sort();
|
|
106
|
+
return `{${entries.join(',')}}`;
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function shapeHash(capability: string): Promise<string> {
|
|
112
|
+
const subject = capabilitySubjects().get(capability);
|
|
113
|
+
if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
|
|
114
|
+
const iface = printedInterface(subject.file, subject.typeName);
|
|
115
|
+
const tables = canonical(await tablesOf(subject.file));
|
|
116
|
+
return createHash('sha256').update(`${iface}\n--tables--\n${tables}`).digest('hex');
|
|
117
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared coverage and the aspect that does the covering must agree.
|
|
3
|
+
*
|
|
4
|
+
* `lxc-dns-at-birth` splits ownership: terraform owns birth DNS, the
|
|
5
|
+
* base-module aspect owns ongoing DNS. Terraform injects
|
|
6
|
+
* `lifecycle { ignore_changes = [nameserver] }`, so it can never correct the
|
|
7
|
+
* birth value — a zone the aspect does not cover has NO owner for ongoing DNS
|
|
8
|
+
* and its birth list is permanent. That is why the nameserver composition only
|
|
9
|
+
* strips the public resolvers for a zone the deployed provider's aspect covers
|
|
10
|
+
* (design D5d).
|
|
11
|
+
*
|
|
12
|
+
* Core reads that coverage from the provider's CAPABILITY DATA rather than
|
|
13
|
+
* looking up the provider's manifest, because core naming a capability to find
|
|
14
|
+
* its provider is what the module-business gate exists to stop. The cost of
|
|
15
|
+
* that choice is two lists in one manifest instead of one, and drift between
|
|
16
|
+
* them is not cosmetic: a zone listed as covered but absent from the aspect
|
|
17
|
+
* loses its public resolvers with nothing owning what replaces them.
|
|
18
|
+
*
|
|
19
|
+
* So this test is the thing that makes the choice safe. It is the reason the
|
|
20
|
+
* duplication is acceptable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from 'bun:test';
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { join, resolve } from 'node:path';
|
|
26
|
+
import { parse } from 'yaml';
|
|
27
|
+
|
|
28
|
+
function repoRoot(): string {
|
|
29
|
+
let dir = import.meta.dir;
|
|
30
|
+
for (let i = 0; i < 8; i++) {
|
|
31
|
+
if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
|
|
32
|
+
dir = resolve(dir, '..');
|
|
33
|
+
}
|
|
34
|
+
throw new Error('could not locate repo root');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ProviderManifest {
|
|
38
|
+
provides?: {
|
|
39
|
+
capabilities?: { name: string; data?: { aspect?: { covered_zones?: string[] } } }[];
|
|
40
|
+
};
|
|
41
|
+
base_module_aspect?: { applicable_zones?: string[] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every module declaring a `dns_internal` capability, with its manifest. */
|
|
45
|
+
function dnsInternalProviders(): { id: string; manifest: ProviderManifest }[] {
|
|
46
|
+
const modulesDir = join(repoRoot(), 'modules');
|
|
47
|
+
const found: { id: string; manifest: ProviderManifest }[] = [];
|
|
48
|
+
for (const id of readdirSync(modulesDir)) {
|
|
49
|
+
const path = join(modulesDir, id, 'manifest.yml');
|
|
50
|
+
if (!existsSync(path)) continue;
|
|
51
|
+
let manifest: ProviderManifest;
|
|
52
|
+
try {
|
|
53
|
+
manifest = parse(readFileSync(path, 'utf-8')) as ProviderManifest;
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (manifest.provides?.capabilities?.some((c) => c.name === 'dns_internal')) {
|
|
58
|
+
found.push({ id, manifest });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('dns_internal providers declare the coverage their aspect actually has', () => {
|
|
65
|
+
const providers = dnsInternalProviders();
|
|
66
|
+
|
|
67
|
+
test('the scan found the providers (sanity — it actually ran)', () => {
|
|
68
|
+
// knot-unbound-internal and technitium. A scan that silently found nothing
|
|
69
|
+
// would make every assertion below vacuously true.
|
|
70
|
+
expect(providers.map((p) => p.id).sort()).toEqual(['knot-unbound-internal', 'technitium']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test.each(dnsInternalProviders().map((p) => [p.id, p] as const))(
|
|
74
|
+
'%s: declared covered_zones equals base_module_aspect.applicable_zones',
|
|
75
|
+
(_id, provider) => {
|
|
76
|
+
const capability = provider.manifest.provides?.capabilities?.find(
|
|
77
|
+
(c) => c.name === 'dns_internal',
|
|
78
|
+
);
|
|
79
|
+
const declared = capability?.data?.aspect?.covered_zones;
|
|
80
|
+
const applicable = provider.manifest.base_module_aspect?.applicable_zones;
|
|
81
|
+
|
|
82
|
+
expect(declared, 'dns_internal.data.aspect.covered_zones is missing').toBeDefined();
|
|
83
|
+
expect(applicable, 'base_module_aspect.applicable_zones is missing').toBeDefined();
|
|
84
|
+
expect([...(declared ?? [])].sort()).toEqual([...(applicable ?? [])].sort());
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
test('no provider claims to cover `external`', () => {
|
|
89
|
+
// An `external` system is a cloud VPS outside the perimeter with no route
|
|
90
|
+
// to a dmz-resident resolver. Its public resolvers are the only working
|
|
91
|
+
// configuration, not a fallback that might mask a split-horizon error, so
|
|
92
|
+
// claiming coverage there would strip the only addresses it can reach.
|
|
93
|
+
for (const provider of providers) {
|
|
94
|
+
const declared =
|
|
95
|
+
provider.manifest.provides?.capabilities?.find((c) => c.name === 'dns_internal')?.data
|
|
96
|
+
?.aspect?.covered_zones ?? [];
|
|
97
|
+
expect(declared, `${provider.id} claims to cover external`).not.toContain('external');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -33,21 +33,28 @@
|
|
|
33
33
|
* never go away.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { allDeclaredTables } from '@celilo/capabilities';
|
|
37
|
+
|
|
36
38
|
/**
|
|
37
39
|
* Tables tagged `@owner capability:*` in `db/schema.ts`, and the capability
|
|
38
40
|
* each belongs to. Scan A asserts the tagged set EQUALS this map — not that it
|
|
39
|
-
* is empty.
|
|
41
|
+
* is empty.
|
|
42
|
+
*
|
|
43
|
+
* DERIVED from the capability declarations, not hand-written
|
|
44
|
+
* (openspec/changes/capability-owned-tables task 2.7). It used to be a literal,
|
|
45
|
+
* which made `@owner capability:public_web` a comment checked against another
|
|
46
|
+
* comment. Now the tag is checked against the thing that actually governs the
|
|
47
|
+
* table, so a declared table that nobody tagged and a tagged table nobody
|
|
48
|
+
* declared BOTH fail, and the map cannot drift from the declarations because it
|
|
49
|
+
* no longer exists apart from them. These four exist today (audit T1, T2, T3, T6) and Phase 1 migrates
|
|
40
50
|
* nothing; an assertion that core holds none would be red the day it landed.
|
|
41
51
|
*
|
|
42
52
|
* Tracked by #939. Migrating them keeps the ownership CLAIM in core — dropping
|
|
43
53
|
* it is the mistake that made `dns_registration_consumers` necessary (#626).
|
|
44
54
|
*/
|
|
45
|
-
export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
trusted_sources: 'firewall',
|
|
49
|
-
dns_internal_records: 'dns_internal',
|
|
50
|
-
};
|
|
55
|
+
export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> = Object.fromEntries(
|
|
56
|
+
allDeclaredTables().map(({ capability, declaration }) => [declaration.table, capability]),
|
|
57
|
+
);
|
|
51
58
|
|
|
52
59
|
/** One `{file, capability}` pair and how many times core names it. */
|
|
53
60
|
export interface CapabilityNameRow {
|
|
@@ -248,12 +255,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
248
255
|
count: 2,
|
|
249
256
|
why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
|
|
250
257
|
},
|
|
251
|
-
{
|
|
252
|
-
file: 'apps/celilo/src/services/public-web-republish.ts',
|
|
253
|
-
capability: 'public_web',
|
|
254
|
-
count: 1,
|
|
255
|
-
why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal (#945)",
|
|
256
|
-
},
|
|
257
258
|
{
|
|
258
259
|
file: 'apps/celilo/src/services/zone-policy.ts',
|
|
259
260
|
capability: 'public_web',
|
|
@@ -272,6 +273,24 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
272
273
|
count: 1,
|
|
273
274
|
why: 'X3 — reaches capabilitiesMap.dns_internal by name; should read a declared field (X4 is the model) (#945)',
|
|
274
275
|
},
|
|
276
|
+
{
|
|
277
|
+
file: 'packages/capabilities/src/declared-tables.ts',
|
|
278
|
+
capability: 'dns_internal',
|
|
279
|
+
count: 1,
|
|
280
|
+
why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
file: 'packages/capabilities/src/declared-tables.ts',
|
|
284
|
+
capability: 'firewall',
|
|
285
|
+
count: 1,
|
|
286
|
+
why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
file: 'packages/capabilities/src/declared-tables.ts',
|
|
290
|
+
capability: 'public_web',
|
|
291
|
+
count: 1,
|
|
292
|
+
why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
|
|
293
|
+
},
|
|
275
294
|
{
|
|
276
295
|
file: 'packages/capabilities/src/capability-contract.ts',
|
|
277
296
|
capability: 'control_plane_vpn',
|
|
@@ -391,11 +410,6 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
|
|
|
391
410
|
capability: 'firewall',
|
|
392
411
|
why: 'S8 — core reaching into one provider implementation (#941)',
|
|
393
412
|
},
|
|
394
|
-
{
|
|
395
|
-
file: 'apps/celilo/src/services/public-web-republish.ts',
|
|
396
|
-
capability: 'public_web',
|
|
397
|
-
why: "S2 — named for one provider's redeploy behaviour (#945)",
|
|
398
|
-
},
|
|
399
413
|
];
|
|
400
414
|
|
|
401
415
|
export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
|