monitor/core/deployer/recipeParser.ts
2025-04-16 22:30:27 +07:00

127 lines
4.6 KiB
TypeScript

const modulename = 'Deployer';
import YAML from 'js-yaml';
import { txEnv } from '@core/globalData';
import { default as untypedRecipeEngine } from './recipeEngine.js';
import consoleFactory from '@lib/console.js';
import { RECIPE_DEPLOYER_VERSION } from './index.js'; //FIXME: circular_dependency
const console = consoleFactory(modulename);
//Types
type YamlRecipeTaskType = {
action: string;
[key: string]: any;
}
type YamlRecipeType = Partial<{
$engine: number;
$minFxVersion: number;
$onesync: string;
$steamRequired: boolean;
name: string;
author: string;
description: string;
variables: Record<string, any>;
tasks: YamlRecipeTaskType[];
}>;
type ParsedRecipeType = {
raw: string;
name: string;
author: string;
description: string;
variables: Record<string, any>; //TODO: define this
tasks: YamlRecipeTaskType[];
onesync?: string;
fxserverMinVersion?: number;
recipeEngineVersion?: number;
steamRequired?: boolean;
requireDBConfig: boolean;
}
//FIXME: move to the recipeEngine.js file after typescript migration
type RecipeEngineTask = {
validate: (task: YamlRecipeTaskType) => boolean;
run: (options: YamlRecipeTaskType, basePath: string, deployerCtx: unknown) => Promise<void>;
timeoutSeconds: number;
};
type RecipeEngine = Record<string, RecipeEngineTask>;
const recipeEngine = untypedRecipeEngine as RecipeEngine;
/**
* Validates a Recipe file
* FIXME: use Zod for schema validaiton
*/
const recipeParser = (rawRecipe: string) => {
if (typeof rawRecipe !== 'string') throw new Error('not a string');
//Loads YAML
let recipe: YamlRecipeType;
try {
recipe = YAML.load(rawRecipe, { schema: YAML.JSON_SCHEMA }) as YamlRecipeType;
} catch (error) {
console.verbose.dir(error);
throw new Error('invalid yaml');
}
//Basic validation
if (typeof recipe !== 'object') throw new Error('invalid YAML, couldn\'t resolve to object');
if (!Array.isArray(recipe.tasks)) throw new Error('no tasks array found');
//Preparing output
const outRecipe: ParsedRecipeType = {
raw: rawRecipe.trim(),
name: (recipe.name ?? 'unnamed').trim(),
author: (recipe.author ?? 'unknown').trim(),
description: (recipe.description ?? '').trim(),
variables: {},
tasks: [],
requireDBConfig: false,
};
//Checking/parsing meta tag requirements
if (typeof recipe.$onesync == 'string') {
const onesync = recipe.$onesync.trim();
if (!['off', 'legacy', 'on'].includes(onesync)) throw new Error(`the onesync option selected required for this recipe ("${onesync}") is not supported by this FXServer version.`);
outRecipe.onesync = onesync;
}
if (typeof recipe.$minFxVersion == 'number') {
if (recipe.$minFxVersion > txEnv.fxsVersion) throw new Error(`this recipe requires FXServer v${recipe.$minFxVersion} or above`);
outRecipe.fxserverMinVersion = recipe.$minFxVersion; //NOTE: currently no downstream use
}
if (typeof recipe.$engine == 'number') {
if (recipe.$engine < RECIPE_DEPLOYER_VERSION) throw new Error(`unsupported '$engine' version ${recipe.$engine}`);
outRecipe.recipeEngineVersion = recipe.$engine; //NOTE: currently no downstream use
}
if (recipe.$steamRequired === true) {
outRecipe.steamRequired = true;
}
//Validate tasks
if (!Array.isArray(recipe.tasks)) throw new Error('no tasks array found');
recipe.tasks.forEach((task, index) => {
if (typeof task.action !== 'string') throw new Error(`[task${index + 1}] no action specified`);
if (typeof recipeEngine[task.action] === 'undefined') throw new Error(`[task${index + 1}] unknown action '${task.action}'`);
if (!recipeEngine[task.action].validate(task)) throw new Error(`[task${index + 1}:${task.action}] invalid parameters`);
outRecipe.tasks.push(task);
});
//Process inputs
outRecipe.requireDBConfig = recipe.tasks.some((t) => t.action.includes('database'));
const protectedVarNames = ['licenseKey', 'dbHost', 'dbUsername', 'dbPassword', 'dbName', 'dbConnection', 'dbPort'];
if (typeof recipe.variables == 'object' && recipe.variables !== null) {
const varNames = Object.keys(recipe.variables);
if (varNames.some((n) => protectedVarNames.includes(n))) {
throw new Error('One or more of the variables declared in the recipe are not allowed.');
}
Object.assign(outRecipe.variables, recipe.variables);
}
//Output
return outRecipe;
};
export default recipeParser;