@celilo/cli 0.16.2 → 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 +39 -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/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/alerts-poll.ts +26 -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/storage-set-path.test.ts +281 -0
- package/src/cli/commands/storage-set-path.ts +190 -0
- package/src/cli/commands/system-audit.ts +14 -0
- package/src/cli/commands/system-migrate.ts +40 -0
- package/src/cli/commands/system-update.ts +6 -0
- package/src/cli/completion.ts +24 -3
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +12 -0
- package/src/cli/tui/audit-state.test.ts +15 -1
- package/src/cli/tui/audit-state.ts +6 -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 +3 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/alerting/inbound-poller.test.ts +63 -1
- package/src/services/alerting/inbound-poller.ts +42 -0
- package/src/services/alerting/read-records.ts +85 -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 +2 -0
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/transport-reads.test.ts +113 -0
- package/src/services/audit/transport-reads.ts +120 -0
- package/src/services/audit/types.ts +3 -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-storage.ts +29 -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/storage-providers/local.ts +2 -1
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/variables/context.ts +6 -1
|
@@ -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,12 +26,14 @@ 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: [] },
|
|
32
33
|
secretsDecryptable: { results: [] },
|
|
33
34
|
servicesReachable: { results: [] },
|
|
34
35
|
machinesReachable: { results: [] },
|
|
36
|
+
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
35
37
|
trustedSources: { firewalls: [] },
|
|
36
38
|
};
|
|
37
39
|
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* truth for what audit can detect is this file's `AuditDeps` shape.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import {
|
|
14
|
+
type AbandonedOperationsAuditDeps,
|
|
15
|
+
auditAbandonedOperations,
|
|
16
|
+
} from './abandoned-operations';
|
|
13
17
|
import { type BackupsAuditDeps, auditBackups } from './backups';
|
|
14
18
|
import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
|
|
15
19
|
import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
} from './services-credentials';
|
|
26
30
|
import { type ServicesReachableAuditDeps, auditServicesReachable } from './services-reachable';
|
|
27
31
|
import { type TerraformPlanAuditDeps, auditTerraformPlan } from './terraform-plan';
|
|
32
|
+
import { type TransportReadsAuditDeps, auditTransportReads } from './transport-reads';
|
|
28
33
|
import { type TrustedSourcesAuditDeps, auditTrustedSources } from './trusted-sources';
|
|
29
34
|
import {
|
|
30
35
|
type DriftCategory,
|
|
@@ -47,12 +52,14 @@ export interface AuditDeps {
|
|
|
47
52
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
48
53
|
health: HealthAuditDeps;
|
|
49
54
|
backups: BackupsAuditDeps;
|
|
55
|
+
abandonedOperations: AbandonedOperationsAuditDeps;
|
|
50
56
|
undeployedModules: UndeployedModulesAuditDeps;
|
|
51
57
|
unconfiguredModules: UnconfiguredModulesAuditDeps;
|
|
52
58
|
servicesCredentials: ServicesCredentialsAuditDeps;
|
|
53
59
|
secretsDecryptable: SecretsDecryptableAuditDeps;
|
|
54
60
|
servicesReachable: ServicesReachableAuditDeps;
|
|
55
61
|
machinesReachable: MachinesReachableAuditDeps;
|
|
62
|
+
transportReads: TransportReadsAuditDeps;
|
|
56
63
|
trustedSources: TrustedSourcesAuditDeps;
|
|
57
64
|
/** Defaults to `Date.now()`-based ISO string. */
|
|
58
65
|
now?: () => Date;
|
|
@@ -97,12 +104,17 @@ export async function runAudit(
|
|
|
97
104
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
98
105
|
wrap('health', auditHealth(deps.health)),
|
|
99
106
|
wrap('backups', auditBackups(deps.backups)),
|
|
107
|
+
wrap(
|
|
108
|
+
'abandoned_operations',
|
|
109
|
+
Promise.resolve(auditAbandonedOperations(deps.abandonedOperations)),
|
|
110
|
+
),
|
|
100
111
|
wrap('undeployed_modules', auditUndeployedModules(deps.undeployedModules)),
|
|
101
112
|
wrap('unconfigured_modules', auditUnconfiguredModules(deps.unconfiguredModules)),
|
|
102
113
|
wrap('services_credentials', auditServicesCredentials(deps.servicesCredentials)),
|
|
103
114
|
wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
|
|
104
115
|
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
105
116
|
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
117
|
+
wrap('transport_reads', auditTransportReads(deps.transportReads)),
|
|
106
118
|
wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
|
|
107
119
|
]);
|
|
108
120
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { TransportReadStatus } from '../alerting/read-records';
|
|
3
|
+
import { auditTransportReads } from './transport-reads';
|
|
4
|
+
|
|
5
|
+
const NOW = new Date('2026-08-01T12:00:00Z');
|
|
6
|
+
const STALE_AFTER = 30 * 60_000;
|
|
7
|
+
|
|
8
|
+
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
|
|
9
|
+
|
|
10
|
+
function status(over: Partial<TransportReadStatus['last']> | null): TransportReadStatus {
|
|
11
|
+
return {
|
|
12
|
+
transportModuleId: 'signal',
|
|
13
|
+
last:
|
|
14
|
+
over === null
|
|
15
|
+
? null
|
|
16
|
+
: { at: ago(0), outcome: 'received', messages: 0, lastSuccessAt: ago(0), ...over },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const run = (statuses: TransportReadStatus[]) =>
|
|
21
|
+
auditTransportReads({ statuses, now: NOW, staleAfterMs: STALE_AFTER });
|
|
22
|
+
|
|
23
|
+
describe('auditTransportReads', () => {
|
|
24
|
+
test('a transport read successfully just now is not a finding', async () => {
|
|
25
|
+
expect(await run([status({})])).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// THE case this check exists for. Six tokens were issued and zero consumed
|
|
29
|
+
// over a week, and nothing anywhere was red (#501).
|
|
30
|
+
test('a transport not read successfully for hours is drift', async () => {
|
|
31
|
+
const findings = await run([
|
|
32
|
+
status({
|
|
33
|
+
at: ago(60_000),
|
|
34
|
+
outcome: 'failed',
|
|
35
|
+
error: 'refused',
|
|
36
|
+
lastSuccessAt: ago(3 * 3600_000),
|
|
37
|
+
}),
|
|
38
|
+
]);
|
|
39
|
+
expect(findings).toHaveLength(1);
|
|
40
|
+
expect(findings[0]).toMatchObject({
|
|
41
|
+
category: 'transport_reads',
|
|
42
|
+
code: 'transport_reads_stale',
|
|
43
|
+
severity: 'drift',
|
|
44
|
+
subject: 'signal',
|
|
45
|
+
});
|
|
46
|
+
expect(findings[0].message).toContain('3h ago');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The decision that makes staleness safe to page on: an EMPTY read is a
|
|
50
|
+
// success, so a quiet transport keeps refreshing lastSuccessAt. If empty
|
|
51
|
+
// reads counted as failure this check would page on every quiet afternoon
|
|
52
|
+
// and be switched off within a week.
|
|
53
|
+
test('a quiet transport — successful reads, zero messages — is NOT stale', async () => {
|
|
54
|
+
const findings = await run([
|
|
55
|
+
status({ at: ago(0), outcome: 'received', messages: 0, lastSuccessAt: ago(0) }),
|
|
56
|
+
]);
|
|
57
|
+
expect(findings).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a transport that has never succeeded is drift, even if attempts are recent', async () => {
|
|
61
|
+
const findings = await run([
|
|
62
|
+
status({
|
|
63
|
+
at: ago(0),
|
|
64
|
+
outcome: 'failed',
|
|
65
|
+
error: 'connection refused',
|
|
66
|
+
lastSuccessAt: undefined,
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
expect(findings[0]).toMatchObject({ code: 'transport_never_read', severity: 'drift' });
|
|
70
|
+
expect(findings[0].details).toContain('connection refused');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a transport with no record at all is drift, not silently fine', async () => {
|
|
74
|
+
const findings = await run([status(null)]);
|
|
75
|
+
expect(findings[0]).toMatchObject({ code: 'transport_never_polled', severity: 'drift' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// A transport with no `receive` is working as designed. Paging about it
|
|
79
|
+
// would be paging about a healthy system, which teaches operators to ignore
|
|
80
|
+
// the check.
|
|
81
|
+
test('a unidirectional transport is never a finding', async () => {
|
|
82
|
+
const findings = await run([
|
|
83
|
+
status({ at: ago(10 * 3600_000), outcome: 'unidirectional', lastSuccessAt: undefined }),
|
|
84
|
+
]);
|
|
85
|
+
expect(findings).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('just inside the threshold is not yet drift', async () => {
|
|
89
|
+
expect(await run([status({ lastSuccessAt: ago(STALE_AFTER - 1_000) })])).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('just outside the threshold is drift', async () => {
|
|
93
|
+
const findings = await run([status({ lastSuccessAt: ago(STALE_AFTER + 1_000) })]);
|
|
94
|
+
expect(findings).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// The remediation must point at something that does not consume the queue.
|
|
98
|
+
// `celilo alerts poll` would READ, and a suggestion that eats the operator's
|
|
99
|
+
// acknowledgement is worse than no suggestion (#541).
|
|
100
|
+
test('a stale finding sends you to the journal, not to a read', async () => {
|
|
101
|
+
const findings = await run([status({ lastSuccessAt: ago(3 * 3600_000) })]);
|
|
102
|
+
expect(findings[0].remediation).toBe('celilo module journal signal');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('each transport is judged on its own record', async () => {
|
|
106
|
+
const findings = await run([
|
|
107
|
+
{ transportModuleId: 'signal', last: status({}).last },
|
|
108
|
+
{ transportModuleId: 'sms', last: status({ lastSuccessAt: ago(9 * 3600_000) }).last },
|
|
109
|
+
]);
|
|
110
|
+
expect(findings).toHaveLength(1);
|
|
111
|
+
expect(findings[0].subject).toBe('sms');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-reads check — is celilo still able to READ replies?
|
|
3
|
+
*
|
|
4
|
+
* Every other signal that a notification transport is working proves the wrong
|
|
5
|
+
* half. `send` succeeding proves outbound. A daemon answering its API proves a
|
|
6
|
+
* socket. Neither can see the return leg fail, and for a week none of them did:
|
|
7
|
+
* six alert tokens were issued, zero were consumed, and nothing anywhere was
|
|
8
|
+
* red (#501).
|
|
9
|
+
*
|
|
10
|
+
* This is the check that would have caught it. It asserts the contract —
|
|
11
|
+
* celilo can read this transport — using first-hand evidence rather than a
|
|
12
|
+
* proxy: the recorded outcome of the reads the poller already performs every
|
|
13
|
+
* few minutes.
|
|
14
|
+
*
|
|
15
|
+
* WHY STALENESS IS A SOUND SIGNAL HERE, and it rests on one decision:
|
|
16
|
+
* an empty read is recorded as a SUCCESS. A transport nobody has replied on
|
|
17
|
+
* still records a successful read on every poll. So "no successful read in a
|
|
18
|
+
* while" cannot mean "quiet" — it can only mean the reads stopped happening or
|
|
19
|
+
* stopped working. Had zero-messages been recorded as failure, this check would
|
|
20
|
+
* page every time an operator had a peaceful afternoon, and would be turned off
|
|
21
|
+
* within a week.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately consumes pre-computed state rather than reading the store
|
|
24
|
+
* itself, so it stays unit-testable and cannot become a second thing that
|
|
25
|
+
* performs reads. A check that drained the queue to find out whether the queue
|
|
26
|
+
* could be drained would eat the acknowledgement it was protecting (#541).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { TransportReadStatus } from '../alerting/read-records';
|
|
30
|
+
import type { DriftFinding } from './types';
|
|
31
|
+
|
|
32
|
+
export type { TransportReadStatus };
|
|
33
|
+
|
|
34
|
+
export interface TransportReadsAuditDeps {
|
|
35
|
+
/** One entry per transport that has at least one route pointing at it. */
|
|
36
|
+
statuses: TransportReadStatus[];
|
|
37
|
+
now: Date;
|
|
38
|
+
/**
|
|
39
|
+
* How long without a successful read before it counts as drift.
|
|
40
|
+
*
|
|
41
|
+
* The poller runs every five minutes, so this is a multiple of that rather
|
|
42
|
+
* than a guess: it has to absorb a missed tick, a slow sweep, and a restart
|
|
43
|
+
* without crying wolf, while still catching a transport that has genuinely
|
|
44
|
+
* stopped being readable.
|
|
45
|
+
*/
|
|
46
|
+
staleAfterMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CATEGORY = 'transport_reads' as const;
|
|
50
|
+
|
|
51
|
+
function describeAge(ms: number): string {
|
|
52
|
+
const mins = Math.floor(ms / 60_000);
|
|
53
|
+
if (mins < 60) return `${mins}m`;
|
|
54
|
+
const hours = Math.floor(mins / 60);
|
|
55
|
+
return hours < 48 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function auditTransportReads(deps: TransportReadsAuditDeps): Promise<DriftFinding[]> {
|
|
59
|
+
const findings: DriftFinding[] = [];
|
|
60
|
+
|
|
61
|
+
for (const status of deps.statuses) {
|
|
62
|
+
const id = status.transportModuleId;
|
|
63
|
+
|
|
64
|
+
// Nothing recorded at all. Either the poller has never run, or this
|
|
65
|
+
// transport was added and never polled. Both mean no reply from here has
|
|
66
|
+
// ever been collectable, which is worth saying out loud rather than
|
|
67
|
+
// treating an empty store as "fine so far".
|
|
68
|
+
if (!status.last) {
|
|
69
|
+
findings.push({
|
|
70
|
+
category: CATEGORY,
|
|
71
|
+
severity: 'drift',
|
|
72
|
+
code: 'transport_never_polled',
|
|
73
|
+
message: `${id}: celilo has never recorded a read attempt`,
|
|
74
|
+
details:
|
|
75
|
+
'No inbound read has been attempted for this transport, so a\n' +
|
|
76
|
+
'reply sent to it would not be collected. This is the state a\n' +
|
|
77
|
+
'newly-added transport is in until the first poll runs.',
|
|
78
|
+
remediation: 'celilo alerts poll',
|
|
79
|
+
actionable: true,
|
|
80
|
+
subject: id,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A transport with no `receive` is unidirectional BY DESIGN — pages go out,
|
|
86
|
+
// replies were never possible, and the route's can_ack already records
|
|
87
|
+
// that. Flagging it would be flagging a working system.
|
|
88
|
+
if (status.last.outcome === 'unidirectional') continue;
|
|
89
|
+
|
|
90
|
+
if (!status.last.lastSuccessAt) {
|
|
91
|
+
findings.push({
|
|
92
|
+
category: CATEGORY,
|
|
93
|
+
severity: 'drift',
|
|
94
|
+
code: 'transport_never_read',
|
|
95
|
+
message: `${id}: no read has ever SUCCEEDED`,
|
|
96
|
+
details: `Reads have been attempted — the most recent was ${status.last.outcome}${status.last.error ? ` (${status.last.error})` : ''} — but none has\never succeeded. Acknowledgements sent to this transport cannot be\ncollected, and outbound paging will keep working, so nothing else\nwill report this.`,
|
|
97
|
+
remediation: `celilo module journal ${id}`,
|
|
98
|
+
actionable: true,
|
|
99
|
+
subject: id,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const age = deps.now.getTime() - new Date(status.last.lastSuccessAt).getTime();
|
|
105
|
+
if (age > deps.staleAfterMs) {
|
|
106
|
+
findings.push({
|
|
107
|
+
category: CATEGORY,
|
|
108
|
+
severity: 'drift',
|
|
109
|
+
code: 'transport_reads_stale',
|
|
110
|
+
message: `${id}: last successful read ${describeAge(age)} ago`,
|
|
111
|
+
details: `The most recent attempt was ${status.last.outcome}${status.last.error ? `: ${status.last.error}` : ''}.\nAn empty read still counts as a success, so this is not "nobody\nreplied" — reads are either not happening or not working, and a\nreply sent now would not be collected.`,
|
|
112
|
+
remediation: `celilo module journal ${id}`,
|
|
113
|
+
actionable: true,
|
|
114
|
+
subject: id,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return findings;
|
|
120
|
+
}
|
|
@@ -30,12 +30,15 @@ export type DriftCategory =
|
|
|
30
30
|
| 'module_configs'
|
|
31
31
|
| 'health'
|
|
32
32
|
| 'backups'
|
|
33
|
+
| 'abandoned_operations'
|
|
33
34
|
| 'undeployed_modules'
|
|
34
35
|
| 'unconfigured_modules'
|
|
35
36
|
| 'services_credentials'
|
|
36
37
|
| 'secrets_decryptable'
|
|
37
38
|
| 'services_reachable'
|
|
38
39
|
| 'machines_reachable'
|
|
40
|
+
| 'disk_space'
|
|
41
|
+
| 'transport_reads'
|
|
39
42
|
| 'trusted_sources';
|
|
40
43
|
|
|
41
44
|
export type DriftSeverity = 'todo' | 'drift' | 'blocked';
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
7
|
import { join } from 'node:path';
|
|
9
8
|
import { eq } from 'drizzle-orm';
|
|
10
9
|
import { getDbPath } from '../config/paths';
|
|
@@ -20,6 +19,7 @@ import { encryptFileToFile } from './backup-cipher';
|
|
|
20
19
|
import { buildManifest } from './backup-manifest';
|
|
21
20
|
import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
|
|
22
21
|
import type { BackupSchedule } from './backup-schedule';
|
|
22
|
+
import { stagingDirFor } from './backup-staging';
|
|
23
23
|
import {
|
|
24
24
|
createStorageProvider,
|
|
25
25
|
getBackupStorage,
|
|
@@ -113,7 +113,7 @@ export async function createSystemStateBackup(
|
|
|
113
113
|
});
|
|
114
114
|
const opId = startOperation('__system__', 'backup');
|
|
115
115
|
|
|
116
|
-
const tempDir =
|
|
116
|
+
const tempDir = stagingDirFor(record.id);
|
|
117
117
|
|
|
118
118
|
try {
|
|
119
119
|
mkdirSync(tempDir, { recursive: true });
|
|
@@ -328,7 +328,7 @@ export async function createModuleBackup(
|
|
|
328
328
|
// Envelope layout (v1.0):
|
|
329
329
|
// manifest.json - schema version, host, moduleId, dataSchemaVersion
|
|
330
330
|
// data/ - on_backup hook artifacts (the hook treats this as backup_dir)
|
|
331
|
-
const tempDir =
|
|
331
|
+
const tempDir = stagingDirFor(record.id);
|
|
332
332
|
const envelopeDir = join(tempDir, 'envelope');
|
|
333
333
|
const dataDir = join(envelopeDir, 'data');
|
|
334
334
|
|
|
@@ -496,7 +496,7 @@ export async function importModuleBackup(
|
|
|
496
496
|
moduleVersion: manifest.version,
|
|
497
497
|
});
|
|
498
498
|
|
|
499
|
-
const tempDir =
|
|
499
|
+
const tempDir = stagingDirFor(record.id);
|
|
500
500
|
const artifactDir = join(tempDir, 'artifacts');
|
|
501
501
|
|
|
502
502
|
try {
|
|
@@ -129,6 +129,7 @@ describe('backup/restore in-flight refusal', () => {
|
|
|
129
129
|
status: 'completed' as const,
|
|
130
130
|
errorMessage: null,
|
|
131
131
|
name: null,
|
|
132
|
+
pid: process.pid,
|
|
132
133
|
startedAt: new Date(),
|
|
133
134
|
completedAt: null,
|
|
134
135
|
};
|
|
@@ -152,6 +153,7 @@ describe('backup/restore in-flight refusal', () => {
|
|
|
152
153
|
status: 'completed' as const,
|
|
153
154
|
errorMessage: null,
|
|
154
155
|
name: null,
|
|
156
|
+
pid: process.pid,
|
|
155
157
|
startedAt: new Date(),
|
|
156
158
|
completedAt: null,
|
|
157
159
|
};
|