feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

This commit is contained in:
jubnl
2026-08-20 08:19:02 +02:00
parent 7439bcd33c
commit 67f237733e
10 changed files with 341 additions and 5 deletions
+1
View File
@@ -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);
+47
View File
@@ -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 });
}
+15 -1
View File
@@ -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;
});
}