@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration gate for celilo#1010, over a database that already holds records.
|
|
3
|
+
*
|
|
4
|
+
* `0027` rebuilds `dns_internal_records` to drop the foreign key on
|
|
5
|
+
* `provider_module_id`. A rebuild is copy, drop, rename — the most destructive
|
|
6
|
+
* shape a migration takes — and it will run on celilo-mgr, which holds the live
|
|
7
|
+
* internal DNS ledger including the `zone_routable_ip` view overrides the
|
|
8
|
+
* resolver's split-horizon config is reconciled from.
|
|
9
|
+
*
|
|
10
|
+
* Every other test in the suite starts from an empty database and can only prove
|
|
11
|
+
* the forward invariant. This one builds a database at the schema BEFORE the
|
|
12
|
+
* change, puts real rows in it, and runs the real migrator over it — the same
|
|
13
|
+
* shape as `dns-registrations-migration.test.ts`, and for the same reason: the
|
|
14
|
+
* installed base is where the risk is, and a clean-start suite cannot see it.
|
|
15
|
+
*
|
|
16
|
+
* Task 2.6 asks for backup and restore proven before this runs on celilo-mgr.
|
|
17
|
+
* This is the stronger half of that: it proves the migration PRESERVES the rows,
|
|
18
|
+
* so a restore is the fallback rather than the plan.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Database } from 'bun:sqlite';
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
23
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
27
|
+
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
28
|
+
import { findMigrationsFolder } from './client';
|
|
29
|
+
|
|
30
|
+
/** The last migration before this change — the schema celilo-mgr is on today. */
|
|
31
|
+
const LAST_LEGACY_TAG = '0026_module_integrity_version';
|
|
32
|
+
|
|
33
|
+
interface JournalEntry {
|
|
34
|
+
idx: number;
|
|
35
|
+
version: string;
|
|
36
|
+
when: number;
|
|
37
|
+
tag: string;
|
|
38
|
+
breakpoints: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A migrations folder truncated at `LAST_LEGACY_TAG`, built from the real files. */
|
|
42
|
+
function legacyMigrationsFolder(into: string): string {
|
|
43
|
+
const source = findMigrationsFolder();
|
|
44
|
+
const journal = JSON.parse(readFileSync(join(source, 'meta', '_journal.json'), 'utf8')) as {
|
|
45
|
+
version: string;
|
|
46
|
+
dialect: string;
|
|
47
|
+
entries: JournalEntry[];
|
|
48
|
+
};
|
|
49
|
+
const cutoff = journal.entries.findIndex((e) => e.tag === LAST_LEGACY_TAG);
|
|
50
|
+
if (cutoff === -1) throw new Error(`Journal has no entry for ${LAST_LEGACY_TAG}`);
|
|
51
|
+
const kept = journal.entries.slice(0, cutoff + 1);
|
|
52
|
+
|
|
53
|
+
mkdirSync(join(into, 'meta'), { recursive: true });
|
|
54
|
+
for (const entry of kept) {
|
|
55
|
+
cpSync(join(source, `${entry.tag}.sql`), join(into, `${entry.tag}.sql`));
|
|
56
|
+
}
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(into, 'meta', '_journal.json'),
|
|
59
|
+
JSON.stringify({ ...journal, entries: kept }, null, 2),
|
|
60
|
+
);
|
|
61
|
+
return into;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('0027 over a database that already holds an internal DNS ledger', () => {
|
|
65
|
+
let tempDir: string;
|
|
66
|
+
let dbPath: string;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsint-'));
|
|
70
|
+
dbPath = join(tempDir, 'legacy.db');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function openLegacyDatabase(): Database {
|
|
78
|
+
const sqlite = new Database(dbPath, { create: true });
|
|
79
|
+
sqlite.run('PRAGMA foreign_keys = ON');
|
|
80
|
+
migrate(drizzle(sqlite), {
|
|
81
|
+
migrationsFolder: legacyMigrationsFolder(join(tempDir, 'legacy-migrations')),
|
|
82
|
+
});
|
|
83
|
+
return sqlite;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function seedModule(sqlite: Database, id: string): void {
|
|
87
|
+
sqlite.run(
|
|
88
|
+
'INSERT INTO modules (id, name, source_path, version, manifest_data) VALUES (?, ?, ?, ?, ?)',
|
|
89
|
+
[id, id, `/srv/${id}`, '1.0.0', JSON.stringify({ id })],
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function seedLedger(sqlite: Database): void {
|
|
94
|
+
seedModule(sqlite, 'technitium');
|
|
95
|
+
seedModule(sqlite, 'knot-unbound-internal');
|
|
96
|
+
seedModule(sqlite, 'caddy');
|
|
97
|
+
seedModule(sqlite, 'forgejo');
|
|
98
|
+
sqlite.run(
|
|
99
|
+
'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, ?)',
|
|
100
|
+
['technitium', 'caddy', 'auth.example.org', '192.168.0.253', '10.0.10.14'],
|
|
101
|
+
);
|
|
102
|
+
sqlite.run(
|
|
103
|
+
'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, NULL)',
|
|
104
|
+
['technitium', 'forgejo', 'git.example.org', '192.168.0.253'],
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runCurrentMigrations(sqlite: Database): void {
|
|
109
|
+
migrate(drizzle(sqlite), { migrationsFolder: findMigrationsFolder() });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('the rebuild carries every row across, values intact', () => {
|
|
113
|
+
const sqlite = openLegacyDatabase();
|
|
114
|
+
seedLedger(sqlite);
|
|
115
|
+
|
|
116
|
+
runCurrentMigrations(sqlite);
|
|
117
|
+
|
|
118
|
+
const rows = sqlite
|
|
119
|
+
.query<
|
|
120
|
+
{ host: string; ip: string; zone_routable_ip: string | null; provider_module_id: string },
|
|
121
|
+
[]
|
|
122
|
+
>(
|
|
123
|
+
'SELECT host, ip, zone_routable_ip, provider_module_id FROM dns_internal_records ORDER BY host',
|
|
124
|
+
)
|
|
125
|
+
.all();
|
|
126
|
+
expect(rows).toHaveLength(2);
|
|
127
|
+
expect(rows[0]?.host).toBe('auth.example.org');
|
|
128
|
+
// The override is the value whose loss is silent: the resolver keeps
|
|
129
|
+
// answering, just with an address in-zone clients cannot route to.
|
|
130
|
+
expect(rows[0]?.zone_routable_ip).toBe('10.0.10.14');
|
|
131
|
+
expect(rows[1]?.zone_routable_ip).toBeNull();
|
|
132
|
+
// Attribution survives; only its ON DELETE action changed.
|
|
133
|
+
expect(rows[0]?.provider_module_id).toBe('technitium');
|
|
134
|
+
sqlite.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('after migrating, removing the PROVIDER no longer empties the ledger', () => {
|
|
138
|
+
const sqlite = openLegacyDatabase();
|
|
139
|
+
seedLedger(sqlite);
|
|
140
|
+
runCurrentMigrations(sqlite);
|
|
141
|
+
|
|
142
|
+
sqlite.run('DELETE FROM modules WHERE id = ?', ['technitium']);
|
|
143
|
+
|
|
144
|
+
expect(
|
|
145
|
+
sqlite.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM dns_internal_records').get()?.c,
|
|
146
|
+
).toBe(2);
|
|
147
|
+
sqlite.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The behaviour that did NOT change. Dropping a foreign key is an easy way to
|
|
152
|
+
* lose the one you meant to keep, and nothing else would notice.
|
|
153
|
+
*/
|
|
154
|
+
test('the consumer cascade still fires after the rebuild', () => {
|
|
155
|
+
const sqlite = openLegacyDatabase();
|
|
156
|
+
seedLedger(sqlite);
|
|
157
|
+
runCurrentMigrations(sqlite);
|
|
158
|
+
|
|
159
|
+
sqlite.run('DELETE FROM modules WHERE id = ?', ['caddy']);
|
|
160
|
+
|
|
161
|
+
const left = sqlite
|
|
162
|
+
.query<{ consumer_module_id: string }, []>(
|
|
163
|
+
'SELECT consumer_module_id FROM dns_internal_records',
|
|
164
|
+
)
|
|
165
|
+
.all();
|
|
166
|
+
expect(left.map((r) => r.consumer_module_id)).toEqual(['forgejo']);
|
|
167
|
+
sqlite.close();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** The unique index is recreated by the rebuild, not left behind with the old table. */
|
|
171
|
+
test('the provider+host uniqueness survives the rebuild', () => {
|
|
172
|
+
const sqlite = openLegacyDatabase();
|
|
173
|
+
seedLedger(sqlite);
|
|
174
|
+
runCurrentMigrations(sqlite);
|
|
175
|
+
|
|
176
|
+
expect(() =>
|
|
177
|
+
sqlite.run(
|
|
178
|
+
'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip) VALUES (?, ?, ?, ?)',
|
|
179
|
+
['technitium', 'forgejo', 'auth.example.org', '192.168.0.253'],
|
|
180
|
+
),
|
|
181
|
+
).toThrow();
|
|
182
|
+
sqlite.close();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen-watermark recurrence gate (celilo#169).
|
|
3
|
+
*
|
|
4
|
+
* The state under test is one no correct deploy can produce, so it is seeded
|
|
5
|
+
* by hand rather than reached: a database written by a celilo from the
|
|
6
|
+
* imperative hand-list era, where schema changes were applied directly and
|
|
7
|
+
* `__drizzle_migrations` never recorded them. Its ledger therefore remembers
|
|
8
|
+
* an old migration while the tables and columns of every later one are already
|
|
9
|
+
* present.
|
|
10
|
+
*
|
|
11
|
+
* That is the carve-out CLAUDE.md draws around seeding state: deploying
|
|
12
|
+
* anything cannot reproduce this row, because current celilo has recorded
|
|
13
|
+
* every migration it applied since ISS-0100 made drizzle authoritative. A
|
|
14
|
+
* suite that starts from an empty database can only ever prove the forward
|
|
15
|
+
* invariant, and says nothing about the installed base.
|
|
16
|
+
*
|
|
17
|
+
* What made it a hazard: drizzle's migrator is watermark-only. It re-runs
|
|
18
|
+
* every migration newer than the newest ledger row, so the first `ALTER TABLE
|
|
19
|
+
* ... ADD` dies on `duplicate column name`, the transaction rolls back, and
|
|
20
|
+
* the throw happens inside `createDbClient` — so EVERY celilo command on that
|
|
21
|
+
* box fails at database open, not just a migrate. celilo-mgr was remediated by
|
|
22
|
+
* hand once. This gate is what stops the next one needing a runbook.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
26
|
+
import { rmSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { type DbClient, createDbClient } from './client';
|
|
30
|
+
import { runMigrationsOn } from './migrate';
|
|
31
|
+
import { findSchemaDrift } from './schema-introspection';
|
|
32
|
+
|
|
33
|
+
describe('runMigrationsOn — a database from the hand-list era', () => {
|
|
34
|
+
const paths: string[] = [];
|
|
35
|
+
|
|
36
|
+
const freshDb = (): { db: DbClient; path: string } => {
|
|
37
|
+
const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
|
|
38
|
+
paths.push(path);
|
|
39
|
+
return { db: createDbClient({ path }), path };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const path of paths.splice(0)) {
|
|
44
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
45
|
+
rmSync(`${path}${suffix}`, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Freeze the ledger to its oldest entry, leaving the schema fully applied.
|
|
52
|
+
* This is the hand-list-era shape: the objects exist, the ledger has
|
|
53
|
+
* forgotten who made them.
|
|
54
|
+
*/
|
|
55
|
+
const freezeWatermark = (db: DbClient): void => {
|
|
56
|
+
db.$client.run(
|
|
57
|
+
'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const appliedCount = (db: DbClient): number =>
|
|
62
|
+
db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
|
|
63
|
+
?.c ?? 0;
|
|
64
|
+
|
|
65
|
+
test('converges instead of dying on the first already-applied statement', () => {
|
|
66
|
+
const { db } = freshDb();
|
|
67
|
+
const migrationCount = appliedCount(db);
|
|
68
|
+
freezeWatermark(db);
|
|
69
|
+
expect(appliedCount(db)).toBe(1);
|
|
70
|
+
|
|
71
|
+
// Red before the ledger repair: drizzle re-runs 0001 and throws
|
|
72
|
+
// "duplicate column name: role", leaving the ledger frozen.
|
|
73
|
+
expect(() => runMigrationsOn(db)).not.toThrow();
|
|
74
|
+
|
|
75
|
+
expect(appliedCount(db)).toBe(migrationCount);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('leaves the schema whole, so the drift detector goes green', () => {
|
|
79
|
+
const { db } = freshDb();
|
|
80
|
+
freezeWatermark(db);
|
|
81
|
+
|
|
82
|
+
runMigrationsOn(db);
|
|
83
|
+
|
|
84
|
+
const drift = findSchemaDrift(db.$client);
|
|
85
|
+
expect(drift.missingTables).toEqual([]);
|
|
86
|
+
expect(drift.missingColumns).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('is idempotent — a second pass applies nothing and still converges', () => {
|
|
90
|
+
const { db } = freshDb();
|
|
91
|
+
freezeWatermark(db);
|
|
92
|
+
runMigrationsOn(db);
|
|
93
|
+
const afterBaseline = appliedCount(db);
|
|
94
|
+
|
|
95
|
+
runMigrationsOn(db);
|
|
96
|
+
|
|
97
|
+
expect(appliedCount(db)).toBe(afterBaseline);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The repair must not become a blanket "assume it already ran". It fires only
|
|
102
|
+
* where the answer is unambiguous — the declared schema is entirely present,
|
|
103
|
+
* so every migration plainly did run and the ledger is what is wrong. A
|
|
104
|
+
* PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
|
|
105
|
+
* was actually in: it carried 0011's column and not 0010's table. Stamping
|
|
106
|
+
* there would record migrations that never ran and bury the missing schema
|
|
107
|
+
* for good, so it keeps failing and a human decides.
|
|
108
|
+
*/
|
|
109
|
+
test('refuses to stamp when the schema is only partly there', () => {
|
|
110
|
+
const { db } = freshDb();
|
|
111
|
+
freezeWatermark(db);
|
|
112
|
+
db.$client.run('DROP TABLE `dns_registrations`');
|
|
113
|
+
|
|
114
|
+
expect(() => runMigrationsOn(db)).toThrow();
|
|
115
|
+
// The ledger is left exactly as found, so the drift is still diagnosable.
|
|
116
|
+
expect(appliedCount(db)).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The path that actually matters. Every celilo command opens the database
|
|
121
|
+
* through createDbClient, which migrates on open, so a frozen watermark
|
|
122
|
+
* failed there rather than anywhere an operator could aim a fix at — and
|
|
123
|
+
* `celilo system migrate`, the command whose whole job is repairing this,
|
|
124
|
+
* reached its own repair through the same open and died first.
|
|
125
|
+
*/
|
|
126
|
+
test('repairs on database open, not just when migrate is called by hand', () => {
|
|
127
|
+
const { db, path } = freshDb();
|
|
128
|
+
const migrationCount = appliedCount(db);
|
|
129
|
+
freezeWatermark(db);
|
|
130
|
+
db.$client.close();
|
|
131
|
+
|
|
132
|
+
const reopened = createDbClient({ path });
|
|
133
|
+
|
|
134
|
+
expect(appliedCount(reopened)).toBe(migrationCount);
|
|
135
|
+
expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a healthy database is untouched by the fallback', () => {
|
|
139
|
+
const { db } = freshDb();
|
|
140
|
+
const before = appliedCount(db);
|
|
141
|
+
|
|
142
|
+
runMigrationsOn(db);
|
|
143
|
+
|
|
144
|
+
expect(appliedCount(db)).toBe(before);
|
|
145
|
+
expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/db/migrate.ts
CHANGED
|
@@ -1,14 +1,82 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
1
2
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
3
|
+
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
|
2
4
|
import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
|
|
5
|
+
import { findSchemaDrift } from './schema-introspection';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Record every migration past the ledger's watermark as applied, running none
|
|
9
|
+
* of them. Only safe when the caller has already established that the schema
|
|
10
|
+
* the code declares is entirely present.
|
|
11
|
+
*
|
|
12
|
+
* Replaying the statements instead would be wrong, and quietly so. Migrations
|
|
13
|
+
* are not idempotent: `0021_dns_registration_consumers` rebuilds a table by
|
|
14
|
+
* copying it aside, `DROP TABLE`-ing the original and renaming the copy over
|
|
15
|
+
* it. Run that against a schema that is already current and it drops a live
|
|
16
|
+
* table. Skipping the statements that fail with "already exists" does not save
|
|
17
|
+
* you either, because `DROP` and `INSERT ... SELECT` do not fail that way.
|
|
18
|
+
*
|
|
19
|
+
* So the ledger is corrected and the schema is left alone.
|
|
20
|
+
*/
|
|
21
|
+
function stampLedgerAsApplied(sqlite: Database, migrationsFolder: string): void {
|
|
22
|
+
const newest = sqlite
|
|
23
|
+
.query<{ created_at: number }, []>(
|
|
24
|
+
'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1',
|
|
25
|
+
)
|
|
26
|
+
.get();
|
|
27
|
+
const watermark = Number(newest?.created_at ?? 0);
|
|
28
|
+
|
|
29
|
+
sqlite.run('BEGIN');
|
|
30
|
+
try {
|
|
31
|
+
for (const migration of readMigrationFiles({ migrationsFolder })) {
|
|
32
|
+
if (migration.folderMillis <= watermark) continue;
|
|
33
|
+
sqlite.run('INSERT INTO `__drizzle_migrations` ("hash", "created_at") VALUES (?, ?)', [
|
|
34
|
+
migration.hash,
|
|
35
|
+
migration.folderMillis,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
sqlite.run('COMMIT');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
sqlite.run('ROLLBACK');
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
3
44
|
|
|
4
45
|
/**
|
|
5
46
|
* Apply pending drizzle migrations to an open DB. Idempotent — drizzle applies
|
|
6
47
|
* only migrations newer than the latest recorded in `__drizzle_migrations`.
|
|
7
48
|
* The single migration mechanism (ISS-0100); createDbClient also calls this
|
|
8
49
|
* shape on open (auto-migrate).
|
|
50
|
+
*
|
|
51
|
+
* The stock migrator runs first and handles every database celilo has written
|
|
52
|
+
* since ISS-0100, so a healthy box takes exactly the path it took before.
|
|
53
|
+
*
|
|
54
|
+
* It fails on one database celilo did not write: one from the imperative
|
|
55
|
+
* hand-list era, where schema changes were applied directly and
|
|
56
|
+
* `__drizzle_migrations` never recorded them. Its ledger remembers an old
|
|
57
|
+
* migration while the objects of every later one are already there, and
|
|
58
|
+
* drizzle — being watermark-only — re-runs them and dies on the first
|
|
59
|
+
* `ALTER TABLE ... ADD`. The throw happens inside `createDbClient`, so every
|
|
60
|
+
* celilo command on that box fails at database open. celilo-mgr, the one box
|
|
61
|
+
* in that state, was remediated by hand (celilo#169); this is so the next one
|
|
62
|
+
* is not.
|
|
63
|
+
*
|
|
64
|
+
* The repair is only attempted when the schema the code declares is ALREADY
|
|
65
|
+
* COMPLETE, because that is the one case with an unambiguous answer: every
|
|
66
|
+
* migration has plainly run, so the ledger is what is wrong. A database missing
|
|
67
|
+
* some of it is the genuinely hard case — celilo-mgr had 0011's column and not
|
|
68
|
+
* 0010's table — and there is no safe automatic answer, so it keeps failing
|
|
69
|
+
* with drizzle's own error and a human decides.
|
|
9
70
|
*/
|
|
10
71
|
export function runMigrationsOn(db: DbClient): void {
|
|
11
|
-
|
|
72
|
+
const migrationsFolder = findMigrationsFolder();
|
|
73
|
+
try {
|
|
74
|
+
migrate(db, { migrationsFolder });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const drift = findSchemaDrift(db.$client);
|
|
77
|
+
if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) throw error;
|
|
78
|
+
stampLedgerAsApplied(db.$client, migrationsFolder);
|
|
79
|
+
}
|
|
12
80
|
}
|
|
13
81
|
|
|
14
82
|
/**
|
package/src/db/schema.ts
CHANGED
|
@@ -801,16 +801,33 @@ export const publicDnsEvidence = sqliteTable('public_dns_evidence', {
|
|
|
801
801
|
*
|
|
802
802
|
* `celilo system doctor` reads this to assert service hostnames resolve to
|
|
803
803
|
* the firewall natIp (LAN-reachable) and not a zone-side container IP that
|
|
804
|
-
* a LAN device can't route to. Rows die with
|
|
804
|
+
* a LAN device can't route to. Rows die with their CONSUMER via FK cascade, and
|
|
805
|
+
* NOT with their provider (celilo#1010 — see `providerModuleId`).
|
|
805
806
|
* @owner capability:dns_internal — resolver configuration; migrates to the provider (T6)
|
|
806
807
|
*/
|
|
807
808
|
export const dnsInternalRecords = sqliteTable(
|
|
808
809
|
'dns_internal_records',
|
|
809
810
|
{
|
|
810
811
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
812
|
+
/**
|
|
813
|
+
* The resolver serving this record. A PLAIN column with no foreign key, and
|
|
814
|
+
* that is the celilo#1010 correction rather than an oversight.
|
|
815
|
+
*
|
|
816
|
+
* It used to cascade, so swapping `technitium` for `knot-unbound-internal`
|
|
817
|
+
* deleted the fleet's entire internal DNS ledger, `zone_routable_ip` view
|
|
818
|
+
* overrides included. `web_routes` cascades on its consumer only and the two
|
|
819
|
+
* docblocks claimed to be siblings, so the divergence read as intent and was
|
|
820
|
+
* not. The claim on a capability-owned table is the CONSUMER
|
|
821
|
+
* (openspec/changes/capability-owned-tables D3/D8), and `dns_internal`'s
|
|
822
|
+
* declaration cannot express anything else.
|
|
823
|
+
*
|
|
824
|
+
* Attribution is still real and still enforced — it is half
|
|
825
|
+
* `dns_internal_records_provider_host_idx` — it just no longer decides when a
|
|
826
|
+
* LIVE record is forgotten. A provider leaving now leaves the ledger for the
|
|
827
|
+
* next one to reconcile from, which is what stage 1's provider-arrival
|
|
828
|
+
* backfill assumes. Migration `0027`.
|
|
829
|
+
*/
|
|
830
|
+
providerModuleId: text('provider_module_id').notNull(),
|
|
814
831
|
consumerModuleId: text('consumer_module_id')
|
|
815
832
|
.notNull()
|
|
816
833
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
@@ -29,6 +29,17 @@ export default function registerHost(context) {
|
|
|
29
29
|
}
|
|
30
30
|
`;
|
|
31
31
|
|
|
32
|
+
const TEST_DHCP_SERVER_MODULE = `
|
|
33
|
+
export default function createDhcpServer(context) {
|
|
34
|
+
return {
|
|
35
|
+
async setDnsServers() {},
|
|
36
|
+
async getDnsServers() { return [context.config.marker]; },
|
|
37
|
+
async setDomainName() {},
|
|
38
|
+
async getDomainName() { return context.config.marker; },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
|
|
32
43
|
describe('Capability Loader', () => {
|
|
33
44
|
let db: DbClient;
|
|
34
45
|
let tempDir: string;
|
|
@@ -97,6 +108,50 @@ describe('Capability Loader', () => {
|
|
|
97
108
|
expect(result.dns_registrar).toBeTruthy();
|
|
98
109
|
});
|
|
99
110
|
|
|
111
|
+
test('prefers a provider explicitly scoped to the well-known capability zone', async () => {
|
|
112
|
+
for (const moduleId of ['upstream-dhcp', 'internal-dhcp']) {
|
|
113
|
+
const modulePath = join(tempDir, moduleId);
|
|
114
|
+
const scriptsDir = join(modulePath, 'scripts');
|
|
115
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
116
|
+
writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE);
|
|
117
|
+
db.$client.run(
|
|
118
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${modulePath}', '{}')`,
|
|
119
|
+
);
|
|
120
|
+
upsertModuleConfig(db, moduleId, 'marker', moduleId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Insert the zone-agnostic upstream provider first to prove selection is
|
|
124
|
+
// policy-driven rather than database-order-driven.
|
|
125
|
+
db.$client.run(
|
|
126
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`,
|
|
127
|
+
);
|
|
128
|
+
db.$client.run(
|
|
129
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('internal-dhcp', 'dhcp_server', '1.0.0', '{}', '["internal"]', unixepoch())`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger);
|
|
133
|
+
const dhcp = result.dhcp_server as { getDomainName(): Promise<string> };
|
|
134
|
+
expect(await dhcp.getDomainName()).toBe('internal-dhcp');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
|
|
138
|
+
const modulePath = join(tempDir, 'upstream-dhcp');
|
|
139
|
+
const scriptsDir = join(modulePath, 'scripts');
|
|
140
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
141
|
+
writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE);
|
|
142
|
+
db.$client.run(
|
|
143
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('upstream-dhcp', 'upstream-dhcp', '1.0.0', '${modulePath}', '{}')`,
|
|
144
|
+
);
|
|
145
|
+
db.$client.run(
|
|
146
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`,
|
|
147
|
+
);
|
|
148
|
+
upsertModuleConfig(db, 'upstream-dhcp', 'marker', 'upstream-dhcp');
|
|
149
|
+
|
|
150
|
+
const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger);
|
|
151
|
+
const dhcp = result.dhcp_server as { getDomainName(): Promise<string> };
|
|
152
|
+
expect(await dhcp.getDomainName()).toBe('upstream-dhcp');
|
|
153
|
+
});
|
|
154
|
+
|
|
100
155
|
test('injects a read-only web_routes view into the public_web provider own hooks (ISS-0035)', async () => {
|
|
101
156
|
const modulePath = join(tempDir, 'caddy');
|
|
102
157
|
mkdirSync(modulePath, { recursive: true });
|
|
@@ -27,6 +27,8 @@ import type {
|
|
|
27
27
|
TrustedSourceStore,
|
|
28
28
|
} from '@celilo/capabilities';
|
|
29
29
|
import { and, eq } from 'drizzle-orm';
|
|
30
|
+
import { selectCapabilityProvider } from '../capabilities/lookup';
|
|
31
|
+
import { WELL_KNOWN_CAPABILITIES } from '../capabilities/well-known';
|
|
30
32
|
import type { DbClient } from '../db/client';
|
|
31
33
|
import {
|
|
32
34
|
NETWORK_ZONES,
|
|
@@ -279,7 +281,20 @@ export async function loadCapabilityFunctions(
|
|
|
279
281
|
continue;
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
|
|
284
|
+
// A well-known capability's required zone is also its runtime selection
|
|
285
|
+
// context. Prefer a provider that explicitly serves that zone, then fall
|
|
286
|
+
// back to a zone-agnostic provider. Without this, multiple DHCP providers
|
|
287
|
+
// (for example an upstream router plus an internal LAN server) are selected
|
|
288
|
+
// by database insertion order and a consumer can reconfigure the wrong
|
|
289
|
+
// network boundary.
|
|
290
|
+
const selectionZone = WELL_KNOWN_CAPABILITIES[capName]?.required_zone;
|
|
291
|
+
const capability = selectCapabilityProvider(allProviders, selectionZone);
|
|
292
|
+
if (!capability) {
|
|
293
|
+
debugLog(
|
|
294
|
+
`${capName}: no provider serves required zone ${selectionZone ?? '(unspecified)'}, skipping`,
|
|
295
|
+
);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
283
298
|
debugLog(`${capName}: found provider module ${capability.moduleId}`);
|
|
284
299
|
|
|
285
300
|
const providerModule = db
|
|
@@ -258,4 +258,51 @@ resource "local_file" "test" {
|
|
|
258
258
|
expect(errors[0]?.variable).toBe('$self:invalid_var');
|
|
259
259
|
});
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The capability-secret gate at import consumes these, so what happens when
|
|
264
|
+
* templates cannot be read is a security property rather than a detail. It
|
|
265
|
+
* has to FAIL CLOSED: an unreadable tree must abort the import, never hand
|
|
266
|
+
* the gate an empty reference set that reads as "this module references no
|
|
267
|
+
* secrets". It was already true here — but true by inspection, which is the
|
|
268
|
+
* weakest way for a security property to be true.
|
|
269
|
+
*/
|
|
270
|
+
describe('capability references, and failing closed', () => {
|
|
271
|
+
test('collects $capability: references from templates', async () => {
|
|
272
|
+
const dir = await mkdtemp(join(tmpdir(), 'celilo-tplrefs-'));
|
|
273
|
+
try {
|
|
274
|
+
await mkdir(join(dir, 'terraform'), { recursive: true });
|
|
275
|
+
await writeFile(
|
|
276
|
+
join(dir, 'terraform', 'main.tf.tpl'),
|
|
277
|
+
'acme_dns = "$capability:dns_internal.tsig_key"\nzone = "$capability:dns_internal.dns.domain"\n',
|
|
278
|
+
);
|
|
279
|
+
const manifest = createTestManifest({
|
|
280
|
+
requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = await validateModuleTemplates(dir, manifest);
|
|
284
|
+
|
|
285
|
+
expect(result.capabilityReferences.sort()).toEqual([
|
|
286
|
+
'dns_internal.dns.domain',
|
|
287
|
+
'dns_internal.tsig_key',
|
|
288
|
+
]);
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(dir, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('an unreadable module tree fails, and yields no references', async () => {
|
|
295
|
+
// A path that does not exist stands in for any read failure. The PAIRING
|
|
296
|
+
// is the property: success:false travels WITH the empty array, and
|
|
297
|
+
// `import.ts` returns on !success before the access gate runs, so the
|
|
298
|
+
// empty set can never be mistaken for "nothing referenced".
|
|
299
|
+
const result = await validateModuleTemplates(
|
|
300
|
+
join(tmpdir(), 'celilo-no-such-module-tree-9f3a2b'),
|
|
301
|
+
createTestManifest(),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(result.success).toBe(false);
|
|
305
|
+
expect(result.capabilityReferences).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
261
308
|
});
|
|
@@ -19,6 +19,12 @@ export interface TemplateValidationError {
|
|
|
19
19
|
export interface TemplateValidationResult {
|
|
20
20
|
success: boolean;
|
|
21
21
|
errors: TemplateValidationError[];
|
|
22
|
+
/**
|
|
23
|
+
* `<capability>.<path>` paths the templates reference. The capability-access
|
|
24
|
+
* check at import consumes these, so a secret referenced only from a template
|
|
25
|
+
* is refused at import rather than later at generation.
|
|
26
|
+
*/
|
|
27
|
+
capabilityReferences: string[];
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
@@ -277,10 +283,16 @@ export async function validateModuleTemplates(
|
|
|
277
283
|
// Find all .tpl files
|
|
278
284
|
const templateFiles = await findTemplateFiles(modulePath, modulePath);
|
|
279
285
|
|
|
280
|
-
// Validate each template file
|
|
286
|
+
// Validate each template file. Every one is parsed here anyway, so the
|
|
287
|
+
// capability references fall out of work already being done — no second
|
|
288
|
+
// walk of the module tree.
|
|
289
|
+
const capabilityReferences = new Set<string>();
|
|
281
290
|
for (const relativePath of templateFiles) {
|
|
282
291
|
const fullPath = join(modulePath, relativePath);
|
|
283
292
|
const content = await readFile(fullPath, 'utf-8');
|
|
293
|
+
for (const variable of parseVariables(content)) {
|
|
294
|
+
if (variable.type === 'capability') capabilityReferences.add(variable.path);
|
|
295
|
+
}
|
|
284
296
|
const errors = validateTemplateContent(content, manifest, relativePath);
|
|
285
297
|
allErrors.push(...errors);
|
|
286
298
|
}
|
|
@@ -288,6 +300,7 @@ export async function validateModuleTemplates(
|
|
|
288
300
|
return {
|
|
289
301
|
success: allErrors.length === 0,
|
|
290
302
|
errors: allErrors,
|
|
303
|
+
capabilityReferences: [...capabilityReferences],
|
|
291
304
|
};
|
|
292
305
|
} catch (error) {
|
|
293
306
|
return {
|
|
@@ -299,6 +312,10 @@ export async function validateModuleTemplates(
|
|
|
299
312
|
error: `Failed to validate templates: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
300
313
|
},
|
|
301
314
|
],
|
|
315
|
+
// Unreadable templates cannot yield references, and import returns on
|
|
316
|
+
// !success before the access check runs, so this can never reach it as a
|
|
317
|
+
// silent pass.
|
|
318
|
+
capabilityReferences: [],
|
|
302
319
|
};
|
|
303
320
|
}
|
|
304
321
|
}
|