@celilo/cli 0.16.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +39 -9
- package/drizzle/0019_backup_pid.sql +18 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/schemas/system_config.json +1 -1
- package/src/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/alerts-poll.ts +26 -1
- package/src/cli/commands/backup-sweep.ts +62 -0
- package/src/cli/commands/module-operations.test.ts +45 -1
- package/src/cli/commands/module-operations.ts +35 -12
- package/src/cli/commands/module-show.ts +1 -0
- package/src/cli/commands/storage-set-path.test.ts +281 -0
- package/src/cli/commands/storage-set-path.ts +190 -0
- package/src/cli/commands/system-audit.ts +14 -0
- package/src/cli/commands/system-migrate.ts +40 -0
- package/src/cli/commands/system-update.ts +6 -0
- package/src/cli/completion.ts +24 -3
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +12 -0
- package/src/cli/tui/audit-state.test.ts +15 -1
- package/src/cli/tui/audit-state.ts +6 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -1
- package/src/db/schema.ts +53 -9
- package/src/hooks/capability-loader.ts +30 -1
- package/src/ipam/allocator.ts +13 -3
- package/src/services/alerting/builtin-monitors.test.ts +42 -0
- package/src/services/alerting/builtin-monitors.ts +3 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/alerting/inbound-poller.test.ts +63 -1
- package/src/services/alerting/inbound-poller.ts +42 -0
- package/src/services/alerting/read-records.ts +85 -0
- package/src/services/audit/abandoned-operations.test.ts +73 -0
- package/src/services/audit/abandoned-operations.ts +0 -0
- package/src/services/audit/disk-space.test.ts +111 -0
- package/src/services/audit/disk-space.ts +114 -0
- package/src/services/audit/index.test.ts +2 -0
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/transport-reads.test.ts +113 -0
- package/src/services/audit/transport-reads.ts +120 -0
- package/src/services/audit/types.ts +3 -0
- package/src/services/backup-create.ts +4 -4
- package/src/services/backup-in-flight-refusal.test.ts +2 -0
- package/src/services/backup-metadata.ts +4 -0
- package/src/services/backup-staging.test.ts +134 -0
- package/src/services/backup-staging.ts +192 -0
- package/src/services/backup-storage.ts +29 -0
- package/src/services/backup-sweep.test.ts +68 -0
- package/src/services/backup-sweep.ts +62 -0
- package/src/services/config-interview.ts +1 -1
- package/src/services/deploy-ansible.ts +0 -1
- package/src/services/disk-probe.test.ts +74 -0
- package/src/services/disk-probe.ts +145 -0
- package/src/services/fleet-checks.ts +15 -0
- package/src/services/module-operations.test.ts +22 -0
- package/src/services/module-operations.ts +48 -1
- package/src/services/module-subscriptions.test.ts +39 -6
- package/src/services/module-subscriptions.ts +6 -4
- package/src/services/module-types-generator.test.ts +6 -3
- package/src/services/module-types-generator.ts +12 -7
- package/src/services/storage-providers/local.ts +2 -1
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/variables/context.ts +6 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `storage set-path`.
|
|
3
|
+
*
|
|
4
|
+
* The motivating case (#566) is celilo-mgr: `local-backups` points at
|
|
5
|
+
* `/Users/pbanka/hobby/backups/celilo-backups/`, a macOS path that came
|
|
6
|
+
* across when the database was restored onto a Linux host. The directory
|
|
7
|
+
* does not exist there. Relocating must therefore succeed with nothing
|
|
8
|
+
* migrated — not error, and not claim files were moved — and must not
|
|
9
|
+
* carry the four-month-old `✓ Verified` stamp onto the new path.
|
|
10
|
+
*
|
|
11
|
+
* Isolation: CELILO_DB_PATH / CELILO_DATA_DIR are set before the SUT is
|
|
12
|
+
* imported, so nothing touches the production database.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
16
|
+
import {
|
|
17
|
+
chmodSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
utimesSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { homedir, tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
const testRoot = mkdtempSync(join(tmpdir(), 'celilo-setpath-'));
|
|
30
|
+
process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
|
|
31
|
+
process.env.CELILO_DATA_DIR = join(testRoot, 'data');
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
addBackupStorage,
|
|
35
|
+
getBackupStorageByStorageId,
|
|
36
|
+
getStorageCredentials,
|
|
37
|
+
updateStorageCredentials,
|
|
38
|
+
} = await import('../../services/backup-storage');
|
|
39
|
+
const { handleStorageSetPath, inspectSourceDir, planRelocation, resolveTargetPath } = await import(
|
|
40
|
+
'./storage-set-path'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('planRelocation (pure)', () => {
|
|
48
|
+
const base = { sourceDir: '/old/celilo-backups', targetDir: '/new/celilo-backups' };
|
|
49
|
+
|
|
50
|
+
test('migrates when the source has files', () => {
|
|
51
|
+
const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: true });
|
|
52
|
+
expect(plan.migrate).toBe(true);
|
|
53
|
+
expect(plan.note).toContain('Migrating archives');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('PRODUCTION CASE: source missing — skips migration, says so, does not error', () => {
|
|
57
|
+
const plan = planRelocation({ ...base, sourceState: 'missing', migrateRequested: true });
|
|
58
|
+
expect(plan.migrate).toBe(false);
|
|
59
|
+
expect(plan.note).toContain('does not exist');
|
|
60
|
+
expect(plan.note).toContain('nothing to migrate');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('source unreadable — skips migration and says nothing was moved', () => {
|
|
64
|
+
const plan = planRelocation({ ...base, sourceState: 'unreadable', migrateRequested: true });
|
|
65
|
+
expect(plan.migrate).toBe(false);
|
|
66
|
+
expect(plan.note).toContain('not readable');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('source empty — skips migration', () => {
|
|
70
|
+
const plan = planRelocation({ ...base, sourceState: 'empty', migrateRequested: true });
|
|
71
|
+
expect(plan.migrate).toBe(false);
|
|
72
|
+
expect(plan.note).toContain('empty');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('--no-migrate wins even over a populated source', () => {
|
|
76
|
+
const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: false });
|
|
77
|
+
expect(plan.migrate).toBe(false);
|
|
78
|
+
expect(plan.note).toContain('--no-migrate');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('resolveTargetPath (pure)', () => {
|
|
83
|
+
test('rejects a path identical to the current one', () => {
|
|
84
|
+
const result = resolveTargetPath('/var/backups', '/var/backups/');
|
|
85
|
+
expect(result).toMatchObject({ success: false });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('preserves a path containing a space', () => {
|
|
89
|
+
const result = resolveTargetPath('/tmp/back ups/celilo', '/somewhere/else');
|
|
90
|
+
expect(result).toEqual({ path: '/tmp/back ups/celilo' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('expands a leading tilde', () => {
|
|
94
|
+
const result = resolveTargetPath('~/backups', '/somewhere/else');
|
|
95
|
+
expect(result).toMatchObject({ path: join(homedir(), 'backups') });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('inspectSourceDir', () => {
|
|
100
|
+
test('missing directory reports missing, not unreadable', () => {
|
|
101
|
+
expect(inspectSourceDir(join(testRoot, 'no-such-dir'))).toBe('missing');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('empty directory reports empty', () => {
|
|
105
|
+
const dir = join(testRoot, 'empty-dir');
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
expect(inspectSourceDir(dir)).toBe('empty');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('directory with files reports populated', () => {
|
|
111
|
+
const dir = join(testRoot, 'full-dir');
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
writeFileSync(join(dir, 'a.backup'), 'x');
|
|
114
|
+
expect(inspectSourceDir(dir)).toBe('populated');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('unreadable directory reports unreadable, not missing', () => {
|
|
118
|
+
const parent = join(testRoot, 'locked');
|
|
119
|
+
const dir = join(parent, 'celilo-backups');
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
chmodSync(parent, 0o000);
|
|
122
|
+
try {
|
|
123
|
+
expect(inspectSourceDir(dir)).toBe('unreadable');
|
|
124
|
+
} finally {
|
|
125
|
+
chmodSync(parent, 0o700);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('updateStorageCredentials', () => {
|
|
131
|
+
test('clears the verification stamp, so a stale ✓ never survives a credential change (#566)', async () => {
|
|
132
|
+
const storage = await addBackupStorage({
|
|
133
|
+
name: 'Stamp Backups',
|
|
134
|
+
providerName: 'local',
|
|
135
|
+
credentials: { path: '/Users/nobody/old' },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { getDb } = await import('../../db/client');
|
|
139
|
+
const { backupStorages } = await import('../../db/schema');
|
|
140
|
+
const { eq } = await import('drizzle-orm');
|
|
141
|
+
getDb()
|
|
142
|
+
.update(backupStorages)
|
|
143
|
+
.set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') })
|
|
144
|
+
.where(eq(backupStorages.id, storage.id))
|
|
145
|
+
.run();
|
|
146
|
+
|
|
147
|
+
await updateStorageCredentials(storage.id, { path: '/var/lib/celilo/backups' });
|
|
148
|
+
|
|
149
|
+
// Asserted WITHOUT a follow-up verify: even if celilo dies between
|
|
150
|
+
// the path change and re-verification, the row must not still claim
|
|
151
|
+
// the destination was verified.
|
|
152
|
+
const after = getBackupStorageByStorageId(storage.storageId);
|
|
153
|
+
expect(after?.verified).toBe(false);
|
|
154
|
+
expect(after?.verifiedAt).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('handleStorageSetPath (end to end, isolated DB)', () => {
|
|
159
|
+
test('PRODUCTION CASE: current path does not exist — path changes, nothing migrated, re-verified against the new path', async () => {
|
|
160
|
+
const storage = await addBackupStorage({
|
|
161
|
+
name: 'Ghost Backups',
|
|
162
|
+
providerName: 'local',
|
|
163
|
+
credentials: { path: '/Users/nobody/hobby/backups' },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Simulate the stale stamp: the row says Verified from a host that
|
|
167
|
+
// no longer exists.
|
|
168
|
+
const { getDb } = await import('../../db/client');
|
|
169
|
+
const { backupStorages } = await import('../../db/schema');
|
|
170
|
+
const { eq } = await import('drizzle-orm');
|
|
171
|
+
getDb()
|
|
172
|
+
.update(backupStorages)
|
|
173
|
+
.set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') })
|
|
174
|
+
.where(eq(backupStorages.id, storage.id))
|
|
175
|
+
.run();
|
|
176
|
+
|
|
177
|
+
const newPath = join(testRoot, 'relocated');
|
|
178
|
+
const result = await handleStorageSetPath([storage.storageId, newPath]);
|
|
179
|
+
|
|
180
|
+
expect(result.success).toBe(true);
|
|
181
|
+
|
|
182
|
+
const creds = await getStorageCredentials(storage.id);
|
|
183
|
+
expect(creds).toMatchObject({ path: newPath });
|
|
184
|
+
|
|
185
|
+
// The stamp must describe the NEW path, not the dead one.
|
|
186
|
+
const after = getBackupStorageByStorageId(storage.storageId);
|
|
187
|
+
expect(after?.verified).toBe(true);
|
|
188
|
+
expect(after?.verifiedAt?.getTime()).toBeGreaterThan(
|
|
189
|
+
new Date('2026-04-08T02:24:43.000Z').getTime(),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('moves existing archives to the new location', async () => {
|
|
194
|
+
const oldPath = join(testRoot, 'movable-old');
|
|
195
|
+
const newPath = join(testRoot, 'movable-new');
|
|
196
|
+
mkdirSync(join(oldPath, 'celilo-backups', '2026-08-01'), { recursive: true });
|
|
197
|
+
writeFileSync(join(oldPath, 'celilo-backups', '2026-08-01', 'x.backup'), 'payload');
|
|
198
|
+
|
|
199
|
+
const storage = await addBackupStorage({
|
|
200
|
+
name: 'Movable Backups',
|
|
201
|
+
providerName: 'local',
|
|
202
|
+
credentials: { path: oldPath },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const result = await handleStorageSetPath([storage.storageId, newPath]);
|
|
206
|
+
|
|
207
|
+
expect(result.success).toBe(true);
|
|
208
|
+
expect(existsSync(join(newPath, 'celilo-backups', '2026-08-01', 'x.backup'))).toBe(true);
|
|
209
|
+
expect(existsSync(join(oldPath, 'celilo-backups'))).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('preserves archive mtimes — a move does not restamp backups', async () => {
|
|
213
|
+
const oldPath = join(testRoot, 'mtime-old');
|
|
214
|
+
const newPath = join(testRoot, 'mtime-new');
|
|
215
|
+
const archive = join(oldPath, 'celilo-backups', 'a.backup');
|
|
216
|
+
mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
|
|
217
|
+
writeFileSync(archive, 'payload');
|
|
218
|
+
const stamp = new Date('2026-04-26T01:37:53.740Z');
|
|
219
|
+
utimesSync(archive, stamp, stamp);
|
|
220
|
+
|
|
221
|
+
const storage = await addBackupStorage({
|
|
222
|
+
name: 'Mtime Backups',
|
|
223
|
+
providerName: 'local',
|
|
224
|
+
credentials: { path: oldPath },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await handleStorageSetPath([storage.storageId, newPath]);
|
|
228
|
+
|
|
229
|
+
const moved = statSync(join(newPath, 'celilo-backups', 'a.backup'));
|
|
230
|
+
expect(Math.round(moved.mtimeMs)).toBe(stamp.getTime());
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('--no-migrate leaves the old archives where they are', async () => {
|
|
234
|
+
const oldPath = join(testRoot, 'kept-old');
|
|
235
|
+
const newPath = join(testRoot, 'kept-new');
|
|
236
|
+
mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
|
|
237
|
+
writeFileSync(join(oldPath, 'celilo-backups', 'y.backup'), 'payload');
|
|
238
|
+
|
|
239
|
+
const storage = await addBackupStorage({
|
|
240
|
+
name: 'Kept Backups',
|
|
241
|
+
providerName: 'local',
|
|
242
|
+
credentials: { path: oldPath },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const result = await handleStorageSetPath([storage.storageId, newPath], {
|
|
246
|
+
'no-migrate': true,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.success).toBe(true);
|
|
250
|
+
expect(existsSync(join(oldPath, 'celilo-backups', 'y.backup'))).toBe(true);
|
|
251
|
+
expect(existsSync(join(newPath, 'celilo-backups', 'y.backup'))).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('handles a new path containing a space', async () => {
|
|
255
|
+
const oldPath = join(testRoot, 'spacey-old');
|
|
256
|
+
const newPath = join(testRoot, 'back ups', "Bob's celilo");
|
|
257
|
+
mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
|
|
258
|
+
writeFileSync(join(oldPath, 'celilo-backups', 'z.backup'), 'payload');
|
|
259
|
+
|
|
260
|
+
const storage = await addBackupStorage({
|
|
261
|
+
name: 'Spacey Backups',
|
|
262
|
+
providerName: 'local',
|
|
263
|
+
credentials: { path: oldPath },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const result = await handleStorageSetPath([storage.storageId, newPath]);
|
|
267
|
+
|
|
268
|
+
expect(result.success).toBe(true);
|
|
269
|
+
expect(existsSync(join(newPath, 'celilo-backups', 'z.backup'))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('rejects an unknown storage id', async () => {
|
|
273
|
+
const result = await handleStorageSetPath(['no-such-storage', join(testRoot, 'x')]);
|
|
274
|
+
expect(result).toMatchObject({ success: false });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('requires both a storage id and a path', async () => {
|
|
278
|
+
const result = await handleStorageSetPath(['only-one-arg']);
|
|
279
|
+
expect(result).toMatchObject({ success: false });
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Set Path Command
|
|
3
|
+
* Relocate a local storage destination to a new directory, migrating
|
|
4
|
+
* any archives that are actually there.
|
|
5
|
+
*
|
|
6
|
+
* Motivating case (#566): celilo-mgr's `local-backups` points at
|
|
7
|
+
* `/Users/pbanka/...`, a macOS path carried across when the database was
|
|
8
|
+
* restored onto a Linux host. The directory does not exist there, so the
|
|
9
|
+
* relocation must complete cleanly with nothing to migrate rather than
|
|
10
|
+
* erroring — and must not carry the old `✓ Verified` stamp onto the new
|
|
11
|
+
* path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { cpSync, readdirSync, rmSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
getBackupStorageByStorageId,
|
|
19
|
+
getStorageCredentials,
|
|
20
|
+
updateStorageCredentials,
|
|
21
|
+
verifyBackupStorage,
|
|
22
|
+
} from '../../services/backup-storage';
|
|
23
|
+
import { BACKUP_PREFIX } from '../../services/storage-providers/local';
|
|
24
|
+
import { celiloIntro, celiloOutro } from '../prompts';
|
|
25
|
+
import type { CommandResult } from '../types';
|
|
26
|
+
import { probePathWriteable } from './storage-add-local';
|
|
27
|
+
|
|
28
|
+
/** What the current archive directory turned out to be, on disk. */
|
|
29
|
+
export type SourceState = 'missing' | 'unreadable' | 'empty' | 'populated';
|
|
30
|
+
|
|
31
|
+
export interface RelocationPlan {
|
|
32
|
+
/** Whether to copy the source tree to the target. */
|
|
33
|
+
migrate: boolean;
|
|
34
|
+
/** One line for the operator explaining what will (not) happen. */
|
|
35
|
+
note: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether to migrate. Pure — takes an already-observed source
|
|
40
|
+
* state so it is testable without a filesystem (Rule 10.4).
|
|
41
|
+
*/
|
|
42
|
+
export function planRelocation(input: {
|
|
43
|
+
sourceState: SourceState;
|
|
44
|
+
sourceDir: string;
|
|
45
|
+
targetDir: string;
|
|
46
|
+
migrateRequested: boolean;
|
|
47
|
+
}): RelocationPlan {
|
|
48
|
+
const { sourceState, sourceDir, targetDir, migrateRequested } = input;
|
|
49
|
+
|
|
50
|
+
if (!migrateRequested) {
|
|
51
|
+
return { migrate: false, note: `Migration skipped (--no-migrate). ${sourceDir} left as-is.` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
switch (sourceState) {
|
|
55
|
+
case 'missing':
|
|
56
|
+
return { migrate: false, note: `${sourceDir} does not exist — nothing to migrate.` };
|
|
57
|
+
case 'unreadable':
|
|
58
|
+
return { migrate: false, note: `${sourceDir} is not readable — nothing migrated.` };
|
|
59
|
+
case 'empty':
|
|
60
|
+
return { migrate: false, note: `${sourceDir} is empty — nothing to migrate.` };
|
|
61
|
+
case 'populated':
|
|
62
|
+
return { migrate: true, note: `Migrating archives from ${sourceDir} to ${targetDir}.` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate and normalise the requested path. Pure apart from `~`
|
|
68
|
+
* expansion, which reads the environment but touches no filesystem.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTargetPath(
|
|
71
|
+
raw: string,
|
|
72
|
+
currentPath: string,
|
|
73
|
+
): CommandResult | { path: string } {
|
|
74
|
+
const expanded = raw.startsWith('~/') || raw === '~' ? raw.replace('~', homedir()) : raw;
|
|
75
|
+
const resolved = resolve(expanded);
|
|
76
|
+
|
|
77
|
+
if (resolved === resolve(currentPath)) {
|
|
78
|
+
return { success: false, error: `Storage path is already '${resolved}' — nothing to do.` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { path: resolved };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Observe the archive directory. Distinguishes absent from unreadable. */
|
|
85
|
+
export function inspectSourceDir(dir: string): SourceState {
|
|
86
|
+
try {
|
|
87
|
+
return readdirSync(dir).length === 0 ? 'empty' : 'populated';
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unreadable';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function handleStorageSetPath(
|
|
94
|
+
args: string[],
|
|
95
|
+
flags: Record<string, boolean | string> = {},
|
|
96
|
+
): Promise<CommandResult> {
|
|
97
|
+
try {
|
|
98
|
+
celiloIntro('Relocate Backup Storage');
|
|
99
|
+
|
|
100
|
+
const storageId = args[0];
|
|
101
|
+
const newPathArg = args[1];
|
|
102
|
+
if (!storageId || !newPathArg) {
|
|
103
|
+
return {
|
|
104
|
+
success: false,
|
|
105
|
+
error:
|
|
106
|
+
'Storage ID and new path are required\n\nUsage: celilo storage set-path <storage-id> <new-path> [--no-migrate]',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const storage = getBackupStorageByStorageId(storageId);
|
|
111
|
+
if (!storage) {
|
|
112
|
+
return { success: false, error: `Storage not found: ${storageId}` };
|
|
113
|
+
}
|
|
114
|
+
if (storage.providerName !== 'local') {
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
error: `'${storage.storageId}' is a ${storage.providerName} destination — set-path only applies to local storage.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const credentials = await getStorageCredentials(storage.id);
|
|
122
|
+
if (!('path' in credentials)) {
|
|
123
|
+
return { success: false, error: `Storage '${storage.storageId}' has no path configured.` };
|
|
124
|
+
}
|
|
125
|
+
const currentPath = credentials.path;
|
|
126
|
+
|
|
127
|
+
const resolved = resolveTargetPath(newPathArg, currentPath);
|
|
128
|
+
if ('success' in resolved) return resolved;
|
|
129
|
+
const newPath = resolved.path;
|
|
130
|
+
|
|
131
|
+
const writeError = probePathWriteable(newPath);
|
|
132
|
+
if (writeError !== null) {
|
|
133
|
+
return { success: false, error: `Path '${newPath}' is not writeable: ${writeError}` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sourceDir = join(currentPath, BACKUP_PREFIX);
|
|
137
|
+
const targetDir = join(newPath, BACKUP_PREFIX);
|
|
138
|
+
const plan = planRelocation({
|
|
139
|
+
sourceState: inspectSourceDir(sourceDir),
|
|
140
|
+
sourceDir,
|
|
141
|
+
targetDir,
|
|
142
|
+
migrateRequested: flags['no-migrate'] !== true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log(`\n${plan.note}`);
|
|
146
|
+
|
|
147
|
+
if (plan.migrate) {
|
|
148
|
+
// A backup archive's mtime is part of what it is; a move must not
|
|
149
|
+
// restamp it. Bun's cpSync already preserves timestamps, so the
|
|
150
|
+
// flag is a no-op today — it is here for Node semantics, where the
|
|
151
|
+
// default is the other way. The mtime test guards the behavior,
|
|
152
|
+
// not this flag.
|
|
153
|
+
cpSync(sourceDir, targetDir, { recursive: true, force: true, preserveTimestamps: true });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await updateStorageCredentials(storage.id, { ...credentials, path: newPath });
|
|
157
|
+
console.log(`✓ Path updated: ${currentPath} → ${newPath}`);
|
|
158
|
+
|
|
159
|
+
if (plan.migrate) {
|
|
160
|
+
// Only after the DB points at the copy, so a failure here leaves
|
|
161
|
+
// archives duplicated rather than orphaned.
|
|
162
|
+
try {
|
|
163
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.log(
|
|
166
|
+
`⚠ Copied, but could not remove ${sourceDir}: ${error instanceof Error ? error.message : String(error)}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { result } = await verifyBackupStorage(storage.id);
|
|
172
|
+
if (!result.success) {
|
|
173
|
+
console.log(`✗ ${result.message}`);
|
|
174
|
+
celiloOutro(
|
|
175
|
+
`Path changed but verification failed.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
|
|
176
|
+
);
|
|
177
|
+
return { success: false, error: result.message };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(`✓ ${result.message}`);
|
|
181
|
+
celiloOutro(`'${storage.storageId}' now stores backups at ${targetDir}/`);
|
|
182
|
+
|
|
183
|
+
return { success: true, message: `Relocated ${storage.storageId} to ${newPath}` };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
error: `Failed to set storage path: ${error instanceof Error ? error.message : String(error)}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -35,8 +35,10 @@ import type { ModuleManifest } from '../../manifest/schema';
|
|
|
35
35
|
import { RegistryClient } from '../../registry/client';
|
|
36
36
|
import { decryptSecret } from '../../secrets/encryption';
|
|
37
37
|
import { getOrCreateMasterKey } from '../../secrets/master-key';
|
|
38
|
+
import { readAllTransportStatuses } from '../../services/alerting/read-records';
|
|
38
39
|
import { runAudit } from '../../services/audit';
|
|
39
40
|
import type { DriftFinding, SystemAuditReport } from '../../services/audit';
|
|
41
|
+
import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
|
|
40
42
|
import { loadBackupAuditInfo } from '../../services/audit/backup-source';
|
|
41
43
|
import {
|
|
42
44
|
type LatestCliVersionFetcher,
|
|
@@ -362,6 +364,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
362
364
|
moduleConfigs: { modules: installedConfigs },
|
|
363
365
|
health: { results: healthResults },
|
|
364
366
|
backups: { modules: installedBackupInfo },
|
|
367
|
+
abandonedOperations: { records: loadAbandonedOperations(db) },
|
|
365
368
|
undeployedModules: {
|
|
366
369
|
modules: installed.map((m) => ({ id: m.id, state: m.state })),
|
|
367
370
|
},
|
|
@@ -378,6 +381,17 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
378
381
|
secretsDecryptable: { results: secretResults },
|
|
379
382
|
servicesReachable: { results: serviceReachableResults },
|
|
380
383
|
machinesReachable: { results: machineReachableResults },
|
|
384
|
+
// Reads the record the poller already writes — this check never performs a
|
|
385
|
+
// read of its own. One that did would drain the queue and eat the
|
|
386
|
+
// acknowledgement it exists to protect (#541).
|
|
387
|
+
transportReads: {
|
|
388
|
+
statuses: readAllTransportStatuses(db),
|
|
389
|
+
now: new Date(),
|
|
390
|
+
// Six missed polls. The poller runs every five minutes, so this absorbs a
|
|
391
|
+
// slow sweep, a restart, and a missed tick without crying wolf — while
|
|
392
|
+
// still catching a transport that has genuinely stopped being readable.
|
|
393
|
+
staleAfterMs: 30 * 60_000,
|
|
394
|
+
},
|
|
381
395
|
trustedSources: { firewalls: collectFirewallReach(db) },
|
|
382
396
|
};
|
|
383
397
|
}
|
|
@@ -8,11 +8,49 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Database } from 'bun:sqlite';
|
|
11
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
12
|
+
import { getEventBusPath } from '../../config/paths';
|
|
11
13
|
import { getDb } from '../../db/client';
|
|
12
14
|
import { runMigrationsOn } from '../../db/migrate';
|
|
13
15
|
import { findSchemaDrift } from '../../db/schema-introspection';
|
|
16
|
+
import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
|
|
17
|
+
import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
|
|
14
18
|
import type { CommandResult } from '../types';
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Arm celilo's own housekeeping subscribers.
|
|
22
|
+
*
|
|
23
|
+
* Here because this command is what the .deb postinst runs on every apt
|
|
24
|
+
* upgrade — the one moment guaranteed to happen after new CLI code lands.
|
|
25
|
+
* A subscriber registered only from module install/update would not appear
|
|
26
|
+
* until some module happened to be touched next, which can be weeks and
|
|
27
|
+
* looks exactly like a feature that shipped and silently does nothing.
|
|
28
|
+
*
|
|
29
|
+
* Best-effort: a bus that can't be opened must not fail a schema migration.
|
|
30
|
+
*/
|
|
31
|
+
function ensureCoreSubscribers(): void {
|
|
32
|
+
try {
|
|
33
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: defineEvents({}) });
|
|
34
|
+
try {
|
|
35
|
+
ensureOperationsSweepSubscriber(bus);
|
|
36
|
+
// The backup sweep is armed here for the same reason, plus a sharper one:
|
|
37
|
+
// its row already exists on every deployed fleet, carrying the 60s bus
|
|
38
|
+
// default that made scheduled backups impossible. Correcting the default
|
|
39
|
+
// in code does nothing until something re-registers, and module
|
|
40
|
+
// install/update can be weeks away. This is the upgrade path.
|
|
41
|
+
//
|
|
42
|
+
// This also re-arms a sweep an operator paused by hand. Deliberate: the
|
|
43
|
+
// pause exists only because staging leaked, and the reaper that stops it
|
|
44
|
+
// leaking ships in this same binary.
|
|
45
|
+
ensureBackupSweepSubscriber(bus);
|
|
46
|
+
} finally {
|
|
47
|
+
bus.close();
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Nothing to do — the next module install/update arms it.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
16
54
|
function countApplied(sqlite: Database): number {
|
|
17
55
|
try {
|
|
18
56
|
const row = sqlite
|
|
@@ -57,6 +95,8 @@ export async function handleSystemMigrate(): Promise<CommandResult> {
|
|
|
57
95
|
};
|
|
58
96
|
}
|
|
59
97
|
|
|
98
|
+
ensureCoreSubscribers();
|
|
99
|
+
|
|
60
100
|
const lines = [
|
|
61
101
|
applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
|
|
62
102
|
`Schema current: ${drift.tableCount} tables.`,
|
|
@@ -26,6 +26,7 @@ import { backups, moduleConfigs as moduleConfigsTbl, modules } from '../../db/sc
|
|
|
26
26
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
27
27
|
import { RegistryClient } from '../../registry/client';
|
|
28
28
|
import { runAudit } from '../../services/audit';
|
|
29
|
+
import { loadAbandonedOperations } from '../../services/audit/abandoned-operations';
|
|
29
30
|
import { fetchLatestCliVersion } from '../../services/audit/cli-version';
|
|
30
31
|
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
31
32
|
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
@@ -567,6 +568,7 @@ export async function handleSystemUpdate(
|
|
|
567
568
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
568
569
|
})),
|
|
569
570
|
},
|
|
571
|
+
abandonedOperations: { records: loadAbandonedOperations(db) },
|
|
570
572
|
undeployedModules: {
|
|
571
573
|
modules: installed.map((m) => ({ id: m.id, state: m.state })),
|
|
572
574
|
},
|
|
@@ -585,6 +587,7 @@ export async function handleSystemUpdate(
|
|
|
585
587
|
secretsDecryptable: { results: [] },
|
|
586
588
|
servicesReachable: { results: [] },
|
|
587
589
|
machinesReachable: { results: [] },
|
|
590
|
+
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
588
591
|
trustedSources: { firewalls: [] },
|
|
589
592
|
};
|
|
590
593
|
|
|
@@ -777,6 +780,9 @@ export function rebuildAuditDepsForRerun(
|
|
|
777
780
|
configs: configsByModule.get(m.id) ?? {},
|
|
778
781
|
})),
|
|
779
782
|
},
|
|
783
|
+
// Unchanged across an orchestrator run — an upgrade does not reclaim
|
|
784
|
+
// abandoned operations, so re-reading them would be the same rows.
|
|
785
|
+
abandonedOperations: original.abandonedOperations,
|
|
780
786
|
backups: {
|
|
781
787
|
...original.backups,
|
|
782
788
|
modules: upgradable.map((m) => ({
|
package/src/cli/completion.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
|
|
|
7
7
|
import { getDb } from '../db/client';
|
|
8
8
|
import { capabilities, modules } from '../db/schema';
|
|
9
9
|
import type { ModuleManifest } from '../manifest/schema';
|
|
10
|
+
import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
|
|
10
11
|
import { listPrincipals } from '../services/api-access';
|
|
11
12
|
import { listBackups } from '../services/backup-metadata';
|
|
12
13
|
import { listBackupStorages } from '../services/backup-storage';
|
|
@@ -479,6 +480,23 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
479
480
|
return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
// Monitor targets - a module ID or one of celilo's own schedulable checks.
|
|
484
|
+
// Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
|
|
485
|
+
// a new built-in check is completable the moment it is schedulable.
|
|
486
|
+
if (
|
|
487
|
+
command === 'monitor' &&
|
|
488
|
+
(args[1] === 'add' || args[1] === 'run' || args[1] === 'enable' || args[1] === 'disable') &&
|
|
489
|
+
currentIndex === 2
|
|
490
|
+
) {
|
|
491
|
+
const db = getDb();
|
|
492
|
+
const moduleIds = db
|
|
493
|
+
.select({ id: modules.id })
|
|
494
|
+
.from(modules)
|
|
495
|
+
.all()
|
|
496
|
+
.map((m) => m.id);
|
|
497
|
+
return filterSuggestions([...SCHEDULABLE_BUILTIN_CHECKS, ...moduleIds], args[2] || '');
|
|
498
|
+
}
|
|
499
|
+
|
|
482
500
|
// Alerts subcommands
|
|
483
501
|
if (command === 'alerts' && currentIndex === 1) {
|
|
484
502
|
return filterSuggestions(['list', 'ack', 'silence', 'resolve', 'sweep', 'poll'], args[1] || '');
|
|
@@ -486,7 +504,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
486
504
|
|
|
487
505
|
// Storage subcommands
|
|
488
506
|
if (command === 'storage' && currentIndex === 1) {
|
|
489
|
-
const subcommands = ['add', 'list', 'remove', 'verify', 'set-default'];
|
|
507
|
+
const subcommands = ['add', 'list', 'remove', 'verify', 'set-default', 'set-path'];
|
|
490
508
|
return filterSuggestions(subcommands, args[1] || '');
|
|
491
509
|
}
|
|
492
510
|
|
|
@@ -496,10 +514,13 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
496
514
|
return filterSuggestions(providers, args[2] || '');
|
|
497
515
|
}
|
|
498
516
|
|
|
499
|
-
// Storage remove/verify/set-default - complete with storage IDs
|
|
517
|
+
// Storage remove/verify/set-default/set-path - complete with storage IDs
|
|
500
518
|
if (
|
|
501
519
|
command === 'storage' &&
|
|
502
|
-
(args[1] === 'remove' ||
|
|
520
|
+
(args[1] === 'remove' ||
|
|
521
|
+
args[1] === 'verify' ||
|
|
522
|
+
args[1] === 'set-default' ||
|
|
523
|
+
args[1] === 'set-path') &&
|
|
503
524
|
currentIndex === 2
|
|
504
525
|
) {
|
|
505
526
|
const storages = listBackupStorages();
|
package/src/cli/fuel-gauge.ts
CHANGED
|
@@ -334,7 +334,6 @@ export class FuelGauge {
|
|
|
334
334
|
* Strip ANSI codes from text
|
|
335
335
|
*/
|
|
336
336
|
private stripAnsi(text: string): string {
|
|
337
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character needed for ANSI code matching
|
|
338
337
|
return text.replace(/\u001b\[[0-9;]*m/g, '');
|
|
339
338
|
}
|
|
340
339
|
|
|
@@ -361,7 +361,7 @@ _celilo_system_config_keys() {
|
|
|
361
361
|
'network.internal.gateway:Internal gateway IP'
|
|
362
362
|
'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
|
|
363
363
|
'network.secure-mgmt.gateway:Control-plane gateway IP'
|
|
364
|
-
'network.vpn.subnet:VPN client subnet (WireGuard remote access)'
|
|
364
|
+
'network.control-plane-vpn.subnet:Administrative VPN client subnet (WireGuard remote access)'
|
|
365
365
|
'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
|
|
366
366
|
'dns.primary:Primary DNS server'
|
|
367
367
|
'dns.fallback:Fallback DNS servers'
|