@celilo/cli 1.6.0 → 1.7.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 +2 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/MODULE_PRIMITIVES.md +6 -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 +187 -2
- package/src/capabilities/validation.ts +53 -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/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/module/import.ts +20 -5
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/consumer-cleanup.ts +5 -3
- 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-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/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- 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 +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
package/src/cli/completion.ts
CHANGED
|
@@ -325,7 +325,15 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
if (command === 'service' && currentIndex === 1) {
|
|
328
|
-
const subcommands = [
|
|
328
|
+
const subcommands = [
|
|
329
|
+
'add',
|
|
330
|
+
'list',
|
|
331
|
+
'verify',
|
|
332
|
+
'reconfigure',
|
|
333
|
+
'remove',
|
|
334
|
+
'config',
|
|
335
|
+
'set-credentials',
|
|
336
|
+
];
|
|
329
337
|
return filterSuggestions(subcommands, args[1] || '');
|
|
330
338
|
}
|
|
331
339
|
|
|
@@ -356,6 +364,13 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
356
364
|
return filterSuggestions(serviceIds, args[2] || '');
|
|
357
365
|
}
|
|
358
366
|
|
|
367
|
+
// Service set-credentials - complete with service IDs
|
|
368
|
+
if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) {
|
|
369
|
+
const services = await listContainerServices();
|
|
370
|
+
const serviceIds = services.map((s) => s.serviceId);
|
|
371
|
+
return filterSuggestions(serviceIds, args[2] || '');
|
|
372
|
+
}
|
|
373
|
+
|
|
359
374
|
// Service config operations
|
|
360
375
|
if (command === 'service' && args[1] === 'config' && currentIndex === 2) {
|
|
361
376
|
const operations = ['get', 'set'];
|
package/src/cli/index.ts
CHANGED
|
@@ -109,6 +109,7 @@ import { handleServiceConfigSet } from './commands/service-config-set';
|
|
|
109
109
|
import { handleServiceList } from './commands/service-list';
|
|
110
110
|
import { handleServiceReconfigure } from './commands/service-reconfigure';
|
|
111
111
|
import { handleServiceRemove } from './commands/service-remove';
|
|
112
|
+
import { handleServiceSetCredentials } from './commands/service-set-credentials';
|
|
112
113
|
import { handleServiceVerify } from './commands/service-verify';
|
|
113
114
|
import { handleStatus } from './commands/status';
|
|
114
115
|
import { handleSubscribersAdd } from './commands/subscribers-add';
|
|
@@ -721,6 +722,7 @@ Subcommands:
|
|
|
721
722
|
Options:
|
|
722
723
|
--zone <zone> Filter by network zone
|
|
723
724
|
verify <service-id> Re-verify a container service connection
|
|
725
|
+
set-credentials <service-id> Update a provider endpoint or API credential
|
|
724
726
|
reconfigure <service-id> Re-run configuration interview (change template, storage, etc.)
|
|
725
727
|
remove <id> Remove a container service
|
|
726
728
|
Options:
|
|
@@ -751,6 +753,9 @@ Examples:
|
|
|
751
753
|
# Verify a service connection
|
|
752
754
|
celilo service verify proxmox-home-lab
|
|
753
755
|
|
|
756
|
+
# Move a Proxmox endpoint while retaining its existing token
|
|
757
|
+
celilo service set-credentials proxmox-home-lab --api-url https://10.77.20.50:8006
|
|
758
|
+
|
|
754
759
|
# Get service configuration
|
|
755
760
|
celilo service config get proxmox-home-lab
|
|
756
761
|
celilo service config get proxmox-home-lab name
|
|
@@ -1750,6 +1755,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1750
1755
|
return handleServiceVerify(parsed.args, parsed.flags);
|
|
1751
1756
|
}
|
|
1752
1757
|
|
|
1758
|
+
if (parsed.subcommand === 'set-credentials') {
|
|
1759
|
+
return handleServiceSetCredentials(parsed.args, parsed.flags);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1753
1762
|
if (parsed.subcommand === 'reconfigure') {
|
|
1754
1763
|
return handleServiceReconfigure(parsed.args, parsed.flags);
|
|
1755
1764
|
}
|
package/src/db/client.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
|
|
6
6
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
7
|
-
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
8
7
|
import { getDbPath } from '../config/paths';
|
|
8
|
+
import { runMigrationsOn } from './migrate';
|
|
9
9
|
import * as schema from './schema';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -83,15 +83,17 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
83
83
|
// idempotent: it applies every migration newer than the latest recorded in
|
|
84
84
|
// `__drizzle_migrations` and no-ops once current.
|
|
85
85
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
86
|
+
// A DB from the hand-list era has a frozen `__drizzle_migrations` watermark,
|
|
87
|
+
// so drizzle re-runs already-applied migrations and throws (celilo#169).
|
|
88
|
+
// runMigrationsOn repairs that itself where the schema is already complete.
|
|
89
|
+
// It has to happen HERE and not only in `celilo system migrate`, because
|
|
90
|
+
// that command reaches its own repair through getDb() — this line — and so
|
|
91
|
+
// would die before getting there. A PARTIALLY applied schema still throws
|
|
92
|
+
// and still needs a human. `celilo system doctor` (checkSchemaDrift) detects
|
|
93
|
+
// the drift.
|
|
91
94
|
if (!readonly) {
|
|
92
95
|
try {
|
|
93
|
-
|
|
94
|
-
migrate(db, { migrationsFolder });
|
|
96
|
+
runMigrationsOn(db);
|
|
95
97
|
} catch (error) {
|
|
96
98
|
console.error('Failed to run migrations:', error);
|
|
97
99
|
throw error;
|
|
@@ -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
|
/**
|
|
@@ -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
|
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
|
|
@@ -248,12 +248,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
248
248
|
count: 2,
|
|
249
249
|
why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
|
|
250
250
|
},
|
|
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
251
|
{
|
|
258
252
|
file: 'apps/celilo/src/services/zone-policy.ts',
|
|
259
253
|
capability: 'public_web',
|
|
@@ -391,11 +385,6 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
|
|
|
391
385
|
capability: 'firewall',
|
|
392
386
|
why: 'S8 — core reaching into one provider implementation (#941)',
|
|
393
387
|
},
|
|
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
388
|
];
|
|
400
389
|
|
|
401
390
|
export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
|
|
@@ -214,7 +214,59 @@ export function ensureInboundSubscriber(bus: SubscriberRegistrar): void {
|
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
/** A live alert about to be deleted along with the monitor that owns it. */
|
|
218
|
+
export interface DroppedAlert {
|
|
219
|
+
key: string;
|
|
220
|
+
message: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Delete a monitor, and report the live alerts that go with it.
|
|
225
|
+
*
|
|
226
|
+
* The alerts are DELETED, not resolved. `alerts.monitorId` is
|
|
227
|
+
* `on delete cascade`, so the row goes the moment the monitor does. This used
|
|
228
|
+
* to call `resolveMonitorAlerts` first; that write was unreachable — the very
|
|
229
|
+
* next statement dropped the same rows, and nothing read them in between, so
|
|
230
|
+
* the resolved state existed for the duration of one statement. Deleted rather
|
|
231
|
+
* than restored, because resolving properly would need the alert rows to
|
|
232
|
+
* outlive their monitor, which the FK forbids and which no reader wants:
|
|
233
|
+
* `resolvedAt` is only ever read as a liveness predicate (`ack.ts`), never
|
|
234
|
+
* reported. Do not put the call back without changing the FK first.
|
|
235
|
+
*
|
|
236
|
+
* What IS worth keeping is what was lost, which is why the live alerts are
|
|
237
|
+
* returned. A monitor dropped while holding a firing alert takes a real
|
|
238
|
+
* failure and the coverage of it away together, and a caller that only names
|
|
239
|
+
* the monitor cannot tell an operator what stopped being watched.
|
|
240
|
+
*/
|
|
241
|
+
export function deleteMonitor(db: DbClient, monitorId: string): DroppedAlert[] {
|
|
242
|
+
const dropped = db
|
|
243
|
+
.select({ key: alerts.key, message: alerts.message })
|
|
244
|
+
.from(alerts)
|
|
245
|
+
.where(and(eq(alerts.monitorId, monitorId), isNotNull(alerts.activeKey)))
|
|
246
|
+
.all();
|
|
247
|
+
|
|
219
248
|
db.delete(monitors).where(eq(monitors.id, monitorId)).run();
|
|
249
|
+
return dropped;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Drop the `module_hook` monitor belonging to a module that is going away.
|
|
254
|
+
*
|
|
255
|
+
* Called from the removal path rather than expressed as a foreign key, because
|
|
256
|
+
* `monitors.target` cannot carry one: it holds a module id for `module_hook`
|
|
257
|
+
* and an audit check name for `builtin_check`, and SQLite has no conditional
|
|
258
|
+
* reference. Splitting the table to get the constraint would need a synthetic
|
|
259
|
+
* monitor identity for `alerts.monitorId` — the trade the file header already
|
|
260
|
+
* weighs and declines.
|
|
261
|
+
*
|
|
262
|
+
* Deleting cascades the monitor's alerts away (`alerts.monitorId` is
|
|
263
|
+
* `on delete cascade`), which is what releases anything they were suppressing.
|
|
264
|
+
* They are deleted, not resolved — see `deleteMonitor`. Returns what was live
|
|
265
|
+
* so the caller can say what stopped being watched, or null if the module had
|
|
266
|
+
* no monitor.
|
|
267
|
+
*/
|
|
268
|
+
export function deleteMonitorForModule(db: DbClient, moduleId: string): DroppedAlert[] | null {
|
|
269
|
+
const monitor = findMonitor(db, 'module_hook', moduleId);
|
|
270
|
+
if (!monitor) return null;
|
|
271
|
+
return deleteMonitor(db, monitor.id);
|
|
220
272
|
}
|
|
@@ -23,6 +23,7 @@ import type { DbClient } from '../../db/client';
|
|
|
23
23
|
import type { Alert, Monitor } from '../../db/schema';
|
|
24
24
|
import { MONITOR_INTERVAL_FLOOR_MINUTES } from '../cadence';
|
|
25
25
|
import { isScheduled, loadModuleHealthCadences } from './health-cadence';
|
|
26
|
+
import { type DroppedAlert, deleteMonitor } from './monitors';
|
|
26
27
|
import type { NotifyDeps, NotifyOutcome } from './notifier';
|
|
27
28
|
import { deliverDeferred, notifyAlert } from './notifier';
|
|
28
29
|
import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
|
|
@@ -102,6 +103,19 @@ export interface SweepReport {
|
|
|
102
103
|
* former, because nothing ever reached the daemon.
|
|
103
104
|
*/
|
|
104
105
|
failures: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Modules whose `module_hook` monitor was dropped because the module itself
|
|
108
|
+
* is gone, and the live alerts each one took with it.
|
|
109
|
+
*
|
|
110
|
+
* The alerts are DELETED by the monitor's cascade, not resolved, so a firing
|
|
111
|
+
* check and the coverage of it disappear in the same instant with nothing
|
|
112
|
+
* else recording either. A bare module name is not enough to act on: the row
|
|
113
|
+
* that mattered on the fleet was holding `Cannot reach router: Router login
|
|
114
|
+
* failed`, and `1 stranded-dropped (greenwave)` would not have told anyone
|
|
115
|
+
* that a router had stopped being checked. Same reason `noPolicy`, `skipped`
|
|
116
|
+
* and `failures` all name their subject rather than counting it.
|
|
117
|
+
*/
|
|
118
|
+
strandedDropped: { moduleId: string; alerts: DroppedAlert[] }[];
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/**
|
|
@@ -129,6 +143,7 @@ export async function runSweep(
|
|
|
129
143
|
noPolicy: [],
|
|
130
144
|
skipped: [],
|
|
131
145
|
failures: [],
|
|
146
|
+
strandedDropped: [],
|
|
132
147
|
};
|
|
133
148
|
|
|
134
149
|
// 1. Run due monitors.
|
|
@@ -139,8 +154,30 @@ export async function runSweep(
|
|
|
139
154
|
// manifest could never reach an existing install (design.md D2/D8). A
|
|
140
155
|
// `builtin_check` has no module and no manifest, so its row is the config.
|
|
141
156
|
const cadences = loadModuleHealthCadences(db);
|
|
157
|
+
|
|
158
|
+
// Reconcile away monitors whose module is gone, before anything tries to run
|
|
159
|
+
// them. `loadModuleHealthCadences` holds every module, so a miss here means
|
|
160
|
+
// no module row — and a `module_hook` monitor without one is unschedulable
|
|
161
|
+
// forever: its cadence resolves to null, `isScheduled` says false, and the
|
|
162
|
+
// sweep never selects it again. Anything it left firing would sit there with
|
|
163
|
+
// no path back to `resolved`, suppressing every alert it is an ancestor of
|
|
164
|
+
// (celilo#1029).
|
|
165
|
+
//
|
|
166
|
+
// The removal path deletes the monitor itself, so this only fires for rows a
|
|
167
|
+
// celilo without that fix left behind, or a removal that died between the two
|
|
168
|
+
// deletes. Same stance `setMonitorEnabled` already takes: a monitor that will
|
|
169
|
+
// never report again must not hold live alerts.
|
|
170
|
+
const stranded = monitors.filter((m) => m.kind === 'module_hook' && !cadences.has(m.target));
|
|
171
|
+
for (const monitor of stranded) {
|
|
172
|
+
report.strandedDropped.push({
|
|
173
|
+
moduleId: monitor.target,
|
|
174
|
+
alerts: deleteMonitor(db, monitor.id),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const active = stranded.length > 0 ? monitors.filter((m) => !stranded.includes(m)) : monitors;
|
|
178
|
+
|
|
142
179
|
const due = selectDueMonitors(
|
|
143
|
-
|
|
180
|
+
active.map((m) => {
|
|
144
181
|
if (m.kind !== 'module_hook') {
|
|
145
182
|
return {
|
|
146
183
|
id: m.id,
|
|
@@ -31,10 +31,12 @@ import { deleteTrustedSourcesForModule } from './trusted-sources';
|
|
|
31
31
|
* nothing minted on anyone's behalf. Capabilities are registered at IMPORT, not
|
|
32
32
|
* deploy, so the `capabilities` table routinely names providers that were never
|
|
33
33
|
* deployed. The same predicate `remove-guard.ts` uses to decide a module is not
|
|
34
|
-
* a dependent — the guard
|
|
35
|
-
*
|
|
34
|
+
* a dependent — the guard, the cleanup and the provider-arrival backfill must
|
|
35
|
+
* keep ONE definition of a live module (D8). Exported rather than re-spelled:
|
|
36
|
+
* `module-remove.ts` had its own copy of the literal, and `provider-arrival.ts`
|
|
37
|
+
* would have been a third.
|
|
36
38
|
*/
|
|
37
|
-
const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
|
|
39
|
+
export const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
|
|
38
40
|
|
|
39
41
|
export type CleanupSkipReason = 'paused' | 'not-deployed';
|
|
40
42
|
|