feat: overhaul server automation, files editor, and CS2 setup workflows
This commit is contained in:
@@ -0,0 +1,793 @@
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import * as tar from 'tar-stream';
|
||||
import type { Headers } from 'tar-stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
import type {
|
||||
GameAutomationRule,
|
||||
ServerAutomationEvent,
|
||||
ServerAutomationAction,
|
||||
ServerAutomationGitHubReleaseExtractAction,
|
||||
ServerAutomationHttpDirectoryExtractAction,
|
||||
} from '@source/shared';
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonSendCommand,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
|
||||
|
||||
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
|
||||
cs2: [
|
||||
{
|
||||
id: 'cs2-install-latest-metamod',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'install-cs2-metamod',
|
||||
type: 'http_directory_extract',
|
||||
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
|
||||
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
|
||||
destination: '/game/csgo',
|
||||
stripComponents: 0,
|
||||
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs2-install-latest-counterstrikesharp-runtime',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'install-cs2-runtime',
|
||||
type: 'github_release_extract',
|
||||
owner: 'roflmuffin',
|
||||
repo: 'CounterStrikeSharp',
|
||||
assetNamePatterns: [
|
||||
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
|
||||
'^counterstrikesharp-with-runtime.*\\.zip$',
|
||||
],
|
||||
destination: '/game/csgo',
|
||||
stripComponents: 0,
|
||||
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface ServerAutomationContext {
|
||||
serverId: string;
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
event: ServerAutomationEvent;
|
||||
node: DaemonNodeConnection;
|
||||
automationRulesRaw: unknown;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerAutomationRunResult {
|
||||
workflowsMatched: number;
|
||||
workflowsExecuted: number;
|
||||
workflowsSkipped: number;
|
||||
workflowsFailed: number;
|
||||
actionFailures: number;
|
||||
failures: ServerAutomationFailure[];
|
||||
}
|
||||
|
||||
interface ExtractedFile {
|
||||
path: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface ServerAutomationFailure {
|
||||
level: 'action' | 'workflow';
|
||||
workflowId: string;
|
||||
actionId?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface GitHubReleaseResponse {
|
||||
tag_name: string;
|
||||
assets: GitHubReleaseAsset[];
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function readWorkflowId(value: unknown): string | null {
|
||||
if (!isObject(value)) return null;
|
||||
const id = value.id;
|
||||
if (typeof id !== 'string' || id.trim() === '') return null;
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeWorkflow(
|
||||
gameSlug: string,
|
||||
workflow: GameAutomationRule,
|
||||
): GameAutomationRule {
|
||||
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
||||
if (workflow.id !== 'cs2-install-latest-counterstrikesharp-runtime') return workflow;
|
||||
|
||||
const normalizedActions = workflow.actions.map((action) => {
|
||||
if (action.type !== 'github_release_extract') return action;
|
||||
if (action.id !== 'install-cs2-runtime') return action;
|
||||
|
||||
const destination = (action.destination ?? '').trim();
|
||||
if (destination !== '' && destination !== '/') return action;
|
||||
|
||||
return {
|
||||
...action,
|
||||
destination: '/game/csgo',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
actions: normalizedActions,
|
||||
};
|
||||
}
|
||||
|
||||
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
|
||||
const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? [];
|
||||
if (!Array.isArray(raw)) {
|
||||
return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
const configured = raw as GameAutomationRule[];
|
||||
if (defaults.length === 0) {
|
||||
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
const existingIds = new Set(
|
||||
raw
|
||||
.map(readWorkflowId)
|
||||
.filter((workflowId): workflowId is string => workflowId !== null),
|
||||
);
|
||||
|
||||
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
|
||||
if (missingDefaults.length === 0) {
|
||||
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
|
||||
const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
|
||||
return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`;
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePathSegments(path: string): string[] {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
}
|
||||
|
||||
function joinServerPath(base: string, relative: string): string {
|
||||
const baseSegments = normalizePathSegments(base);
|
||||
const relativeSegments = normalizePathSegments(relative);
|
||||
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function normalizeArchivePath(path: string, stripComponents = 0): string | null {
|
||||
const segments = normalizePathSegments(path);
|
||||
const stripped = segments.slice(Math.max(0, stripComponents));
|
||||
if (stripped.length === 0) return null;
|
||||
return stripped.join('/');
|
||||
}
|
||||
|
||||
async function hasMarker(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
event: ServerAutomationEvent,
|
||||
workflowId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await daemonReadFile(node, serverUuid, markerPath(event, workflowId));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMarker(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
event: ServerAutomationEvent,
|
||||
workflowId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await daemonWriteFile(
|
||||
node,
|
||||
serverUuid,
|
||||
markerPath(event, workflowId),
|
||||
JSON.stringify(payload, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function githubHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
};
|
||||
|
||||
const token = process.env.GITHUB_TOKEN?.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function compileAssetPatterns(patterns: string[]): RegExp[] {
|
||||
const compiled: RegExp[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const tryCompile = (pattern: string) => {
|
||||
const key = pattern.trim();
|
||||
if (!key || seen.has(key)) return;
|
||||
try {
|
||||
compiled.push(new RegExp(key, 'i'));
|
||||
seen.add(key);
|
||||
} catch {
|
||||
// Ignore invalid regex patterns in configuration.
|
||||
}
|
||||
};
|
||||
|
||||
for (const pattern of patterns) {
|
||||
tryCompile(pattern);
|
||||
|
||||
// Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\.").
|
||||
// Collapse double backslashes once and compile a fallback variant.
|
||||
if (pattern.includes('\\\\')) {
|
||||
tryCompile(pattern.replace(/\\\\/g, '\\'));
|
||||
}
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(
|
||||
action: ServerAutomationGitHubReleaseExtractAction,
|
||||
): Promise<GitHubReleaseResponse> {
|
||||
const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`;
|
||||
const response = await fetch(releaseUrl, {
|
||||
headers: githubHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const release = (await response.json()) as GitHubReleaseResponse;
|
||||
if (!Array.isArray(release.assets)) {
|
||||
throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`);
|
||||
}
|
||||
|
||||
return release;
|
||||
}
|
||||
|
||||
interface DirectoryAssetCandidate {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
function extractNumberParts(value: string): number[] {
|
||||
const matches = value.match(/\d+/g);
|
||||
if (!matches) return [];
|
||||
return matches
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter((num) => Number.isFinite(num));
|
||||
}
|
||||
|
||||
function compareNumberPartsDesc(a: number[], b: number[]): number {
|
||||
const maxLength = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < maxLength; i += 1) {
|
||||
const left = a[i] ?? -1;
|
||||
const right = b[i] ?? -1;
|
||||
if (left !== right) {
|
||||
return right - left;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate {
|
||||
const sorted = [...candidates].sort((left, right) => {
|
||||
const numberDiff = compareNumberPartsDesc(
|
||||
extractNumberParts(left.name),
|
||||
extractNumberParts(right.name),
|
||||
);
|
||||
if (numberDiff !== 0) return numberDiff;
|
||||
return right.name.localeCompare(left.name);
|
||||
});
|
||||
|
||||
return sorted[0] ?? candidates[0]!;
|
||||
}
|
||||
|
||||
function extractDirectoryCandidates(
|
||||
html: string,
|
||||
indexUrl: string,
|
||||
assetPattern: RegExp,
|
||||
): DirectoryAssetCandidate[] {
|
||||
const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi;
|
||||
const candidates: DirectoryAssetCandidate[] = [];
|
||||
|
||||
let match: RegExpExecArray | null = null;
|
||||
while ((match = hrefRegex.exec(html)) !== null) {
|
||||
const href = (match[2] ?? '').trim();
|
||||
if (!href || href.endsWith('/')) continue;
|
||||
|
||||
try {
|
||||
const resolvedUrl = new URL(href, indexUrl);
|
||||
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
|
||||
if (!filename || !assetPattern.test(filename)) continue;
|
||||
|
||||
candidates.push({
|
||||
name: filename,
|
||||
downloadUrl: resolvedUrl.toString(),
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed links.
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveLatestDirectoryAsset(
|
||||
action: ServerAutomationHttpDirectoryExtractAction,
|
||||
): Promise<DirectoryAssetCandidate> {
|
||||
let assetPattern: RegExp;
|
||||
try {
|
||||
assetPattern = new RegExp(action.assetNamePattern, 'i');
|
||||
} catch {
|
||||
throw new Error(`Invalid assetNamePattern regex for action ${action.id}`);
|
||||
}
|
||||
|
||||
const response = await fetch(action.indexUrl, {
|
||||
headers: { 'User-Agent': 'SourceGamePanel/1.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
`No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
return pickLatestDirectoryAsset(candidates);
|
||||
}
|
||||
|
||||
async function downloadBinary(url: string, maxBytes: number): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
||||
if (contentLength > maxBytes) {
|
||||
throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error('Downloaded artifact is empty');
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZipFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
|
||||
const archive = await unzipper.Open.buffer(buffer);
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
for (const entry of archive.files) {
|
||||
if (entry.type !== 'File') continue;
|
||||
|
||||
const normalized = normalizeArchivePath(entry.path, stripComponents);
|
||||
if (!normalized) continue;
|
||||
|
||||
files.push({
|
||||
path: normalized,
|
||||
data: await entry.buffer(),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractTarFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const extract = tar.extract();
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
extract.on('entry', (header: Headers, stream, next) => {
|
||||
const type = header.type ?? 'file';
|
||||
const normalized = normalizeArchivePath(header.name, stripComponents);
|
||||
const isFileType = type === 'file' || type === 'contiguous-file';
|
||||
|
||||
if (!isFileType || !normalized) {
|
||||
stream.resume();
|
||||
stream.on('end', next);
|
||||
stream.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('end', () => {
|
||||
files.push({ path: normalized, data: Buffer.concat(chunks) });
|
||||
next();
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
extract.on('finish', () => resolve(files));
|
||||
extract.on('error', reject);
|
||||
extract.end(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArtifactFiles(
|
||||
artifact: Buffer,
|
||||
assetName: string,
|
||||
stripComponents = 0,
|
||||
): Promise<ExtractedFile[]> {
|
||||
const name = assetName.toLowerCase();
|
||||
|
||||
if (name.endsWith('.zip')) {
|
||||
return extractZipFiles(artifact, stripComponents);
|
||||
}
|
||||
|
||||
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
return extractTarFiles(gunzipSync(artifact), stripComponents);
|
||||
}
|
||||
|
||||
if (name.endsWith('.tar')) {
|
||||
return extractTarFiles(artifact, stripComponents);
|
||||
}
|
||||
|
||||
const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName;
|
||||
return [{ path: normalized, data: artifact }];
|
||||
}
|
||||
|
||||
async function executeGitHubReleaseExtract(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationGitHubReleaseExtractAction,
|
||||
): Promise<void> {
|
||||
const release = await fetchLatestRelease(action);
|
||||
const patterns = compileAssetPatterns(action.assetNamePatterns);
|
||||
|
||||
if (patterns.length === 0) {
|
||||
throw new Error(`No valid asset regex pattern for action ${action.id}`);
|
||||
}
|
||||
|
||||
const asset = release.assets.find((candidate) =>
|
||||
patterns.some((pattern) => pattern.test(candidate.name)),
|
||||
);
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(
|
||||
`No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
|
||||
const files = await extractArtifactFiles(
|
||||
artifact,
|
||||
asset.name,
|
||||
Number(action.stripComponents) || 0,
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Extracted artifact has no files: ${asset.name}`);
|
||||
}
|
||||
|
||||
const destination = action.destination ?? '/';
|
||||
|
||||
for (const file of files) {
|
||||
const targetPath = joinServerPath(destination, file.path);
|
||||
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
|
||||
}
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
release: release.tag_name,
|
||||
asset: asset.name,
|
||||
filesWritten: files.length,
|
||||
},
|
||||
'Automation action completed: github_release_extract',
|
||||
);
|
||||
}
|
||||
|
||||
async function executeHttpDirectoryExtract(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationHttpDirectoryExtractAction,
|
||||
): Promise<void> {
|
||||
const selectedAsset = await resolveLatestDirectoryAsset(action);
|
||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
|
||||
const files = await extractArtifactFiles(
|
||||
artifact,
|
||||
selectedAsset.name,
|
||||
Number(action.stripComponents) || 0,
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`);
|
||||
}
|
||||
|
||||
const destination = action.destination ?? '/';
|
||||
for (const file of files) {
|
||||
const targetPath = joinServerPath(destination, file.path);
|
||||
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
|
||||
}
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
source: action.indexUrl,
|
||||
asset: selectedAsset.name,
|
||||
filesWritten: files.length,
|
||||
},
|
||||
'Automation action completed: http_directory_extract',
|
||||
);
|
||||
}
|
||||
|
||||
async function executeAction(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationAction,
|
||||
): Promise<void> {
|
||||
switch (action.type) {
|
||||
case 'github_release_extract': {
|
||||
await executeGitHubReleaseExtract(app, context, action);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'http_directory_extract': {
|
||||
await executeHttpDirectoryExtract(app, context, action);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'write_file': {
|
||||
const payload =
|
||||
action.encoding === 'base64'
|
||||
? Buffer.from(action.data, 'base64')
|
||||
: action.data;
|
||||
|
||||
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
path: action.path,
|
||||
},
|
||||
'Automation action completed: write_file',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'send_command': {
|
||||
await daemonSendCommand(context.node, context.serverUuid, action.command);
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
command: action.command,
|
||||
},
|
||||
'Automation action completed: send_command',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
const unknownAction = action as { type?: unknown };
|
||||
throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServerAutomationEvent(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
): Promise<ServerAutomationRunResult> {
|
||||
const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug)
|
||||
.filter((rule) => isObject(rule))
|
||||
.filter((rule) => rule.event === context.event)
|
||||
.filter((rule) => rule.enabled !== false)
|
||||
.filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0);
|
||||
|
||||
const result: ServerAutomationRunResult = {
|
||||
workflowsMatched: workflows.length,
|
||||
workflowsExecuted: 0,
|
||||
workflowsSkipped: 0,
|
||||
workflowsFailed: 0,
|
||||
actionFailures: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const runOnce = workflow.runOncePerServer !== false;
|
||||
|
||||
try {
|
||||
if (
|
||||
runOnce &&
|
||||
!context.force &&
|
||||
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
|
||||
) {
|
||||
result.workflowsSkipped += 1;
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
'Skipping automation workflow (already completed)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const action of workflow.actions) {
|
||||
try {
|
||||
await executeAction(app, context, action);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
result.actionFailures += 1;
|
||||
result.failures.push({
|
||||
level: 'action',
|
||||
workflowId: workflow.id,
|
||||
actionId: action.id,
|
||||
message,
|
||||
});
|
||||
app.log.error(
|
||||
{
|
||||
err: error,
|
||||
errorMessage: message,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
actionId: action.id,
|
||||
},
|
||||
'Automation action failed',
|
||||
);
|
||||
|
||||
if (workflow.continueOnError) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (runOnce) {
|
||||
await writeMarker(context.node, context.serverUuid, context.event, workflow.id, {
|
||||
workflowId: workflow.id,
|
||||
event: context.event,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
'Automation workflow completed',
|
||||
);
|
||||
result.workflowsExecuted += 1;
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
result.workflowsFailed += 1;
|
||||
result.failures.push({
|
||||
level: 'workflow',
|
||||
workflowId: workflow.id,
|
||||
message,
|
||||
});
|
||||
app.log.error(
|
||||
{
|
||||
err: error,
|
||||
errorMessage: message,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
'Automation workflow failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user