@celilo/cli 0.3.26 → 0.3.28
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/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `storage add local`'s pre-save write probe.
|
|
3
|
+
*
|
|
4
|
+
* Background: an operator on celilo-mgmt typed `/var/backups/celilo`
|
|
5
|
+
* for the path. The CLI accepted it, saved a storage row, then the
|
|
6
|
+
* heavyweight verify step failed with EACCES — leaving an unverified
|
|
7
|
+
* row that confused subsequent `system update` runs. The probe
|
|
8
|
+
* catches unwriteable paths before any persistent state is created.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { probePathWriteable } from './storage-add-local';
|
|
16
|
+
|
|
17
|
+
describe('probePathWriteable', () => {
|
|
18
|
+
let tempRoot: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempRoot = mkdtempSync(join(tmpdir(), 'celilo-probe-test-'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns null for a writeable existing directory', () => {
|
|
29
|
+
expect(probePathWriteable(tempRoot)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("creates parent dirs that don't exist yet (mkdir -p semantics)", () => {
|
|
33
|
+
// The probe's mkdirSync uses `recursive: true`, so a path several
|
|
34
|
+
// levels below the temp root works on the first try. This is the
|
|
35
|
+
// common case for the user's typed path under their data dir.
|
|
36
|
+
const deep = join(tempRoot, 'a', 'b', 'c', 'backups');
|
|
37
|
+
expect(probePathWriteable(deep)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('returns an error message for a path under a read-only ancestor', () => {
|
|
41
|
+
const readOnly = join(tempRoot, 'readonly');
|
|
42
|
+
mkdirSync(readOnly);
|
|
43
|
+
// 0o500 = read+execute for owner, no write. mkdirSync into it
|
|
44
|
+
// should EACCES.
|
|
45
|
+
require('node:fs').chmodSync(readOnly, 0o500);
|
|
46
|
+
try {
|
|
47
|
+
const target = join(readOnly, 'celilo-backups');
|
|
48
|
+
const err = probePathWriteable(target);
|
|
49
|
+
expect(err).not.toBeNull();
|
|
50
|
+
expect(err).toContain('EACCES');
|
|
51
|
+
} finally {
|
|
52
|
+
// Restore permissions so afterEach can clean up.
|
|
53
|
+
require('node:fs').chmodSync(readOnly, 0o700);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('cleans up the probe directory on success (no leftover state)', () => {
|
|
58
|
+
const dir = join(tempRoot, 'check');
|
|
59
|
+
expect(probePathWriteable(dir)).toBeNull();
|
|
60
|
+
// The probe creates `<dir>/.celilo-write-probe` then removes it.
|
|
61
|
+
// The directory itself stays (mkdir -p), but the probe sentinel
|
|
62
|
+
// doesn't.
|
|
63
|
+
const { existsSync } = require('node:fs');
|
|
64
|
+
expect(existsSync(join(dir, '.celilo-write-probe'))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Configure a local filesystem backup storage destination
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
|
-
import { resolve } from 'node:path';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { getDataDir } from '../../config/paths';
|
|
8
10
|
import {
|
|
9
11
|
addBackupStorage,
|
|
10
12
|
setDefaultBackupStorage,
|
|
@@ -14,6 +16,27 @@ import { celiloIntro, celiloOutro, promptConfirm, promptText } from '../prompts'
|
|
|
14
16
|
import type { CommandResult } from '../types';
|
|
15
17
|
import { validateRequired } from '../validators';
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Best-effort write probe. Attempts to mkdir -p a `.celilo-write-probe`
|
|
21
|
+
* subdir, then deletes it. Returns null on success, or a one-line
|
|
22
|
+
* error message on failure (suitable for surfacing to the operator).
|
|
23
|
+
*
|
|
24
|
+
* Exists so we can catch unwriteable paths AT INTERVIEW TIME instead
|
|
25
|
+
* of saving a storage row, failing the heavyweight verify step, and
|
|
26
|
+
* leaving a dangling unverified row that confuses subsequent
|
|
27
|
+
* `system update` runs (the regression that prompted this work).
|
|
28
|
+
*/
|
|
29
|
+
export function probePathWriteable(path: string): string | null {
|
|
30
|
+
const probe = join(path, '.celilo-write-probe');
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(probe, { recursive: true });
|
|
33
|
+
rmSync(probe, { recursive: true, force: true });
|
|
34
|
+
return null;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return err instanceof Error ? err.message : String(err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
/**
|
|
18
41
|
* Expand leading tilde to the user's home directory.
|
|
19
42
|
* Node.js fs functions don't expand ~ like the shell does.
|
|
@@ -45,15 +68,55 @@ export async function handleStorageAddLocal(
|
|
|
45
68
|
validate: validateRequired('Storage name'),
|
|
46
69
|
}));
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
// Default path lives under celilo's own data dir
|
|
72
|
+
// (~/.local/share/celilo/backups on Linux, ~/Library/Application
|
|
73
|
+
// Support/celilo/backups on macOS). The directory is owned by the
|
|
74
|
+
// running user, mkdir -p succeeds without sudo, and operators who
|
|
75
|
+
// want NAS / external paths can override at the prompt. This
|
|
76
|
+
// replaces the prior placeholder of "/var/backups/celilo" which
|
|
77
|
+
// looked authoritative but required root to write.
|
|
78
|
+
const defaultPath = join(getDataDir(), 'backups');
|
|
79
|
+
|
|
80
|
+
// Probe writability BEFORE saving the storage row. The previous
|
|
81
|
+
// flow (save → verify → fail with EACCES → leave dangling
|
|
82
|
+
// unverified row) was confusing and required the operator to know
|
|
83
|
+
// about `storage verify` to recover. Probing here means an
|
|
84
|
+
// unwriteable path never produces persistent state.
|
|
85
|
+
//
|
|
86
|
+
// Non-interactive (--name + --path): probe once, fail loud.
|
|
87
|
+
// Interactive: re-prompt until a writeable path is supplied.
|
|
88
|
+
let resolvedPath: string;
|
|
89
|
+
if (flagPath !== undefined) {
|
|
90
|
+
resolvedPath = resolve(expandTilde(flagPath));
|
|
91
|
+
const writeError = probePathWriteable(resolvedPath);
|
|
92
|
+
if (writeError !== null) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: `Path '${resolvedPath}' is not writeable: ${writeError}\n\nTry a path under your home directory (e.g. '${defaultPath}') or run with elevated permissions.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
let candidate: string | undefined;
|
|
100
|
+
while (candidate === undefined) {
|
|
101
|
+
const typed = await promptText({
|
|
102
|
+
message: 'Storage directory path:',
|
|
103
|
+
defaultValue: defaultPath,
|
|
104
|
+
placeholder: defaultPath,
|
|
105
|
+
validate: validateRequired('Storage path'),
|
|
106
|
+
});
|
|
107
|
+
const resolved = resolve(expandTilde(typed));
|
|
108
|
+
const writeError = probePathWriteable(resolved);
|
|
109
|
+
if (writeError === null) {
|
|
110
|
+
candidate = resolved;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
console.log(
|
|
114
|
+
`\n✗ Path '${resolved}' is not writeable: ${writeError}\nTry a path under your home directory (default '${defaultPath}' works without sudo).\n`,
|
|
115
|
+
);
|
|
116
|
+
// Loop continues — re-prompt with the same default suggestion.
|
|
117
|
+
}
|
|
118
|
+
resolvedPath = candidate;
|
|
119
|
+
}
|
|
57
120
|
|
|
58
121
|
const storage = await addBackupStorage({
|
|
59
122
|
name,
|
|
@@ -67,6 +130,10 @@ export async function handleStorageAddLocal(
|
|
|
67
130
|
const { result } = await verifyBackupStorage(storage.id);
|
|
68
131
|
|
|
69
132
|
if (!result.success) {
|
|
133
|
+
// Should be unreachable in interactive mode (the pre-save probe
|
|
134
|
+
// covers the EACCES path); could still fire for less-common
|
|
135
|
+
// verify failures (full disk, etc.). Keep the dangling-row
|
|
136
|
+
// recovery hint as a safety net.
|
|
70
137
|
console.log(`\n✗ Verification failed: ${result.message}`);
|
|
71
138
|
celiloOutro(
|
|
72
139
|
`Storage '${storage.storageId}' added but not verified.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `system update` storage pre-flight.
|
|
3
|
+
*
|
|
4
|
+
* Background: an operator on celilo-mgmt hit a confusing chain when
|
|
5
|
+
* they ran `celilo storage add local /var/backups/celilo` (a system
|
|
6
|
+
* path that needs root). The verify step failed with EACCES, leaving
|
|
7
|
+
* an unverified storage row on disk. The next `celilo system update`
|
|
8
|
+
* said "No default backup storage configured" — technically true
|
|
9
|
+
* (unverified storage never auto-defaults), but misleading: there
|
|
10
|
+
* IS a storage, it just needs fixing. The pre-flight now distinguishes
|
|
11
|
+
* the cases and points at the right next command for each.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import type { ModuleSnapshot } from '../../services/update/orchestrator';
|
|
16
|
+
import {
|
|
17
|
+
type BackupStorageLike,
|
|
18
|
+
checkBackupStoragePreflight,
|
|
19
|
+
shouldTakeBackup,
|
|
20
|
+
} from './system-update';
|
|
21
|
+
|
|
22
|
+
const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
|
|
23
|
+
const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
|
|
24
|
+
|
|
25
|
+
describe('checkBackupStoragePreflight', () => {
|
|
26
|
+
test('verified default → ok', () => {
|
|
27
|
+
const result = checkBackupStoragePreflight({
|
|
28
|
+
defaultStorage: verified('home-backups'),
|
|
29
|
+
allStorages: [verified('home-backups')],
|
|
30
|
+
});
|
|
31
|
+
expect(result.kind).toBe('ok');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('default exists but unverified → suggests `storage verify`', () => {
|
|
35
|
+
const result = checkBackupStoragePreflight({
|
|
36
|
+
defaultStorage: unverified('home-backups'),
|
|
37
|
+
allStorages: [unverified('home-backups')],
|
|
38
|
+
});
|
|
39
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
40
|
+
expect(result.message).toContain("Default backup storage 'home-backups' is not verified");
|
|
41
|
+
expect(result.message).toContain('celilo storage verify home-backups');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('no storage at all → suggests `storage add local`', () => {
|
|
45
|
+
const result = checkBackupStoragePreflight({
|
|
46
|
+
defaultStorage: null,
|
|
47
|
+
allStorages: [],
|
|
48
|
+
});
|
|
49
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
50
|
+
expect(result.message).toContain('No backup storage configured');
|
|
51
|
+
expect(result.message).toContain('celilo storage add local');
|
|
52
|
+
// Always offer the --no-backup escape so the operator knows the
|
|
53
|
+
// CLI self-update can still run on a system without storage.
|
|
54
|
+
expect(result.message).toContain('--no-backup');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The exact case the operator hit on celilo-mgmt: ran storage add,
|
|
58
|
+
// path was unwriteable (/var/backups/celilo without sudo), verify
|
|
59
|
+
// failed, the unverified row stayed in the DB and isn't default.
|
|
60
|
+
test('only unverified storage → suggests `storage verify`, not `storage add`', () => {
|
|
61
|
+
const result = checkBackupStoragePreflight({
|
|
62
|
+
defaultStorage: null,
|
|
63
|
+
allStorages: [unverified('local-backups')],
|
|
64
|
+
});
|
|
65
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
66
|
+
expect(result.message).toContain('none is verified yet');
|
|
67
|
+
expect(result.message).toContain('Storages: local-backups');
|
|
68
|
+
expect(result.message).toContain('celilo storage verify <storage-id>');
|
|
69
|
+
// Critically, this case does NOT direct the operator to run
|
|
70
|
+
// `storage add local` — they already did, the row exists.
|
|
71
|
+
expect(result.message).not.toContain('celilo storage add local');
|
|
72
|
+
// Surface the most common cause inline rather than burying it.
|
|
73
|
+
expect(result.message).toContain('elevated permissions');
|
|
74
|
+
expect(result.message).toContain('--no-backup');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('multiple unverified storages → lists all storage IDs', () => {
|
|
78
|
+
const result = checkBackupStoragePreflight({
|
|
79
|
+
defaultStorage: null,
|
|
80
|
+
allStorages: [unverified('local-a'), unverified('local-b'), unverified('local-c')],
|
|
81
|
+
});
|
|
82
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
83
|
+
expect(result.message).toContain('Storages: local-a, local-b, local-c');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('verified storage exists but none is default → suggests `storage set-default`', () => {
|
|
87
|
+
const result = checkBackupStoragePreflight({
|
|
88
|
+
defaultStorage: null,
|
|
89
|
+
allStorages: [verified('home-backups'), unverified('s3-cold')],
|
|
90
|
+
});
|
|
91
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
92
|
+
expect(result.message).toContain('verified but no default is set');
|
|
93
|
+
expect(result.message).toContain('Verified: home-backups');
|
|
94
|
+
// Doesn't mention the unverified one in the "Verified:" listing
|
|
95
|
+
// (that would mislead).
|
|
96
|
+
expect(result.message).not.toContain('Verified: home-backups, s3-cold');
|
|
97
|
+
expect(result.message).toContain('celilo storage set-default <storage-id>');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('multiple verified, no default → lists all verified IDs', () => {
|
|
101
|
+
const result = checkBackupStoragePreflight({
|
|
102
|
+
defaultStorage: null,
|
|
103
|
+
allStorages: [verified('home-backups'), verified('s3-cold'), unverified('nas')],
|
|
104
|
+
});
|
|
105
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
106
|
+
expect(result.message).toContain('Verified: home-backups, s3-cold');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const snap = (
|
|
111
|
+
id: string,
|
|
112
|
+
installedVersion: string,
|
|
113
|
+
latestVersion: string | null,
|
|
114
|
+
): ModuleSnapshot => ({
|
|
115
|
+
id,
|
|
116
|
+
installedVersion,
|
|
117
|
+
latestVersion,
|
|
118
|
+
installedProvides: {},
|
|
119
|
+
pendingRequires: {},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const snapshotsOf = (...entries: ModuleSnapshot[]): Map<string, ModuleSnapshot> =>
|
|
123
|
+
new Map(entries.map((s) => [s.id, s]));
|
|
124
|
+
|
|
125
|
+
describe('shouldTakeBackup', () => {
|
|
126
|
+
test('false when no module updates at all', () => {
|
|
127
|
+
expect(
|
|
128
|
+
shouldTakeBackup({
|
|
129
|
+
snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+5')),
|
|
130
|
+
wasDeployed: new Set(['caddy']),
|
|
131
|
+
}),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('true when a deployed module has a registry-newer version', () => {
|
|
136
|
+
expect(
|
|
137
|
+
shouldTakeBackup({
|
|
138
|
+
snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+6')),
|
|
139
|
+
wasDeployed: new Set(['caddy']),
|
|
140
|
+
}),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The exact case the operator hit on celilo-mgmt: namecheap was
|
|
145
|
+
// imported but never deployed; system update wanted to back up the
|
|
146
|
+
// celilo DB even though no live state would be touched.
|
|
147
|
+
test('false when only IMPORTED modules have updates (live state untouched)', () => {
|
|
148
|
+
expect(
|
|
149
|
+
shouldTakeBackup({
|
|
150
|
+
snapshots: snapshotsOf(snap('namecheap', '3.1.0+10', '3.1.1+4')),
|
|
151
|
+
wasDeployed: new Set(), // nothing deployed
|
|
152
|
+
}),
|
|
153
|
+
).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('true when any deployed module needs updating, regardless of imported ones', () => {
|
|
157
|
+
expect(
|
|
158
|
+
shouldTakeBackup({
|
|
159
|
+
snapshots: snapshotsOf(
|
|
160
|
+
snap('namecheap', '3.1.0+10', '3.1.1+4'), // imported, has update
|
|
161
|
+
snap('caddy', '2.0.0+5', '2.0.0+6'), // deployed, has update
|
|
162
|
+
),
|
|
163
|
+
wasDeployed: new Set(['caddy']),
|
|
164
|
+
}),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('false when registry has no info (latestVersion null)', () => {
|
|
169
|
+
// Network failure — we don't know if updates exist. Default to
|
|
170
|
+
// "no backup needed" rather than blocking the run; the per-module
|
|
171
|
+
// upgrade step will report individual failures if any.
|
|
172
|
+
expect(
|
|
173
|
+
shouldTakeBackup({
|
|
174
|
+
snapshots: snapshotsOf(snap('caddy', '2.0.0+5', null)),
|
|
175
|
+
wasDeployed: new Set(['caddy']),
|
|
176
|
+
}),
|
|
177
|
+
).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('false on empty snapshots', () => {
|
|
181
|
+
expect(
|
|
182
|
+
shouldTakeBackup({
|
|
183
|
+
snapshots: new Map(),
|
|
184
|
+
wasDeployed: new Set(),
|
|
185
|
+
}),
|
|
186
|
+
).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { spawnSync } from 'node:child_process';
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
20
21
|
import { dirname, join } from 'node:path';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import { eq } from 'drizzle-orm';
|
|
@@ -50,6 +51,126 @@ import { fetchAndUpgrade } from './module-upgrade';
|
|
|
50
51
|
*/
|
|
51
52
|
const MANAGED_PACKAGES = ['@celilo/cli', '@celilo/event-bus', '@celilo/e2e'] as const;
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Result of inspecting backup-storage configuration before letting
|
|
56
|
+
* `runSystemUpdate` take a celilo-DB snapshot. Three failure shapes
|
|
57
|
+
* the operator might hit, each with a distinct friendly message:
|
|
58
|
+
*
|
|
59
|
+
* 1. No storage rows at all → run `storage add local`
|
|
60
|
+
* 2. Storage rows exist, none verified → run `storage verify <id>`
|
|
61
|
+
* (the case the operator hits when an unverified storage was
|
|
62
|
+
* left behind by a failed verify)
|
|
63
|
+
* 3. Verified storage exists, none is default → run
|
|
64
|
+
* `storage set-default <id>`
|
|
65
|
+
* 4. Default exists but isn't verified → run `storage verify <id>`
|
|
66
|
+
*
|
|
67
|
+
* Pure data in / data out so it's testable without setting up an
|
|
68
|
+
* audit, registry, or DB.
|
|
69
|
+
*/
|
|
70
|
+
export interface BackupStorageLike {
|
|
71
|
+
storageId: string;
|
|
72
|
+
verified: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function checkBackupStoragePreflight(input: {
|
|
76
|
+
defaultStorage: BackupStorageLike | null;
|
|
77
|
+
allStorages: BackupStorageLike[];
|
|
78
|
+
}): { kind: 'ok' } | { kind: 'error'; message: string } {
|
|
79
|
+
const { defaultStorage, allStorages } = input;
|
|
80
|
+
|
|
81
|
+
if (defaultStorage) {
|
|
82
|
+
if (defaultStorage.verified) return { kind: 'ok' };
|
|
83
|
+
return {
|
|
84
|
+
kind: 'error',
|
|
85
|
+
message: `Default backup storage '${defaultStorage.storageId}' is not verified.
|
|
86
|
+
|
|
87
|
+
Run: celilo storage verify ${defaultStorage.storageId}
|
|
88
|
+
|
|
89
|
+
Then re-run system update.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (allStorages.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'error',
|
|
96
|
+
message: `No backup storage configured.
|
|
97
|
+
|
|
98
|
+
celilo system update snapshots the celilo DB before applying module
|
|
99
|
+
updates as a safety net. Configure backup storage first:
|
|
100
|
+
|
|
101
|
+
celilo storage add local
|
|
102
|
+
|
|
103
|
+
Or skip the safety net entirely (the CLI self-update still runs):
|
|
104
|
+
|
|
105
|
+
celilo system update --no-backup`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const verified = allStorages.filter((s) => s.verified);
|
|
110
|
+
if (verified.length === 0) {
|
|
111
|
+
const ids = allStorages.map((s) => s.storageId).join(', ');
|
|
112
|
+
return {
|
|
113
|
+
kind: 'error',
|
|
114
|
+
message: `Backup storage exists but none is verified yet.
|
|
115
|
+
|
|
116
|
+
Storages: ${ids}
|
|
117
|
+
|
|
118
|
+
Verify one before re-running system update:
|
|
119
|
+
|
|
120
|
+
celilo storage verify <storage-id>
|
|
121
|
+
|
|
122
|
+
Common causes of failed verification:
|
|
123
|
+
- The path requires elevated permissions (try a path under your
|
|
124
|
+
home directory; the default '${join(homedir(), '.local/share/celilo/backups')}'
|
|
125
|
+
works without sudo).
|
|
126
|
+
- The disk is full or the mount point isn't writeable.
|
|
127
|
+
|
|
128
|
+
Or skip the safety net entirely (the CLI self-update still runs):
|
|
129
|
+
|
|
130
|
+
celilo system update --no-backup`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ids = verified.map((s) => s.storageId).join(', ');
|
|
135
|
+
return {
|
|
136
|
+
kind: 'error',
|
|
137
|
+
message: `Backup storage is verified but no default is set.
|
|
138
|
+
|
|
139
|
+
Verified: ${ids}
|
|
140
|
+
|
|
141
|
+
Set a default before re-running system update:
|
|
142
|
+
|
|
143
|
+
celilo storage set-default <storage-id>
|
|
144
|
+
|
|
145
|
+
Or skip the safety net entirely (the CLI self-update still runs):
|
|
146
|
+
|
|
147
|
+
celilo system update --no-backup`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Should `system update` take a celilo-DB snapshot for this run?
|
|
153
|
+
* True iff at least one module that's currently DEPLOYED has a
|
|
154
|
+
* registry-newer version waiting. Pure data in / data out.
|
|
155
|
+
*
|
|
156
|
+
* Excludes IMPORTED-but-not-yet-deployed modules — refreshing their
|
|
157
|
+
* on-disk source has no impact on live state, so the safety net
|
|
158
|
+
* isn't needed (and an operator who hasn't configured storage yet
|
|
159
|
+
* shouldn't be blocked by a snapshot they don't need).
|
|
160
|
+
*/
|
|
161
|
+
export function shouldTakeBackup(input: {
|
|
162
|
+
snapshots: Map<string, ModuleSnapshot>;
|
|
163
|
+
wasDeployed: Set<string>;
|
|
164
|
+
}): boolean {
|
|
165
|
+
for (const [id, s] of input.snapshots) {
|
|
166
|
+
if (!input.wasDeployed.has(id)) continue;
|
|
167
|
+
if (!s.latestVersion) continue;
|
|
168
|
+
if (s.latestVersion === s.installedVersion) continue;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
53
174
|
function readInstalledCliVersion(): string {
|
|
54
175
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
55
176
|
const candidates = [
|
|
@@ -489,45 +610,36 @@ export async function handleSystemUpdate(
|
|
|
489
610
|
}
|
|
490
611
|
|
|
491
612
|
// Decide whether the celilo-db snapshot is even needed for this run.
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
)
|
|
499
|
-
|
|
613
|
+
//
|
|
614
|
+
// The snapshot is a safety net — its purpose is to let `system update`
|
|
615
|
+
// roll back if a deploy / health step bricks a running module. So
|
|
616
|
+
// we ONLY need it when the run will actually upgrade-and-redeploy
|
|
617
|
+
// a currently-deployed module. Two cases that don't qualify:
|
|
618
|
+
//
|
|
619
|
+
// 1. Nothing has new code waiting at all (everyone's at latest).
|
|
620
|
+
// 2. The only modules with new code are IMPORTED-but-not-deployed.
|
|
621
|
+
// Their upgrade is a pure source-files-on-disk refresh; no
|
|
622
|
+
// live state to roll back, no risk to mitigate. Forcing a
|
|
623
|
+
// backup here means an operator on a fresh celilo-mgmt with
|
|
624
|
+
// nothing deployed yet has to configure backup storage just
|
|
625
|
+
// to refresh the on-disk modules they imported — which is
|
|
626
|
+
// exactly the friction that prompted this code path.
|
|
627
|
+
const effectiveNoBackup = noBackup || !shouldTakeBackup({ snapshots, wasDeployed });
|
|
500
628
|
|
|
501
|
-
// Pre-flight the storage check so a missing default doesn't
|
|
502
|
-
// orchestrator's snapshot hook (where the throw would
|
|
503
|
-
// hostile stack trace). Skip when we're already not
|
|
629
|
+
// Pre-flight the storage check so a missing/unusable default doesn't
|
|
630
|
+
// reach the orchestrator's snapshot hook (where the throw would
|
|
631
|
+
// surface as a hostile stack trace). Skip when we're already not
|
|
632
|
+
// going to backup.
|
|
504
633
|
if (!effectiveNoBackup) {
|
|
505
|
-
const { getDefaultBackupStorage } = await import(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
celilo storage add local
|
|
516
|
-
|
|
517
|
-
Or skip the safety net entirely (the CLI self-update still runs):
|
|
518
|
-
|
|
519
|
-
celilo system update --no-backup`,
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
if (!storage.verified) {
|
|
523
|
-
return {
|
|
524
|
-
success: false,
|
|
525
|
-
error: `Default backup storage '${storage.storageId}' is not verified.
|
|
526
|
-
|
|
527
|
-
Run: celilo storage verify ${storage.storageId}
|
|
528
|
-
|
|
529
|
-
Then re-run system update.`,
|
|
530
|
-
};
|
|
634
|
+
const { getDefaultBackupStorage, listBackupStorages } = await import(
|
|
635
|
+
'../../services/backup-storage'
|
|
636
|
+
);
|
|
637
|
+
const preflight = checkBackupStoragePreflight({
|
|
638
|
+
defaultStorage: getDefaultBackupStorage(),
|
|
639
|
+
allStorages: listBackupStorages(),
|
|
640
|
+
});
|
|
641
|
+
if (preflight.kind === 'error') {
|
|
642
|
+
return { success: false, error: preflight.message };
|
|
531
643
|
}
|
|
532
644
|
}
|
|
533
645
|
|