@celilo/cli 0.16.1 → 0.17.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_SUBSYSTEMS.md +4 -1
- package/package.json +3 -3
- package/src/cli/commands/alerts-poll.ts +26 -1
- 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 +12 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +6 -3
- package/src/cli/index.ts +12 -0
- package/src/cli/tui/audit-state.test.ts +15 -1
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/module/packaging/release-metadata.test.ts +77 -3
- package/src/module/packaging/release-metadata.ts +11 -1
- package/src/services/alerting/builtin-monitors.ts +1 -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/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -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 +1 -0
- package/src/services/backup-storage.ts +29 -0
- package/src/services/storage-providers/local.ts +2 -1
- package/src/services/update/orchestrator.test.ts +1 -0
|
@@ -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
|
+
}
|
|
@@ -104,6 +104,35 @@ export async function addBackupStorage(params: {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Replace a storage destination's credentials.
|
|
109
|
+
*
|
|
110
|
+
* Clears the verification stamp in the same statement. A `verified`
|
|
111
|
+
* flag describes the destination the credentials pointed at; once they
|
|
112
|
+
* change it describes somewhere else, and carrying it forward is how
|
|
113
|
+
* celilo-mgr ended up reporting `✓ Verified` for a macOS path on a
|
|
114
|
+
* Linux host (#566). Callers re-verify against the new destination.
|
|
115
|
+
*/
|
|
116
|
+
export async function updateStorageCredentials(
|
|
117
|
+
id: string,
|
|
118
|
+
credentials: Record<string, unknown>,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const masterKey = await getOrCreateMasterKey();
|
|
121
|
+
const encrypted = encryptSecret(JSON.stringify(credentials), masterKey);
|
|
122
|
+
|
|
123
|
+
getDb()
|
|
124
|
+
.update(backupStorages)
|
|
125
|
+
.set({
|
|
126
|
+
credentialsEncrypted: JSON.stringify(encrypted),
|
|
127
|
+
verified: false,
|
|
128
|
+
verifiedAt: null,
|
|
129
|
+
verificationError: null,
|
|
130
|
+
updatedAt: new Date(),
|
|
131
|
+
})
|
|
132
|
+
.where(eq(backupStorages.id, id))
|
|
133
|
+
.run();
|
|
134
|
+
}
|
|
135
|
+
|
|
107
136
|
/**
|
|
108
137
|
* Get backup storage by storage ID (user-facing identifier)
|
|
109
138
|
*/
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
import { dirname, join, relative } from 'node:path';
|
|
15
15
|
import type { StorageProvider, StorageVerifyResult } from './types';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Subdirectory of the configured path that actually holds archives. */
|
|
18
|
+
export const BACKUP_PREFIX = 'celilo-backups';
|
|
18
19
|
|
|
19
20
|
export interface LocalStorageConfig {
|
|
20
21
|
path: string;
|
|
@@ -82,6 +82,7 @@ const cleanAudit: AuditDeps = {
|
|
|
82
82
|
secretsDecryptable: { results: [] },
|
|
83
83
|
servicesReachable: { results: [] },
|
|
84
84
|
machinesReachable: { results: [] },
|
|
85
|
+
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
85
86
|
trustedSources: { firewalls: [] },
|
|
86
87
|
};
|
|
87
88
|
|