@celilo/cli 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +35 -8
- package/drizzle/0019_backup_pid.sql +18 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/schemas/system_config.json +1 -1
- package/src/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/backup-sweep.ts +62 -0
- package/src/cli/commands/module-operations.test.ts +45 -1
- package/src/cli/commands/module-operations.ts +35 -12
- package/src/cli/commands/module-show.ts +1 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-migrate.ts +40 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +18 -0
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -1
- package/src/db/schema.ts +53 -9
- package/src/hooks/capability-loader.ts +30 -1
- package/src/ipam/allocator.ts +13 -3
- package/src/services/alerting/builtin-monitors.test.ts +42 -0
- package/src/services/alerting/builtin-monitors.ts +2 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/audit/abandoned-operations.test.ts +73 -0
- package/src/services/audit/abandoned-operations.ts +0 -0
- package/src/services/audit/disk-space.test.ts +111 -0
- package/src/services/audit/disk-space.ts +114 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +9 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/backup-create.ts +4 -4
- package/src/services/backup-in-flight-refusal.test.ts +2 -0
- package/src/services/backup-metadata.ts +4 -0
- package/src/services/backup-staging.test.ts +134 -0
- package/src/services/backup-staging.ts +192 -0
- package/src/services/backup-sweep.test.ts +68 -0
- package/src/services/backup-sweep.ts +62 -0
- package/src/services/config-interview.ts +1 -1
- package/src/services/deploy-ansible.ts +0 -1
- package/src/services/disk-probe.test.ts +74 -0
- package/src/services/disk-probe.ts +145 -0
- package/src/services/fleet-checks.ts +15 -0
- package/src/services/module-operations.test.ts +22 -0
- package/src/services/module-operations.ts +48 -1
- package/src/services/module-subscriptions.test.ts +39 -6
- package/src/services/module-subscriptions.ts +6 -4
- package/src/services/module-types-generator.test.ts +6 -3
- package/src/services/module-types-generator.ts +12 -7
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/variables/context.ts +6 -1
|
@@ -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';
|
|
@@ -48,6 +52,7 @@ export interface AuditDeps {
|
|
|
48
52
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
49
53
|
health: HealthAuditDeps;
|
|
50
54
|
backups: BackupsAuditDeps;
|
|
55
|
+
abandonedOperations: AbandonedOperationsAuditDeps;
|
|
51
56
|
undeployedModules: UndeployedModulesAuditDeps;
|
|
52
57
|
unconfiguredModules: UnconfiguredModulesAuditDeps;
|
|
53
58
|
servicesCredentials: ServicesCredentialsAuditDeps;
|
|
@@ -99,6 +104,10 @@ export async function runAudit(
|
|
|
99
104
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
100
105
|
wrap('health', auditHealth(deps.health)),
|
|
101
106
|
wrap('backups', auditBackups(deps.backups)),
|
|
107
|
+
wrap(
|
|
108
|
+
'abandoned_operations',
|
|
109
|
+
Promise.resolve(auditAbandonedOperations(deps.abandonedOperations)),
|
|
110
|
+
),
|
|
102
111
|
wrap('undeployed_modules', auditUndeployedModules(deps.undeployedModules)),
|
|
103
112
|
wrap('unconfigured_modules', auditUnconfiguredModules(deps.unconfiguredModules)),
|
|
104
113
|
wrap('services_credentials', auditServicesCredentials(deps.servicesCredentials)),
|
|
@@ -30,12 +30,14 @@ 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'
|
|
39
41
|
| 'transport_reads'
|
|
40
42
|
| 'trusted_sources';
|
|
41
43
|
|
|
@@ -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
|
};
|
|
@@ -43,6 +43,10 @@ export function createBackupRecord(params: CreateBackupParams): BackupRecord {
|
|
|
43
43
|
metadata: {},
|
|
44
44
|
status: 'in_progress' as BackupStatus,
|
|
45
45
|
errorMessage: null,
|
|
46
|
+
// Recorded so the staging reaper can distinguish a live backup from one
|
|
47
|
+
// whose process was killed before its `finally` ran — see
|
|
48
|
+
// services/backup-staging.ts.
|
|
49
|
+
pid: process.pid,
|
|
46
50
|
startedAt: now,
|
|
47
51
|
completedAt: null,
|
|
48
52
|
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type ReapStagingDeps,
|
|
4
|
+
STAGING_PREFIX,
|
|
5
|
+
STAGING_TTL_MS,
|
|
6
|
+
type StagingOwner,
|
|
7
|
+
reapOrphanedStaging,
|
|
8
|
+
stagingDirFor,
|
|
9
|
+
} from './backup-staging';
|
|
10
|
+
|
|
11
|
+
const NOW = new Date('2026-08-06T12:00:00Z').getTime();
|
|
12
|
+
|
|
13
|
+
function deps(
|
|
14
|
+
owners: Record<string, StagingOwner | null>,
|
|
15
|
+
options: {
|
|
16
|
+
dirs?: string[];
|
|
17
|
+
runnablePids?: number[];
|
|
18
|
+
unremovable?: string[];
|
|
19
|
+
} = {},
|
|
20
|
+
): ReapStagingDeps & { removed: string[] } {
|
|
21
|
+
const removed: string[] = [];
|
|
22
|
+
return {
|
|
23
|
+
removed,
|
|
24
|
+
listStagingDirs: () => options.dirs ?? Object.keys(owners).map((id) => stagingDirFor(id)),
|
|
25
|
+
lookupOwner: (id) => owners[id] ?? null,
|
|
26
|
+
isPidRunnable: (pid) => (options.runnablePids ?? []).includes(pid),
|
|
27
|
+
remove: (path) => {
|
|
28
|
+
if (options.unremovable?.includes(path)) throw new Error('EACCES');
|
|
29
|
+
removed.push(path);
|
|
30
|
+
},
|
|
31
|
+
now: () => NOW,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function owner(over: Partial<StagingOwner> = {}): StagingOwner {
|
|
36
|
+
return { status: 'in_progress', pid: 111, startedAt: new Date(NOW - 60_000), ...over };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('reapOrphanedStaging', () => {
|
|
40
|
+
test('reclaims staging whose backup record no longer exists', () => {
|
|
41
|
+
const d = deps({ 'gone-id': null });
|
|
42
|
+
const report = reapOrphanedStaging(d);
|
|
43
|
+
|
|
44
|
+
expect(report.reclaimed).toHaveLength(1);
|
|
45
|
+
expect(report.reclaimed[0]?.reason).toBe('record-absent');
|
|
46
|
+
expect(d.removed).toEqual([stagingDirFor('gone-id')]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.each([['completed'], ['failed']])('reclaims staging for a %s record', (status) => {
|
|
50
|
+
const d = deps({ 'done-id': owner({ status }) });
|
|
51
|
+
const report = reapOrphanedStaging(d);
|
|
52
|
+
|
|
53
|
+
expect(report.reclaimed[0]?.reason).toBe('record-terminal');
|
|
54
|
+
expect(d.removed).toEqual([stagingDirFor('done-id')]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('reclaims staging whose owning process is dead', () => {
|
|
58
|
+
// in_progress, inside the TTL — only the liveness probe can tell.
|
|
59
|
+
const d = deps({ 'dead-id': owner({ pid: 999 }) }, { runnablePids: [] });
|
|
60
|
+
const report = reapOrphanedStaging(d);
|
|
61
|
+
|
|
62
|
+
expect(report.reclaimed[0]?.reason).toBe('process-dead');
|
|
63
|
+
expect(d.removed).toEqual([stagingDirFor('dead-id')]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The one that must never regress: a running backup writing gigabytes into
|
|
67
|
+
// its staging dir must survive a concurrent sweep.
|
|
68
|
+
test('KEEPS staging owned by a live backup', () => {
|
|
69
|
+
const d = deps({ 'live-id': owner({ pid: 111 }) }, { runnablePids: [111] });
|
|
70
|
+
const report = reapOrphanedStaging(d);
|
|
71
|
+
|
|
72
|
+
expect(report.reclaimed).toHaveLength(0);
|
|
73
|
+
expect(report.kept).toEqual([stagingDirFor('live-id')]);
|
|
74
|
+
expect(d.removed).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('reclaims a record past the TTL even when its pid looks alive', () => {
|
|
78
|
+
// Guards pid reuse: after the pid space wraps, a stale record's pid names
|
|
79
|
+
// an unrelated live process and liveness alone would strand this forever.
|
|
80
|
+
const d = deps(
|
|
81
|
+
{ 'stale-id': owner({ pid: 111, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }) },
|
|
82
|
+
{ runnablePids: [111] },
|
|
83
|
+
);
|
|
84
|
+
const report = reapOrphanedStaging(d);
|
|
85
|
+
|
|
86
|
+
expect(report.reclaimed[0]?.reason).toBe('expired');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('keeps a pid-less record until it expires, then reclaims it', () => {
|
|
90
|
+
const fresh = deps({ 'old-fmt': owner({ pid: null }) });
|
|
91
|
+
expect(reapOrphanedStaging(fresh).kept).toHaveLength(1);
|
|
92
|
+
|
|
93
|
+
const expired = deps({
|
|
94
|
+
'old-fmt': owner({ pid: null, startedAt: new Date(NOW - STAGING_TTL_MS - 1) }),
|
|
95
|
+
});
|
|
96
|
+
expect(reapOrphanedStaging(expired).reclaimed[0]?.reason).toBe('expired');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('ignores directories that are not staging, rather than deleting them', () => {
|
|
100
|
+
const d = deps(
|
|
101
|
+
{},
|
|
102
|
+
{
|
|
103
|
+
dirs: [
|
|
104
|
+
'/tmp/something-else',
|
|
105
|
+
'/tmp/celilo-unrelated',
|
|
106
|
+
`/tmp/${STAGING_PREFIX}`, // prefix with no record id
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
const report = reapOrphanedStaging(d);
|
|
111
|
+
|
|
112
|
+
expect(report.ignored).toHaveLength(3);
|
|
113
|
+
expect(report.reclaimed).toHaveLength(0);
|
|
114
|
+
expect(d.removed).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('an undeletable directory is reported as kept, not as reclaimed space', () => {
|
|
118
|
+
const path = stagingDirFor('locked-id');
|
|
119
|
+
const d = deps({ 'locked-id': null }, { unremovable: [path] });
|
|
120
|
+
const report = reapOrphanedStaging(d);
|
|
121
|
+
|
|
122
|
+
expect(report.reclaimed).toHaveLength(0);
|
|
123
|
+
expect(report.kept).toEqual([path]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('one undeletable directory does not stop the rest of the pass', () => {
|
|
127
|
+
const locked = stagingDirFor('a-locked');
|
|
128
|
+
const d = deps({ 'a-locked': null, 'b-orphan': null }, { unremovable: [locked] });
|
|
129
|
+
const report = reapOrphanedStaging(d);
|
|
130
|
+
|
|
131
|
+
expect(report.reclaimed.map((r) => r.recordId)).toEqual(['b-orphan']);
|
|
132
|
+
expect(report.kept).toEqual([locked]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reclaiming backup staging directories whose owner is gone.
|
|
3
|
+
*
|
|
4
|
+
* `backup-create.ts` assembles every envelope in a temp directory and removes
|
|
5
|
+
* it in a `finally`. That is correct and it is not enough: a `finally` does not
|
|
6
|
+
* run when the process is killed by a signal it cannot intercept — a dispatcher
|
|
7
|
+
* timeout, an OOM, an operator's Ctrl-C, a host reboot. Those are exactly the
|
|
8
|
+
* cases that strand the LARGEST directories, because the longer a backup has
|
|
9
|
+
* run the more it has written.
|
|
10
|
+
*
|
|
11
|
+
* Measured on celilo-mgr 2026-08-05: the scheduled sweep inherited the event
|
|
12
|
+
* bus's 60s default timeout against a forgejo backup that needs ~5.5 minutes,
|
|
13
|
+
* so every attempt was SIGTERMed at ~1.4 GB of staging, three times an hour,
|
|
14
|
+
* for days. 15 GB stranded in 26 hours, then 27 GB in the next 5.7. Nothing
|
|
15
|
+
* reclaimed it, because the only cleanup was the `finally` that never ran.
|
|
16
|
+
*
|
|
17
|
+
* So reclamation must not be a property of how a backup ends. This pass asks a
|
|
18
|
+
* different question — "is anyone still using this directory?" — and answers it
|
|
19
|
+
* from state that outlives the process.
|
|
20
|
+
*
|
|
21
|
+
* Identity comes free: a staging directory is named `celilo-backup-<record.id>`,
|
|
22
|
+
* so the directory names its own backup record. No lockfile, no marker, no
|
|
23
|
+
* second source of truth that could itself be stranded — and notably nothing
|
|
24
|
+
* written BY the process whose death is the problem.
|
|
25
|
+
*
|
|
26
|
+
* Dependencies are injected so the decision logic tests with no filesystem and
|
|
27
|
+
* no database.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
/** Every staging directory starts with this. The rest of the name is the record id. */
|
|
34
|
+
export const STAGING_PREFIX = 'celilo-backup-';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where a backup record's staging lives. Single source of truth — `backup-create.ts`
|
|
38
|
+
* builds its temp dir from this so the reaper can never drift from the writer.
|
|
39
|
+
*/
|
|
40
|
+
export function stagingDirFor(recordId: string): string {
|
|
41
|
+
return join(tmpdir(), `${STAGING_PREFIX}${recordId}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long an `in_progress` backup record may vouch for its staging before the
|
|
46
|
+
* directory is reclaimed regardless of what its pid appears to be doing.
|
|
47
|
+
*
|
|
48
|
+
* This is not redundancy on the liveness check — it is the only check that
|
|
49
|
+
* survives pid reuse, for the same reason `OPERATION_TTL_MS` exists in
|
|
50
|
+
* `module-operations.ts`. A pid is a recycled number: once the pid space wraps,
|
|
51
|
+
* a stale record's pid names an unrelated live process and the liveness probe
|
|
52
|
+
* reports "still running" forever, stranding the directory permanently.
|
|
53
|
+
*
|
|
54
|
+
* Six hours is comfortably longer than any real backup (the largest measured is
|
|
55
|
+
* ~5.5 minutes) and short enough that a wedged record costs one cycle rather
|
|
56
|
+
* than a filesystem.
|
|
57
|
+
*/
|
|
58
|
+
export const STAGING_TTL_MS = 6 * 60 * 60 * 1000;
|
|
59
|
+
|
|
60
|
+
/** What the backup record says about the process that owns a staging directory. */
|
|
61
|
+
export interface StagingOwner {
|
|
62
|
+
status: string;
|
|
63
|
+
/** Null for records written before backups recorded their pid. */
|
|
64
|
+
pid: number | null;
|
|
65
|
+
startedAt: Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ReapStagingDeps {
|
|
69
|
+
/** Absolute paths of every `celilo-backup-*` entry in the temp dir. */
|
|
70
|
+
listStagingDirs(): string[];
|
|
71
|
+
/** The backup record for this id, or null when it no longer exists. */
|
|
72
|
+
lookupOwner(recordId: string): StagingOwner | null;
|
|
73
|
+
isPidRunnable(pid: number): boolean;
|
|
74
|
+
remove(path: string): void;
|
|
75
|
+
now(): number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ReapedStaging {
|
|
79
|
+
path: string;
|
|
80
|
+
recordId: string;
|
|
81
|
+
/** Why it was reclaimable — surfaced so a sweep can explain itself. */
|
|
82
|
+
reason: 'record-absent' | 'record-terminal' | 'process-dead' | 'expired';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Written to a backup record reclaimed while it still claimed to be running.
|
|
87
|
+
*
|
|
88
|
+
* A record left `in_progress` after its process died misreports the system
|
|
89
|
+
* twice: `celilo backup list` shows work apparently underway, and the `backups`
|
|
90
|
+
* drift check can read a module as recently backed up when every attempt in
|
|
91
|
+
* fact died. Nine such rows were live on celilo-mgr while forgejo had no usable
|
|
92
|
+
* backup at all.
|
|
93
|
+
*/
|
|
94
|
+
export const ABANDONED_BACKUP_MESSAGE =
|
|
95
|
+
'abandoned — the backup process ended without recording an outcome';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether reclaiming this directory also means its record was lying about
|
|
99
|
+
* being in progress.
|
|
100
|
+
*
|
|
101
|
+
* Only the two reasons reached from the `in_progress` branch qualify:
|
|
102
|
+
* `record-terminal` already has an outcome and `record-absent` has no row to
|
|
103
|
+
* correct.
|
|
104
|
+
*/
|
|
105
|
+
export function impliesAbandonedRecord(reason: ReapedStaging['reason']): boolean {
|
|
106
|
+
return reason === 'process-dead' || reason === 'expired';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ReapStagingReport {
|
|
110
|
+
reclaimed: ReapedStaging[];
|
|
111
|
+
/** Left alone because a live backup is using it. */
|
|
112
|
+
kept: string[];
|
|
113
|
+
/** Names that did not look like staging at all. Never touched. */
|
|
114
|
+
ignored: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decide, for one staging directory, whether its owner is gone.
|
|
119
|
+
*
|
|
120
|
+
* Returns null when the directory must be left alone. Every branch that
|
|
121
|
+
* reclaims must be able to say why; "I could not prove it is alive" is not a
|
|
122
|
+
* reason to delete, which is why an unrecognised state keeps the directory.
|
|
123
|
+
*/
|
|
124
|
+
function reclaimReason(
|
|
125
|
+
owner: StagingOwner | null,
|
|
126
|
+
deps: Pick<ReapStagingDeps, 'isPidRunnable' | 'now'>,
|
|
127
|
+
): ReapedStaging['reason'] | null {
|
|
128
|
+
// The record was deleted, or never committed. Nothing will ever finish this.
|
|
129
|
+
if (!owner) return 'record-absent';
|
|
130
|
+
|
|
131
|
+
// Completed or failed: the writer reached an ending and either cleaned up
|
|
132
|
+
// already (in which case we will not see the directory) or was killed after
|
|
133
|
+
// recording its outcome.
|
|
134
|
+
if (owner.status !== 'in_progress') return 'record-terminal';
|
|
135
|
+
|
|
136
|
+
const age = deps.now() - owner.startedAt.getTime();
|
|
137
|
+
if (age > STAGING_TTL_MS) return 'expired';
|
|
138
|
+
|
|
139
|
+
// A record from before backups carried a pid. Age is the only signal
|
|
140
|
+
// available, and it has not expired — leave it.
|
|
141
|
+
if (owner.pid === null) return null;
|
|
142
|
+
|
|
143
|
+
return deps.isPidRunnable(owner.pid) ? null : 'process-dead';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reclaim every staging directory whose owning backup is no longer running.
|
|
148
|
+
*
|
|
149
|
+
* Conservative by construction: a directory is removed only when its owner is
|
|
150
|
+
* provably gone. A running backup — `in_progress` record, live pid, inside the
|
|
151
|
+
* TTL — is always kept, so a long-running or concurrent backup can never be
|
|
152
|
+
* destroyed by this pass.
|
|
153
|
+
*
|
|
154
|
+
* A name that is not `celilo-backup-<id>` is ignored rather than removed. This
|
|
155
|
+
* runs against a shared temp directory as a privileged user; deleting something
|
|
156
|
+
* it does not understand is not its job.
|
|
157
|
+
*/
|
|
158
|
+
export function reapOrphanedStaging(deps: ReapStagingDeps): ReapStagingReport {
|
|
159
|
+
const report: ReapStagingReport = { reclaimed: [], kept: [], ignored: [] };
|
|
160
|
+
|
|
161
|
+
for (const path of deps.listStagingDirs()) {
|
|
162
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
163
|
+
if (!name.startsWith(STAGING_PREFIX)) {
|
|
164
|
+
report.ignored.push(path);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const recordId = name.slice(STAGING_PREFIX.length);
|
|
169
|
+
if (recordId.length === 0) {
|
|
170
|
+
report.ignored.push(path);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reason = reclaimReason(deps.lookupOwner(recordId), deps);
|
|
175
|
+
if (!reason) {
|
|
176
|
+
report.kept.push(path);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A directory that cannot be removed is not fatal: the next pass retries,
|
|
181
|
+
// and failing the whole sweep over one undeletable path would stop backups
|
|
182
|
+
// entirely. Treated as kept so the report never claims space it did not free.
|
|
183
|
+
try {
|
|
184
|
+
deps.remove(path);
|
|
185
|
+
report.reclaimed.push({ path, recordId, reason });
|
|
186
|
+
} catch {
|
|
187
|
+
report.kept.push(path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return report;
|
|
192
|
+
}
|
|
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import type { ModuleManifest } from '../manifest/schema';
|
|
3
3
|
import type { BackupSchedule } from './backup-schedule';
|
|
4
4
|
import {
|
|
5
|
+
BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
5
6
|
BACKUP_SWEEP_PATTERN,
|
|
6
7
|
BACKUP_SWEEP_SUBSCRIBER,
|
|
8
|
+
BACKUP_SWEEP_TIMEOUT_MS,
|
|
7
9
|
type BackupSweepDeps,
|
|
8
10
|
type BackupSweepModule,
|
|
9
11
|
ensureBackupSweepSubscriber,
|
|
@@ -33,11 +35,54 @@ function deps(
|
|
|
33
35
|
prune: async ({ id }) => {
|
|
34
36
|
pruned.push(id);
|
|
35
37
|
},
|
|
38
|
+
reapStaging: () => ({ reclaimed: [], kept: [], ignored: [] }),
|
|
36
39
|
...overrides,
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
describe('runBackupSweep', () => {
|
|
44
|
+
// Ordering is the point, not just that it happens: this pass is the only
|
|
45
|
+
// thing creating staging on a schedule, and a box already short on disk
|
|
46
|
+
// needs the space back BEFORE another few GB are requested.
|
|
47
|
+
test('reclaims orphaned staging before backing anything up', async () => {
|
|
48
|
+
const order: string[] = [];
|
|
49
|
+
const report = await runBackupSweep(
|
|
50
|
+
deps([moduleWith('forgejo', 'daily')], {
|
|
51
|
+
reapStaging: () => {
|
|
52
|
+
order.push('reap');
|
|
53
|
+
return {
|
|
54
|
+
reclaimed: [{ path: '/tmp/celilo-backup-x', recordId: 'x', reason: 'process-dead' }],
|
|
55
|
+
kept: [],
|
|
56
|
+
ignored: [],
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
backup: async () => {
|
|
60
|
+
order.push('backup');
|
|
61
|
+
return { success: true };
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(order).toEqual(['reap', 'backup']);
|
|
67
|
+
expect(report.staging.reclaimed).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('reclaims staging even when no module is due to back up', async () => {
|
|
71
|
+
const report = await runBackupSweep(
|
|
72
|
+
deps([moduleWith('forgejo', 'daily')], {
|
|
73
|
+
isDue: () => false,
|
|
74
|
+
reapStaging: () => ({
|
|
75
|
+
reclaimed: [{ path: '/tmp/celilo-backup-y', recordId: 'y', reason: 'record-absent' }],
|
|
76
|
+
kept: [],
|
|
77
|
+
ignored: [],
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(report.backedUp).toEqual([]);
|
|
83
|
+
expect(report.staging.reclaimed).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
41
86
|
test('backs up a module whose declared cadence is due', async () => {
|
|
42
87
|
const d = deps([moduleWith('authentik', 'daily')]);
|
|
43
88
|
const report = await runBackupSweep(d);
|
|
@@ -129,6 +174,8 @@ describe('ensureBackupSweepSubscriber', () => {
|
|
|
129
174
|
pattern: string;
|
|
130
175
|
handler: string;
|
|
131
176
|
registeredBy?: string;
|
|
177
|
+
maxAttempts?: number;
|
|
178
|
+
timeoutMs?: number;
|
|
132
179
|
}> = [];
|
|
133
180
|
ensureBackupSweepSubscriber({
|
|
134
181
|
subscribe: (options) => calls.push(options),
|
|
@@ -140,9 +187,30 @@ describe('ensureBackupSweepSubscriber', () => {
|
|
|
140
187
|
pattern: BACKUP_SWEEP_PATTERN,
|
|
141
188
|
handler: 'celilo backup sweep',
|
|
142
189
|
registeredBy: 'celilo-backup',
|
|
190
|
+
maxAttempts: BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
191
|
+
timeoutMs: BACKUP_SWEEP_TIMEOUT_MS,
|
|
143
192
|
},
|
|
144
193
|
]);
|
|
145
194
|
// 1h is the coarsest tick that can still serve an `hourly` cadence.
|
|
146
195
|
expect(BACKUP_SWEEP_PATTERN).toBe('timer.tick.1h');
|
|
147
196
|
});
|
|
197
|
+
|
|
198
|
+
// Both values are the bug. Registering without them inherits the bus
|
|
199
|
+
// defaults of 60000ms / 3 attempts, which cannot finish a backup that needs
|
|
200
|
+
// ~5.5 minutes and then retries the impossible twice more per tick — three
|
|
201
|
+
// killed backups and three stranded staging directories every hour.
|
|
202
|
+
test('states the budget explicitly rather than inheriting the bus defaults', () => {
|
|
203
|
+
let registered: { maxAttempts?: number; timeoutMs?: number } | undefined;
|
|
204
|
+
ensureBackupSweepSubscriber({
|
|
205
|
+
subscribe: (options) => {
|
|
206
|
+
registered = options;
|
|
207
|
+
return options;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(registered?.timeoutMs).toBeDefined();
|
|
212
|
+
expect(registered?.maxAttempts).toBeDefined();
|
|
213
|
+
expect(registered?.timeoutMs).toBeGreaterThan(60_000);
|
|
214
|
+
expect(registered?.maxAttempts).toBe(1);
|
|
215
|
+
});
|
|
148
216
|
});
|
|
@@ -18,23 +18,72 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ModuleManifest } from '../manifest/schema';
|
|
20
20
|
import { type BackupSchedule, effectiveBackupSchedule } from './backup-schedule';
|
|
21
|
+
import type { ReapStagingReport } from './backup-staging';
|
|
21
22
|
import { InFlightError } from './module-operations';
|
|
22
23
|
|
|
23
24
|
export const BACKUP_SWEEP_SUBSCRIBER = 'celilo-backup-sweep';
|
|
24
25
|
export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How long the sweep may run before the dispatcher kills it.
|
|
29
|
+
*
|
|
30
|
+
* Set EXPLICITLY because the bus default is 60 seconds and this pass cannot
|
|
31
|
+
* finish in 60 seconds. A single forgejo backup measured 2026-08-06 takes ~5.5
|
|
32
|
+
* minutes — snapshotting SQLite, archiving repositories, and streaming ~1.3 GB
|
|
33
|
+
* over SSH — and the sweep runs every due module serially, so the budget covers
|
|
34
|
+
* the sum rather than the slowest one.
|
|
35
|
+
*
|
|
36
|
+
* Inheriting the default made every scheduled forgejo backup structurally
|
|
37
|
+
* impossible: killed at 60s, mid-encrypt, three times an hour, for days.
|
|
38
|
+
*
|
|
39
|
+
* Four hours is generous on purpose. The cost of it being too large is one
|
|
40
|
+
* delayed reclamation of a wedged sweep; the cost of it being too small is a
|
|
41
|
+
* backup that can never succeed. `celilo-mgmt.registry-poll` set the precedent
|
|
42
|
+
* at 30 minutes for the same reason.
|
|
43
|
+
*
|
|
44
|
+
* ponytail: one flat number for a serial sweep. If the fleet grows enough that
|
|
45
|
+
* the sum stops fitting, the upgrade is for the sweep to emit a per-module
|
|
46
|
+
* backup event carrying its own budget.
|
|
47
|
+
*/
|
|
48
|
+
export const BACKUP_SWEEP_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One attempt per tick.
|
|
52
|
+
*
|
|
53
|
+
* The bus default of 3 is right for a transient fault and wrong for this pass.
|
|
54
|
+
* A sweep that cannot finish inside its budget will not finish on the retry
|
|
55
|
+
* either — retrying it produced three killed backups and three stranded staging
|
|
56
|
+
* directories per hour instead of one, which is how 27 GB accumulated in 5.7
|
|
57
|
+
* hours. The hourly tick is already the retry.
|
|
58
|
+
*/
|
|
59
|
+
export const BACKUP_SWEEP_MAX_ATTEMPTS = 1;
|
|
60
|
+
|
|
26
61
|
export interface SubscriberRegistrar {
|
|
27
62
|
subscribe(options: {
|
|
28
63
|
name: string;
|
|
29
64
|
pattern: string;
|
|
30
65
|
handler: string;
|
|
31
66
|
registeredBy?: string;
|
|
67
|
+
maxAttempts?: number;
|
|
68
|
+
timeoutMs?: number;
|
|
32
69
|
}): unknown;
|
|
33
70
|
}
|
|
34
71
|
|
|
35
72
|
/**
|
|
36
73
|
* Idempotent: `bus.subscribe` upserts by name, so this is safe to call on
|
|
37
74
|
* every module install and update.
|
|
75
|
+
*
|
|
76
|
+
* Also called from `celilo system migrate`, which the .deb postinst runs on
|
|
77
|
+
* every apt upgrade. Registering only from module install/update meant a
|
|
78
|
+
* corrected budget would not reach an existing fleet until some module happened
|
|
79
|
+
* to be touched next — indistinguishable from a fix that shipped and silently
|
|
80
|
+
* did nothing. celilo-mgr sat at the 60s default with the row already present.
|
|
81
|
+
*
|
|
82
|
+
* This re-arms a deliberately paused sweep, and that is intentional: the pause
|
|
83
|
+
* only ever existed because staging leaked, and the reaper that makes it not
|
|
84
|
+
* leak ships in this same binary. Re-arming without the reaper present is the
|
|
85
|
+
* failure this comment exists to prevent — do not lift this call into a release
|
|
86
|
+
* that does not carry `reapOrphanedStaging`.
|
|
38
87
|
*/
|
|
39
88
|
export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
|
|
40
89
|
bus.subscribe({
|
|
@@ -42,6 +91,8 @@ export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
|
|
|
42
91
|
pattern: BACKUP_SWEEP_PATTERN,
|
|
43
92
|
handler: 'celilo backup sweep',
|
|
44
93
|
registeredBy: 'celilo-backup',
|
|
94
|
+
maxAttempts: BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
95
|
+
timeoutMs: BACKUP_SWEEP_TIMEOUT_MS,
|
|
45
96
|
});
|
|
46
97
|
}
|
|
47
98
|
|
|
@@ -57,9 +108,13 @@ export interface BackupSweepDeps {
|
|
|
57
108
|
backup(moduleId: string): Promise<{ success: boolean; error?: string }>;
|
|
58
109
|
/** Apply the module's declared retention. No-op when it declares none. */
|
|
59
110
|
prune(module: BackupSweepModule): Promise<void>;
|
|
111
|
+
/** Reclaim staging left by backups whose process died. See backup-staging.ts. */
|
|
112
|
+
reapStaging(): ReapStagingReport;
|
|
60
113
|
}
|
|
61
114
|
|
|
62
115
|
export interface BackupSweepReport {
|
|
116
|
+
/** Staging reclaimed before this pass created any of its own. */
|
|
117
|
+
staging: ReapStagingReport;
|
|
63
118
|
backedUp: string[];
|
|
64
119
|
/** Explicit `schedule: manual` — the author opted out. */
|
|
65
120
|
skippedManual: string[];
|
|
@@ -70,7 +125,14 @@ export interface BackupSweepReport {
|
|
|
70
125
|
}
|
|
71
126
|
|
|
72
127
|
export async function runBackupSweep(deps: BackupSweepDeps): Promise<BackupSweepReport> {
|
|
128
|
+
// Reclaim BEFORE backing anything up, not after. This pass is the only thing
|
|
129
|
+
// that creates staging on a schedule, so it is where the orphans come from —
|
|
130
|
+
// and a box already short on disk needs the space freed before we ask for
|
|
131
|
+
// another ~4 GB of it, not once we are finished with it.
|
|
132
|
+
const staging = deps.reapStaging();
|
|
133
|
+
|
|
73
134
|
const report: BackupSweepReport = {
|
|
135
|
+
staging,
|
|
74
136
|
backedUp: [],
|
|
75
137
|
skippedManual: [],
|
|
76
138
|
skippedNotDue: [],
|
|
@@ -260,7 +260,7 @@ export async function autoDeriveMachineConfig(
|
|
|
260
260
|
upsertModuleConfig(db, moduleId, variable.name, derived);
|
|
261
261
|
configured.push(variable.name);
|
|
262
262
|
|
|
263
|
-
// Handle per_selection follow-ups (e.g.,
|
|
263
|
+
// Handle per_selection follow-ups (e.g., `zone.<zone>.ip` from the zone list)
|
|
264
264
|
if (variable.options && variable.per_selection) {
|
|
265
265
|
for (const selectedVal of derived.split(',')) {
|
|
266
266
|
const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal);
|
|
@@ -22,7 +22,6 @@ export interface AnsibleResult {
|
|
|
22
22
|
* Parse raw Ansible output lines into concise human-readable status.
|
|
23
23
|
* Returns null for lines that should be suppressed (decorative separators, etc.)
|
|
24
24
|
*/
|
|
25
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping ANSI escape codes
|
|
26
25
|
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
|
|
27
26
|
|
|
28
27
|
/**
|