@celilo/cli 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +61 -9
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +7 -7
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/events.ts +34 -3
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +127 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +39 -9
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +104 -22
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +52 -3
- package/src/services/alerting/sweep-runner.ts +41 -8
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/fleet-checks.ts +48 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +32 -49
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- package/src/test-utils/setup-test-db.ts +13 -23
|
@@ -17,8 +17,13 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
|
17
17
|
import { shellEscape } from '../utils/shell';
|
|
18
18
|
import { encryptFileToFile } from './backup-cipher';
|
|
19
19
|
import { buildManifest } from './backup-manifest';
|
|
20
|
-
import {
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
completeBackup,
|
|
22
|
+
createBackupRecord,
|
|
23
|
+
failBackup,
|
|
24
|
+
loadBackupHistory,
|
|
25
|
+
} from './backup-metadata';
|
|
26
|
+
import { isBackupDueFromHistory } from './backup-schedule';
|
|
22
27
|
import { stagingDirFor } from './backup-staging';
|
|
23
28
|
import {
|
|
24
29
|
createStorageProvider,
|
|
@@ -26,8 +31,10 @@ import {
|
|
|
26
31
|
getBackupStorageByStorageId,
|
|
27
32
|
getDefaultBackupStorage,
|
|
28
33
|
} from './backup-storage';
|
|
34
|
+
import type { Cadence } from './cadence';
|
|
29
35
|
import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
30
36
|
import { getModuleSystems } from './deployed-systems';
|
|
37
|
+
import { parseStoredConfigValue } from './module-config';
|
|
31
38
|
import {
|
|
32
39
|
completeOperation,
|
|
33
40
|
failOperation,
|
|
@@ -49,8 +56,6 @@ export interface BackupCreateResult {
|
|
|
49
56
|
error?: string;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
export type { BackupSchedule } from './backup-schedule';
|
|
53
|
-
|
|
54
59
|
/**
|
|
55
60
|
* Resolve the target storage destination
|
|
56
61
|
*/
|
|
@@ -198,46 +203,47 @@ export async function createSystemStateBackup(
|
|
|
198
203
|
}
|
|
199
204
|
|
|
200
205
|
/**
|
|
201
|
-
*
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Check if a module is due for backup based on its schedule
|
|
206
|
+
* Check if a module is due for backup based on its schedule.
|
|
207
|
+
*
|
|
208
|
+
* The policy lives in `isBackupDueFromHistory`; this reads the history it
|
|
209
|
+
* needs. Worth knowing what the previous version did, because its shape was
|
|
210
|
+
* the bug: it asked for `limit: 1` — the single most recent row, whatever its
|
|
211
|
+
* outcome — and then `.find()`-ed a *completed* one within that. Those two
|
|
212
|
+
* lines contradict each other. Once an attempt failed, the newest row was that
|
|
213
|
+
* failure, the find matched nothing, and "no successful backup exists" meant
|
|
214
|
+
* unconditionally due — at every hourly tick, forever, however the module's
|
|
215
|
+
* cadence read (celilo#685).
|
|
213
216
|
*/
|
|
214
|
-
export function isBackupDue(moduleId: string, schedule:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const existing = listBackups({ moduleId, limit: 1 });
|
|
218
|
-
const lastBackup = existing.find(
|
|
219
|
-
(b) => b.moduleId === moduleId && b.status === 'completed' && b.backupType === 'module_data',
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
if (!lastBackup) return true;
|
|
223
|
-
|
|
224
|
-
const elapsed = Date.now() - new Date(lastBackup.startedAt).getTime();
|
|
225
|
-
return elapsed >= SCHEDULE_INTERVALS[schedule];
|
|
217
|
+
export function isBackupDue(moduleId: string, schedule: Cadence): boolean {
|
|
218
|
+
return isBackupDueFromHistory(schedule, loadBackupHistory(moduleId), Date.now());
|
|
226
219
|
}
|
|
227
220
|
|
|
228
221
|
/**
|
|
229
|
-
* Find all installed modules that have an on_backup hook
|
|
222
|
+
* Find all installed modules that have an on_backup hook.
|
|
223
|
+
*
|
|
224
|
+
* Each carries its operator config alongside its manifest, because every
|
|
225
|
+
* caller then has to resolve a cadence or a retention policy out of the two
|
|
226
|
+
* together — and a caller that got the manifest alone would silently ignore
|
|
227
|
+
* the operator's override.
|
|
230
228
|
*/
|
|
231
229
|
export function findBackupEligibleModules(): Array<{
|
|
232
230
|
module: typeof modules.$inferSelect;
|
|
233
231
|
manifest: ModuleManifest;
|
|
232
|
+
configs: Record<string, unknown>;
|
|
234
233
|
}> {
|
|
235
234
|
const db = getDb();
|
|
236
235
|
const allModules = db.select().from(modules).all();
|
|
236
|
+
const configsByModule = new Map<string, Record<string, unknown>>();
|
|
237
|
+
for (const row of db.select().from(moduleConfigs).all()) {
|
|
238
|
+
const forModule = configsByModule.get(row.moduleId) ?? {};
|
|
239
|
+
forModule[row.key] = parseStoredConfigValue(row);
|
|
240
|
+
configsByModule.set(row.moduleId, forModule);
|
|
241
|
+
}
|
|
237
242
|
|
|
238
243
|
const eligible: Array<{
|
|
239
244
|
module: typeof modules.$inferSelect;
|
|
240
245
|
manifest: ModuleManifest;
|
|
246
|
+
configs: Record<string, unknown>;
|
|
241
247
|
}> = [];
|
|
242
248
|
|
|
243
249
|
for (const mod of allModules) {
|
|
@@ -246,7 +252,7 @@ export function findBackupEligibleModules(): Array<{
|
|
|
246
252
|
const manifest = mod.manifestData as unknown as ModuleManifest;
|
|
247
253
|
if (!manifest.hooks?.on_backup) continue;
|
|
248
254
|
|
|
249
|
-
eligible.push({ module: mod, manifest });
|
|
255
|
+
eligible.push({ module: mod, manifest, configs: configsByModule.get(mod.id) ?? {} });
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
return eligible;
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { desc, eq, like } from 'drizzle-orm';
|
|
7
|
+
import { and, desc, eq, gt, isNotNull, like } from 'drizzle-orm';
|
|
8
8
|
import { getDb } from '../db/client';
|
|
9
9
|
import { type Backup, type BackupStatus, type BackupType, backups } from '../db/schema';
|
|
10
|
+
import type { BackupHistory } from './backup-schedule';
|
|
10
11
|
|
|
11
12
|
/** Short ID length used for display and lookup */
|
|
12
13
|
export const SHORT_ID_LENGTH = 8;
|
|
@@ -156,6 +157,56 @@ export function listBackups(options?: {
|
|
|
156
157
|
return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
161
|
+
* What a module's backup history says, for the due-ness decision.
|
|
162
|
+
*
|
|
163
|
+
* Only `module_data` rows count. A module's cadence is about its own data; a
|
|
164
|
+
* system backup that happens to name it must not make it look covered.
|
|
165
|
+
*
|
|
166
|
+
* Split from the decision itself so the policy stays pure and testable
|
|
167
|
+
* (Rule 2.3) — see `isBackupDueFromHistory`.
|
|
168
|
+
*/
|
|
169
|
+
export function loadBackupHistory(moduleId: string): BackupHistory {
|
|
170
|
+
const db = getDb();
|
|
171
|
+
const forThisModule = and(eq(backups.moduleId, moduleId), eq(backups.backupType, 'module_data'));
|
|
172
|
+
|
|
173
|
+
// Ordered and measured by `completedAt`, matching the freshness audit
|
|
174
|
+
// (services/audit/backup-source.ts). Measuring due-ness from `startedAt`
|
|
175
|
+
// while the audit measured from `completedAt` meant the two paths could
|
|
176
|
+
// disagree about a module's age by the duration of the backup itself —
|
|
177
|
+
// design.md D6. A `completed` row with no `completedAt` is not a backup
|
|
178
|
+
// either path will count.
|
|
179
|
+
const [lastSuccess] = db
|
|
180
|
+
.select()
|
|
181
|
+
.from(backups)
|
|
182
|
+
.where(and(forThisModule, eq(backups.status, 'completed'), isNotNull(backups.completedAt)))
|
|
183
|
+
.orderBy(desc(backups.completedAt))
|
|
184
|
+
.limit(1)
|
|
185
|
+
.all();
|
|
186
|
+
|
|
187
|
+
const [lastAttempt] = db
|
|
188
|
+
.select()
|
|
189
|
+
.from(backups)
|
|
190
|
+
.where(forThisModule)
|
|
191
|
+
.orderBy(desc(backups.startedAt))
|
|
192
|
+
.limit(1)
|
|
193
|
+
.all();
|
|
194
|
+
|
|
195
|
+
// Attempts since the last success — every row newer than it, or every row at
|
|
196
|
+
// all when there has never been one. An `in_progress` row is included: the
|
|
197
|
+
// decision only ever uses this to back OFF, so counting a live attempt is
|
|
198
|
+
// the safe direction.
|
|
199
|
+
const since = lastSuccess
|
|
200
|
+
? and(forThisModule, gt(backups.startedAt, lastSuccess.startedAt))
|
|
201
|
+
: forThisModule;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
lastSuccessAt: lastSuccess?.completedAt ?? null,
|
|
205
|
+
lastAttemptAt: lastAttempt?.startedAt ?? null,
|
|
206
|
+
consecutiveFailures: db.select({ id: backups.id }).from(backups).where(since).all().length,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
159
210
|
/**
|
|
160
211
|
* Every backup record still claiming to be in progress, oldest first.
|
|
161
212
|
*
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention resolves per dimension, and an unset dimension is UNBOUNDED.
|
|
3
|
+
*
|
|
4
|
+
* The trap this file exists for: `backup.retention` is an optional block, so a
|
|
5
|
+
* manifest that omits it prunes nothing at all — its inner `count: 7` /
|
|
6
|
+
* `max_age_days: 30` defaults never apply. If setting one dimension let the
|
|
7
|
+
* other fall back to those defaults, an operator asking to keep 3 copies would
|
|
8
|
+
* silently arm a 30-day deletion on a module that had been keeping everything.
|
|
9
|
+
* Every other failure here is recoverable; that one destroys backups.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import type { Backup } from '../db/schema';
|
|
14
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
15
|
+
import {
|
|
16
|
+
BACKUP_RETENTION_COUNT_CONFIG_KEY,
|
|
17
|
+
BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY,
|
|
18
|
+
effectiveBackupRetention,
|
|
19
|
+
identifyExpiredBackups,
|
|
20
|
+
prunesNothing,
|
|
21
|
+
} from './backup-retention';
|
|
22
|
+
|
|
23
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
24
|
+
const UNBOUNDED = Number.POSITIVE_INFINITY;
|
|
25
|
+
|
|
26
|
+
function manifestWith(retention?: { count?: number; max_age_days?: number }): ModuleManifest {
|
|
27
|
+
return { backup: retention ? { retention } : undefined } as unknown as ModuleManifest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Newest first, matching what `listCompletedBackupsForModule` returns. */
|
|
31
|
+
function backupsAgedDays(...ages: number[]): Backup[] {
|
|
32
|
+
return ages.map(
|
|
33
|
+
(days, i) =>
|
|
34
|
+
({
|
|
35
|
+
id: `backup-${i}`,
|
|
36
|
+
storagePath: `path-${i}`,
|
|
37
|
+
storageId: 'storage-1',
|
|
38
|
+
startedAt: new Date(Date.now() - days * DAY),
|
|
39
|
+
}) as unknown as Backup,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('effectiveBackupRetention', () => {
|
|
44
|
+
test('nothing declared and nothing set is unbounded in both dimensions', () => {
|
|
45
|
+
const policy = effectiveBackupRetention(manifestWith(), {});
|
|
46
|
+
expect(policy).toEqual({ count: UNBOUNDED, maxAgeDays: UNBOUNDED });
|
|
47
|
+
expect(prunesNothing(policy)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// THE data-loss trap. Setting one dimension must not arm the other.
|
|
51
|
+
test('setting one dimension leaves the other unbounded when the manifest declares none', () => {
|
|
52
|
+
const policy = effectiveBackupRetention(manifestWith(), {
|
|
53
|
+
[BACKUP_RETENTION_COUNT_CONFIG_KEY]: 3,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(policy.count).toBe(3);
|
|
57
|
+
expect(policy.maxAgeDays).toBe(UNBOUNDED);
|
|
58
|
+
expect(prunesNothing(policy)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an override bounds one dimension while the manifest bounds the other', () => {
|
|
62
|
+
const policy = effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), {
|
|
63
|
+
[BACKUP_RETENTION_COUNT_CONFIG_KEY]: 3,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(policy).toEqual({ count: 3, maxAgeDays: 30 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('both dimensions can be overridden independently', () => {
|
|
70
|
+
const policy = effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), {
|
|
71
|
+
[BACKUP_RETENTION_COUNT_CONFIG_KEY]: 2,
|
|
72
|
+
[BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY]: 90,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(policy).toEqual({ count: 2, maxAgeDays: 90 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the manifest's suggestion applies when nobody has overridden", () => {
|
|
79
|
+
expect(effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), {})).toEqual({
|
|
80
|
+
count: 7,
|
|
81
|
+
maxAgeDays: 30,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a non-positive or unparseable override falls back rather than deleting everything', () => {
|
|
86
|
+
// Validated at SET time, so this is hand-edited state. A `0` read as "keep
|
|
87
|
+
// zero copies" would delete every backup the module has.
|
|
88
|
+
for (const bad of [0, -1, 'lots', 2.5]) {
|
|
89
|
+
const policy = effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), {
|
|
90
|
+
[BACKUP_RETENTION_COUNT_CONFIG_KEY]: bad,
|
|
91
|
+
});
|
|
92
|
+
expect(policy.count).toBe(7);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('identifyExpiredBackups with unbounded dimensions', () => {
|
|
98
|
+
test('an unbounded count never prunes on count', () => {
|
|
99
|
+
const expired = identifyExpiredBackups(backupsAgedDays(1, 2, 3, 4, 5), {
|
|
100
|
+
count: UNBOUNDED,
|
|
101
|
+
maxAgeDays: UNBOUNDED,
|
|
102
|
+
});
|
|
103
|
+
expect(expired).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a bounded count prunes past it, and age alone prunes nothing', () => {
|
|
107
|
+
const expired = identifyExpiredBackups(backupsAgedDays(1, 2, 100, 200), {
|
|
108
|
+
count: 3,
|
|
109
|
+
maxAgeDays: UNBOUNDED,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(expired.map((b) => b.id)).toEqual(['backup-3']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a bounded age prunes past it while every copy is kept on count', () => {
|
|
116
|
+
const expired = identifyExpiredBackups(backupsAgedDays(1, 2, 100), {
|
|
117
|
+
count: UNBOUNDED,
|
|
118
|
+
maxAgeDays: 30,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(expired.map((b) => b.id)).toEqual(['backup-2']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -1,19 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Backup retention
|
|
3
|
-
*
|
|
4
|
-
* Policies are count-based (keep last N) and age-based (delete older than X
|
|
5
|
-
*
|
|
2
|
+
* Backup retention: how many copies to keep, and for how long.
|
|
3
|
+
*
|
|
4
|
+
* Policies are count-based (keep last N) and age-based (delete older than X
|
|
5
|
+
* days), and the two are INDEPENDENT — whichever limit is hit first triggers
|
|
6
|
+
* deletion. The manifest suggests; the operator's `backup_retention_count` and
|
|
7
|
+
* `backup_retention_max_age_days` overrides decide, each dimension resolved on
|
|
8
|
+
* its own.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ An unset dimension is UNBOUNDED, never a default bound. Today an absent
|
|
11
|
+
* `backup.retention` block means prune nothing at all — the block is optional,
|
|
12
|
+
* so its inner `count: 7` / `max_age_days: 30` defaults never apply. If setting
|
|
13
|
+
* one dimension made the other fall back to those defaults, an operator asking
|
|
14
|
+
* to keep 3 copies would silently also arm a 30-day deletion they never asked
|
|
15
|
+
* for, on a module that had been keeping everything. Deleting backups nobody
|
|
16
|
+
* asked to delete is the one failure here that cannot be undone.
|
|
6
17
|
*/
|
|
7
18
|
|
|
8
19
|
import type { Backup } from '../db/schema';
|
|
20
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
9
21
|
import { deleteBackupRecord, listCompletedBackupsForModule } from './backup-metadata';
|
|
10
22
|
import { createStorageProvider } from './backup-storage';
|
|
23
|
+
import { configOverride } from './module-config';
|
|
24
|
+
|
|
25
|
+
/** The `module_configs` keys an operator's retention policy is stored under. */
|
|
26
|
+
export const BACKUP_RETENTION_COUNT_CONFIG_KEY = 'backup_retention_count';
|
|
27
|
+
export const BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY = 'backup_retention_max_age_days';
|
|
11
28
|
|
|
12
29
|
export interface RetentionPolicy {
|
|
30
|
+
/** Copies to keep. `Infinity` means unbounded — keep every copy. */
|
|
13
31
|
count: number;
|
|
32
|
+
/** Days to keep. `Infinity` means unbounded — never delete on age. */
|
|
14
33
|
maxAgeDays: number;
|
|
15
34
|
}
|
|
16
35
|
|
|
36
|
+
/** Neither dimension bounded: nothing is ever pruned, so the pass can be skipped. */
|
|
37
|
+
export function prunesNothing(policy: RetentionPolicy): boolean {
|
|
38
|
+
return (
|
|
39
|
+
policy.count === Number.POSITIVE_INFINITY && policy.maxAgeDays === Number.POSITIVE_INFINITY
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Takes the module's operator config as loaded from `module_configs`. An
|
|
45
|
+
* unparseable or non-positive override leaves that dimension to the manifest:
|
|
46
|
+
* values are validated at SET time, so a bad one here means hand-edited state,
|
|
47
|
+
* and keeping too much is the only safe direction to fail in.
|
|
48
|
+
*/
|
|
49
|
+
export function effectiveBackupRetention(
|
|
50
|
+
manifest: ModuleManifest,
|
|
51
|
+
configs: Record<string, unknown> | undefined,
|
|
52
|
+
): RetentionPolicy {
|
|
53
|
+
const declared = manifest.backup?.retention;
|
|
54
|
+
return {
|
|
55
|
+
count: positiveIntegerOr(
|
|
56
|
+
configOverride(configs, BACKUP_RETENTION_COUNT_CONFIG_KEY),
|
|
57
|
+
declared?.count,
|
|
58
|
+
),
|
|
59
|
+
maxAgeDays: positiveIntegerOr(
|
|
60
|
+
configOverride(configs, BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY),
|
|
61
|
+
declared?.max_age_days,
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Override, else the manifest's suggestion, else unbounded. Never a default bound. */
|
|
67
|
+
function positiveIntegerOr(override: string | undefined, suggested: number | undefined): number {
|
|
68
|
+
if (override !== undefined) {
|
|
69
|
+
const parsed = Number(override);
|
|
70
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
71
|
+
}
|
|
72
|
+
return suggested ?? Number.POSITIVE_INFINITY;
|
|
73
|
+
}
|
|
74
|
+
|
|
17
75
|
export interface PruneResult {
|
|
18
76
|
moduleId: string;
|
|
19
77
|
deleted: number;
|
|
@@ -21,7 +79,10 @@ export interface PruneResult {
|
|
|
21
79
|
}
|
|
22
80
|
|
|
23
81
|
/**
|
|
24
|
-
* Identify backups that should be pruned per the retention policy
|
|
82
|
+
* Identify backups that should be pruned per the retention policy.
|
|
83
|
+
*
|
|
84
|
+
* An unbounded dimension is `Infinity`, which needs no special case: no index
|
|
85
|
+
* reaches it and no age exceeds it.
|
|
25
86
|
*/
|
|
26
87
|
export function identifyExpiredBackups(backupsList: Backup[], policy: RetentionPolicy): Backup[] {
|
|
27
88
|
const now = Date.now();
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup cadence: what a manifest declares, and when the sweep acts on it.
|
|
3
|
+
*
|
|
4
|
+
* The due-ness tests are the interesting half. celilo#685 was a *daily* module
|
|
5
|
+
* attempted 24 times a day for a day, each attempt assembling ~1.9 GB before
|
|
6
|
+
* being OOM-killed, because due-ness was measured only from the last SUCCESS
|
|
7
|
+
* and a module that cannot succeed never advances that timestamp.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_BACKUP_SCHEDULE,
|
|
14
|
+
MAX_RAPID_RETRIES,
|
|
15
|
+
effectiveBackupSchedule,
|
|
16
|
+
isBackupDueFromHistory,
|
|
17
|
+
} from './backup-schedule';
|
|
18
|
+
import { type Cadence, parseCadence } from './cadence';
|
|
19
|
+
|
|
20
|
+
const HOUR = 60 * 60 * 1000;
|
|
21
|
+
const DAY = 24 * HOUR;
|
|
22
|
+
const NOW = Date.UTC(2026, 7, 13, 12, 0, 0);
|
|
23
|
+
|
|
24
|
+
function ago(ms: number): Date {
|
|
25
|
+
return new Date(NOW - ms);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse a cadence in a test, failing loudly rather than passing null on (Rule 7.2). */
|
|
29
|
+
function cadence(value: string): Cadence {
|
|
30
|
+
const parsed = parseCadence(value);
|
|
31
|
+
if (parsed === null) throw new Error(`test fixture is not a cadence: ${value}`);
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function manifestWith(schedule?: string): ModuleManifest {
|
|
36
|
+
return { backup: schedule ? { schedule } : undefined } as unknown as ModuleManifest;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('effectiveBackupSchedule', () => {
|
|
40
|
+
test('an absent cadence from both sources means daily, not manual', () => {
|
|
41
|
+
expect(effectiveBackupSchedule(manifestWith(), undefined)).toEqual(cadence('daily'));
|
|
42
|
+
expect(DEFAULT_BACKUP_SCHEDULE).toEqual(cadence('daily'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("the manifest's suggestion is honoured when nobody has overridden", () => {
|
|
46
|
+
expect(effectiveBackupSchedule(manifestWith('manual'), undefined)).toBe('manual');
|
|
47
|
+
expect(effectiveBackupSchedule(manifestWith('weekly'), undefined)).toEqual(cadence('weekly'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the operator override wins over the suggestion', () => {
|
|
51
|
+
expect(effectiveBackupSchedule(manifestWith('daily'), 'hourly')).toEqual(cadence('hourly'));
|
|
52
|
+
expect(effectiveBackupSchedule(manifestWith('daily'), '6h')).toEqual(cadence('6h'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('`manual` is reachable from either source', () => {
|
|
56
|
+
expect(effectiveBackupSchedule(manifestWith('daily'), 'manual')).toBe('manual');
|
|
57
|
+
expect(effectiveBackupSchedule(manifestWith('manual'), undefined)).toBe('manual');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('an override applies to a module whose manifest suggests nothing', () => {
|
|
61
|
+
expect(effectiveBackupSchedule(manifestWith(), 'weekly')).toEqual(cadence('weekly'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an unparseable override falls back to the manifest, never to manual', () => {
|
|
65
|
+
// Values are validated at SET time, so this is hand-edited state. Backing
|
|
66
|
+
// up MORE often than asked is the safe direction; silently never backing
|
|
67
|
+
// up is not.
|
|
68
|
+
expect(effectiveBackupSchedule(manifestWith('weekly'), 'dailyy')).toEqual(cadence('weekly'));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('isBackupDueFromHistory', () => {
|
|
73
|
+
test('manual never runs on a schedule', () => {
|
|
74
|
+
expect(
|
|
75
|
+
isBackupDueFromHistory(
|
|
76
|
+
'manual',
|
|
77
|
+
{ lastSuccessAt: null, lastAttemptAt: null, consecutiveFailures: 0 },
|
|
78
|
+
NOW,
|
|
79
|
+
),
|
|
80
|
+
).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a module that has never been backed up is due', () => {
|
|
84
|
+
expect(
|
|
85
|
+
isBackupDueFromHistory(
|
|
86
|
+
cadence('daily'),
|
|
87
|
+
{ lastSuccessAt: null, lastAttemptAt: null, consecutiveFailures: 0 },
|
|
88
|
+
NOW,
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a fresh success is not due again until its interval has passed', () => {
|
|
94
|
+
const history = {
|
|
95
|
+
lastSuccessAt: ago(2 * HOUR),
|
|
96
|
+
lastAttemptAt: ago(2 * HOUR),
|
|
97
|
+
consecutiveFailures: 0,
|
|
98
|
+
};
|
|
99
|
+
expect(isBackupDueFromHistory(cadence('daily'), history, NOW)).toBe(false);
|
|
100
|
+
expect(isBackupDueFromHistory(cadence('hourly'), history, NOW)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a single failure retries on the next tick', () => {
|
|
104
|
+
// Most failures are transient. Waiting a whole cadence period after one bad
|
|
105
|
+
// minute at the storage endpoint would cost more coverage than it saves.
|
|
106
|
+
expect(
|
|
107
|
+
isBackupDueFromHistory(
|
|
108
|
+
cadence('daily'),
|
|
109
|
+
{
|
|
110
|
+
lastSuccessAt: ago(2 * DAY),
|
|
111
|
+
lastAttemptAt: ago(5 * 60 * 1000),
|
|
112
|
+
consecutiveFailures: 1,
|
|
113
|
+
},
|
|
114
|
+
NOW,
|
|
115
|
+
),
|
|
116
|
+
).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a run of failures backs off to the module cadence', () => {
|
|
120
|
+
// The celilo#685 shape: daily module, no success in a week, failing every
|
|
121
|
+
// hour. Under the old rule this was due at every tick forever.
|
|
122
|
+
const forgejo = {
|
|
123
|
+
lastSuccessAt: ago(7 * DAY),
|
|
124
|
+
lastAttemptAt: ago(1 * HOUR),
|
|
125
|
+
consecutiveFailures: 20,
|
|
126
|
+
};
|
|
127
|
+
expect(isBackupDueFromHistory(cadence('daily'), forgejo, NOW)).toBe(false);
|
|
128
|
+
|
|
129
|
+
// ...and still runs once its own interval has elapsed. Backing off is not
|
|
130
|
+
// giving up.
|
|
131
|
+
expect(
|
|
132
|
+
isBackupDueFromHistory(cadence('daily'), { ...forgejo, lastAttemptAt: ago(25 * HOUR) }, NOW),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the back-off boundary is MAX_RAPID_RETRIES', () => {
|
|
137
|
+
const justFailed = { lastSuccessAt: ago(7 * DAY), lastAttemptAt: ago(1 * HOUR) };
|
|
138
|
+
expect(
|
|
139
|
+
isBackupDueFromHistory(
|
|
140
|
+
cadence('daily'),
|
|
141
|
+
{ ...justFailed, consecutiveFailures: MAX_RAPID_RETRIES - 1 },
|
|
142
|
+
NOW,
|
|
143
|
+
),
|
|
144
|
+
).toBe(true);
|
|
145
|
+
expect(
|
|
146
|
+
isBackupDueFromHistory(
|
|
147
|
+
cadence('daily'),
|
|
148
|
+
{ ...justFailed, consecutiveFailures: MAX_RAPID_RETRIES },
|
|
149
|
+
NOW,
|
|
150
|
+
),
|
|
151
|
+
).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a never-succeeded module also backs off once it is clearly failing', () => {
|
|
155
|
+
// `signal` on celilo-mgr: no successful backup has ever existed. Without
|
|
156
|
+
// this branch "never succeeded" reads as "always due" and the doomed
|
|
157
|
+
// attempt runs every tick.
|
|
158
|
+
expect(
|
|
159
|
+
isBackupDueFromHistory(
|
|
160
|
+
cadence('daily'),
|
|
161
|
+
{ lastSuccessAt: null, lastAttemptAt: ago(1 * HOUR), consecutiveFailures: 8 },
|
|
162
|
+
NOW,
|
|
163
|
+
),
|
|
164
|
+
).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
});
|