genezio
Version:
Command line utility to interact with Genezio infrastructure.
270 lines (269 loc) • 12.7 kB
JavaScript
import { spawn } from "child_process";
import { UserError } from "../errors.js";
import colors from "colors";
import _ from "lodash";
import { Logger } from "tslog";
import { FunctionType } from "../projectConfiguration/yaml/models.js";
import getProjectInfoByName from "../requests/getProjectInfoByName.js";
import { execaCommand } from "execa";
import { ENVIRONMENT, PORT_LOCAL_ENVIRONMENT } from "../constants.js";
import { getDatabaseByName } from "../requests/database.js";
import { getAuthentication } from "../requests/authentication.js";
import { retrieveLocalFunctionUrl, retrieveLocalSSRUrl } from "../commands/local.js";
/**
* Determines whether a given value is a valid `FunctionConfiguration` object.
*
* This type guard checks whether the provided value is an object that conforms
* to the `FunctionConfiguration` interface.
*
* @param potentialFunctionObject The value to be checked, of an unknown type.
* @returns `true` if the value is a `FunctionConfiguration`, otherwise `false`.
*/
function isFunctionConfiguration(potentialFunctionObject) {
return (typeof potentialFunctionObject === "object" &&
potentialFunctionObject !== null &&
typeof potentialFunctionObject.name === "string" &&
typeof potentialFunctionObject.path === "string" &&
typeof potentialFunctionObject.entry === "string" &&
Object.values(FunctionType).includes(potentialFunctionObject.type));
}
/**
* Determines whether a given value is a valid `DatabaseConfiguration` object.
*
* This type guard checks whether the provided value is an instance of
* `DatabaseConfiguration` and whether it conforms to the expected structure.
*
* @param potentialDatabaseObject The value to be checked, of an unknown type.
* @returns `true` if the value is a `DatabaseConfiguration`, otherwise `false`.
*/
function isDatabaseObject(potentialDatabaseObject) {
return (typeof potentialDatabaseObject === "object" &&
potentialDatabaseObject !== null &&
typeof potentialDatabaseObject.name === "string" &&
typeof potentialDatabaseObject.region === "string");
}
function isAuthenticationObject(potentialAuthenticationObject) {
return (typeof potentialAuthenticationObject === "object" &&
potentialAuthenticationObject !== null &&
typeof potentialAuthenticationObject.database ===
"object" &&
potentialAuthenticationObject.database !== null);
}
/**
* Determines whether a given value is an object with a specific field.
*
* @param obj The object to be checked.
* @param key The field to check for.
* @returns `true` if the object is not `null` and contains the specified field, otherwise `false`.
*/
function assertIsObjectWithField(obj, key) {
return typeof obj === "object" && obj !== null && key in obj;
}
/**
* Resolves and retrieves a specific field value from a hierarchical configuration object.
*
* The function navigates through the given configuration object based on a dot-separated path
* to locate the desired field value. It supports nested structures, including arrays, by
* iteratively resolving each segment of the path.
*
* @param configuration The root configuration object of type `YamlProjectConfiguration`.
* @param stage The stage name.
* @param path A dot-separated string representing the path to the desired object in the configuration. e.g. "backend.functions.<function-name>""
* @param field The specific field to retrieve from the resolved object. e.g. "url"
* @returns A promise that resolves to the string value of the requested field.
* @throws UserError if the path cannot be resolved, the field is not supported, or a function URL cannot be found.
*/
export async function resolveConfigurationVariable(configuration, stage, path /* e.g. backend.functions.<function-name> */, field /* e.g. url */, options) {
if (options?.isLocal && !options?.port) {
options.port = PORT_LOCAL_ENVIRONMENT;
}
const keys = path.split(".");
// The object or value currently referenced by the path as it is traversed through the configuration
let resourceObject = configuration;
// Traverse the path to locate the desired object
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (Array.isArray(resourceObject)) {
resourceObject = resourceObject.find((item) => item.name === key);
}
else {
resourceObject = resourceObject?.[key];
}
if (resourceObject === undefined) {
throw new UserError(`The attribute ${key} from ${path} is not supported or does not exist in the given resource.`);
}
}
if (path.startsWith("nestjs") || path.startsWith("nitro")) {
if (field === "url") {
const ssrFramework = path.split(".")[0];
if (options?.isLocal) {
return retrieveLocalSSRUrl(ssrFramework);
}
const response = await getProjectInfoByName(configuration.name).catch((error) => {
throw new UserError(`Failed to retrieve the project ${configuration.name} with error: ${error}. You cannot use the url attribute.`);
});
// Currently the assumption is that there is only one function for SSR frameworks
// Get the first function URL
const functionUrl = response.projectEnvs?.find((env) => env.name === stage)
?.functions?.[0]?.cloudUrl;
if (!functionUrl) {
throw new UserError(`The function for the SSR framework ${ssrFramework} is not deployed in the stage ${stage}.`);
}
return functionUrl;
}
}
if (isFunctionConfiguration(resourceObject) && path.startsWith("backend.functions")) {
const functionObj = resourceObject;
// Retrieve custom output fields for a function object such as `url`
if (field === "url") {
if (options?.isLocal) {
return retrieveLocalFunctionUrl(`function-${functionObj.name}`, functionObj.type);
}
const response = await getProjectInfoByName(configuration.name).catch((error) => {
throw new UserError(`Failed to retrieve the project ${configuration.name} with error: ${error}. You cannot use the url attribute.`);
});
const functionUrl = response.projectEnvs
.find((env) => env.name === stage)
?.functions?.find((func) => func.name === "function-" + functionObj.name)?.cloudUrl;
if (!functionUrl) {
throw new UserError(`The function ${functionObj.name} is not deployed in the stage ${stage}.`);
}
return functionUrl;
}
const inputField = functionObj[field];
if (inputField === undefined) {
throw new UserError(`The attribute ${field} is not supported for function ${functionObj.name}. You can use one of the following attributes: ${Object.keys(functionObj).join(", ")} and url.`);
}
return inputField;
}
if (isDatabaseObject(resourceObject) && path.startsWith("services.databases")) {
const databaseObj = resourceObject;
if (field === "uri") {
const databaseName = databaseObj.name;
const databaseResponse = await getDatabaseByName(databaseName);
if (!databaseResponse?.connectionUrl) {
throw new UserError(`Cannot retrieve the connection URL for the database ${databaseObj.name}.`);
}
return databaseResponse?.connectionUrl;
}
const inputField = databaseObj[field];
if (inputField === undefined) {
throw new UserError(`The attribute ${field} is not supported for database ${databaseObj.name}. You can use one of the following attributes: ${Object.keys(databaseObj).join(", ")} and uri.`);
}
return inputField;
}
if (isAuthenticationObject(resourceObject) && path.startsWith("services.authentication")) {
const authenticationObj = resourceObject;
if (field === "token") {
const response = await getProjectInfoByName(configuration.name).catch(() => {
throw new UserError(`Failed to retrieve the project ${configuration.name}. You cannot use the token attribute.`);
});
const projectEnv = response.projectEnvs.find((env) => env.name === stage);
if (!projectEnv) {
throw new UserError(`The stage ${stage} is not found in the project.`);
}
const authenticationResponse = await getAuthentication(projectEnv?.id);
return authenticationResponse?.token;
}
if (field === "region") {
const response = await getProjectInfoByName(configuration.name).catch(() => {
throw new UserError(`Failed to retrieve the project ${configuration.name}. You cannot use the region attribute.`);
});
const projectEnv = response.projectEnvs.find((env) => env.name === stage);
if (!projectEnv) {
throw new UserError(`The stage ${stage} is not found in the project.`);
}
if (ENVIRONMENT === "dev") {
return "dev-fkt";
}
const authenticationResponse = await getAuthentication(projectEnv?.id);
return authenticationResponse?.region;
}
const inputField = authenticationObj[field];
if (inputField === undefined) {
throw new UserError(`The attribute ${field} is not supported for authentication. You can use one of the following attributes: ${Object.keys(authenticationObj).join(", ")}, token and region.`);
}
}
if (assertIsObjectWithField(resourceObject, field)) {
const result = resourceObject[field];
if (typeof result === "string") {
return result;
}
else {
throw new UserError(`The attribute ${field} is an object and not a string.`);
}
}
else {
throw new UserError(`The attribute ${field} is not supported or does not exist in the given resource.`);
}
}
export async function runScript(scripts, cwd, environment) {
if (!scripts) {
return;
}
if (!Array.isArray(scripts)) {
scripts = [scripts];
}
for (const script of scripts) {
await execaCommand(script, { cwd, shell: true, env: environment });
}
}
const frontendLogsColors = {
order: [colors.magenta, colors.yellow, colors.green, colors.red],
index: 0,
};
export async function runFrontendStartScript(scripts, cwd, environment) {
if (!scripts) {
return;
}
if (!Array.isArray(scripts)) {
scripts = [scripts];
}
const logColor = frontendLogsColors.order[frontendLogsColors.index++ % frontendLogsColors.order.length];
const frontendLogger = new Logger({ name: "frontendLogger", prettyLogTemplate: "" });
let debounceCount = 0;
let logsBuffer = [];
// A debounce is needed when logs are printed on `data` event because the logs are printed in chunks.
// We want to print chunk of logs together if they are printed within a short time frame.
const debouncedLog = _.debounce(() => {
debounceCount = 0;
if (logsBuffer.length === 0)
return;
const logs = Buffer.concat(logsBuffer).toString().trim();
frontendLogger.info(`${logColor(`[Frontend logs, path: ${cwd}]\n| `)}${logs.split("\n").join(`\n${logColor("| ")}`)}`);
logsBuffer = [];
}, 400);
const printFrontendLogs = (logChunk) => {
logsBuffer.push(logChunk);
debounceCount += 1;
// If we have 5 debounces, we should flush the logs
if (debounceCount >= 5) {
debouncedLog.flush();
return;
}
debouncedLog();
};
for (const script of scripts) {
await new Promise((resolve, reject) => {
const child = spawn(script, {
cwd,
shell: true,
stdio: "pipe",
env: { ...process.env, ...environment },
});
child.stderr.on("data", printFrontendLogs);
child.stdout.on("data", printFrontendLogs);
child.on("error", (error) => {
reject(`Failed to run script: ${script} - ${error}`);
});
child.on("exit", (code) => {
if (code !== 0 && code !== null) {
reject(`Failed to run script: ${script} - the process exit code is: ${code}`);
}
resolve();
});
}).catch((error) => {
throw new UserError(error);
});
}
}