UNPKG

genezio

Version:

Command line utility to interact with Genezio infrastructure.

498 lines (497 loc) 19.4 kB
import { parse as parseYaml, stringify as stringifyYaml } from "yaml-transmute"; import zod from "zod"; import nativeFs from "fs"; import { neonDatabaseRegions, legacyRegions, mongoDatabaseRegions } from "../../utils/configs.js"; import { GENEZIO_CONFIGURATION_FILE_NOT_FOUND, UserError, zodFormatError } from "../../errors.js"; import { AuthenticationDatabaseType, DatabaseType, FunctionType, InstanceSize, Language, } from "./models.js"; import { FUNCTION_EXTENSIONS, supportedArchitectures, supportedNodeRuntimes, supportedPythonRuntimes, } from "../../models/projectOptions.js"; import { PackageManagerType } from "../../packageManagers/packageManager.js"; import { TriggerType } from "./models.js"; import { isValidCron } from "cron-validator"; import { tryV2Migration } from "./migration.js"; import yaml, { YAMLParseError } from "yaml"; import { isUnique } from "../../utils/yaml.js"; import path from "path"; import { MongoClusterTier, MongoClusterType } from "../../models/requests.js"; function parseGenezioConfig(config) { const languageSchema = zod.object({ name: zod.nativeEnum(Language), runtime: zod.enum([...supportedNodeRuntimes, ...supportedPythonRuntimes]).optional(), architecture: zod.enum(supportedArchitectures).optional(), packageManager: zod.nativeEnum(PackageManagerType).optional(), }); const scriptSchema = zod.array(zod.string()).or(zod.string()).optional(); const environmentSchema = zod.record(zod.string(), zod.string()); const methodSchema = zod .object({ name: zod.string(), type: zod.literal(TriggerType.jsonrpc).or(zod.literal(TriggerType.http)), auth: zod.boolean().optional(), }) .or(zod .object({ name: zod.string(), type: zod.literal(TriggerType.cron), cronString: zod.string(), auth: zod.boolean().optional(), }) .refine(({ type, cronString }) => { if (type === TriggerType.cron && cronString && !isValidCron(cronString)) { return false; } return true; }, "The cronString is not valid. Check https://crontab.guru/ for more information.") .refine(({ type, cronString }) => { const cronParts = cronString?.split(" "); if (type === TriggerType.cron && cronParts && cronParts[2] != "*" && cronParts[4] != "*") { return false; } return true; }, "The day of the month and day of the week cannot be specified at the same time.")); const classSchema = zod.object({ name: zod.string().optional(), path: zod.string(), type: zod.nativeEnum(TriggerType).optional(), methods: zod.array(methodSchema).optional(), timeout: zod.number().optional(), storageSize: zod.number().optional(), instanceSize: zod.nativeEnum(InstanceSize).optional(), vcpuCount: zod.number().optional(), memoryMb: zod.number().optional(), maxConcurrentRequestsPerInstance: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent requests per instance should be greater than 0."), maxConcurrentInstances: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent instances should be greater than 0."), cooldownTime: zod.number().optional(), persistent: zod.boolean().optional(), }); const functionsSchema = zod .object({ name: zod.string().refine((value) => { const nameRegex = new RegExp("^[a-zA-Z][-a-zA-Z0-9]*$"); return nameRegex.test(value); }, "Must start with a letter and contain only letters, numbers and dashes."), path: zod.string(), // handler is mandatory only if type is AWS handler: zod.string().optional(), entry: zod.string().refine((value) => { const filename = path.basename(value); return (filename.split(".").length === 2 && FUNCTION_EXTENSIONS.includes(filename.split(".")[1])); }, "The handler should be in the format 'file.extension'. example: index.js / index.mjs / index.cjs / index.py"), type: zod.nativeEnum(FunctionType).default(FunctionType.aws), timeout: zod.number().optional(), storageSize: zod.number().optional(), instanceSize: zod.nativeEnum(InstanceSize).optional(), vcpuCount: zod.number().optional(), memoryMb: zod.number().optional(), maxConcurrentRequestsPerInstance: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent requests per instance should be greater than 0."), maxConcurrentInstances: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent instances should be greater than 0."), cooldownTime: zod.number().optional(), healthcheckPath: zod.string().optional(), }) .refine(({ type, handler }) => !(type === FunctionType.aws && !handler), "The handler is mandatory for type aws functions.") .refine(({ type, healthcheckPath }) => !(type !== FunctionType.persistent && healthcheckPath), "The healthcheckPath field is only supported for persistent functions."); const databaseSchema = zod .object({ name: zod.string(), type: zod.literal(DatabaseType.neon), region: zod .enum(neonDatabaseRegions.map((r) => r.value)) .optional(), }) .or(zod.object({ name: zod.string(), type: zod.literal(DatabaseType.mongo), region: zod .enum(mongoDatabaseRegions.map((r) => r.value)) .optional(), clusterType: zod.nativeEnum(MongoClusterType).optional(), clusterName: zod.string().optional(), clusterTier: zod.nativeEnum(MongoClusterTier).optional(), })); const cronSchema = zod .object({ name: zod.string(), function: zod.string(), schedule: zod.string(), endpoint: zod.string().optional(), }) .refine(({ schedule }) => { if (schedule && !isValidCron(schedule)) { return false; } return true; }, "The schedule expression is not valid. Please visit https://crontab.guru/ to validate it."); const redirectUrlSchema = zod.string(); const authEmailSettings = zod.object({ resetPassword: zod .object({ redirectUrl: redirectUrlSchema, }) .optional(), emailVerification: zod .object({ redirectUrl: redirectUrlSchema, }) .optional(), }); const authenticationSchema = zod.object({ database: zod .object({ type: zod.nativeEnum(AuthenticationDatabaseType), uri: zod.string(), }) .or(zod.object({ name: zod.string() })), providers: zod .object({ email: zod.boolean().optional(), web3: zod.boolean().optional(), google: zod .object({ clientId: zod.string(), clientSecret: zod.string(), }) .optional(), }) .optional(), settings: authEmailSettings.optional(), }); const servicesSchema = zod .object({ databases: zod.array(databaseSchema).optional(), email: zod.boolean().optional(), authentication: authenticationSchema.optional(), crons: zod.array(cronSchema).optional(), }) .refine(({ crons }) => { const isUniqueCron = isUnique(crons ?? [], "name"); return isUniqueCron; }, `You can't have two crons with the same name.`); const backendSchema = zod .object({ path: zod.string(), language: languageSchema, environment: environmentSchema.optional(), scripts: zod .object({ deploy: scriptSchema, local: scriptSchema, }) .optional(), classes: zod.array(classSchema).optional(), functions: zod.array(functionsSchema).optional(), }) .refine(({ functions }) => { const isUniqueFunction = isUnique(functions ?? [], "name"); return isUniqueFunction; }, `You can't have two functions with the same name.`); const frontendSchema = zod.object({ name: zod.string().optional(), path: zod.string(), sdk: zod .object({ language: zod.nativeEnum(Language), path: zod.string().optional(), }) .optional(), subdomain: zod.string().optional(), publish: zod.string().optional(), environment: environmentSchema.optional(), scripts: zod .object({ build: scriptSchema, start: scriptSchema, deploy: scriptSchema, }) .optional(), redirects: zod .object({ from: zod.string(), to: zod.string(), status: zod .number() .default(301) .refine((status) => status === 301 || status === 302 || status === 303 || status === 307 || status === 308, "The redirect status code should be 301, 302, 303, 307 or 308."), }) .array() .optional(), rewrites: zod .object({ from: zod.string(), to: zod.string(), }) .array() .optional(), }); // Define SSR frameworks schema const ssrFrameworkSchema = zod.object({ path: zod.string(), packageManager: zod.nativeEnum(PackageManagerType).optional(), scripts: zod .object({ deploy: scriptSchema, build: scriptSchema, start: scriptSchema, }) .optional(), environment: environmentSchema.optional(), subdomain: zod.string().optional(), runtime: zod.enum([...supportedNodeRuntimes, ...supportedPythonRuntimes]).optional(), entryFile: zod.string().optional(), timeout: zod.number().optional(), storageSize: zod.number().optional(), instanceSize: zod.nativeEnum(InstanceSize).optional(), vcpuCount: zod.number().optional(), memoryMb: zod.number().optional(), maxConcurrentRequestsPerInstance: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent requests per instance should be greater than 0."), maxConcurrentInstances: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent instances should be greater than 0."), cooldownTime: zod.number().optional(), type: zod.literal(FunctionType.persistent).optional(), }); // Define container schema const containerSchema = zod .object({ path: zod.string(), timeout: zod.number().optional(), storageSize: zod.number().optional(), instanceSize: zod.nativeEnum(InstanceSize).optional(), vcpuCount: zod.number().optional(), memoryMb: zod.number().optional(), maxConcurrentRequestsPerInstance: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent requests per instance should be greater than 0."), maxConcurrentInstances: zod .number() .optional() .refine((value) => { if (value && value < 1) { return false; } return true; }, "The maximum number of concurrent instances should be greater than 0."), cooldownTime: zod.number().optional(), environment: environmentSchema.optional(), type: zod.literal(FunctionType.persistent).optional(), healthcheckPath: zod.string().optional(), }) .refine(({ type, healthcheckPath }) => !(type !== FunctionType.persistent && healthcheckPath), "The healthcheckPath field is only supported for persistent containers."); const v2Schema = zod.object({ name: zod.string().refine((value) => { const nameRegex = new RegExp("^[a-zA-Z][-a-zA-Z0-9]*$"); return nameRegex.test(value); }, "Must start with a letter and contain only letters, numbers and dashes."), region: zod.enum(legacyRegions.map((r) => r.value)).optional(), yamlVersion: zod.number(), backend: backendSchema.optional(), services: servicesSchema.optional(), frontend: zod.array(frontendSchema).or(frontendSchema).optional(), nestjs: ssrFrameworkSchema.optional(), nextjs: ssrFrameworkSchema.optional(), nuxt: ssrFrameworkSchema.optional(), nitro: ssrFrameworkSchema.optional(), container: containerSchema.optional(), remix: ssrFrameworkSchema.optional(), streamlit: ssrFrameworkSchema.optional(), }); const parsedConfig = v2Schema.parse(config); return parsedConfig; } function fillDefaultGenezioConfig(config) { const defaultConfig = structuredClone(config); defaultConfig.region ??= "us-east-1"; if (defaultConfig.backend) { switch (defaultConfig.backend.language.name) { case Language.ts: case Language.js: defaultConfig.backend.language.packageManager ??= PackageManagerType.npm; break; case Language.python: case Language.pythonAsgi: defaultConfig.backend.language.packageManager ??= PackageManagerType.pip; break; } } if (defaultConfig.frontend && !Array.isArray(defaultConfig.frontend)) { defaultConfig.frontend = [defaultConfig.frontend]; } return defaultConfig; } function replaceVariableInScript(script, variables) { if (!script) { return script; } if (Array.isArray(script)) { return script.map((s) => replaceVariableInScript(s, variables)); } else { let newScript = script; if (variables.projectName) { newScript = newScript.replaceAll(/\${{\s*projectName\s*}}/g, variables.projectName); } if (variables.stage) { newScript = newScript.replaceAll(/\${{\s*stage\s*}}/g, variables.stage); } return newScript; } } function replaceVariables(config, variables) { if (config.backend?.scripts) { for (const [key, script] of Object.entries(config.backend.scripts)) { config.backend.scripts[key] = replaceVariableInScript(script, variables); } } if (config.frontend) { if (Array.isArray(config.frontend)) { for (const frontend of config.frontend) { if (frontend.scripts) { for (const [key, script] of Object.entries(frontend.scripts)) { frontend.scripts[key] = replaceVariableInScript(script, variables); } } } } else { if (config.frontend.scripts) { for (const [key, script] of Object.entries(config.frontend.scripts)) { config.frontend.scripts[key] = replaceVariableInScript(script, variables); } } } } return config; } export class YamlConfigurationIOController { constructor(filePath = "./genezio.yaml", variables = { stage: "prod" }, fs = nativeFs) { this.filePath = filePath; this.variables = variables; this.fs = fs; this.ctx = undefined; this.cachedConfig = undefined; this.latestRead = undefined; } async read(fillDefaults = true, cache = true) { let lastModified; try { lastModified = this.fs.statSync(this.filePath).mtime; } catch { throw new UserError(GENEZIO_CONFIGURATION_FILE_NOT_FOUND); } if (this.cachedConfig && cache && this.latestRead && this.latestRead >= lastModified) { if (fillDefaults) { return fillDefaultGenezioConfig(replaceVariables(structuredClone(this.cachedConfig), this.variables)); } return structuredClone(this.cachedConfig); } const fileContent = (await this.fs.promises.readFile(this.filePath, "utf8")); this.latestRead = new Date(); let rawConfig, ctx; try { [rawConfig, ctx] = parseYaml(fileContent); } catch (e) { if (e instanceof YAMLParseError) { throw new UserError(`There was a problem parsing your YAML configuration!\n${e.message}`); } throw e; } let genezioConfig; try { genezioConfig = parseGenezioConfig(rawConfig); } catch (e) { let v2RawConfig = undefined; if (!("yamlVersion" in rawConfig)) { v2RawConfig = await tryV2Migration(rawConfig); } if (v2RawConfig) { genezioConfig = parseGenezioConfig(v2RawConfig); await this.fs.promises.writeFile(this.filePath, yaml.stringify(genezioConfig)); } else { if (e instanceof zod.ZodError) { throw new UserError(`There was a problem parsing your YAML configuration!\n${zodFormatError(e)}`); } throw new UserError(`There was a problem parsing your YAML configuration!\n${e}`); } } this.variables.projectName = genezioConfig.name; // Cache the context and the checked config this.ctx = ctx; this.cachedConfig = structuredClone(genezioConfig); // Fill default values if (fillDefaults) { return fillDefaultGenezioConfig(replaceVariables(genezioConfig, this.variables)); } return genezioConfig; } async write(data) { this.fs.writeFileSync(this.filePath, stringifyYaml(data, this.ctx)); this.latestRead = new Date(); this.cachedConfig = structuredClone(data); } } export default new YamlConfigurationIOController();