mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
178 lines
6.8 KiB
TypeScript
178 lines
6.8 KiB
TypeScript
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Req, UseGuards } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
import type { StorageUsage } from '@trek/shared';
|
|
import { redactStorageSecrets } from './storage-secrets';
|
|
import type { User } from '../../types';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { AdminGuard } from '../auth/admin.guard';
|
|
import { CurrentUser } from '../auth/current-user.decorator';
|
|
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, StorageMigrationRequestDto, StorageTestRequestDto } from './storage-admin.dto';
|
|
import {
|
|
BackfillBusyError,
|
|
BackfillTargetError,
|
|
MigrationRequestError,
|
|
MigrationTargetError,
|
|
} from './storage-jobs.service';
|
|
import { StatsBusyError } from './storage-stats.service';
|
|
import { StorageConflictError } from './storage.types';
|
|
|
|
/**
|
|
* /api/admin/storage — the admin surface over the storage registry (spec:
|
|
* docs/superpowers/specs/2026-08-19-storage-admin-config-design.md). Backends,
|
|
* category assignment, and mirror composition are managed here; secrets are
|
|
* stored encrypted and only ever rendered as the mask. Pipeline refusals are
|
|
* operator-grade registry messages, surfaced verbatim in the 400 envelope.
|
|
*/
|
|
@Controller('api/admin/storage')
|
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
|
@ManagedForbidden('storage backends and their credentials are hoster-level configuration')
|
|
export class StorageAdminController {
|
|
constructor(
|
|
private readonly service: StorageAdminService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
@Get()
|
|
async get() {
|
|
return await this.service.state();
|
|
}
|
|
|
|
@Put()
|
|
async update(@CurrentUser() user: User, @Body() body: StorageConfigDto, @Req() req: Request) {
|
|
try {
|
|
await this.service.applyConfig(body);
|
|
} catch (err) {
|
|
// The conflict branch must come before the blanket 400: a
|
|
// StorageConflictError IS an Error, so the generic catch-all below
|
|
// would otherwise swallow it into a 400 (audit #7 — the client can't
|
|
// tell "stale version, reload and retry" from "bad config" without 409).
|
|
if (err instanceof StorageConflictError) throw new HttpException({ error: err.message }, 409);
|
|
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
}
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.storage_update',
|
|
ip: getClientIp(req),
|
|
details: redactStorageSecrets(body) as unknown as Record<string, unknown>,
|
|
});
|
|
// 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.
|
|
await 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;
|
|
}
|
|
|
|
/** Start a replica catch-up for a routed mirror. One at a time, globally. */
|
|
@Post('backends/:name/backfill')
|
|
@HttpCode(200)
|
|
async backfillStart(@CurrentUser() user: User, @Param('name') name: string, @Req() req: Request): Promise<{ started: true }> {
|
|
try {
|
|
this.service.startBackfill(name);
|
|
} catch (err) {
|
|
if (err instanceof BackfillTargetError) throw new HttpException({ error: err.message }, 404);
|
|
if (err instanceof BackfillBusyError) throw new HttpException({ error: err.message }, 409);
|
|
throw err;
|
|
}
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.storage_backfill',
|
|
ip: getClientIp(req),
|
|
details: { backend: name },
|
|
});
|
|
return { started: true };
|
|
}
|
|
|
|
@Delete('backends/:name/backfill')
|
|
async backfillCancel(@CurrentUser() user: User, @Param('name') name: string, @Req() req: Request): Promise<{ cancelled: true }> {
|
|
if (!this.service.cancelBackfill(name)) {
|
|
throw new HttpException({ error: `no active sync for '${name}'` }, 404);
|
|
}
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.storage_backfill_cancel',
|
|
ip: getClientIp(req),
|
|
details: { backend: name },
|
|
});
|
|
return { cancelled: true };
|
|
}
|
|
|
|
/** Start a category migration: copy → flip → delta sweep. One storage job at a time. */
|
|
@Post('migrations')
|
|
@HttpCode(200)
|
|
async migrationStart(@CurrentUser() user: User, @Body() body: StorageMigrationRequestDto, @Req() req: Request): Promise<{ started: true }> {
|
|
const { category, to } = body;
|
|
try {
|
|
await this.service.startMigration(category, to);
|
|
} catch (err) {
|
|
if (err instanceof MigrationRequestError) throw new HttpException({ error: err.message }, 400);
|
|
if (err instanceof MigrationTargetError) throw new HttpException({ error: err.message }, 404);
|
|
if (err instanceof BackfillBusyError) throw new HttpException({ error: err.message }, 409);
|
|
throw err;
|
|
}
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.storage_migration',
|
|
ip: getClientIp(req),
|
|
details: { category, to },
|
|
});
|
|
return { started: true };
|
|
}
|
|
|
|
@Delete('migrations/:category')
|
|
async migrationCancel(@CurrentUser() user: User, @Param('category') category: string, @Req() req: Request): Promise<{ cancelled: true }> {
|
|
if (!this.service.cancelMigration(category)) {
|
|
throw new HttpException({ error: `no running migration for '${category}'` }, 404);
|
|
}
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.storage_migration_cancel',
|
|
ip: getClientIp(req),
|
|
details: { category },
|
|
});
|
|
return { cancelled: true };
|
|
}
|
|
|
|
@Post('stats/refresh')
|
|
@HttpCode(200)
|
|
async statsRefresh(@CurrentUser() user: User, @Req() req: Request): Promise<StorageUsage> {
|
|
let usage: StorageUsage;
|
|
try {
|
|
usage = await this.service.refreshStats();
|
|
} catch (err) {
|
|
if (err instanceof StatsBusyError) throw new HttpException({ error: err.message }, 409);
|
|
throw err;
|
|
}
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'admin.storage_stats_refresh',
|
|
ip: getClientIp(req),
|
|
});
|
|
return usage;
|
|
}
|
|
}
|