@celilo/cli 1.7.0 → 1.9.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 -0
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +182 -9
- package/src/hooks/broker.test.ts +152 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/executor.ts +234 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/import.ts +19 -1
- package/src/module/packaging/module-state-directory.test.ts +99 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +96 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +129 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +68 -7
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/consumer-cleanup.ts +13 -7
- package/src/services/dns-internal-records.test.ts +74 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +8 -4
- package/src/services/port-forwards.ts +0 -11
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/test-utils/setup-test-db.ts +0 -80
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
6
|
+
import { deriveInstanceModuleId, ownedSystemModuleIds } from './module-instances';
|
|
7
|
+
|
|
8
|
+
describe('deriveInstanceModuleId', () => {
|
|
9
|
+
test('is deterministic, so an instantiate is safe to retry', () => {
|
|
10
|
+
const a = deriveInstanceModuleId('byoi', 'lab', 'sub-abc123');
|
|
11
|
+
const b = deriveInstanceModuleId('byoi', 'lab', 'sub-abc123');
|
|
12
|
+
expect(a).toBe(b);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('produces a valid kebab-case module id from an opaque key', () => {
|
|
16
|
+
// celilo never interprets the key, so it may be anything at all.
|
|
17
|
+
const id = deriveInstanceModuleId('byoi', 'lab', 'Sally Smith <sally@example.com> 🎉');
|
|
18
|
+
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
19
|
+
expect(id.startsWith('byoi-lab-')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('two parents using the same key get different ids', () => {
|
|
23
|
+
expect(deriveInstanceModuleId('byoi', 'lab', 'shared-key')).not.toBe(
|
|
24
|
+
deriveInstanceModuleId('forgejo', 'lab', 'shared-key'),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('one parent reusing a key across two submodules gets different ids', () => {
|
|
29
|
+
expect(deriveInstanceModuleId('forgejo', 'runner', 'proj-1')).not.toBe(
|
|
30
|
+
deriveInstanceModuleId('forgejo', 'builder', 'proj-1'),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Without a separator in the hash input, ("a", "b-c") and ("a-b", "c") hash
|
|
35
|
+
// the same string. The kind of ambiguity that never shows up until it does.
|
|
36
|
+
test('a hyphen moving between parent and submodule changes the id', () => {
|
|
37
|
+
expect(deriveInstanceModuleId('a', 'b-c', 'k')).not.toBe(
|
|
38
|
+
deriveInstanceModuleId('a-b', 'c', 'k'),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a different key changes the id', () => {
|
|
43
|
+
expect(deriveInstanceModuleId('byoi', 'lab', 'one')).not.toBe(
|
|
44
|
+
deriveInstanceModuleId('byoi', 'lab', 'two'),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('module_instances storage', () => {
|
|
50
|
+
let root: string;
|
|
51
|
+
let db: DbClient;
|
|
52
|
+
|
|
53
|
+
const addModule = (id: string) =>
|
|
54
|
+
db.$client.run(
|
|
55
|
+
"INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}','/tmp/'||?)",
|
|
56
|
+
[id, id, id],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const addInstance = (parent: string, submodule: string, key: string) => {
|
|
60
|
+
const id = deriveInstanceModuleId(parent, submodule, key);
|
|
61
|
+
addModule(id);
|
|
62
|
+
db.$client.run(
|
|
63
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
64
|
+
[id, parent, submodule, key],
|
|
65
|
+
);
|
|
66
|
+
return id;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const instanceCount = () =>
|
|
70
|
+
(db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n;
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-instances-'));
|
|
74
|
+
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
75
|
+
for (const id of ['byoi', 'forgejo']) addModule(id);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
db.$client.close();
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('identity is the triple, not the key alone', () => {
|
|
84
|
+
const columns = db.$client
|
|
85
|
+
.query("PRAGMA index_info('module_instances_identity_idx')")
|
|
86
|
+
.all() as Array<{ name: string }>;
|
|
87
|
+
expect(columns.map((c) => c.name)).toEqual(['parent_id', 'submodule', 'instance_key']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('two parents may use the same instance key', () => {
|
|
91
|
+
addInstance('byoi', 'lab', 'shared');
|
|
92
|
+
expect(() => addInstance('forgejo', 'lab', 'shared')).not.toThrow();
|
|
93
|
+
expect(instanceCount()).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('one parent cannot reuse a key for the same submodule', () => {
|
|
97
|
+
addInstance('byoi', 'lab', 'dup');
|
|
98
|
+
expect(() =>
|
|
99
|
+
db.$client.run(
|
|
100
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
101
|
+
['byoi-lab-other', 'byoi', 'lab', 'dup'],
|
|
102
|
+
),
|
|
103
|
+
).toThrow(/UNIQUE constraint failed/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// A row must never outlive the parent it names.
|
|
107
|
+
test('removing a parent removes its instance rows and leaves other parents alone', () => {
|
|
108
|
+
addInstance('byoi', 'lab', 'a');
|
|
109
|
+
addInstance('forgejo', 'runner', 'b');
|
|
110
|
+
expect(instanceCount()).toBe(2);
|
|
111
|
+
|
|
112
|
+
db.$client.run("DELETE FROM modules WHERE id='byoi'");
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
db.$client.query('SELECT parent_id FROM module_instances').all() as Array<{
|
|
116
|
+
parent_id: string;
|
|
117
|
+
}>,
|
|
118
|
+
).toEqual([{ parent_id: 'forgejo' }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('an instance defaults to pending with no failure recorded', () => {
|
|
122
|
+
addInstance('byoi', 'lab', 'fresh');
|
|
123
|
+
const row = db.$client
|
|
124
|
+
.query('SELECT state, failure_reason, retryable FROM module_instances')
|
|
125
|
+
.get() as { state: string; failure_reason: string | null; retryable: number | null };
|
|
126
|
+
expect(row.state).toBe('pending');
|
|
127
|
+
expect(row.failure_reason).toBeNull();
|
|
128
|
+
expect(row.retryable).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('ownedSystemModuleIds', () => {
|
|
133
|
+
let root: string;
|
|
134
|
+
let db: DbClient;
|
|
135
|
+
|
|
136
|
+
const addModule = (id: string) =>
|
|
137
|
+
db.$client.run(
|
|
138
|
+
"INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}','/tmp/'||?)",
|
|
139
|
+
[id, id, id],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const addInstance = (parent: string, submodule: string, key: string) => {
|
|
143
|
+
const id = deriveInstanceModuleId(parent, submodule, key);
|
|
144
|
+
addModule(id);
|
|
145
|
+
db.$client.run(
|
|
146
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
147
|
+
[id, parent, submodule, key],
|
|
148
|
+
);
|
|
149
|
+
return id;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-owned-'));
|
|
154
|
+
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
155
|
+
for (const id of ['forgejo', 'caddy']) addModule(id);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterEach(async () => {
|
|
159
|
+
db.$client.close();
|
|
160
|
+
await rm(root, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The state of every fleet until submodules ship, and the reason this is
|
|
164
|
+
// correct on its own rather than merely inert: an empty table gives every
|
|
165
|
+
// module itself, so a caller reaches only what it provisioned.
|
|
166
|
+
test('with no instances in existence, a module owns exactly itself', () => {
|
|
167
|
+
expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
|
|
168
|
+
expect(ownedSystemModuleIds('forgejo', db)).toEqual(['forgejo']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a parent gets itself and every instance it owns', () => {
|
|
172
|
+
const a = addInstance('forgejo', 'runner', 'proj-1');
|
|
173
|
+
const b = addInstance('forgejo', 'runner', 'proj-2');
|
|
174
|
+
|
|
175
|
+
const owned = ownedSystemModuleIds('forgejo', db);
|
|
176
|
+
|
|
177
|
+
expect(owned).toContain('forgejo');
|
|
178
|
+
expect(owned).toContain(a);
|
|
179
|
+
expect(owned).toContain(b);
|
|
180
|
+
expect(owned).toHaveLength(3);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a parent does not reach another parent's instances", () => {
|
|
184
|
+
addInstance('forgejo', 'runner', 'proj-1');
|
|
185
|
+
expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Ownership is one level deep by construction, so the answer terminates
|
|
189
|
+
// without anyone reasoning about depth.
|
|
190
|
+
test('an instance owns nobody, so the answer is one level and terminates', () => {
|
|
191
|
+
const instance = addInstance('forgejo', 'runner', 'proj-1');
|
|
192
|
+
expect(ownedSystemModuleIds(instance, db)).toEqual([instance]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a module that does not exist owns only the name it was asked about', () => {
|
|
196
|
+
expect(ownedSystemModuleIds('never-installed', db)).toEqual(['never-installed']);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance identity (openspec/changes/submodules, D2 and D4).
|
|
3
|
+
*
|
|
4
|
+
* A parent module addresses the instances it owns by an opaque key it chose.
|
|
5
|
+
* celilo addresses them by a `modules.id`, because an instance IS a module row,
|
|
6
|
+
* and that is what lets every reader keyed on `moduleId` keep working. This
|
|
7
|
+
* file is the single place those two names meet, so the mapping cannot drift.
|
|
8
|
+
*
|
|
9
|
+
* The rest of the submodules mechanism (the symlink farm an instance's install
|
|
10
|
+
* is, the deploy-worker capability, the lifecycle) is not here. This is the
|
|
11
|
+
* identity and the ownership question, landed ahead of it because
|
|
12
|
+
* `hook-process-boundary` stage 3 needs `ownedSystemModuleIds` and nothing else
|
|
13
|
+
* from that change.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { eq } from 'drizzle-orm';
|
|
18
|
+
import type { DbClient } from '../db/client';
|
|
19
|
+
import { moduleInstances } from '../db/schema';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bits of hash in a derived instance id, as hex characters.
|
|
23
|
+
*
|
|
24
|
+
* 12 hex characters is 48 bits, so a birthday collision needs on the order of
|
|
25
|
+
* 16 million instances of ONE submodule under ONE parent. A fleet runs tens.
|
|
26
|
+
* Short enough to read in `celilo module list`, long enough that nobody has to
|
|
27
|
+
* think about it.
|
|
28
|
+
*/
|
|
29
|
+
const INSTANCE_ID_HASH_CHARS = 12;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `modules.id` an instance runs as.
|
|
33
|
+
*
|
|
34
|
+
* Derived rather than supplied, for two reasons. The parent's key is opaque and
|
|
35
|
+
* may be anything (D2 forbids celilo interpreting it), while `modules.id` must
|
|
36
|
+
* be kebab-case. And identity is the TRIPLE of parent, submodule and key, so
|
|
37
|
+
* the first two have to be inside the hash, or two parents using the same key
|
|
38
|
+
* would collide.
|
|
39
|
+
*
|
|
40
|
+
* Deterministic: the same triple always yields the same id, which is what makes
|
|
41
|
+
* an instantiate safe to retry.
|
|
42
|
+
*/
|
|
43
|
+
export function deriveInstanceModuleId(
|
|
44
|
+
parentId: string,
|
|
45
|
+
submodule: string,
|
|
46
|
+
instanceKey: string,
|
|
47
|
+
): string {
|
|
48
|
+
// The separator matters. Hashing the concatenation alone would let
|
|
49
|
+
// ("a", "b-c") and ("a-b", "c") collide, which is the kind of ambiguity that
|
|
50
|
+
// never shows up until it does.
|
|
51
|
+
const digest = createHash('sha256')
|
|
52
|
+
.update(`${parentId} ${submodule} ${instanceKey}`)
|
|
53
|
+
.digest('hex')
|
|
54
|
+
.slice(0, INSTANCE_ID_HASH_CHARS);
|
|
55
|
+
|
|
56
|
+
return `${parentId}-${submodule}-${digest}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every module id whose systems `moduleId` transitively owns: itself, plus each
|
|
61
|
+
* of its instances.
|
|
62
|
+
*
|
|
63
|
+
* This is the allow-list behind D12 of `openspec/changes/hook-process-boundary`
|
|
64
|
+
* ("a module may reach the systems it provisioned, or that its submodules
|
|
65
|
+
* provisioned"). Feed it to `getModuleSystems` per id, or use it directly as an
|
|
66
|
+
* `IN (...)` set.
|
|
67
|
+
*
|
|
68
|
+
* NOT RECURSIVE, and that is a property of the model rather than a shortcut.
|
|
69
|
+
* Nesting is refused when a parent is imported: a submodule may not declare
|
|
70
|
+
* submodules of its own, so ownership is exactly one level deep by
|
|
71
|
+
* construction. A parent owns instances, and an instance owns nothing. So this
|
|
72
|
+
* is one indexed lookup on `module_instances_parent_idx` rather than a walk,
|
|
73
|
+
* which is what makes it cheap enough to run at hook-invocation time.
|
|
74
|
+
*
|
|
75
|
+
* A recursive walk that happens to terminate and one that cannot recurse are
|
|
76
|
+
* different things, and only the second is safe to call per invocation without
|
|
77
|
+
* reasoning about depth.
|
|
78
|
+
*
|
|
79
|
+
* An instance asking gets only itself, which is correct: an instance provisions
|
|
80
|
+
* its own systems and owns nobody else's.
|
|
81
|
+
*
|
|
82
|
+
* Correct with no instances in existence, which is the state of every fleet
|
|
83
|
+
* until submodules ship: the table is empty, so every module gets `[itself]`,
|
|
84
|
+
* and a caller reaches only the systems it provisioned. That is the answer the
|
|
85
|
+
* allow-list wants today, and it picks up submodule behaviour later with no
|
|
86
|
+
* second edit.
|
|
87
|
+
*/
|
|
88
|
+
export function ownedSystemModuleIds(moduleId: string, db: DbClient): string[] {
|
|
89
|
+
const instances = db
|
|
90
|
+
.select({ moduleId: moduleInstances.moduleId })
|
|
91
|
+
.from(moduleInstances)
|
|
92
|
+
.where(eq(moduleInstances.parentId, moduleId))
|
|
93
|
+
.all();
|
|
94
|
+
|
|
95
|
+
return [moduleId, ...instances.map((row) => row.moduleId)];
|
|
96
|
+
}
|
|
@@ -20,7 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { type RunResult, type Runner, createMockRunner } from '@celilo/capabilities';
|
|
21
21
|
import type { DbClient } from '../db/client';
|
|
22
22
|
import { moduleSystems, modules } from '../db/schema';
|
|
23
|
-
import {
|
|
23
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
24
24
|
import { planJournalRead, readModuleJournal } from './module-journal';
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -64,7 +64,7 @@ let db: DbClient;
|
|
|
64
64
|
|
|
65
65
|
beforeEach(async () => {
|
|
66
66
|
dir = mkdtempSync(join(tmpdir(), 'module-journal-'));
|
|
67
|
-
db = await
|
|
67
|
+
db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
afterEach(() => {
|
|
@@ -9,7 +9,7 @@ import { closeDb, getDb } from '../db/client';
|
|
|
9
9
|
import { ModuleManifestSchema } from '../manifest/schema';
|
|
10
10
|
import { ModuleSubscriptionSchema } from '../manifest/schema';
|
|
11
11
|
import type { ModuleManifest } from '../manifest/schema';
|
|
12
|
-
import {
|
|
12
|
+
import { setupTestDatabaseAt as migrateDbFile } from '../test-utils/database';
|
|
13
13
|
import {
|
|
14
14
|
registerModuleSubscriptions,
|
|
15
15
|
resolveSubscription,
|
|
@@ -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', () => {
|
|
@@ -6,8 +6,9 @@ import type { PortForwardStore } from '@celilo/capabilities';
|
|
|
6
6
|
import { eq } from 'drizzle-orm';
|
|
7
7
|
import type { DbClient } from '../db/client';
|
|
8
8
|
import { portForwards } from '../db/schema';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
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' };
|
|
@@ -21,7 +22,7 @@ describe('port-forward store', () => {
|
|
|
21
22
|
dir = mkdtempSync(join(tmpdir(), 'pf-'));
|
|
22
23
|
const dbPath = join(dir, 'celilo.db');
|
|
23
24
|
process.env.CELILO_DB_PATH = dbPath;
|
|
24
|
-
db = await
|
|
25
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
25
26
|
store = buildPortForwardStore(db, 'caddy');
|
|
26
27
|
});
|
|
27
28
|
afterEach(() => {
|
|
@@ -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
|
-
}
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import type { DbClient } from '../db/client';
|
|
6
6
|
import { systemConfig } from '../db/schema';
|
|
7
7
|
import { loadTrustedSubnets } from '../hooks/capability-loader';
|
|
8
|
-
import {
|
|
8
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
9
9
|
import {
|
|
10
10
|
buildTrustedSourceStore,
|
|
11
11
|
composeTrustedSubnets,
|
|
@@ -26,7 +26,7 @@ describe('trusted-source store', () => {
|
|
|
26
26
|
dir = mkdtempSync(join(tmpdir(), 'ts-'));
|
|
27
27
|
const dbPath = join(dir, 'celilo.db');
|
|
28
28
|
process.env.CELILO_DB_PATH = dbPath;
|
|
29
|
-
db = await
|
|
29
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
30
30
|
});
|
|
31
31
|
afterEach(() => {
|
|
32
32
|
db.$client.close();
|
|
@@ -138,7 +138,7 @@ describe('the render input excludes registrations; the reporting view includes t
|
|
|
138
138
|
dir = mkdtempSync(join(tmpdir(), 'ts-render-'));
|
|
139
139
|
const dbPath = join(dir, 'celilo.db');
|
|
140
140
|
process.env.CELILO_DB_PATH = dbPath;
|
|
141
|
-
db = await
|
|
141
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
142
142
|
db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
|
|
143
143
|
buildTrustedSourceStore(db, 'wireguard').replace(FW, {
|
|
144
144
|
subnets: [VPN],
|
|
@@ -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
|
|
|
@@ -58,8 +58,30 @@ const removeModule = async (moduleId: string): Promise<void> => {
|
|
|
58
58
|
db.$client.prepare('DELETE FROM module_configs WHERE module_id = ?').run(moduleId);
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every module these tests name, as a real row. `module_configs` carries a
|
|
63
|
+
* foreign key onto `modules`, so without the parent the config write
|
|
64
|
+
* `ensureIngressIps` performs is rejected and the function reports failure —
|
|
65
|
+
* which is what happens in production too, and never happened here while the
|
|
66
|
+
* test helper ran with foreign keys off (celilo#1074).
|
|
67
|
+
*
|
|
68
|
+
* Worth knowing WHICH tests that would have broken. Only some: "two modules get
|
|
69
|
+
* two different addresses" fails loudly, but "REUSES the same address on a
|
|
70
|
+
* second generate" compares one unwritten value to another and passes. So a
|
|
71
|
+
* repair that chased the red would have left this file's central guard asserting
|
|
72
|
+
* `undefined === undefined`.
|
|
73
|
+
*/
|
|
74
|
+
const NAMED_MODULES = ['technitium', 'knot-unbound-internal', 'caddy', 'caddy-internal'];
|
|
75
|
+
|
|
61
76
|
beforeEach(async () => {
|
|
62
77
|
db = await setupTestDatabase();
|
|
78
|
+
for (const id of NAMED_MODULES) {
|
|
79
|
+
db.$client
|
|
80
|
+
.prepare(
|
|
81
|
+
'INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, ?, ?, ?)',
|
|
82
|
+
)
|
|
83
|
+
.run(id, id, '1.0.0', `/test/${id}`, '{}');
|
|
84
|
+
}
|
|
63
85
|
db.$client
|
|
64
86
|
.prepare('INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)')
|
|
65
87
|
.run('network.internal.subnet', '10.226.1.0/24');
|
|
@@ -78,6 +100,13 @@ describe('ensureIngressIps', () => {
|
|
|
78
100
|
// every time the module is regenerated, and nothing reports a problem.
|
|
79
101
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
80
102
|
const first = storedIp('technitium');
|
|
103
|
+
// Assert the read SUCCEEDED before comparing two of them. Without this the
|
|
104
|
+
// test passes whenever BOTH reads fail the same way, which is exactly what
|
|
105
|
+
// happened when foreign keys were switched on and the config write this
|
|
106
|
+
// depends on started being rejected (celilo#1074): `undefined` equals
|
|
107
|
+
// `undefined`, and the file's central guard stayed green while proving
|
|
108
|
+
// nothing.
|
|
109
|
+
expect(first).toBeDefined();
|
|
81
110
|
|
|
82
111
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
83
112
|
const second = storedIp('technitium');
|
|
@@ -88,6 +117,7 @@ describe('ensureIngressIps', () => {
|
|
|
88
117
|
test('stays stable across many generates, not just two', async () => {
|
|
89
118
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
90
119
|
const first = storedIp('technitium');
|
|
120
|
+
expect(first).toBeDefined();
|
|
91
121
|
|
|
92
122
|
for (let i = 0; i < 5; i++) {
|
|
93
123
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
@@ -179,6 +209,7 @@ describe('releasing ingress IPs on module removal', () => {
|
|
|
179
209
|
// one address per install/remove cycle until it runs out.
|
|
180
210
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
181
211
|
const first = storedIp('technitium');
|
|
212
|
+
expect(first).toBeDefined();
|
|
182
213
|
|
|
183
214
|
await removeModule('technitium');
|
|
184
215
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
13
13
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
14
|
-
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
14
|
+
import { type DbClient, createDbClient, findMigrationsFolder } from '../db/client';
|
|
15
15
|
import * as schema from '../db/schema';
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -31,6 +31,10 @@ import * as schema from '../db/schema';
|
|
|
31
31
|
*/
|
|
32
32
|
export async function setupTestDatabase(): Promise<DbClient> {
|
|
33
33
|
const sqlite = new Database(':memory:');
|
|
34
|
+
// Matches `createDbClient` (db/client.ts). SQLite defaults this OFF per
|
|
35
|
+
// connection, so without it every `onDelete: 'cascade'` in the schema is
|
|
36
|
+
// enforced in production and inert in the suite (celilo#1074).
|
|
37
|
+
sqlite.run('PRAGMA foreign_keys = ON');
|
|
34
38
|
const db = drizzle(sqlite, { schema });
|
|
35
39
|
const migrationsFolder = findMigrationsFolder();
|
|
36
40
|
await migrate(db, { migrationsFolder });
|
|
@@ -60,6 +64,8 @@ export async function setupTestDatabaseFile(): Promise<{
|
|
|
60
64
|
const tempDir = await mkdtemp(join(tmpdir(), 'celilo-test-'));
|
|
61
65
|
const dbPath = join(tempDir, 'test.db');
|
|
62
66
|
const sqlite = new Database(dbPath);
|
|
67
|
+
// See setupTestDatabase — celilo#1074.
|
|
68
|
+
sqlite.run('PRAGMA foreign_keys = ON');
|
|
63
69
|
const db = drizzle(sqlite, { schema });
|
|
64
70
|
const migrationsFolder = findMigrationsFolder();
|
|
65
71
|
await migrate(db, { migrationsFolder });
|
|
@@ -72,6 +78,30 @@ export async function setupTestDatabaseFile(): Promise<{
|
|
|
72
78
|
return { db, path: dbPath, cleanup };
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Create a test database at a path the CALLER chooses.
|
|
83
|
+
*
|
|
84
|
+
* The other two helpers pick the location — memory, or a temp directory they
|
|
85
|
+
* own. This one exists for tests that must hand the same path to something
|
|
86
|
+
* else, typically a spawned CLI through `CELILO_DB_PATH`.
|
|
87
|
+
*
|
|
88
|
+
* It was a second module, `test-utils/setup-test-db.ts`, exporting a function
|
|
89
|
+
* ALSO called `setupTestDatabase` and another also called
|
|
90
|
+
* `cleanupTestDatabase`, with different arities and — until celilo#1074 —
|
|
91
|
+
* opposite foreign-key semantics. Which contract a test was under depended
|
|
92
|
+
* entirely on which file its import line named, and nothing at the call site
|
|
93
|
+
* showed it. One caller had already aliased the import to `migrateDbFile` to
|
|
94
|
+
* make it readable. The two colliding names are gone rather than renamed: there
|
|
95
|
+
* is no longer a wrong one to pick.
|
|
96
|
+
*
|
|
97
|
+
* @param testDbPath - Where to create the database file
|
|
98
|
+
*/
|
|
99
|
+
export async function setupTestDatabaseAt(testDbPath: string): Promise<DbClient> {
|
|
100
|
+
const db = createDbClient({ path: testDbPath });
|
|
101
|
+
await migrate(db, { migrationsFolder: findMigrationsFolder() });
|
|
102
|
+
return db;
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
/**
|
|
76
106
|
* Clean up test database
|
|
77
107
|
*
|
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
|
|