mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually
This commit is contained in:
@@ -19,6 +19,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
|
||||
'admin.invite_create': 'created invite',
|
||||
'admin.storage_update': 'updated storage configuration',
|
||||
'admin.storage_test': 'tested a storage backend',
|
||||
'immich.private_ip_configured': 'configured Immich with private IP',
|
||||
};
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
|
||||
'PluginsController.reload',
|
||||
'PluginsController.upload',
|
||||
'StorageAdminController.get',
|
||||
'StorageAdminController.test',
|
||||
'StorageAdminController.update',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, HttpException, Put, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { redactStorageSecrets } from './storage-secrets';
|
||||
import type { User } from '../../types';
|
||||
@@ -9,7 +9,7 @@ import { AuditService } from '../audit/audit.service';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { ManagedForbidden } from '../common/managed';
|
||||
import { StorageAdminService } from './storage-admin.service';
|
||||
import { StorageConfigDto } from './storage-admin.dto';
|
||||
import { StorageConfigDto, StorageTestRequestDto } from './storage-admin.dto';
|
||||
|
||||
/**
|
||||
* /api/admin/storage — the admin surface over the storage registry (spec:
|
||||
@@ -48,4 +48,28 @@ export class StorageAdminController {
|
||||
// The defaults-tab contract: never echo the request — answer the fresh effective world.
|
||||
return this.service.state();
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200) // a probe answers 200 with per-target results, not a 201 resource
|
||||
async test(@CurrentUser() user: User, @Body() body: StorageTestRequestDto, @Req() req: Request) {
|
||||
let result;
|
||||
try {
|
||||
result = await this.service.testBackend(body.backend);
|
||||
} catch (err) {
|
||||
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
|
||||
}
|
||||
// Audited because the probe writes and deletes an object; names only, never options.
|
||||
this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'admin.storage_test',
|
||||
ip: getClientIp(req),
|
||||
details: {
|
||||
backend: body.backend.name,
|
||||
type: body.backend.type,
|
||||
ok: result.ok,
|
||||
targets: result.targets.map(({ name, ok }) => ({ name, ok })),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { StorageAdminState, StorageConfig } from '@trek/shared';
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageTestResponse } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import {
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
import { StorageService } from './storage.service';
|
||||
import { SEED_CONFIG_PATH } from './storage-paths';
|
||||
import {
|
||||
decryptBackendSecrets,
|
||||
encryptionGateError,
|
||||
encryptStorageSecrets,
|
||||
listPlaintextSecrets,
|
||||
maskBackendOptions,
|
||||
unmaskStorageConfig,
|
||||
} from './storage-secrets';
|
||||
import { ephemeralDriverFor, probeDriver, type ProbeTargetResult } from './storage-probe';
|
||||
import { StorageBackendError } from './storage.types';
|
||||
|
||||
/**
|
||||
@@ -76,6 +78,54 @@ export class StorageAdminService {
|
||||
this.registry.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe an (unsaved or stored) candidate with ephemeral drivers. Mirrors are
|
||||
* expanded and probed per target — the probe bypasses MirrorDriver, which
|
||||
* hides replica failures by design. Registry state is never touched.
|
||||
*/
|
||||
async testBackend(candidate: StorageBackend): Promise<StorageTestResponse> {
|
||||
const { backends } = unmaskStorageConfig(
|
||||
{ backends: [candidate], categories: {} },
|
||||
this.storedBackendsRow(),
|
||||
);
|
||||
const backend = backends[0]!;
|
||||
const targets = this.probeTargetsFor(backend).map(decryptBackendSecrets) as Array<
|
||||
Extract<StorageBackend, { type: 'local' | 's3' }>
|
||||
>;
|
||||
const results: ProbeTargetResult[] = [];
|
||||
for (const target of targets) {
|
||||
results.push(await this.probeTarget(target));
|
||||
}
|
||||
return { ok: results.every((r) => r.ok), targets: results };
|
||||
}
|
||||
|
||||
/** Expand a mirror candidate into its concrete targets via the live snapshot. */
|
||||
private probeTargetsFor(backend: StorageBackend): StorageBackend[] {
|
||||
if (backend.type !== 'mirror') return [backend];
|
||||
const byName = new Map(this.registry.snapshot().backends.map((b) => [b.name, b]));
|
||||
const names = [backend.options.primary, ...backend.options.replicas];
|
||||
return names.map((name) => {
|
||||
const resolved = byName.get(name);
|
||||
if (!resolved) {
|
||||
throw new StorageBackendError(`mirror '${backend.name}' references unknown backend '${name}'`);
|
||||
}
|
||||
if (resolved.type === 'mirror') {
|
||||
throw new StorageBackendError(`mirror '${backend.name}' nests mirror '${name}' — nesting is rejected`);
|
||||
}
|
||||
return { name: resolved.name, type: resolved.type, options: resolved.options } as StorageBackend;
|
||||
});
|
||||
}
|
||||
|
||||
private async probeTarget(target: Extract<StorageBackend, { type: 'local' | 's3' }>): Promise<ProbeTargetResult> {
|
||||
let driver;
|
||||
try {
|
||||
driver = ephemeralDriverFor(target);
|
||||
} catch (err) {
|
||||
return { name: target.name, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
return probeDriver(target.name, driver);
|
||||
}
|
||||
|
||||
/** The raw stored backends row — the unmask source (tolerates absent/garbage rows). */
|
||||
private storedBackendsRow(): unknown {
|
||||
const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', BACKENDS_KEY);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { StorageBackend } from '@trek/shared';
|
||||
import { LocalDriver } from './drivers/local.driver';
|
||||
import { S3Driver } from './drivers/s3.driver';
|
||||
import { StorageBackendError, type StorageDriver } from './storage.types';
|
||||
|
||||
/**
|
||||
* Test-connection probes (spec: POST /api/admin/storage/test). Drivers are
|
||||
* EPHEMERAL — constructed per probe, never registered, never touching
|
||||
* registry state. A mirror is probed per target (primary and each replica
|
||||
* individually) because MirrorDriver hides replica failures by design.
|
||||
*
|
||||
* Residue: a local probe may leave an empty `trek-probe/` directory in the
|
||||
* target root. It is inert — not a category prefix, invisible to lists
|
||||
* (objects only), and outside every retention pattern.
|
||||
*/
|
||||
|
||||
export interface ProbeTargetResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** put → stat → delete of a unique probe key; any throw becomes the target's error. */
|
||||
export async function probeDriver(name: string, driver: StorageDriver): Promise<ProbeTargetResult> {
|
||||
const key = `trek-probe/${randomUUID()}`;
|
||||
try {
|
||||
await driver.put(key, Readable.from('trek storage probe'));
|
||||
const stat = await driver.stat(key);
|
||||
if (!stat) throw new StorageBackendError('probe object vanished between put and stat');
|
||||
await driver.delete(key);
|
||||
return { name, ok: true };
|
||||
} catch (err) {
|
||||
return { name, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-mirror backend definition (secrets already decrypted) → throwaway driver. */
|
||||
export function ephemeralDriverFor(backend: Extract<StorageBackend, { type: 'local' | 's3' }>): StorageDriver {
|
||||
if (backend.type === 'local') {
|
||||
const driver = new LocalDriver({ id: `probe-${backend.name}`, root: backend.options.root });
|
||||
driver.init({ ensurePrefixes: [], cleanSpool: false });
|
||||
return driver;
|
||||
}
|
||||
return new S3Driver({ id: `probe-${backend.name}`, ...backend.options });
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type StorageBackendTypeId,
|
||||
type StorageConfig,
|
||||
} from '@trek/shared';
|
||||
import { is_encrypted_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { decrypt_api_key, is_encrypted_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { StorageBackendError } from './storage.types';
|
||||
|
||||
/**
|
||||
@@ -131,3 +131,17 @@ export function redactStorageSecrets(config: StorageConfig): StorageConfig {
|
||||
backends: config.backends.map((backend) => mapSecrets(backend, () => '***')),
|
||||
};
|
||||
}
|
||||
|
||||
/** Decrypt secret fields for ephemeral use (test probes). Never persisted. */
|
||||
export function decryptBackendSecrets(backend: StorageBackend): StorageBackend {
|
||||
return mapSecrets(backend, (value, field) => {
|
||||
if (typeof value !== 'string' || value === '') return value;
|
||||
const plain = decrypt_api_key(value);
|
||||
if (plain === null) {
|
||||
throw new StorageBackendError(
|
||||
`${backend.type} backend '${backend.name}': could not decrypt '${field}' — was ENCRYPTION_KEY changed or the row edited by hand?`,
|
||||
);
|
||||
}
|
||||
return plain;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user