@celilo/cli 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +38 -9
- package/drizzle/0019_backup_pid.sql +18 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/schemas/system_config.json +1 -1
- package/src/api/remote-client.test.ts +62 -0
- package/src/api/serve.ts +14 -6
- package/src/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/apt-upgrade.test.ts +20 -1
- package/src/cli/commands/apt-upgrade.ts +12 -2
- package/src/cli/commands/backup-sweep.ts +62 -0
- package/src/cli/commands/events.ts +90 -0
- package/src/cli/commands/module-operations.test.ts +45 -1
- package/src/cli/commands/module-operations.ts +35 -12
- package/src/cli/commands/module-show.ts +1 -0
- package/src/cli/commands/module-update.test.ts +72 -1
- package/src/cli/commands/module-update.ts +45 -22
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-migrate.test.ts +56 -0
- package/src/cli/commands/system-migrate.ts +92 -4
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +19 -0
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -1
- package/src/db/migration-status.test.ts +114 -0
- package/src/db/migration-status.ts +78 -0
- package/src/db/schema-introspection.ts +8 -1
- package/src/db/schema.ts +53 -9
- package/src/hooks/capability-loader.ts +30 -1
- package/src/ipam/allocator.ts +13 -3
- package/src/services/alerting/builtin-monitors.test.ts +42 -0
- package/src/services/alerting/builtin-monitors.ts +2 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/audit/abandoned-operations.test.ts +73 -0
- package/src/services/audit/abandoned-operations.ts +0 -0
- package/src/services/audit/disk-space.test.ts +111 -0
- package/src/services/audit/disk-space.ts +114 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +9 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/backup-create.ts +4 -4
- package/src/services/backup-in-flight-refusal.test.ts +2 -0
- package/src/services/backup-metadata.ts +4 -0
- package/src/services/backup-staging.test.ts +134 -0
- package/src/services/backup-staging.ts +192 -0
- package/src/services/backup-sweep.test.ts +68 -0
- package/src/services/backup-sweep.ts +62 -0
- package/src/services/bus-interview.ts +11 -5
- package/src/services/config-interview.ts +1 -1
- package/src/services/deploy-ansible.ts +0 -1
- package/src/services/disk-probe.test.ts +74 -0
- package/src/services/disk-probe.ts +145 -0
- package/src/services/events-daemon.test.ts +244 -0
- package/src/services/events-daemon.ts +295 -8
- package/src/services/fleet-checks.test.ts +75 -4
- package/src/services/fleet-checks.ts +97 -12
- package/src/services/interview-errors.ts +20 -0
- package/src/services/module-operations.test.ts +22 -0
- package/src/services/module-operations.ts +48 -1
- package/src/services/module-subscriptions.test.ts +39 -6
- package/src/services/module-subscriptions.ts +6 -4
- package/src/services/module-types-generator.test.ts +6 -3
- package/src/services/module-types-generator.ts +12 -7
- package/src/services/remote-responder.test.ts +70 -0
- package/src/services/remote-responder.ts +27 -10
- package/src/services/responder-probe.ts +3 -1
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/variables/context.ts +6 -1
package/src/cli/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
handleEventsRepair,
|
|
32
32
|
handleEventsReply,
|
|
33
33
|
handleEventsRespond,
|
|
34
|
+
handleEventsRestartDaemon,
|
|
34
35
|
handleEventsResyncSubscriptions,
|
|
35
36
|
handleEventsRun,
|
|
36
37
|
handleEventsRunHook,
|
|
@@ -303,6 +304,7 @@ Subcommands:
|
|
|
303
304
|
respond Run the terminal responder; answer deploy prompts from another shell
|
|
304
305
|
install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
|
|
305
306
|
uninstall-daemon [--system] Remove the installed supervisor unit
|
|
307
|
+
restart-daemon [--system] Restart the dispatcher and verify the new process is on current code
|
|
306
308
|
show-daemon [--system] Print the currently installed unit file
|
|
307
309
|
|
|
308
310
|
Description:
|
|
@@ -1439,6 +1441,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1439
1441
|
return handleEventsInstallDaemon(parsed.args, parsed.flags);
|
|
1440
1442
|
case 'uninstall-daemon':
|
|
1441
1443
|
return handleEventsUninstallDaemon(parsed.args, parsed.flags);
|
|
1444
|
+
case 'restart-daemon':
|
|
1445
|
+
return handleEventsRestartDaemon(parsed.args, parsed.flags);
|
|
1442
1446
|
case 'show-daemon':
|
|
1443
1447
|
return handleEventsShowDaemon(parsed.args, parsed.flags);
|
|
1444
1448
|
default:
|
|
@@ -2133,7 +2137,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2133
2137
|
}
|
|
2134
2138
|
|
|
2135
2139
|
if (parsed.subcommand === 'migrate') {
|
|
2136
|
-
return handleSystemMigrate();
|
|
2140
|
+
return handleSystemMigrate(parsed.args, parsed.flags);
|
|
2137
2141
|
}
|
|
2138
2142
|
|
|
2139
2143
|
return {
|
|
@@ -77,12 +77,14 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
77
77
|
'module_configs',
|
|
78
78
|
'health',
|
|
79
79
|
'backups',
|
|
80
|
+
'abandoned_operations',
|
|
80
81
|
'undeployed_modules',
|
|
81
82
|
'unconfigured_modules',
|
|
82
83
|
'services_credentials',
|
|
83
84
|
'secrets_decryptable',
|
|
84
85
|
'services_reachable',
|
|
85
86
|
'machines_reachable',
|
|
87
|
+
'disk_space',
|
|
86
88
|
'transport_reads',
|
|
87
89
|
'trusted_sources',
|
|
88
90
|
];
|
|
@@ -96,12 +98,14 @@ export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
|
96
98
|
module_configs: 'Module configs',
|
|
97
99
|
health: 'Module health',
|
|
98
100
|
backups: 'Backups',
|
|
101
|
+
abandoned_operations: 'Abandoned operations',
|
|
99
102
|
undeployed_modules: 'Undeployed modules',
|
|
100
103
|
unconfigured_modules: 'Unconfigured modules',
|
|
101
104
|
services_credentials: 'Service credentials',
|
|
102
105
|
secrets_decryptable: 'Secrets',
|
|
103
106
|
services_reachable: 'Service reachability',
|
|
104
107
|
machines_reachable: 'Machine reachability',
|
|
108
|
+
disk_space: 'Disk space',
|
|
105
109
|
transport_reads: 'Transport readability',
|
|
106
110
|
trusted_sources: 'Trusted networks',
|
|
107
111
|
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo#604: a table COUNT reported "up to date" for a DB missing a migrated
|
|
3
|
+
* COLUMN, so the rollout's own stop-condition was uncheckable by the tools the
|
|
4
|
+
* runbook named. These assert the column case specifically.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Database } from 'bun:sqlite';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { getMigrationStatus, readMigrationJournal } from './migration-status';
|
|
13
|
+
|
|
14
|
+
/** A journal + a `__drizzle_migrations` table, wired the way drizzle wires them. */
|
|
15
|
+
function seed(dir: string, entries: Array<{ when: number; tag: string }>, appliedWhens: number[]) {
|
|
16
|
+
mkdirSync(join(dir, 'meta'), { recursive: true });
|
|
17
|
+
writeFileSync(
|
|
18
|
+
join(dir, 'meta', '_journal.json'),
|
|
19
|
+
JSON.stringify({ version: '7', dialect: 'sqlite', entries }),
|
|
20
|
+
);
|
|
21
|
+
const db = new Database(':memory:');
|
|
22
|
+
db.run(
|
|
23
|
+
'CREATE TABLE `__drizzle_migrations` (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)',
|
|
24
|
+
);
|
|
25
|
+
for (const when of appliedWhens) {
|
|
26
|
+
db.run('INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES (?, ?)', [
|
|
27
|
+
`h${when}`,
|
|
28
|
+
when,
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
return db;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('getMigrationStatus', () => {
|
|
35
|
+
let dir: string;
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-migstatus-'));
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
40
|
+
|
|
41
|
+
it('names the latest applied migration and every pending one', () => {
|
|
42
|
+
const db = seed(
|
|
43
|
+
dir,
|
|
44
|
+
[
|
|
45
|
+
{ when: 100, tag: '0018_drop_alert_policy_snapshot' },
|
|
46
|
+
{ when: 200, tag: '0019_backup_pid' },
|
|
47
|
+
{ when: 300, tag: '0020_future' },
|
|
48
|
+
],
|
|
49
|
+
[100, 200],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const status = getMigrationStatus(db, dir);
|
|
53
|
+
|
|
54
|
+
expect(status.appliedCount).toBe(2);
|
|
55
|
+
expect(status.latestApplied).toBe('0019_backup_pid');
|
|
56
|
+
expect(status.pending).toEqual(['0020_future']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reports nothing pending once every journal entry is applied', () => {
|
|
60
|
+
const db = seed(dir, [{ when: 100, tag: '0000_init' }], [100]);
|
|
61
|
+
const status = getMigrationStatus(db, dir);
|
|
62
|
+
expect(status.pending).toEqual([]);
|
|
63
|
+
expect(status.latestApplied).toBe('0000_init');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reports a DB that has never been migrated as all-pending', () => {
|
|
67
|
+
mkdirSync(join(dir, 'meta'), { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(dir, 'meta', '_journal.json'),
|
|
70
|
+
JSON.stringify({ entries: [{ when: 1, tag: '0000_init' }] }),
|
|
71
|
+
);
|
|
72
|
+
const status = getMigrationStatus(new Database(':memory:'), dir);
|
|
73
|
+
expect(status.appliedCount).toBe(0);
|
|
74
|
+
expect(status.latestApplied).toBeNull();
|
|
75
|
+
expect(status.pending).toEqual(['0000_init']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('counts columns, not just tables — the signal a column migration needs', () => {
|
|
79
|
+
const db = seed(dir, [], []);
|
|
80
|
+
const status = getMigrationStatus(db, dir);
|
|
81
|
+
// Every schema table is missing here, so the counts are the code's totals.
|
|
82
|
+
expect(status.tableCount).toBeGreaterThan(0);
|
|
83
|
+
expect(status.columnCount).toBeGreaterThan(status.tableCount);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reads journal entries oldest-first regardless of file order', () => {
|
|
87
|
+
mkdirSync(join(dir, 'meta'), { recursive: true });
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(dir, 'meta', '_journal.json'),
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
entries: [
|
|
92
|
+
{ when: 300, tag: 'c' },
|
|
93
|
+
{ when: 100, tag: 'a' },
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
expect(readMigrationJournal(dir).map((e) => e.tag)).toEqual(['a', 'c']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns an empty journal when the folder has none', () => {
|
|
101
|
+
expect(readMigrationJournal(join(dir, 'nope'))).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('getMigrationStatus against the real migrations folder', () => {
|
|
106
|
+
it('sees backups.pid as missing when the column migration did not land', () => {
|
|
107
|
+
// The exact celilo#604 shape: every table present, one migrated column not.
|
|
108
|
+
const db = new Database(':memory:');
|
|
109
|
+
db.run('CREATE TABLE backups (id TEXT PRIMARY KEY)');
|
|
110
|
+
const status = getMigrationStatus(db, join(import.meta.dir, '../../drizzle'));
|
|
111
|
+
expect(status.missingColumns).toContain('backups.pid');
|
|
112
|
+
expect(status.pending).toContain('0019_backup_pid');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only migration interrogation (celilo#604).
|
|
3
|
+
*
|
|
4
|
+
* `system migrate` used to report "Schema current: 35 tables" — a table COUNT,
|
|
5
|
+
* which cannot distinguish "the column migration applied" from "nothing
|
|
6
|
+
* happened". 0019_backup_pid adds a column; a rollout runbook asserting
|
|
7
|
+
* "applied 19 → 20, backups.pid present" had no product surface to check it
|
|
8
|
+
* against and had to reach for `sqlite3` over SSH.
|
|
9
|
+
*
|
|
10
|
+
* This names migrations. Drizzle's `__drizzle_migrations.created_at` is the
|
|
11
|
+
* journal entry's `when`, so the join back to a human tag is exact.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Database } from 'bun:sqlite';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { findSchemaDrift } from './schema-introspection';
|
|
18
|
+
|
|
19
|
+
export interface MigrationStatus {
|
|
20
|
+
/** Rows in `__drizzle_migrations` — what the runbook calls "applied count". */
|
|
21
|
+
appliedCount: number;
|
|
22
|
+
/** Tag of the newest applied migration, e.g. `0019_backup_pid`. */
|
|
23
|
+
latestApplied: string | null;
|
|
24
|
+
/** Tags present on disk that this DB has not applied, oldest first. */
|
|
25
|
+
pending: string[];
|
|
26
|
+
/** Tables the running code declares that the DB lacks. */
|
|
27
|
+
missingTables: string[];
|
|
28
|
+
/** `table.column` the running code declares that the DB lacks. */
|
|
29
|
+
missingColumns: string[];
|
|
30
|
+
tableCount: number;
|
|
31
|
+
columnCount: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface JournalEntry {
|
|
35
|
+
when: number;
|
|
36
|
+
tag: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Journal entries (oldest first), or [] when the folder has no journal. */
|
|
40
|
+
export function readMigrationJournal(migrationsFolder: string): JournalEntry[] {
|
|
41
|
+
const path = join(migrationsFolder, 'meta', '_journal.json');
|
|
42
|
+
if (!existsSync(path)) return [];
|
|
43
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { entries?: JournalEntry[] };
|
|
44
|
+
return [...(parsed.entries ?? [])].sort((a, b) => a.when - b.when);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `created_at` timestamps recorded in `__drizzle_migrations` (empty if untracked). */
|
|
48
|
+
function appliedTimestamps(sqlite: Database): number[] {
|
|
49
|
+
try {
|
|
50
|
+
return sqlite
|
|
51
|
+
.query<{ created_at: number }, []>(
|
|
52
|
+
'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at',
|
|
53
|
+
)
|
|
54
|
+
.all()
|
|
55
|
+
.map((r) => r.created_at);
|
|
56
|
+
} catch {
|
|
57
|
+
// No migrations table — a DB that has never been through the migrator.
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getMigrationStatus(sqlite: Database, migrationsFolder: string): MigrationStatus {
|
|
63
|
+
const journal = readMigrationJournal(migrationsFolder);
|
|
64
|
+
const applied = new Set(appliedTimestamps(sqlite));
|
|
65
|
+
const appliedTags = journal.filter((e) => applied.has(e.when)).map((e) => e.tag);
|
|
66
|
+
const pending = journal.filter((e) => !applied.has(e.when)).map((e) => e.tag);
|
|
67
|
+
const drift = findSchemaDrift(sqlite);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
appliedCount: applied.size,
|
|
71
|
+
latestApplied: appliedTags.at(-1) ?? null,
|
|
72
|
+
pending,
|
|
73
|
+
missingTables: drift.missingTables,
|
|
74
|
+
missingColumns: drift.missingColumns,
|
|
75
|
+
tableCount: drift.tableCount,
|
|
76
|
+
columnCount: drift.columnCount,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -20,6 +20,12 @@ export interface SchemaDrift {
|
|
|
20
20
|
missingColumns: string[];
|
|
21
21
|
/** Total number of tables the code's schema declares. */
|
|
22
22
|
tableCount: number;
|
|
23
|
+
/**
|
|
24
|
+
* Total number of columns the code's schema declares. Reported alongside
|
|
25
|
+
* tableCount so the doctor can say what it actually checked — a table count
|
|
26
|
+
* alone reads as "columns unverified" even when they were (celilo#604).
|
|
27
|
+
*/
|
|
28
|
+
columnCount: number;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
/** Every table name + column names the drizzle schema declares. */
|
|
@@ -84,5 +90,6 @@ export function findSchemaDrift(sqlite: Database): SchemaDrift {
|
|
|
84
90
|
if (!cols.has(c)) missingColumns.push(`${t.name}.${c}`);
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
|
-
|
|
93
|
+
const columnCount = tables.reduce((n, t) => n + t.columns.length, 0);
|
|
94
|
+
return { missingTables, missingColumns, tableCount: tables.length, columnCount };
|
|
88
95
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -249,14 +249,31 @@ export const moduleBuilds = sqliteTable('module_builds', {
|
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
/**
|
|
252
|
-
* Network zones
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
252
|
+
* Network zones — the DEFINED vocabulary, not the active set.
|
|
253
|
+
*
|
|
254
|
+
* A zone being listed here means celilo knows the name and will accept
|
|
255
|
+
* `network.<zone>.*` config for it. It does NOT mean the zone exists on a given
|
|
256
|
+
* fleet: per openspec/specs/progressive-zone-disclosure/spec.md, "deployable
|
|
257
|
+
* zones are those with a configured subnet", so a zone becomes REAL only when
|
|
258
|
+
* something declares `network.<zone>.subnet` — normally the module that supplies
|
|
259
|
+
* the network. Defining a zone here costs nothing and activates nothing.
|
|
260
|
+
*
|
|
261
|
+
* - internal: the semi-trusted LAN the management server sits on
|
|
262
|
+
* - dmz: public-facing services
|
|
263
|
+
* - app: internal application services
|
|
264
|
+
* - secure: authentication and database services
|
|
257
265
|
* - secure-mgmt: celilo's own control plane (management server + management-plane
|
|
258
266
|
* modules). Outside the data-plane tier chain; reaches every tier by trust.
|
|
259
|
-
* - external:
|
|
267
|
+
* - external: internet-hosted services — a cloud/VPS provider's network, NOT a
|
|
268
|
+
* VPN. Addressed by the provider, which is why it is excluded from IPAM below.
|
|
269
|
+
* - control-plane-vpn: the ADMINISTRATIVE remote-access client subnet, activated
|
|
270
|
+
* by the module that terminates the tunnel (`wireguard` writes
|
|
271
|
+
* `network.control-plane-vpn.subnet`). Named for its purpose rather than its
|
|
272
|
+
* technology because a fleet may run more than one VPN — a site-to-site link
|
|
273
|
+
* or a user VPN is a different network with different trust, and `vpn` would
|
|
274
|
+
* have been the wrong name to have to share. Distinct from `external` too:
|
|
275
|
+
* that is someone else's cloud, this is a network the fleet's own firewall
|
|
276
|
+
* holds a leg on and must translate for.
|
|
260
277
|
*/
|
|
261
278
|
export const NETWORK_ZONES = [
|
|
262
279
|
'internal',
|
|
@@ -265,6 +282,7 @@ export const NETWORK_ZONES = [
|
|
|
265
282
|
'secure',
|
|
266
283
|
'secure-mgmt',
|
|
267
284
|
'external',
|
|
285
|
+
'control-plane-vpn',
|
|
268
286
|
] as const;
|
|
269
287
|
|
|
270
288
|
/**
|
|
@@ -276,15 +294,36 @@ export const NETWORK_ZONES = [
|
|
|
276
294
|
export type NetworkZone = (typeof NETWORK_ZONES)[number];
|
|
277
295
|
|
|
278
296
|
/**
|
|
279
|
-
* Zones an IP allocation or reservation can name: every NetworkZone
|
|
280
|
-
*
|
|
297
|
+
* Zones an IP allocation or reservation can name: every NetworkZone whose
|
|
298
|
+
* addresses celilo hands out.
|
|
299
|
+
*
|
|
300
|
+
* Two are excluded, for the same underlying reason — somebody else is the
|
|
301
|
+
* address authority:
|
|
302
|
+
* - `external`, whose systems are addressed by the cloud/VPS provider;
|
|
303
|
+
* - `vpn`, whose client addresses are assigned by the module terminating the
|
|
304
|
+
* tunnel. celilo allocating into that subnet would collide with the VPN
|
|
305
|
+
* server's own assignments.
|
|
281
306
|
*
|
|
282
307
|
* Derived rather than hand-written for the same reason as NetworkZone above —
|
|
283
308
|
* the previous hand-written union was copied into two column definitions and a
|
|
284
309
|
* cast in machine-pool.ts, and the cast had already drifted (it was missing
|
|
285
310
|
* `secure-mgmt`, and its comment claimed the only difference was `external`).
|
|
286
311
|
*/
|
|
287
|
-
export type AllocatableZone = Exclude<NetworkZone, 'external'>;
|
|
312
|
+
export type AllocatableZone = Exclude<NetworkZone, 'external' | 'control-plane-vpn'>;
|
|
313
|
+
|
|
314
|
+
/** The zones whose addresses celilo hands out, as a runtime list. */
|
|
315
|
+
export const ALLOCATABLE_ZONES: AllocatableZone[] = NETWORK_ZONES.filter(
|
|
316
|
+
(zone): zone is AllocatableZone => zone !== 'external' && zone !== 'control-plane-vpn',
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Is this a zone celilo allocates addresses in? Use this rather than testing
|
|
321
|
+
* `zone !== 'external'` by hand — that check predates `vpn` and read as "the one
|
|
322
|
+
* externally-addressed zone" when there are now two.
|
|
323
|
+
*/
|
|
324
|
+
export function isAllocatableZone(zone: NetworkZone): zone is AllocatableZone {
|
|
325
|
+
return (ALLOCATABLE_ZONES as string[]).includes(zone);
|
|
326
|
+
}
|
|
288
327
|
|
|
289
328
|
/**
|
|
290
329
|
* Container services table
|
|
@@ -679,6 +718,11 @@ export const backups = sqliteTable('backups', {
|
|
|
679
718
|
status: text('status').$type<BackupStatus>().notNull().default('in_progress'),
|
|
680
719
|
errorMessage: text('error_message'),
|
|
681
720
|
name: text('name'), // optional human-readable name/annotation
|
|
721
|
+
// The process assembling this backup's staging directory. Lets the staging
|
|
722
|
+
// reaper (services/backup-staging.ts) tell a live backup from one whose
|
|
723
|
+
// process was killed before its `finally` could clean up. Nullable: rows
|
|
724
|
+
// written before this column existed have no pid and age out via the TTL.
|
|
725
|
+
pid: integer('pid'),
|
|
682
726
|
startedAt: integer('started_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
683
727
|
completedAt: integer('completed_at', { mode: 'timestamp' }),
|
|
684
728
|
});
|
|
@@ -28,7 +28,14 @@ import type {
|
|
|
28
28
|
} from '@celilo/capabilities';
|
|
29
29
|
import { and, eq } from 'drizzle-orm';
|
|
30
30
|
import type { DbClient } from '../db/client';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
NETWORK_ZONES,
|
|
33
|
+
capabilities,
|
|
34
|
+
modules,
|
|
35
|
+
secrets,
|
|
36
|
+
systemConfig,
|
|
37
|
+
webRoutes,
|
|
38
|
+
} from '../db/schema';
|
|
32
39
|
import { decryptSecret } from '../secrets/encryption';
|
|
33
40
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
34
41
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
@@ -593,6 +600,7 @@ function buildCapabilityInterface(
|
|
|
593
600
|
zoneTiers: zones?.zoneTiers ?? [],
|
|
594
601
|
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
595
602
|
controlPlaneSubnet: zones?.controlPlaneSubnet,
|
|
603
|
+
frontedSubnets: zones?.frontedSubnets ?? [],
|
|
596
604
|
},
|
|
597
605
|
store,
|
|
598
606
|
undefined, // no upstream — the chain path handles that
|
|
@@ -699,6 +707,22 @@ interface FirewallZones {
|
|
|
699
707
|
trustedSubnets: string[];
|
|
700
708
|
/** celilo's control-plane network, as a DESTINATION for trusted sources. */
|
|
701
709
|
controlPlaneSubnet?: string;
|
|
710
|
+
/**
|
|
711
|
+
* Every declared zone subnet — read from
|
|
712
|
+
* `network.<zone>.subnet` across all of `NETWORK_ZONES`, not just the three
|
|
713
|
+
* data-plane tiers.
|
|
714
|
+
*
|
|
715
|
+
* A downstream firewall must translate for every network behind it, and the
|
|
716
|
+
* tiers are only the ones that happen to form the dmz→app→secure chain.
|
|
717
|
+
* `secure-mgmt` and the control-plane VPN are equally behind it and equally
|
|
718
|
+
* unroutable untranslated. Reading the canonical zone list rather than the tier
|
|
719
|
+
* list means declaring a zone's subnet is sufficient to get it translated — no
|
|
720
|
+
* module registration required.
|
|
721
|
+
*
|
|
722
|
+
* The egress network is NOT excluded here: the firewall derives that from its
|
|
723
|
+
* own routing table, so nothing on this side has to guess which zone it is.
|
|
724
|
+
*/
|
|
725
|
+
frontedSubnets: string[];
|
|
702
726
|
}
|
|
703
727
|
|
|
704
728
|
/** The module that IS celilo's control plane; its network is what we trust. */
|
|
@@ -802,6 +826,10 @@ function loadFirewallZones(db: DbClient): FirewallZones {
|
|
|
802
826
|
zoneTiers,
|
|
803
827
|
trustedSubnets: loadTrustedSubnets(db).map((e) => e.subnet),
|
|
804
828
|
controlPlaneSubnet: loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal'),
|
|
829
|
+
// Every zone celilo knows about, not the tier list — see the field doc.
|
|
830
|
+
frontedSubnets: NETWORK_ZONES.map((zone) => readZoneSubnet(db, zone)).filter(
|
|
831
|
+
(subnet): subnet is string => !!subnet,
|
|
832
|
+
),
|
|
805
833
|
};
|
|
806
834
|
}
|
|
807
835
|
|
|
@@ -953,6 +981,7 @@ async function buildFirewallChain(
|
|
|
953
981
|
zoneTiers: zones.zoneTiers,
|
|
954
982
|
trustedSubnets: zones.trustedSubnets,
|
|
955
983
|
controlPlaneSubnet: zones.controlPlaneSubnet,
|
|
984
|
+
frontedSubnets: zones.frontedSubnets,
|
|
956
985
|
},
|
|
957
986
|
store,
|
|
958
987
|
currentUpstream,
|
package/src/ipam/allocator.ts
CHANGED
|
@@ -9,16 +9,26 @@ import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
|
|
|
9
9
|
import type { DbClient } from '../db/client';
|
|
10
10
|
import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema';
|
|
11
11
|
import type { NewIpAllocation, NewIpReservation, NewVmidReservation } from '../db/schema';
|
|
12
|
+
import { ALLOCATABLE_ZONES, type AllocatableZone } from '../db/schema';
|
|
12
13
|
import type * as schema from '../db/schema';
|
|
13
14
|
import { generateIPsInSubnet, isIPInRange, isInSubnet, stripCIDR } from './subnet-parser';
|
|
14
15
|
|
|
15
16
|
// Type that accepts both database client and transaction
|
|
16
17
|
type DbOrTransaction = BunSQLiteDatabase<typeof schema> | DbClient;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Zones that support IPAM auto-allocation of VMID and container IP.
|
|
21
|
+
*
|
|
22
|
+
* ALIASED to `AllocatableZone`, not hand-written. This was its own union
|
|
23
|
+
* (`'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'`) that happened to
|
|
24
|
+
* agree with `AllocatableZone` — a fourth hand-maintained copy of the zone list,
|
|
25
|
+
* in a repo where three earlier copies had already drifted and left comments
|
|
26
|
+
* saying so. Deriving means a zone celilo stops allocating for (as `vpn` is,
|
|
27
|
+
* since the tunnel module assigns client addresses) cannot be missed here.
|
|
28
|
+
*/
|
|
29
|
+
export type IpamZone = AllocatableZone;
|
|
20
30
|
|
|
21
|
-
const IPAM_ZONES: IpamZone[] =
|
|
31
|
+
const IPAM_ZONES: IpamZone[] = ALLOCATABLE_ZONES;
|
|
22
32
|
|
|
23
33
|
/**
|
|
24
34
|
* Infer which zone an IP address belongs to by checking configured zone subnets.
|
|
@@ -27,6 +27,12 @@ describe('targetKindForCategory', () => {
|
|
|
27
27
|
expect(targetKindForCategory('backups')).toBe('module');
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// Machine-scoped so suppression inherits the machine topology: an unreachable
|
|
31
|
+
// host suppresses its own disk alert instead of paging twice for one dead box.
|
|
32
|
+
test('disk_space is machine-scoped', () => {
|
|
33
|
+
expect(targetKindForCategory('disk_space')).toBe('machine');
|
|
34
|
+
});
|
|
35
|
+
|
|
30
36
|
// Whole-system categories have no narrower subject to suppress against.
|
|
31
37
|
test('unmapped category falls back to system', () => {
|
|
32
38
|
expect(targetKindForCategory('cli_version')).toBe('system');
|
|
@@ -46,6 +52,42 @@ describe('severityForDriftSeverity', () => {
|
|
|
46
52
|
});
|
|
47
53
|
|
|
48
54
|
describe('failingKeysFromFindings', () => {
|
|
55
|
+
// Asserts the WHOLE key, not a prefix. A prefix-only assertion is exactly why
|
|
56
|
+
// #596 survived — machines_reachable emits a UUID where suppression expects a
|
|
57
|
+
// hostname, and `toContain('builtin:machines_reachable/machine:')` passes for
|
|
58
|
+
// both. This check must be pinned to the identifier itself.
|
|
59
|
+
test('disk_space keys on the hostname, in full', () => {
|
|
60
|
+
const keys = failingKeysFromFindings(
|
|
61
|
+
'disk_space',
|
|
62
|
+
[
|
|
63
|
+
finding({
|
|
64
|
+
category: 'disk_space',
|
|
65
|
+
code: 'disk_critical',
|
|
66
|
+
severity: 'blocked',
|
|
67
|
+
subject: 'celilo-mgr',
|
|
68
|
+
message: 'celilo-mgr: root filesystem 96% full',
|
|
69
|
+
}),
|
|
70
|
+
],
|
|
71
|
+
'critical',
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(keys).toHaveLength(1);
|
|
75
|
+
expect(keys[0]?.key).toBe('builtin:disk_space/machine:celilo-mgr');
|
|
76
|
+
expect(keys[0]?.severity).toBe('critical');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// An unmeasurable host records without paging.
|
|
80
|
+
test('an unmeasured disk downgrades to warning', () => {
|
|
81
|
+
const keys = failingKeysFromFindings(
|
|
82
|
+
'disk_space',
|
|
83
|
+
[finding({ category: 'disk_space', severity: 'todo', subject: 'iot' })],
|
|
84
|
+
'critical',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(keys[0]?.key).toBe('builtin:disk_space/machine:iot');
|
|
88
|
+
expect(keys[0]?.severity).toBe('warning');
|
|
89
|
+
});
|
|
90
|
+
|
|
49
91
|
test('projects findings into builtin keys', () => {
|
|
50
92
|
const keys = failingKeysFromFindings('machines_reachable', [finding()], 'critical');
|
|
51
93
|
expect(keys).toEqual([
|
|
@@ -24,10 +24,12 @@ import { type FailingKey, builtinAlertKey } from './keys';
|
|
|
24
24
|
*/
|
|
25
25
|
const TARGET_KIND_BY_CATEGORY: Partial<Record<DriftCategory, string>> = {
|
|
26
26
|
machines_reachable: 'machine',
|
|
27
|
+
disk_space: 'machine',
|
|
27
28
|
transport_reads: 'module',
|
|
28
29
|
services_reachable: 'service',
|
|
29
30
|
services_credentials: 'service',
|
|
30
31
|
backups: 'module',
|
|
32
|
+
abandoned_operations: 'module',
|
|
31
33
|
health: 'module',
|
|
32
34
|
module_versions: 'module',
|
|
33
35
|
module_configs: 'module',
|
|
@@ -13,16 +13,21 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { DbClient } from '../../db/client';
|
|
16
|
+
import { auditAbandonedOperations, loadAbandonedOperations } from '../audit/abandoned-operations';
|
|
16
17
|
import { loadBackupAuditInfo } from '../audit/backup-source';
|
|
17
18
|
import { auditBackups } from '../audit/backups';
|
|
19
|
+
import { auditDiskSpace } from '../audit/disk-space';
|
|
18
20
|
import { auditMachinesReachable } from '../audit/machines-reachable';
|
|
19
21
|
import type { DriftCategory, DriftFinding } from '../audit/types';
|
|
22
|
+
import { probeDiskUsage } from '../disk-probe';
|
|
20
23
|
import { probeMachines } from '../machine-probe';
|
|
21
24
|
|
|
22
25
|
/** Categories a monitor can currently schedule. */
|
|
23
26
|
export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
|
|
24
27
|
'machines_reachable',
|
|
25
28
|
'backups',
|
|
29
|
+
'disk_space',
|
|
30
|
+
'abandoned_operations',
|
|
26
31
|
];
|
|
27
32
|
|
|
28
33
|
export function isSchedulableBuiltin(category: string): category is DriftCategory {
|
|
@@ -37,12 +42,22 @@ export async function runBuiltinCheckForMonitor(
|
|
|
37
42
|
return auditMachinesReachable({ results: await probeMachines() });
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
// Same shape as machines_reachable — one SSH round trip per machine, bounded
|
|
46
|
+
// — but it MEASURES the local box rather than exempting it. See disk-probe.ts.
|
|
47
|
+
if (category === 'disk_space') {
|
|
48
|
+
return auditDiskSpace({ results: await probeDiskUsage() });
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
// Local DB reads only — cheap enough to run on every sweep, which is
|
|
41
52
|
// the whole reason this category is schedulable and most are not.
|
|
42
53
|
if (category === 'backups') {
|
|
43
54
|
return auditBackups({ modules: loadBackupAuditInfo(db) });
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
if (category === 'abandoned_operations') {
|
|
58
|
+
return auditAbandonedOperations({ records: loadAbandonedOperations(db) });
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
throw new Error(
|
|
47
62
|
`Built-in check "${category}" is not schedulable yet. Schedulable: ${SCHEDULABLE_BUILTIN_CHECKS.join(', ')}.`,
|
|
48
63
|
);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
ABANDONED_THRESHOLD,
|
|
4
|
+
ABANDONED_WINDOW_MS,
|
|
5
|
+
type AbandonedOperationRecord,
|
|
6
|
+
auditAbandonedOperations,
|
|
7
|
+
} from './abandoned-operations';
|
|
8
|
+
|
|
9
|
+
const NOW = Date.parse('2026-08-05T12:00:00Z');
|
|
10
|
+
const now = () => NOW;
|
|
11
|
+
|
|
12
|
+
function records(
|
|
13
|
+
moduleId: string,
|
|
14
|
+
operation: 'deploy' | 'backup',
|
|
15
|
+
count: number,
|
|
16
|
+
ageMs = 60_000,
|
|
17
|
+
): AbandonedOperationRecord[] {
|
|
18
|
+
return Array.from({ length: count }, () => ({
|
|
19
|
+
moduleId,
|
|
20
|
+
operation,
|
|
21
|
+
startedAt: NOW - ageMs,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('auditAbandonedOperations', () => {
|
|
26
|
+
it('says nothing about a single abandonment — that is an operator hitting Ctrl-C', () => {
|
|
27
|
+
const findings = auditAbandonedOperations({
|
|
28
|
+
records: records('forgejo', 'backup', ABANDONED_THRESHOLD - 1),
|
|
29
|
+
now,
|
|
30
|
+
});
|
|
31
|
+
expect(findings).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('flags a module whose same operation keeps dying', () => {
|
|
35
|
+
const findings = auditAbandonedOperations({
|
|
36
|
+
records: records('forgejo', 'backup', 63),
|
|
37
|
+
now,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(findings).toHaveLength(1);
|
|
41
|
+
expect(findings[0]?.subject).toBe('forgejo');
|
|
42
|
+
expect(findings[0]?.severity).toBe('drift');
|
|
43
|
+
expect(findings[0]?.message).toContain('63 backup operations abandoned');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// The point of grouping: a module whose backups are being killed and whose
|
|
47
|
+
// deploys are fine should say exactly that.
|
|
48
|
+
it('reports each operation kind separately', () => {
|
|
49
|
+
const findings = auditAbandonedOperations({
|
|
50
|
+
records: [...records('forgejo', 'backup', 5), ...records('technitium', 'deploy', 5)],
|
|
51
|
+
now,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(findings.map((f) => f.subject)).toEqual(['forgejo', 'technitium']);
|
|
55
|
+
expect(findings[1]?.message).toContain('deploy operations');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores abandonments older than the window, so a fixed module stops being flagged', () => {
|
|
59
|
+
const findings = auditAbandonedOperations({
|
|
60
|
+
records: records('forgejo', 'backup', 60, ABANDONED_WINDOW_MS + 60_000),
|
|
61
|
+
now,
|
|
62
|
+
});
|
|
63
|
+
expect(findings).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not merge two modules into one unactionable count', () => {
|
|
67
|
+
const findings = auditAbandonedOperations({
|
|
68
|
+
records: [...records('forgejo', 'backup', 2), ...records('lunacycle', 'backup', 2)],
|
|
69
|
+
now,
|
|
70
|
+
});
|
|
71
|
+
expect(findings).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
Binary file
|