Files
corrosion-admin-panel/backend-nest/src/modules/wipes/wipes.service.ts
Vantz Stockwell cbb3ba6586
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
feat: Wire execution engines for schedules, alerts, wipes, and module install
- schedules/: Add schedule executor in SchedulesService — polls every 60s for
  tasks where next_run <= now, dispatches NATS commands per task_type
  (restart, announcement, command, plugin_reload). Calculates next_run from
  cron expression on create/update/toggle. Bootstraps missing next_run values
  on startup. Wire NatsService into SchedulesModule.

- alerts/: Add alert evaluator in AlertsService — polls every 90s, loads all
  alert_config rows, queries latest server_stats per license, evaluates FPS
  degradation and population drop thresholds. Fires alert_history records on
  breach. Enforces 10-minute in-memory cooldown per alert type per license to
  prevent flooding. Wire ServerStats repo into AlertsModule.

- wipes/: Replace hardcoded dry-run mock with profile-aware simulation.
  Resolves actual WipeProfile by ID (cross-tenant protected), builds
  would_delete/would_preserve lists from wipe_type, factors pre_wipe_config
  (backup, countdown warnings) and post_wipe_config (health checks, retry
  attempts) into estimated_duration_seconds. Returns profile_name and notes.

- store/: Fix installModule stub — creates a real module_installations record
  with status='installed' and installed_at timestamp. Idempotent on retry,
  resets failed installations. Wire ModuleInstallation repo into StoreModule.
  getMyModules now returns real installation data instead of filtered purchases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:02:49 -05:00

238 lines
7.5 KiB
TypeScript

import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WipeProfile } from '../../entities/wipe-profile.entity';
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
import { NatsService } from '../../services/nats.service';
@Injectable()
export class WipesService {
private readonly logger = new Logger(WipesService.name);
constructor(
@InjectRepository(WipeProfile)
private readonly wipeProfileRepo: Repository<WipeProfile>,
@InjectRepository(WipeSchedule)
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
private readonly natsService: NatsService,
) {}
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
return this.wipeProfileRepo.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
});
}
async createProfile(licenseId: string, dto: CreateProfileDto): Promise<WipeProfile> {
const profile = this.wipeProfileRepo.create({
license_id: licenseId,
...dto,
});
return this.wipeProfileRepo.save(profile);
}
async updateProfile(
licenseId: string,
profileId: string,
dto: UpdateProfileDto,
): Promise<WipeProfile> {
const profile = await this.wipeProfileRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) {
throw new NotFoundException(`Wipe profile ${profileId} not found`);
}
Object.assign(profile, dto);
profile.updated_at = new Date();
return this.wipeProfileRepo.save(profile);
}
async deleteProfile(licenseId: string, profileId: string): Promise<void> {
const result = await this.wipeProfileRepo.delete({
id: profileId,
license_id: licenseId,
});
if (result.affected === 0) {
throw new NotFoundException(`Wipe profile ${profileId} not found`);
}
}
async getSchedules(licenseId: string): Promise<WipeSchedule[]> {
return this.wipeScheduleRepo.find({
where: { license_id: licenseId },
relations: ['wipe_profile'],
order: { created_at: 'DESC' },
});
}
async createSchedule(licenseId: string, dto: CreateScheduleDto): Promise<WipeSchedule> {
const schedule = this.wipeScheduleRepo.create({
license_id: licenseId,
...dto,
});
return this.wipeScheduleRepo.save(schedule);
}
async getHistory(licenseId: string, limit: number = 50): Promise<WipeHistory[]> {
return this.wipeHistoryRepo.find({
where: { license_id: licenseId },
relations: ['wipe_profile', 'wipe_schedule', 'map'],
order: { created_at: 'DESC' },
take: limit,
});
}
async triggerWipe(
licenseId: string,
dto: TriggerWipeDto,
): Promise<{ wipe_history_id: string }> {
const history = this.wipeHistoryRepo.create({
license_id: licenseId,
wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id,
trigger_type: 'manual',
status: 'pending',
});
const saved = await this.wipeHistoryRepo.save(history);
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, {
wipe_history_id: saved.id,
wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id ?? null,
trigger_type: 'manual',
timestamp: new Date().toISOString(),
});
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
return { wipe_history_id: saved.id };
}
async triggerDryRun(
licenseId: string,
dto: TriggerWipeDto,
): Promise<{
would_delete: string[];
would_preserve: string[];
estimated_duration_seconds: number;
profile_name: string | null;
notes: string[];
}> {
// Resolve profile config if a profile ID was supplied.
let profile: WipeProfile | null = null;
if (dto.wipe_profile_id) {
profile = await this.wipeProfileRepo.findOne({
where: { id: dto.wipe_profile_id, license_id: licenseId },
});
}
if (!profile && dto.wipe_profile_id) {
throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`);
}
const notes: string[] = [];
// Base files affected by all wipe types.
const would_delete: string[] = ['*.map', '*.sav'];
const would_preserve: string[] = [
'oxide/',
'oxide/plugins/',
'cfg/',
'server.cfg',
];
// Blueprint wipe additions.
if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') {
would_delete.push('player.blueprints.db', 'player.tech.db');
}
// Full wipe: also clear player data and oxide data.
if (dto.wipe_type === 'full') {
would_delete.push(
'player.deaths.db',
'player.identities.db',
'player.states.db',
'player.tokens.db',
'oxide/data/*',
);
would_preserve.splice(would_preserve.indexOf('oxide/'), 1);
}
// Factor in pre_wipe_config from the profile (if set).
let estimatedSeconds = 45;
if (profile) {
const pre = profile.pre_wipe_config as Record<string, any>;
const post = profile.post_wipe_config as Record<string, any>;
if (pre?.backup_before_wipe) {
estimatedSeconds += 60;
notes.push('Pre-wipe backup will run before deletion (+60s)');
would_preserve.push('backups/');
}
if (pre?.kick_players_before_wipe) {
const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? [];
const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0;
if (maxWarning > 0) {
estimatedSeconds += maxWarning * 60;
notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`);
}
}
if (post?.verify_server_started) {
estimatedSeconds += 30;
notes.push('Post-wipe: server health check will run (+30s)');
}
if (post?.rollback_on_failure) {
notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails');
}
if (post?.max_restart_attempts) {
const attempts = post.max_restart_attempts as number;
if (attempts > 1) {
estimatedSeconds += (attempts - 1) * 15;
notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`);
}
}
} else {
notes.push('No profile selected — using default wipe behavior');
}
// Account for world size in time estimate.
// Larger worlds take longer to clear from disk (rough heuristic).
// We don't have world_size here without querying server_config,
// so apply a static estimate per wipe type.
if (dto.wipe_type === 'full') {
estimatedSeconds += 75;
} else if (dto.wipe_type === 'blueprint') {
estimatedSeconds += 10;
}
this.logger.log(
`Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` +
`profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`,
);
return {
would_delete,
would_preserve,
estimated_duration_seconds: estimatedSeconds,
profile_name: profile?.profile_name ?? null,
notes,
};
}
}