mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(config-backup): origin is a stored random id, Settings refills after a settings restore, clis.json reloads
Review round 2 on #661: the hostname in the origin orphaned every snapshot when a Docker container was recreated, so the origin is a random id kept in <data dir>/config-backup-id; after restoring settings.json the Settings form is re-read from /api/settings so Save cannot write the old values back; the restore confirm names the files and the credential ones; clis.json is reloaded (reloadCliRegistry) and users.json needs no restart; API reference and route docs corrected.
This commit is contained in:
1 parent
247740fc77
commit
8b27e4beb6
10 files changed
+186
-23
No files matched your search
+30
-5
@@ -21,7 +21,7 @@
|
||||
* @module config-backup
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
@@ -96,7 +96,7 @@ export interface ConfigBackupManifest {
|
||||
appVersion: string;
|
||||
/** Digest of the set of files, so an unchanged state is not snapshotted twice. */
|
||||
hash: string;
|
||||
/** Host and data dir that made it, so instances sharing one folder keep to their own snapshots. */
|
||||
/** Id of the data dir that made it (see {@link configBackupOrigin}), so instances sharing one folder keep to their own snapshots. */
|
||||
origin?: string;
|
||||
files: ConfigBackupFile[];
|
||||
}
|
||||
@@ -156,8 +156,33 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir:
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Who owns a snapshot: this machine's name plus the data dir (prod and a beta differ by the latter). */
|
||||
export const configBackupOrigin = (dataDir: string): string => `${hostname()}:${resolve(dataDir)}`;
|
||||
const ORIGIN_FILE = 'config-backup-id';
|
||||
const ORIGIN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/**
|
||||
* Who owns a snapshot: a random id made once and kept in the data dir. A hostname would not do (a
|
||||
* Docker container gets a new one on every recreate, which orphaned every earlier snapshot), nor
|
||||
* would the path alone (two machines can share a `$HOME` path). It is a file of its own, not the
|
||||
* hook secret, and is not tracked or restored. If it cannot be written the id is not persisted, so
|
||||
* snapshots made meanwhile are not listed later; the data dir being unwritable breaks far more.
|
||||
*/
|
||||
export function configBackupOrigin(dataDir: string): string {
|
||||
const path = join(dataDir, ORIGIN_FILE);
|
||||
try {
|
||||
const existing = readFileSync(path, 'utf8').trim();
|
||||
if (ORIGIN_RE.test(existing)) return existing;
|
||||
} catch {
|
||||
/* missing: made below */
|
||||
}
|
||||
const id = randomUUID();
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
writeFileSync(path, id + '\n', { mode: 0o600 });
|
||||
} catch {
|
||||
/* see above */
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
|
||||
@@ -1482,7 +1482,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async restoreConfigBackup(id) {
|
||||
if (!confirm('Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.')) return;
|
||||
const names = ((this._configBackups || []).find((b) => b.id === id)?.files || []).map((f) => f.name);
|
||||
const secret = names.filter((n) => ['.env', 'users.json', 'push-keys.json', 'custom-model-hosts.json', 'webhook.json'].includes(n));
|
||||
const list = names.length ? `\n\nFiles replaced: ${names.join(', ')}` : '';
|
||||
const warn = secret.length ? `\n\nThis rolls back credentials and accounts (${secret.join(', ')}): passwords, disabled accounts and keys return to how they were then.` : '';
|
||||
if (!confirm(`Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.${list}${warn}`)) return;
|
||||
const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
@@ -1492,7 +1496,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const d = body.data;
|
||||
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
|
||||
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}. Reload this page to see settings.${restart}`, 'success');
|
||||
// The form still holds the pre-restore values, and Save would PUT them over the restored file.
|
||||
if (d.restored.includes('settings.json')) {
|
||||
await this.loadAppSettingsFromServer();
|
||||
this.openAppSettings();
|
||||
}
|
||||
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
|
||||
this.loadConfigBackups();
|
||||
},
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the
|
||||
* running config (`current`, the default for `to`).
|
||||
*
|
||||
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three
|
||||
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all four
|
||||
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
|
||||
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json` and any key that looks
|
||||
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks
|
||||
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
|
||||
* may have cached: `restartRequired` says which ones only apply after a restart.
|
||||
*/
|
||||
@@ -18,6 +18,7 @@ import { createRequire } from 'node:module';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { reloadCliRegistry } from '../../config/cli-registry/registry.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { intentStore } from '../../intent-store.js';
|
||||
@@ -53,8 +54,9 @@ const APP_VERSION: string = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
/** Files whose running-process copy only reloads on restart (the rest are read per request). */
|
||||
const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json', 'settings.json']);
|
||||
/** Files whose running-process copy only reloads on restart (the rest are read per request; `users.json`
|
||||
* is re-read within a second, `clis.json` is reloaded below, `intents.json` by `reload()`). */
|
||||
const RESTART_REQUIRED = new Set<string>(['.env', 'push-keys.json', 'settings.json']);
|
||||
|
||||
export interface ConfigBackupStatus {
|
||||
settings: ConfigBackupSettings;
|
||||
@@ -203,6 +205,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
|
||||
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
|
||||
if (out.restored.includes('intents.json')) intentStore.reload();
|
||||
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
|
||||
if (out.restored.includes('clis.json')) reloadCliRegistry();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
Reference in new issue
Block a user