UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

318 lines 17.8 kB
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 };