@celilo/cli 0.17.0 → 0.18.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 +35 -8
- 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/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/backup-sweep.ts +62 -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/system-audit.ts +2 -0
- package/src/cli/commands/system-migrate.ts +40 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +18 -0
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -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/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/fleet-checks.ts +15 -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/update/orchestrator.test.ts +1 -0
- package/src/variables/context.ts +6 -1
|
@@ -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
|
};
|
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
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
DISK_BLOCKED_PERCENT,
|
|
4
|
+
DISK_DRIFT_PERCENT,
|
|
5
|
+
type DiskUsageResult,
|
|
6
|
+
auditDiskSpace,
|
|
7
|
+
} from './disk-space';
|
|
8
|
+
|
|
9
|
+
function usage(over: Partial<DiskUsageResult> = {}): DiskUsageResult {
|
|
10
|
+
return {
|
|
11
|
+
hostname: 'celilo-mgr',
|
|
12
|
+
ipAddress: '10.0.120.10',
|
|
13
|
+
usedPercent: 10,
|
|
14
|
+
availableBytes: 102 * 1024 ** 3,
|
|
15
|
+
...over,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('auditDiskSpace', () => {
|
|
20
|
+
test('a healthy filesystem produces no finding', () => {
|
|
21
|
+
expect(auditDiskSpace({ results: [usage({ usedPercent: 10 })] })).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('just below the threshold stays quiet', () => {
|
|
25
|
+
const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_DRIFT_PERCENT - 1 })] });
|
|
26
|
+
expect(findings).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('at the drift threshold reports drift, naming host and usage', () => {
|
|
30
|
+
const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_DRIFT_PERCENT })] });
|
|
31
|
+
|
|
32
|
+
expect(findings).toHaveLength(1);
|
|
33
|
+
expect(findings[0]?.severity).toBe('drift');
|
|
34
|
+
expect(findings[0]?.code).toBe('disk_low');
|
|
35
|
+
expect(findings[0]?.message).toContain('celilo-mgr');
|
|
36
|
+
expect(findings[0]?.message).toContain('85%');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('at the blocked threshold escalates to blocked', () => {
|
|
40
|
+
const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_BLOCKED_PERCENT })] });
|
|
41
|
+
|
|
42
|
+
expect(findings[0]?.severity).toBe('blocked');
|
|
43
|
+
expect(findings[0]?.code).toBe('disk_critical');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// The alert has to name the host to act on, and suppression resolves a
|
|
47
|
+
// machine's ancestor key from its HOSTNAME (alerting/suppression.ts). A UUID
|
|
48
|
+
// subject produces a key suppression can never match — that is #596, filed
|
|
49
|
+
// against machines_reachable. This check must not repeat it.
|
|
50
|
+
test('subjects the finding on the hostname, never a UUID', () => {
|
|
51
|
+
const findings = auditDiskSpace({
|
|
52
|
+
results: [usage({ hostname: 'iot', usedPercent: 99 })],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(findings[0]?.subject).toBe('iot');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('only the offending host is reported in a mixed fleet', () => {
|
|
59
|
+
const findings = auditDiskSpace({
|
|
60
|
+
results: [
|
|
61
|
+
usage({ hostname: 'celilo-mgr', usedPercent: 96 }),
|
|
62
|
+
usage({ hostname: 'iot', usedPercent: 12 }),
|
|
63
|
+
usage({ hostname: 'dns-ext', usedPercent: 40 }),
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(findings).toHaveLength(1);
|
|
68
|
+
expect(findings[0]?.subject).toBe('celilo-mgr');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('reports free space alongside the percentage', () => {
|
|
72
|
+
const findings = auditDiskSpace({
|
|
73
|
+
results: [usage({ usedPercent: 96, availableBytes: 6 * 1024 ** 3 })],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(findings[0]?.message).toContain('6.0 GB free');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Unmeasurable must not read as healthy — but it must not page either, since
|
|
80
|
+
// machines_reachable is already alerting for the same dead host.
|
|
81
|
+
test('an unmeasurable host is recorded as todo, not as healthy', () => {
|
|
82
|
+
const findings = auditDiskSpace({
|
|
83
|
+
results: [usage({ hostname: 'iot', usedPercent: null, message: 'ssh: connect timed out' })],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(findings).toHaveLength(1);
|
|
87
|
+
expect(findings[0]?.severity).toBe('todo');
|
|
88
|
+
expect(findings[0]?.code).toBe('disk_unmeasured');
|
|
89
|
+
expect(findings[0]?.details).toContain('timed out');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('an unreachable host does not hide a filling one', () => {
|
|
93
|
+
const findings = auditDiskSpace({
|
|
94
|
+
results: [
|
|
95
|
+
usage({ hostname: 'iot', usedPercent: null, message: 'unreachable' }),
|
|
96
|
+
usage({ hostname: 'celilo-mgr', usedPercent: 97 }),
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(findings.map((f) => f.subject).sort()).toEqual(['celilo-mgr', 'iot']);
|
|
101
|
+
expect(findings.find((f) => f.subject === 'celilo-mgr')?.severity).toBe('blocked');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('every finding carries the disk_space category', () => {
|
|
105
|
+
const findings = auditDiskSpace({
|
|
106
|
+
results: [usage({ usedPercent: 99 }), usage({ hostname: 'iot', usedPercent: null })],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(findings.every((f) => f.category === 'disk_space')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-space check.
|
|
3
|
+
*
|
|
4
|
+
* Catches the failure that had no detector at all: a filesystem filling up.
|
|
5
|
+
* celilo-mgr reached 34% and climbing at ~4.8 GB/hour from leaked backup
|
|
6
|
+
* staging, and the only reason anyone noticed was an operator running `df` by
|
|
7
|
+
* eye. Every one of the eighteen configured monitors watched module health or
|
|
8
|
+
* machine reachability; not one looked at disk.
|
|
9
|
+
*
|
|
10
|
+
* Reports EARLY, not at exhaustion. A check that fires once a filesystem is
|
|
11
|
+
* full reports an outage instead of preventing one, so the thresholds leave
|
|
12
|
+
* room to act: `drift` at 85% is "you have time", `blocked` at 95% is "you do
|
|
13
|
+
* not". On a 117 GB root that is ~17 GB and ~6 GB of headroom respectively —
|
|
14
|
+
* hours at the leak rate that motivated this, days at any normal one.
|
|
15
|
+
*
|
|
16
|
+
* The audit consumes pre-computed measurements so it stays unit-testable
|
|
17
|
+
* without a live filesystem or SSH client, exactly like `machines-reachable`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DriftFinding } from './types';
|
|
21
|
+
|
|
22
|
+
/** Usage at or above this is divergence worth an operator's attention. */
|
|
23
|
+
export const DISK_DRIFT_PERCENT = 85;
|
|
24
|
+
|
|
25
|
+
/** Usage at or above this is close enough to exhaustion to gate on. */
|
|
26
|
+
export const DISK_BLOCKED_PERCENT = 95;
|
|
27
|
+
|
|
28
|
+
export interface DiskUsageResult {
|
|
29
|
+
/**
|
|
30
|
+
* User-facing hostname, and the identifier the finding is keyed by.
|
|
31
|
+
*
|
|
32
|
+
* NOT the machine's UUID. Suppression resolves a machine's ancestor key from
|
|
33
|
+
* its hostname (`machineAlertKey` in alerting/suppression.ts), so a finding
|
|
34
|
+
* subjected on the UUID produces an alert key suppression can never match —
|
|
35
|
+
* which is exactly the bug filed as #596 against `machines_reachable`. Using
|
|
36
|
+
* the hostname here also satisfies CLAUDE.md: users never see UUIDs.
|
|
37
|
+
*/
|
|
38
|
+
hostname: string;
|
|
39
|
+
ipAddress: string;
|
|
40
|
+
/** Percent of the root filesystem in use, or null when it could not be measured. */
|
|
41
|
+
usedPercent: number | null;
|
|
42
|
+
/** Bytes still available. Omitted when unmeasured. */
|
|
43
|
+
availableBytes?: number;
|
|
44
|
+
/** Why the measurement failed, when `usedPercent` is null. */
|
|
45
|
+
message?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DiskSpaceAuditDeps {
|
|
49
|
+
results: DiskUsageResult[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function humanBytes(bytes: number): string {
|
|
53
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
54
|
+
let value = bytes;
|
|
55
|
+
let unit = 0;
|
|
56
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
57
|
+
value /= 1024;
|
|
58
|
+
unit++;
|
|
59
|
+
}
|
|
60
|
+
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function auditDiskSpace(deps: DiskSpaceAuditDeps): DriftFinding[] {
|
|
64
|
+
const findings: DriftFinding[] = [];
|
|
65
|
+
|
|
66
|
+
for (const result of deps.results) {
|
|
67
|
+
// Unmeasurable is NOT healthy — but it does not page either. The host is
|
|
68
|
+
// already unreachable, `machines_reachable` is already alerting on it, and
|
|
69
|
+
// a second page for one dead host is noise. `todo` records without
|
|
70
|
+
// notifying, which is the existing severity for exactly that.
|
|
71
|
+
if (result.usedPercent === null) {
|
|
72
|
+
findings.push({
|
|
73
|
+
category: 'disk_space',
|
|
74
|
+
severity: 'todo',
|
|
75
|
+
code: 'disk_unmeasured',
|
|
76
|
+
message: `${result.hostname}: disk usage could not be measured`,
|
|
77
|
+
details: result.message,
|
|
78
|
+
remediation:
|
|
79
|
+
'The host is unreachable or `df` failed on it. `machines_reachable` covers reachability; this finding only records that disk is currently unknown, not that it is healthy.',
|
|
80
|
+
actionable: false,
|
|
81
|
+
subject: result.hostname,
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (result.usedPercent < DISK_DRIFT_PERCENT) continue;
|
|
87
|
+
|
|
88
|
+
const critical = result.usedPercent >= DISK_BLOCKED_PERCENT;
|
|
89
|
+
const free =
|
|
90
|
+
result.availableBytes === undefined ? '' : `, ${humanBytes(result.availableBytes)} free`;
|
|
91
|
+
|
|
92
|
+
findings.push({
|
|
93
|
+
category: 'disk_space',
|
|
94
|
+
severity: critical ? 'blocked' : 'drift',
|
|
95
|
+
code: critical ? 'disk_critical' : 'disk_low',
|
|
96
|
+
message: `${result.hostname}: root filesystem ${result.usedPercent}% full${free}`,
|
|
97
|
+
details: critical
|
|
98
|
+
? `At or above ${DISK_BLOCKED_PERCENT}% the host is close enough to exhaustion that writes can begin failing. On the management server a full root filesystem takes the event bus and dispatcher with it, including whatever scheduled work would otherwise clean up.`
|
|
99
|
+
: `At or above ${DISK_DRIFT_PERCENT}% there is still room to act. Find the growth before it becomes an outage rather than after.`,
|
|
100
|
+
remediation: [
|
|
101
|
+
`Find what is growing on ${result.hostname}:`,
|
|
102
|
+
' df -h /',
|
|
103
|
+
' sudo du -xh --max-depth=2 / | sort -h | tail -30',
|
|
104
|
+
'Then fix the source — a retention policy, a size cap, or a prune',
|
|
105
|
+
'schedule. A one-off delete leaves the same thing growing.',
|
|
106
|
+
].join('\n'),
|
|
107
|
+
// Multi-step diagnosis, not a one-shot celilo command.
|
|
108
|
+
actionable: false,
|
|
109
|
+
subject: result.hostname,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
@@ -26,6 +26,7 @@ const emptyDeps = {
|
|
|
26
26
|
moduleConfigs: { modules: [] },
|
|
27
27
|
health: { results: [] },
|
|
28
28
|
backups: { modules: [] },
|
|
29
|
+
abandonedOperations: { records: [] },
|
|
29
30
|
undeployedModules: { modules: [] },
|
|
30
31
|
unconfiguredModules: { modules: [] },
|
|
31
32
|
servicesCredentials: { results: [] },
|