@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
318 lines • 17.8 kB
JavaScript
import path from 'path';
import { merge } from 'es-toolkit/object';
import { isPlainObject } from 'es-toolkit/predicate';
import { readJsonFile } from '../../../utils/file.js';
import { resolveRootPath } from '../../../utils/atlas.js';
import { clone, normalizeOptionalString } from '../../../utils/value.js';
import { findService, listServices } from '../serviceRegistry.js';
import { RUNTIME_SERVICE_ENVIRONMENTS } from '../configShape.js';
import { getRuntimeServiceCatalogService, resolveRuntimeServiceCatalog } from '../runtimeServiceCatalog.js';
export const SERVICE_CONFIG_ROOT_PATH = 'services/config.json';
export const SERVICE_WORKLOAD_CONFIG_FILE_NAME = 'config.json';
const getServiceConfigPath = (cwd = process.cwd()) => resolveRootPath(SERVICE_CONFIG_ROOT_PATH, cwd);
export const getServiceWorkloadConfigPath = (serviceName, cwd = process.cwd()) => resolveRootPath(path.join('services', serviceName, SERVICE_WORKLOAD_CONFIG_FILE_NAME), cwd);
const createDefaultServiceRootConfig = () => ({
services: {}
});
const throwServiceConfigError = message => {
throw new Error(`Invalid Atlas services config. ${message}`);
};
const assertObjectWhenProvided = (value, propertyPath) => {
if (value !== undefined && !isPlainObject(value)) {
throwServiceConfigError(`The "${propertyPath}" property must be an object when provided.`);
}
};
const assertBooleanWhenProvided = (value, propertyPath) => {
if (value !== undefined && typeof value !== 'boolean') {
throwServiceConfigError(`The "${propertyPath}" property must be a boolean when provided.`);
}
};
const assertNonEmptyStringWhenProvided = (value, propertyPath) => {
if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) {
throwServiceConfigError(`The "${propertyPath}" property must be a non-empty string when provided.`);
}
};
const assertStringWhenProvided = (value, propertyPath) => {
if (value !== undefined && typeof value !== 'string') {
throwServiceConfigError(`The "${propertyPath}" property must be a string when provided.`);
}
};
const assertIntegerWhenProvided = (value, propertyPath, minimum = Number.MIN_SAFE_INTEGER) => {
if (value !== undefined && (!Number.isInteger(value) || value < minimum)) {
throwServiceConfigError(`The "${propertyPath}" property must be an integer greater than or equal to ${minimum} when provided.`);
}
};
const assertPositiveNumberWhenProvided = (value, propertyPath) => {
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) {
throwServiceConfigError(`The "${propertyPath}" property must be a positive number when provided.`);
}
};
const RUNTIME_SERVICE_BINDING_PROPERTY_NAMES = ['deploy', 'enabled', 'release', 'runtime', 'secrets', 'variables'];
const RUNTIME_SERVICE_CLOUD_RUN_DEPLOY_PROPERTY_NAMES = ['allowUnauthenticated', 'concurrency', 'cpu', 'ingress', 'maxInstances', 'memory', 'minInstances', 'region', 'serviceAccountEmail', 'timeout'];
const RUNTIME_SERVICE_CLOUD_RUN_INGRESS_VALUES = ['all', 'internal', 'internal-and-cloud-load-balancing'];
const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const DIGEST_PINNED_ARTIFACT_REGISTRY_IMAGE_PATTERN = /^[^\s]+-docker\.pkg\.dev\/[^\s]+\/[^\s]+\/[^\s]+@sha256:[a-fA-F0-9]+$/;
const isDigestPinnedArtifactRegistryImageReference = value => typeof value === 'string' && DIGEST_PINNED_ARTIFACT_REGISTRY_IMAGE_PATTERN.test(value.trim());
const validateRuntimeServiceDeploySection = (deployConfig, propertyPath) => {
assertObjectWhenProvided(deployConfig, propertyPath);
if (!isPlainObject(deployConfig)) {
return;
}
const unsupportedKeys = Object.keys(deployConfig).filter(key => !RUNTIME_SERVICE_CLOUD_RUN_DEPLOY_PROPERTY_NAMES.includes(key));
if (unsupportedKeys.length > 0) {
throwServiceConfigError(`The "${propertyPath}" section only supports explicit Cloud Run properties. Remove unsupported properties: ${unsupportedKeys.map(key => `"${key}"`).join(', ')}.`);
}
assertBooleanWhenProvided(deployConfig.allowUnauthenticated, `${propertyPath}.allowUnauthenticated`);
assertIntegerWhenProvided(deployConfig.concurrency, `${propertyPath}.concurrency`, 1);
assertPositiveNumberWhenProvided(deployConfig.cpu, `${propertyPath}.cpu`);
assertIntegerWhenProvided(deployConfig.maxInstances, `${propertyPath}.maxInstances`, 1);
assertNonEmptyStringWhenProvided(deployConfig.memory, `${propertyPath}.memory`);
assertIntegerWhenProvided(deployConfig.minInstances, `${propertyPath}.minInstances`, 0);
assertNonEmptyStringWhenProvided(deployConfig.region, `${propertyPath}.region`);
assertNonEmptyStringWhenProvided(deployConfig.serviceAccountEmail, `${propertyPath}.serviceAccountEmail`);
assertNonEmptyStringWhenProvided(deployConfig.timeout, `${propertyPath}.timeout`);
if (deployConfig.ingress !== undefined && !RUNTIME_SERVICE_CLOUD_RUN_INGRESS_VALUES.includes(deployConfig.ingress)) {
throwServiceConfigError(`The "${propertyPath}.ingress" property must be one of: ${RUNTIME_SERVICE_CLOUD_RUN_INGRESS_VALUES.map(value => `"${value}"`).join(', ')}.`);
}
if (Number.isInteger(deployConfig.minInstances) && Number.isInteger(deployConfig.maxInstances) && deployConfig.maxInstances < deployConfig.minInstances) {
throwServiceConfigError(`The "${propertyPath}.maxInstances" property must be greater than or equal to "${propertyPath}.minInstances" when both are provided.`);
}
};
const validateRuntimeServiceVariablesSection = (variablesConfig, propertyPath) => {
assertObjectWhenProvided(variablesConfig, propertyPath);
if (!isPlainObject(variablesConfig)) {
return;
}
for (const [variableName, variableValue] of Object.entries(variablesConfig)) {
if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(variableName)) {
throwServiceConfigError(`The "${propertyPath}.${variableName}" property uses an invalid environment variable name.`);
}
assertStringWhenProvided(variableValue, `${propertyPath}.${variableName}`);
}
};
const validateRuntimeServiceSecretsSection = (secretsConfig, propertyPath) => {
assertObjectWhenProvided(secretsConfig, propertyPath);
if (!isPlainObject(secretsConfig)) {
return;
}
for (const [environmentVariableName, secretConfig] of Object.entries(secretsConfig)) {
if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(environmentVariableName)) {
throwServiceConfigError(`The "${propertyPath}.${environmentVariableName}" property uses an invalid environment variable name.`);
}
assertObjectWhenProvided(secretConfig, `${propertyPath}.${environmentVariableName}`);
if (!isPlainObject(secretConfig)) {
continue;
}
const unsupportedKeys = Object.keys(secretConfig).filter(key => !['autoCreate', 'optional', 'secretName', 'version'].includes(key));
if (unsupportedKeys.length > 0) {
throwServiceConfigError(`The "${propertyPath}.${environmentVariableName}" section only supports "secretName", "version", "optional", and "autoCreate". Remove unsupported properties: ${unsupportedKeys.map(key => `"${key}"`).join(', ')}.`);
}
assertNonEmptyStringWhenProvided(secretConfig.secretName, `${propertyPath}.${environmentVariableName}.secretName`);
assertNonEmptyStringWhenProvided(secretConfig.version, `${propertyPath}.${environmentVariableName}.version`);
assertBooleanWhenProvided(secretConfig.optional, `${propertyPath}.${environmentVariableName}.optional`);
assertBooleanWhenProvided(secretConfig.autoCreate, `${propertyPath}.${environmentVariableName}.autoCreate`);
if (!normalizeOptionalString(secretConfig.secretName)) {
throwServiceConfigError(`The "${propertyPath}.${environmentVariableName}.secretName" property is required for runtime service secret bindings.`);
}
}
};
const createRuntimeServiceCatalogResolver = dependencies => {
const resolveRuntimeServiceCatalogImpl = dependencies.resolveRuntimeServiceCatalog ?? resolveRuntimeServiceCatalog;
const cache = new Map();
return catalogVersion => {
const normalizedCatalogVersion = normalizeOptionalString(catalogVersion) ?? '__latest__';
if (!cache.has(normalizedCatalogVersion)) {
cache.set(normalizedCatalogVersion, resolveRuntimeServiceCatalogImpl({
...(dependencies.catalogOptions ?? {}),
...(normalizedCatalogVersion === '__latest__' ? {} : {
catalogVersion: normalizedCatalogVersion
})
}));
}
return cache.get(normalizedCatalogVersion);
};
};
const resolveRuntimeServiceScope = (catalogService, serviceName) => {
const scope = catalogService?.serviceDeploy?.scope;
if (scope === 'global' || scope === 'project') {
return scope;
}
throwServiceConfigError(`Catalog service "${serviceName}" must define serviceDeploy.scope as "global" or "project".`);
};
const assertProjectScopedRuntimeCatalogService = (catalogService, serviceName, propertyPath) => {
const catalogScope = resolveRuntimeServiceScope(catalogService, serviceName);
if (catalogScope !== 'project') {
throwServiceConfigError(`The "${propertyPath}" section is invalid for runtime service "${serviceName}". Atlas runtime services must use project-owned environment bindings, but the runtime catalog defines scope "${catalogScope}".`);
}
};
const validateRuntimeServicePinnedCatalogVersion = (serviceName, catalogVersion, propertyPath, resolveCatalog) => {
if (!normalizeOptionalString(catalogVersion) || !resolveCatalog) {
return;
}
let catalogService;
let resolvedCatalog;
try {
resolvedCatalog = resolveCatalog(catalogVersion);
catalogService = getRuntimeServiceCatalogService(resolvedCatalog.catalog, serviceName, {
catalogVersion: resolvedCatalog.catalogVersion
});
} catch (error) {
throwServiceConfigError(`The "${propertyPath}" property is invalid for runtime service "${serviceName}". ${error.message}`);
}
assertProjectScopedRuntimeCatalogService(catalogService, serviceName, propertyPath);
};
const validateRuntimeServiceReleaseSection = (releaseConfig, propertyPath, options = {}) => {
assertObjectWhenProvided(releaseConfig, propertyPath);
if (!isPlainObject(releaseConfig)) {
return;
}
assertNonEmptyStringWhenProvided(releaseConfig.catalogVersion, `${propertyPath}.catalogVersion`);
assertNonEmptyStringWhenProvided(releaseConfig.image, `${propertyPath}.image`);
if (releaseConfig.image !== undefined && !isDigestPinnedArtifactRegistryImageReference(releaseConfig.image)) {
throwServiceConfigError(`The "${propertyPath}.image" property must be a digest-pinned Artifact Registry Docker image reference.`);
}
validateRuntimeServicePinnedCatalogVersion(options.serviceName, releaseConfig.catalogVersion, `${propertyPath}.catalogVersion`, options.resolveCatalog);
};
const validateRuntimeServiceRuntimeSection = (runtimeConfig, propertyPath) => {
assertObjectWhenProvided(runtimeConfig, propertyPath);
if (!isPlainObject(runtimeConfig)) {
return;
}
const unsupportedKeys = Object.keys(runtimeConfig).filter(key => key !== 'serviceUrl');
if (unsupportedKeys.length > 0) {
throwServiceConfigError(`The "${propertyPath}" section only supports "serviceUrl". Remove unsupported properties: ${unsupportedKeys.map(key => `"${key}"`).join(', ')}.`);
}
assertNonEmptyStringWhenProvided(runtimeConfig.serviceUrl, `${propertyPath}.serviceUrl`);
};
const validateRuntimeServiceBinding = (bindingConfig, propertyPath, options = {}) => {
assertObjectWhenProvided(bindingConfig, propertyPath);
if (!isPlainObject(bindingConfig)) {
return;
}
const unsupportedKeys = Object.keys(bindingConfig).filter(key => !RUNTIME_SERVICE_BINDING_PROPERTY_NAMES.includes(key));
if (unsupportedKeys.length > 0) {
throwServiceConfigError(`The "${propertyPath}" section only supports ${RUNTIME_SERVICE_BINDING_PROPERTY_NAMES.map(key => `"${key}"`).join(', ')}. Remove unsupported properties: ${unsupportedKeys.map(key => `"${key}"`).join(', ')}.`);
}
assertBooleanWhenProvided(bindingConfig.enabled, `${propertyPath}.enabled`);
validateRuntimeServiceDeploySection(bindingConfig.deploy, `${propertyPath}.deploy`);
validateRuntimeServiceVariablesSection(bindingConfig.variables, `${propertyPath}.variables`);
validateRuntimeServiceSecretsSection(bindingConfig.secrets, `${propertyPath}.secrets`);
validateRuntimeServiceReleaseSection(bindingConfig.release, `${propertyPath}.release`, options);
validateRuntimeServiceRuntimeSection(bindingConfig.runtime, `${propertyPath}.runtime`);
};
const validateRuntimeEnvironmentServiceConfig = (serviceName, serviceConfig, catalogService, resolveCatalog) => {
const propertyPath = `services.${serviceName}`;
if (serviceConfig.scope !== undefined || serviceConfig.projects !== undefined) {
throwServiceConfigError(`The "${propertyPath}" section no longer supports legacy "scope" or "projects" properties. Use only "development" and "production" bindings.`);
}
const unsupportedKeys = Object.keys(serviceConfig).filter(key => !RUNTIME_SERVICE_ENVIRONMENTS.includes(key));
if (unsupportedKeys.length > 0) {
throwServiceConfigError(`The "${propertyPath}" section only supports "development" and "production" bindings. Remove unsupported properties: ${unsupportedKeys.map(key => `"${key}"`).join(', ')}.`);
}
assertProjectScopedRuntimeCatalogService(catalogService, serviceName, propertyPath);
for (const environment of RUNTIME_SERVICE_ENVIRONMENTS) {
if (!isPlainObject(serviceConfig[environment])) {
throwServiceConfigError(`The "${propertyPath}.${environment}" property is required and must be an object for runtime service "${serviceName}".`);
}
validateRuntimeServiceBinding(serviceConfig[environment], `${propertyPath}.${environment}`, {
resolveCatalog,
serviceName
});
}
};
const validateRuntimeServiceConfigSection = (serviceName, serviceConfig, catalogService, resolveCatalog) => {
assertObjectWhenProvided(serviceConfig, `services.${serviceName}`);
if (!isPlainObject(serviceConfig)) {
return;
}
validateRuntimeEnvironmentServiceConfig(serviceName, serviceConfig, catalogService, resolveCatalog);
};
export const createDefaultServiceSection = () => clone(createDefaultServiceRootConfig());
export const validateServiceRootSection = (config, dependencies = {}) => {
if (!isPlainObject(config)) {
throw new Error('Invalid Atlas services config. Expected a root config object.');
}
if (config.projects !== undefined) {
throwServiceConfigError('The root "projects" property is no longer supported in services/config.json. Use .firebaserc to map Atlas environments to Google Cloud projects.');
}
assertObjectWhenProvided(config.services, 'services');
const resolveRuntimeCatalog = createRuntimeServiceCatalogResolver(dependencies);
for (const [serviceName, serviceConfig] of Object.entries(config.services ?? {})) {
const service = findService(serviceName);
if (service) {
throwServiceConfigError(`The "services.${serviceName}" section no longer belongs in services/config.json. Move it to services/${serviceName}/${SERVICE_WORKLOAD_CONFIG_FILE_NAME}.`);
}
if (!service) {
const resolvedCatalog = resolveRuntimeCatalog();
const catalogService = getRuntimeServiceCatalogService(resolvedCatalog.catalog, serviceName, {
catalogVersion: resolvedCatalog.catalogVersion
});
validateRuntimeServiceConfigSection(serviceName, serviceConfig, catalogService, resolveRuntimeCatalog);
continue;
}
}
return config;
};
const createCompleteServiceRootConfig = (serviceConfig, dependencies = {}) => {
const defaultServiceConfig = createDefaultServiceSection();
const mergedConfig = clone(defaultServiceConfig);
for (const [key, value] of Object.entries(serviceConfig ?? {})) {
if (key !== 'services') {
mergedConfig[key] = clone(value);
}
}
mergedConfig.services = merge(clone(defaultServiceConfig.services ?? {}), serviceConfig?.services ?? {});
return validateServiceRootSection(mergedConfig, dependencies);
};
const loadBuiltInServiceConfigSections = (cwd, mergedConfig, dependencies = {}) => {
const readJsonFileImpl = dependencies.readJsonFile ?? readJsonFile;
const configPaths = [];
for (const service of listServices()) {
const workloadConfigPath = getServiceWorkloadConfigPath(service.id, cwd);
const workloadConfig = readJsonFileImpl(workloadConfigPath, {
allowMissing: true
});
if (!workloadConfig) {
continue;
}
if (service.validateConfigSection) {
service.validateConfigSection(workloadConfig);
} else {
assertObjectWhenProvided(workloadConfig, service.id);
}
mergedConfig.services[service.id] = merge(clone(service.createDefaultConfigSection?.() ?? {}), clone(workloadConfig));
configPaths.push(workloadConfigPath);
}
return configPaths;
};
export const loadServiceConfig = (cwd = process.cwd(), dependencies = {}) => {
const configPath = getServiceConfigPath(cwd);
const readJsonFileImpl = dependencies.readJsonFile ?? readJsonFile;
const rootServiceConfig = readJsonFileImpl(configPath, {
allowMissing: true
});
const completeRootConfig = createCompleteServiceRootConfig(rootServiceConfig ?? {}, dependencies);
const warnings = [];
const configPaths = rootServiceConfig ? [configPath] : [];
const mergedConfig = {
...completeRootConfig,
services: {
...(completeRootConfig.services ?? {})
}
};
const workloadConfigPaths = loadBuiltInServiceConfigSections(cwd, mergedConfig, dependencies);
configPaths.push(...workloadConfigPaths);
return {
config: mergedConfig,
configPath,
configPaths,
rootConfig: rootServiceConfig,
warnings
};
};
export default {
createDefaultServiceSection,
getServiceWorkloadConfigPath,
loadServiceConfig,
validateServiceRootSection
};