@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.
Files changed (104) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +61 -9
  3. package/drizzle/0024_module_pause.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +7 -7
  6. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  7. package/src/api/remote-client.test.ts +6 -5
  8. package/src/api/serve.ts +41 -7
  9. package/src/api-clients/proxmox.ts +34 -0
  10. package/src/cli/commands/alerts-act.ts +1 -1
  11. package/src/cli/commands/alerts-sweep.ts +2 -0
  12. package/src/cli/commands/backup-create.ts +26 -11
  13. package/src/cli/commands/backup-list.test.ts +83 -0
  14. package/src/cli/commands/backup-list.ts +67 -3
  15. package/src/cli/commands/backup-prune.ts +17 -17
  16. package/src/cli/commands/backup-sweep.ts +20 -8
  17. package/src/cli/commands/events.ts +34 -3
  18. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  19. package/src/cli/commands/firewall-interface-list.ts +123 -0
  20. package/src/cli/commands/machine-add.ts +30 -2
  21. package/src/cli/commands/module-config.test.ts +64 -2
  22. package/src/cli/commands/module-config.ts +159 -8
  23. package/src/cli/commands/module-deploy.ts +2 -2
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import.ts +3 -3
  26. package/src/cli/commands/module-list.ts +12 -1
  27. package/src/cli/commands/module-pause.ts +317 -0
  28. package/src/cli/commands/module-remove.ts +78 -40
  29. package/src/cli/commands/module-status.ts +127 -4
  30. package/src/cli/commands/module-update.test.ts +1 -1
  31. package/src/cli/commands/monitor.ts +116 -19
  32. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  33. package/src/cli/commands/status.ts +25 -3
  34. package/src/cli/commands/system-migrate.ts +14 -0
  35. package/src/cli/commands/system-update.ts +4 -1
  36. package/src/cli/completion.ts +39 -9
  37. package/src/cli/fuel-gauge.ts +4 -4
  38. package/src/cli/index.ts +104 -22
  39. package/src/cli/json-output.test.ts +162 -0
  40. package/src/cli/prompts.ts +53 -74
  41. package/src/cli/service-credential.ts +3 -3
  42. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  43. package/src/cli/tui/audit-state.ts +2 -0
  44. package/src/cli/types.ts +7 -2
  45. package/src/db/schema.ts +73 -15
  46. package/src/hooks/capability-loader.ts +130 -4
  47. package/src/hooks/run-named-hook.ts +28 -0
  48. package/src/hooks/types.ts +2 -1
  49. package/src/manifest/contracts/v1.ts +16 -0
  50. package/src/manifest/schema.ts +40 -65
  51. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  52. package/src/services/alerting/cadence-migration.test.ts +155 -0
  53. package/src/services/alerting/cadence-migration.ts +90 -0
  54. package/src/services/alerting/coverage-source.ts +8 -11
  55. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  56. package/src/services/alerting/deploy-hooks.ts +11 -5
  57. package/src/services/alerting/health-cadence.test.ts +58 -0
  58. package/src/services/alerting/health-cadence.ts +128 -0
  59. package/src/services/alerting/health-coverage.ts +18 -8
  60. package/src/services/alerting/monitors.ts +50 -15
  61. package/src/services/alerting/suppression.test.ts +5 -0
  62. package/src/services/alerting/suppression.ts +18 -1
  63. package/src/services/alerting/sweep-runner.test.ts +52 -3
  64. package/src/services/alerting/sweep-runner.ts +41 -8
  65. package/src/services/audit/backup-source.ts +24 -1
  66. package/src/services/audit/backups.test.ts +95 -10
  67. package/src/services/audit/backups.ts +40 -37
  68. package/src/services/audit/interface-classification.test.ts +220 -0
  69. package/src/services/audit/interface-classification.ts +167 -0
  70. package/src/services/audit/types.ts +2 -1
  71. package/src/services/backup-age-agreement.test.ts +118 -0
  72. package/src/services/backup-create.ts +36 -30
  73. package/src/services/backup-metadata.ts +52 -1
  74. package/src/services/backup-retention.test.ts +123 -0
  75. package/src/services/backup-retention.ts +66 -5
  76. package/src/services/backup-schedule.test.ts +166 -0
  77. package/src/services/backup-schedule.ts +105 -15
  78. package/src/services/backup-staging.ts +14 -1
  79. package/src/services/backup-sweep.test.ts +22 -3
  80. package/src/services/backup-sweep.ts +15 -5
  81. package/src/services/bus-interview.ts +2 -2
  82. package/src/services/bus-secret-flow.test.ts +1 -1
  83. package/src/services/cadence.test.ts +97 -0
  84. package/src/services/cadence.ts +165 -0
  85. package/src/services/fleet-checks.ts +48 -0
  86. package/src/services/machine-detector.ts +23 -1
  87. package/src/services/module-config.ts +33 -0
  88. package/src/services/module-deploy.ts +1 -1
  89. package/src/services/module-pause-observability.test.ts +224 -0
  90. package/src/services/module-pause-quiescence.test.ts +163 -0
  91. package/src/services/module-pause.test.ts +573 -0
  92. package/src/services/module-pause.ts +544 -0
  93. package/src/services/remove-guard.test.ts +175 -0
  94. package/src/services/remove-guard.ts +109 -0
  95. package/src/services/storage-providers/s3.test.ts +96 -13
  96. package/src/services/storage-providers/s3.ts +48 -15
  97. package/src/services/terminal-responder.ts +16 -16
  98. package/src/services/update/dep-graph.test.ts +33 -4
  99. package/src/services/update/dep-graph.ts +39 -17
  100. package/src/services/zone-detector.test.ts +34 -3
  101. package/src/services/zone-detector.ts +32 -49
  102. package/src/test-utils/cli.ts +15 -14
  103. package/src/test-utils/integration-guard.ts +26 -0
  104. 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 { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
21
- import type { BackupSchedule } from './backup-schedule';
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
- * Schedule intervals in milliseconds
202
- */
203
- const SCHEDULE_INTERVALS: Record<BackupSchedule, number> = {
204
- hourly: 60 * 60 * 1000,
205
- daily: 24 * 60 * 60 * 1000,
206
- weekly: 7 * 24 * 60 * 60 * 1000,
207
- monthly: 30 * 24 * 60 * 60 * 1000,
208
- manual: Number.POSITIVE_INFINITY,
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: BackupSchedule): boolean {
215
- if (schedule === 'manual') return false;
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 service.
3
- * Enforces retention policies defined in module manifests.
4
- * Policies are count-based (keep last N) and age-based (delete older than X days).
5
- * Whichever limit is hit first triggers deletion.
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
+ });