UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

650 lines 32.3 kB
import inquirer from 'inquirer'; import crypto from 'crypto'; import { isPlainObject } from 'es-toolkit/predicate'; import { logger } from '../../utils/index.js'; import { createSecret } from '../../utils/secrets.js'; import { loadFeatureContext } from '../../utils/feature.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { readJsonFile, writeJsonFile } from '../../utils/file.js'; import { previewGeneratedServiceConfigArtifact } from './runtimeConfig.js'; import { resolveRuntimeServiceCatalogService } from './runtimeServiceCatalog.js'; import { createDefaultSearchCloudRunServiceAccountEmail } from '../search/config/searchConfig.js'; import { createCompleteRuntimeEnvironmentConfig, resolveRuntimeBindingEnvironment, resolveRuntimeEnvironmentBinding } from './configShape.js'; import { getCommandErrorMessage, isGcloudResourceNotFoundError, parseGcloudJsonOutput, runGcloudFileCommand } from '../../utils/gcloud.js'; import { createCloudRunServiceAgentEmail, createDigestPinnedArtifactRegistryImageReference, ensureArtifactRegistryReaderBinding, resolveArtifactRegistryDockerImageDigest, resolveGoogleCloudProjectNumber } from '../search/runtimeRegistry.js'; const DEFAULT_RUNTIME_SERVICE_DEPLOY_REGION = 'europe-west1'; const DEFAULT_RUNTIME_SERVICE_DEPLOY_CPU = 1; const DEFAULT_RUNTIME_SERVICE_DEPLOY_MEMORY = '256Mi'; const DEFAULT_RUNTIME_SERVICE_DEPLOY_TIMEOUT = '900s'; const DEFAULT_RUNTIME_SERVICE_DEPLOY_INGRESS = 'all'; const DEFAULT_RUNTIME_SERVICE_DEPLOY_MIN_INSTANCES = 0; const RUNTIME_SERVICE_DEPLOY_CPU_VALUES = ['0.08', '0.5', '1', '2', '4', '6', '8']; const RUNTIME_SERVICE_DEPLOY_MEMORY_VALUES = ['128Mi', '256Mi', '512Mi', '1Gi', '2Gi', '4Gi', '8Gi', '16Gi', '32Gi']; const RUNTIME_SERVICE_DEPLOY_TIMEOUT_VALUES = ['30s', '60s', '120s', '300s', '600s', '900s', '1800s', '3600s']; const GCLOUD_MAPPING_DELIMITER_CANDIDATES = ['|', ';', '#', '~', '%', '@', '!']; const RUNTIME_SERVICE_DEPLOY_INGRESS_VALUES = ['all', 'internal', 'internal-and-cloud-load-balancing']; const createRuntimeServiceDeployError = message => { throw new Error(message); }; const normalizeRuntimeServicePositiveNumber = (value, optionLabel) => { if (value === undefined || value === null || value === '') { return undefined; } const parsedValue = Number(value); if (!Number.isFinite(parsedValue) || parsedValue <= 0) { createRuntimeServiceDeployError(`Atlas runtime service deployment option ${optionLabel} must be a positive number.`); } return parsedValue; }; const normalizeRuntimeServiceInteger = (value, optionLabel, minimum) => { if (value === undefined || value === null || value === '') { return undefined; } const parsedValue = Number(value); if (!Number.isInteger(parsedValue) || parsedValue < minimum) { createRuntimeServiceDeployError(`Atlas runtime service deployment option ${optionLabel} must be an integer greater than or equal to ${minimum}.`); } return parsedValue; }; const createTaggedArtifactRegistryImageReference = image => `${image.registryLocation}-docker.pkg.dev/${image.registryProject}/${image.repository}/${image.imageName}:${image.tag}`; const createGcloudMappingArgumentValue = entries => { if (entries.length === 0) { return null; } const delimiter = GCLOUD_MAPPING_DELIMITER_CANDIDATES.find(candidate => entries.every(({ key, value }) => !String(key).includes(candidate) && !String(value).includes(candidate))); if (!delimiter) { createRuntimeServiceDeployError('Atlas could not format Cloud Run environment mappings because all supported gcloud delimiters appear in the configured values.'); } return `^${delimiter}^${entries.map(({ key, value }) => `${key}=${value}`).join(delimiter)}`; }; const assertProjectScopedRuntimeCatalogService = service => { const scope = normalizeOptionalString(service?.serviceDeploy?.scope); if (scope === 'project') { return; } createRuntimeServiceDeployError(`Atlas runtime service ${service?.id ?? '(unknown)'} must define serviceDeploy.scope as "project".`); }; const getCurrentRuntimeServiceRootConfig = (context, serviceName) => context.config?.services?.[serviceName] ?? {}; const resolveRequiredRuntimeBindingEnvironment = (context, serviceName) => { const bindingEnvironment = resolveRuntimeBindingEnvironment(context.environment); if (bindingEnvironment !== null) { return bindingEnvironment; } createRuntimeServiceDeployError(`Atlas could not resolve the runtime service environment binding for ${serviceName} in project ${context.projectId}. Add a development or production mapping to .firebaserc.`); }; const resolveRuntimeServiceCurrentCatalogVersion = (rootConfig, context) => { const bindingConfig = getCurrentRuntimeServiceBinding(rootConfig, context); return normalizeOptionalString(bindingConfig.release?.catalogVersion); }; const resolveRequestedRuntimeServiceCatalogVersion = (rootConfig, context, options = {}) => { const configuredCatalogVersion = normalizeOptionalString(options.catalogVersion); if (options.latest === true) { if (configuredCatalogVersion) { createRuntimeServiceDeployError('Atlas runtime service deployment options --latest and --catalog-version cannot be combined.'); } return null; } return configuredCatalogVersion ?? resolveRuntimeServiceCurrentCatalogVersion(rootConfig, context) ?? null; }; const getCurrentRuntimeServiceBinding = (rootConfig, context) => resolveRuntimeEnvironmentBinding(rootConfig, context.environment) ?? {}; const createNextRuntimeEnvironmentServiceConfig = (currentServiceConfig, bindingEnvironment, nextBinding) => ({ ...createCompleteRuntimeEnvironmentConfig(currentServiceConfig), [bindingEnvironment]: nextBinding }); const resolveRuntimeServiceDeployOptionOverrides = (options = {}) => { const deployOverrides = {}; const region = normalizeOptionalString(options.region); const serviceAccountEmail = normalizeOptionalString(options.serviceAccountEmail); const memory = normalizeOptionalString(options.memory); const timeout = normalizeOptionalString(options.timeout); const ingress = normalizeOptionalString(options.ingress); const cpu = normalizeRuntimeServicePositiveNumber(options.cpu, '--cpu'); const minInstances = normalizeRuntimeServiceInteger(options.minInstances, '--min-instances', 0); const maxInstances = normalizeRuntimeServiceInteger(options.maxInstances, '--max-instances', 1); const concurrency = normalizeRuntimeServiceInteger(options.concurrency, '--concurrency', 1); if (region) { deployOverrides.region = region; } if (serviceAccountEmail) { deployOverrides.serviceAccountEmail = serviceAccountEmail; } if (memory) { deployOverrides.memory = memory; } if (timeout) { deployOverrides.timeout = timeout; } if (ingress) { if (!RUNTIME_SERVICE_DEPLOY_INGRESS_VALUES.includes(ingress)) { createRuntimeServiceDeployError('Atlas runtime service deployment option --ingress must be one of ' + `${RUNTIME_SERVICE_DEPLOY_INGRESS_VALUES.map(value => `"${value}"`).join(', ')}.`); } deployOverrides.ingress = ingress; } if (cpu !== undefined) { deployOverrides.cpu = cpu; } if (minInstances !== undefined) { deployOverrides.minInstances = minInstances; } if (maxInstances !== undefined) { deployOverrides.maxInstances = maxInstances; } if (concurrency !== undefined) { deployOverrides.concurrency = concurrency; } if (options.allowUnauthenticated === true) { deployOverrides.allowUnauthenticated = true; } if (deployOverrides.minInstances !== undefined && deployOverrides.maxInstances !== undefined && deployOverrides.maxInstances < deployOverrides.minInstances) { createRuntimeServiceDeployError('Atlas runtime service deployment option --max-instances must be greater than or equal to --min-instances.'); } return deployOverrides; }; const createRuntimeServicePromptAnswers = questions => Object.fromEntries(questions.map(question => [question.name, question.default])); const createRuntimeServiceSelectableChoices = (supportedValues, defaultValue) => { const normalizedDefaultValue = normalizeOptionalString(defaultValue); if (!normalizedDefaultValue) { return [...supportedValues]; } return supportedValues.includes(normalizedDefaultValue) ? [...supportedValues] : [normalizedDefaultValue, ...supportedValues]; }; const resolveRuntimeServiceSelection = async ({ bindingConfig, catalogService, optionOverrides, projectId, serviceName }, dependencies = {}) => { const promptImpl = dependencies.prompt ?? inquirer.prompt; const currentDeployConfig = isPlainObject(bindingConfig?.deploy) ? bindingConfig.deploy : {}; const defaultDeployConfig = resolveEffectiveRuntimeServiceDeployConfig({ bindingConfig, catalogService, optionOverrides, projectId }); const questions = []; if (optionOverrides.region === undefined && currentDeployConfig.region === undefined) { questions.push({ default: defaultDeployConfig.region, filter: value => normalizeOptionalString(value), message: `Which Cloud Run region should Atlas runtime service ${serviceName} use?`, name: 'region', type: 'input', validate: value => normalizeOptionalString(value) ? true : 'Enter a Cloud Run region, for example europe-west1.' }); } if (optionOverrides.serviceAccountEmail === undefined && currentDeployConfig.serviceAccountEmail === undefined) { questions.push({ default: defaultDeployConfig.serviceAccountEmail, filter: value => normalizeOptionalString(value), message: `Which Cloud Run service account should Atlas runtime service ${serviceName} use?`, name: 'serviceAccountEmail', type: 'input', validate: value => normalizeOptionalString(value) ? true : 'Enter a Cloud Run service account email.' }); } if (optionOverrides.allowUnauthenticated === undefined && currentDeployConfig.allowUnauthenticated === undefined) { questions.push({ default: defaultDeployConfig.allowUnauthenticated === true, message: `Should Atlas runtime service ${serviceName} allow unauthenticated requests?`, name: 'allowUnauthenticated', type: 'confirm' }); } if (optionOverrides.cpu === undefined && currentDeployConfig.cpu === undefined) { questions.push({ choices: createRuntimeServiceSelectableChoices(RUNTIME_SERVICE_DEPLOY_CPU_VALUES, String(defaultDeployConfig.cpu)), default: String(defaultDeployConfig.cpu), message: `How much CPU should Atlas runtime service ${serviceName} use?`, name: 'cpu', type: 'list' }); } if (optionOverrides.memory === undefined && currentDeployConfig.memory === undefined) { questions.push({ choices: createRuntimeServiceSelectableChoices(RUNTIME_SERVICE_DEPLOY_MEMORY_VALUES, defaultDeployConfig.memory), default: defaultDeployConfig.memory, filter: value => normalizeOptionalString(value), message: `How much memory should Atlas runtime service ${serviceName} use?`, name: 'memory', type: 'list' }); } if (optionOverrides.timeout === undefined && currentDeployConfig.timeout === undefined) { questions.push({ choices: createRuntimeServiceSelectableChoices(RUNTIME_SERVICE_DEPLOY_TIMEOUT_VALUES, defaultDeployConfig.timeout), default: defaultDeployConfig.timeout, filter: value => normalizeOptionalString(value), message: `Which request timeout should Atlas runtime service ${serviceName} use?`, name: 'timeout', type: 'list' }); } if (optionOverrides.ingress === undefined && currentDeployConfig.ingress === undefined) { questions.push({ choices: createRuntimeServiceSelectableChoices(RUNTIME_SERVICE_DEPLOY_INGRESS_VALUES, defaultDeployConfig.ingress), default: defaultDeployConfig.ingress, message: `Which ingress mode should Atlas runtime service ${serviceName} use?`, name: 'ingress', type: 'list' }); } if (optionOverrides.minInstances === undefined && currentDeployConfig.minInstances === undefined) { questions.push({ default: String(defaultDeployConfig.minInstances), message: `How many minimum instances should Atlas runtime service ${serviceName} keep warm?`, name: 'minInstances', type: 'input', validate: value => { try { normalizeRuntimeServiceInteger(value, 'minimum instances prompt', 0); return true; } catch (error) { return error.message; } } }); } const answers = questions.length > 0 ? await promptImpl(questions) : createRuntimeServicePromptAnswers([]); const normalizedAnswers = { ...answers, cpu: normalizeRuntimeServicePositiveNumber(answers.cpu, 'CPU prompt'), ingress: normalizeOptionalString(answers.ingress), memory: normalizeOptionalString(answers.memory), minInstances: normalizeRuntimeServiceInteger(answers.minInstances, 'minimum instances prompt', 0), region: normalizeOptionalString(answers.region), serviceAccountEmail: normalizeOptionalString(answers.serviceAccountEmail), timeout: normalizeOptionalString(answers.timeout) }; const selectedDeployConfig = { ...currentDeployConfig }; const assignDeployValue = (propertyName, fallbackValue) => { if (optionOverrides[propertyName] !== undefined) { selectedDeployConfig[propertyName] = optionOverrides[propertyName]; return; } if (currentDeployConfig[propertyName] !== undefined) { return; } selectedDeployConfig[propertyName] = normalizedAnswers[propertyName] !== undefined ? normalizedAnswers[propertyName] : fallbackValue; }; assignDeployValue('region', defaultDeployConfig.region); assignDeployValue('serviceAccountEmail', defaultDeployConfig.serviceAccountEmail); assignDeployValue('allowUnauthenticated', defaultDeployConfig.allowUnauthenticated === true); assignDeployValue('cpu', defaultDeployConfig.cpu); assignDeployValue('memory', defaultDeployConfig.memory); assignDeployValue('timeout', defaultDeployConfig.timeout); assignDeployValue('ingress', defaultDeployConfig.ingress); assignDeployValue('minInstances', defaultDeployConfig.minInstances); assignDeployValue('maxInstances', defaultDeployConfig.maxInstances); assignDeployValue('concurrency', defaultDeployConfig.concurrency); if (selectedDeployConfig.maxInstances !== undefined && selectedDeployConfig.minInstances !== undefined && selectedDeployConfig.maxInstances < selectedDeployConfig.minInstances) { createRuntimeServiceDeployError(`Atlas runtime service ${serviceName} resolved maxInstances below minInstances.`); } return { ...bindingConfig, deploy: selectedDeployConfig }; }; const resolveRuntimeBindingVariables = bindingConfig => Object.entries(isPlainObject(bindingConfig?.variables) ? bindingConfig.variables : {}).map(([name, value]) => ({ key: name, value })); const normalizeRuntimeServiceSecret = secret => { const env = normalizeOptionalString(secret?.env); const secretName = normalizeOptionalString(secret?.secretName) ?? normalizeOptionalString(secret?.id); if (!env || !secretName) { createRuntimeServiceDeployError('Atlas runtime service secret bindings must define both an environment variable name and a secret name.'); } return { autoCreate: secret.autoCreate === true, env, optional: secret.optional === true, secretName, version: normalizeOptionalString(secret.version) ?? null }; }; const resolveRuntimeBindingSecrets = bindingConfig => Object.entries(isPlainObject(bindingConfig?.secrets) ? bindingConfig.secrets : {}).map(([environmentVariableName, secretConfig]) => normalizeRuntimeServiceSecret({ ...secretConfig, env: environmentVariableName })); const resolveRuntimeServiceCatalogDeployConfig = catalogService => isPlainObject(catalogService?.deploy) ? catalogService.deploy : {}; const resolveRuntimeCatalogSecrets = catalogService => { const catalogDeployConfig = resolveRuntimeServiceCatalogDeployConfig(catalogService); if (!Array.isArray(catalogDeployConfig.secrets)) { return []; } return catalogDeployConfig.secrets.map(normalizeRuntimeServiceSecret); }; const mergeRuntimeServiceSecrets = (...secretGroups) => { const secretsByEnvironmentVariable = new Map(); for (const secretGroup of secretGroups) { for (const secret of secretGroup) { secretsByEnvironmentVariable.set(secret.env, secret); } } return [...secretsByEnvironmentVariable.values()]; }; const resolveEffectiveRuntimeServiceDeployConfig = ({ bindingConfig, catalogService, optionOverrides, projectId }) => { const bindingDeployConfig = isPlainObject(bindingConfig?.deploy) ? bindingConfig.deploy : {}; const catalogDeployConfig = resolveRuntimeServiceCatalogDeployConfig(catalogService); return { ...bindingDeployConfig, ...optionOverrides, allowUnauthenticated: optionOverrides.allowUnauthenticated ?? bindingDeployConfig.allowUnauthenticated ?? false, concurrency: optionOverrides.concurrency ?? bindingDeployConfig.concurrency ?? catalogDeployConfig.concurrency, cpu: optionOverrides.cpu ?? bindingDeployConfig.cpu ?? catalogDeployConfig.cpu ?? DEFAULT_RUNTIME_SERVICE_DEPLOY_CPU, ingress: optionOverrides.ingress ?? bindingDeployConfig.ingress ?? catalogDeployConfig.ingress ?? DEFAULT_RUNTIME_SERVICE_DEPLOY_INGRESS, maxInstances: optionOverrides.maxInstances ?? bindingDeployConfig.maxInstances ?? catalogDeployConfig.maxInstances, memory: optionOverrides.memory ?? bindingDeployConfig.memory ?? catalogDeployConfig.memory ?? DEFAULT_RUNTIME_SERVICE_DEPLOY_MEMORY, minInstances: optionOverrides.minInstances ?? bindingDeployConfig.minInstances ?? catalogDeployConfig.minInstances ?? DEFAULT_RUNTIME_SERVICE_DEPLOY_MIN_INSTANCES, region: optionOverrides.region ?? bindingDeployConfig.region ?? catalogService.image.registryLocation ?? DEFAULT_RUNTIME_SERVICE_DEPLOY_REGION, serviceAccountEmail: optionOverrides.serviceAccountEmail ?? bindingDeployConfig.serviceAccountEmail ?? createDefaultSearchCloudRunServiceAccountEmail(projectId), timeout: optionOverrides.timeout ?? bindingDeployConfig.timeout ?? catalogDeployConfig.timeout ?? DEFAULT_RUNTIME_SERVICE_DEPLOY_TIMEOUT }; }; const ensureRuntimeServiceDeploySecrets = async (projectId, secrets = [], dependencies = {}) => { const createSecretImpl = dependencies.createSecret ?? createSecret; const randomBytesImpl = dependencies.randomBytes ?? crypto.randomBytes; const createdSecrets = []; const resolvedSecrets = []; const warnings = []; for (const secret of secrets) { try { runGcloudFileCommand(['secrets', 'describe', secret.secretName, `--project=${projectId}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); resolvedSecrets.push(secret); } catch (error) { if (!isGcloudResourceNotFoundError(error)) { throw new Error(`Could not inspect secret ${secret.secretName} for Atlas runtime service deployment. ${getCommandErrorMessage(error)}`); } if (secret.autoCreate === true) { const secretValue = randomBytesImpl(128).toString('base64'); const secretResult = await createSecretImpl(secret.secretName, secretValue, projectId); createdSecrets.push({ ...secretResult, env: secret.env }); resolvedSecrets.push(secret); continue; } if (secret.optional === true) { warnings.push(`Optional secret ${secret.secretName} was not found and will be skipped for this deployment.`); continue; } createRuntimeServiceDeployError(`Secret ${secret.secretName} does not exist in project ${projectId}. Create it first or mark it autoCreate in the runtime service config.`); } } return { createdSecrets, resolvedSecrets, warnings }; }; const createRuntimeServiceDeployCommandArgs = ({ allowUnauthenticated, deployConfig, imageReference, projectId, resolvedSecrets, resolvedVariables, resourceName }) => { const secretsArgumentValue = createGcloudMappingArgumentValue(resolvedSecrets.map(secret => ({ key: secret.env, value: `${secret.secretName}:${secret.version ?? 'latest'}` }))); const variablesArgumentValue = createGcloudMappingArgumentValue(resolvedVariables); return ['beta', 'run', 'deploy', resourceName, `--project=${projectId}`, '--platform=managed', `--region=${deployConfig.region}`, `--image=${imageReference}`, `--service-account=${deployConfig.serviceAccountEmail}`, `--cpu=${String(deployConfig.cpu)}`, `--memory=${deployConfig.memory}`, `--min-instances=${String(deployConfig.minInstances)}`, `--timeout=${deployConfig.timeout}`, `--ingress=${deployConfig.ingress}`, ...(deployConfig.concurrency !== undefined ? [`--concurrency=${String(deployConfig.concurrency)}`] : []), ...(deployConfig.maxInstances !== undefined ? [`--max-instances=${String(deployConfig.maxInstances)}`] : []), ...(variablesArgumentValue ? [`--update-env-vars=${variablesArgumentValue}`] : []), ...(secretsArgumentValue ? [`--update-secrets=${secretsArgumentValue}`] : []), allowUnauthenticated ? '--allow-unauthenticated' : '--no-allow-unauthenticated']; }; const resolveRuntimeServiceUrlAfterDeploy = ({ projectId, region, resourceName }, dependencies = {}) => { const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? ((args, options) => runGcloudFileCommand(args, options, dependencies.runCommand)); const parseGcloudJsonOutputImpl = dependencies.parseGcloudJsonOutput ?? parseGcloudJsonOutput; try { const output = runGcloudFileCommandImpl(['run', 'services', 'describe', resourceName, `--project=${projectId}`, `--region=${region}`, '--format=json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); const description = parseGcloudJsonOutputImpl(output, `Cloud Run service ${resourceName}`); const serviceUrl = normalizeOptionalString(description?.status?.url) ?? normalizeOptionalString(description?.status?.address?.url); if (serviceUrl) { return serviceUrl; } createRuntimeServiceDeployError(`Atlas could not resolve status.url for Cloud Run service ${resourceName} in project ${projectId}.`); } catch (error) { const detail = error instanceof Error ? normalizeOptionalString(getCommandErrorMessage(error)) ?? error.message : String(error); createRuntimeServiceDeployError(`Atlas could not resolve the deployed URL for runtime service ${resourceName} in project ${projectId}. ${detail}`); } }; const createNextRuntimeServiceRootConfig = (context, serviceName, nextBinding, dependencies = {}) => { const readJsonFileImpl = dependencies.readJsonFile ?? readJsonFile; const currentRootConfig = readJsonFileImpl(context.configPath, { allowMissing: true }) ?? context.config ?? { services: {} }; const bindingEnvironment = resolveRequiredRuntimeBindingEnvironment(context, serviceName); const nextServiceConfig = createNextRuntimeEnvironmentServiceConfig(currentRootConfig.services?.[serviceName], bindingEnvironment, nextBinding); const nextRootConfig = { ...currentRootConfig, services: { ...(currentRootConfig.services ?? {}), [serviceName]: nextServiceConfig } }; delete nextRootConfig.projects; return nextRootConfig; }; const persistRuntimeServiceConfig = (context, serviceName, nextBinding, dependencies = {}) => { const writeJsonFileImpl = dependencies.writeJsonFile ?? writeJsonFile; const nextRootConfig = createNextRuntimeServiceRootConfig(context, serviceName, nextBinding, dependencies); writeJsonFileImpl(context.configPath, nextRootConfig); return nextRootConfig; }; export const createRuntimeServiceDeployPlan = async (serviceName, options = {}, dependencies = {}, cwd = process.cwd()) => { const loadFeatureContextImpl = dependencies.loadFeatureContext ?? loadFeatureContext; const resolveRuntimeServiceCatalogServiceImpl = dependencies.resolveRuntimeServiceCatalogService ?? resolveRuntimeServiceCatalogService; const resolveArtifactRegistryDockerImageDigestImpl = dependencies.resolveArtifactRegistryDockerImageDigest ?? resolveArtifactRegistryDockerImageDigest; const resolveGoogleCloudProjectNumberImpl = dependencies.resolveGoogleCloudProjectNumber ?? resolveGoogleCloudProjectNumber; const createCloudRunServiceAgentEmailImpl = dependencies.createCloudRunServiceAgentEmail ?? createCloudRunServiceAgentEmail; const ensureArtifactRegistryReaderBindingImpl = dependencies.ensureArtifactRegistryReaderBinding ?? ensureArtifactRegistryReaderBinding; const context = await loadFeatureContextImpl('services', options, { cwd }); const currentRootServiceConfig = getCurrentRuntimeServiceRootConfig(context, serviceName); const currentCatalogVersion = resolveRequestedRuntimeServiceCatalogVersion(currentRootServiceConfig, context, options); const resolvedCatalog = resolveRuntimeServiceCatalogServiceImpl(serviceName, { ...(currentCatalogVersion ? { catalogVersion: currentCatalogVersion } : {}), runCommand: dependencies.runCommand }); const catalogService = resolvedCatalog.service; assertProjectScopedRuntimeCatalogService(catalogService); const currentBindingConfig = getCurrentRuntimeServiceBinding(currentRootServiceConfig, context); const optionOverrides = resolveRuntimeServiceDeployOptionOverrides(options); const selectedBindingConfig = await resolveRuntimeServiceSelection({ bindingConfig: currentBindingConfig, catalogService, optionOverrides, projectId: context.projectId, serviceName }, dependencies); const effectiveDeployConfig = resolveEffectiveRuntimeServiceDeployConfig({ bindingConfig: selectedBindingConfig, catalogService, optionOverrides: {}, projectId: context.projectId }); if (!effectiveDeployConfig.serviceAccountEmail) { createRuntimeServiceDeployError(`Atlas could not resolve a Cloud Run service account for runtime service ${serviceName}.`); } const taggedImageReference = createTaggedArtifactRegistryImageReference(catalogService.image); const digest = resolveArtifactRegistryDockerImageDigestImpl(taggedImageReference, { runCommand: dependencies.runCommand }); const digestPinnedImageReference = createDigestPinnedArtifactRegistryImageReference({ imageName: catalogService.image.imageName, location: catalogService.image.registryLocation, projectId: catalogService.image.registryProject, repository: catalogService.image.repository }, digest); const repositoryAccess = []; if (catalogService.image.registryProject !== context.projectId) { const consumerProjectNumber = resolveGoogleCloudProjectNumberImpl(context.projectId, { runCommand: dependencies.runCommand }); const serviceAgentEmail = createCloudRunServiceAgentEmailImpl(consumerProjectNumber); repositoryAccess.push(ensureArtifactRegistryReaderBindingImpl({ location: catalogService.image.registryLocation, projectId: catalogService.image.registryProject, repository: catalogService.image.repository }, serviceAgentEmail, { dryRun: options.dryRun === true, runCommand: dependencies.runCommand })); } const resolvedVariables = resolveRuntimeBindingVariables(selectedBindingConfig); const resolvedSecrets = mergeRuntimeServiceSecrets(resolveRuntimeCatalogSecrets(catalogService), resolveRuntimeBindingSecrets(selectedBindingConfig)); const secretResolution = await ensureRuntimeServiceDeploySecrets(context.projectId, resolvedSecrets, dependencies); const deployCommandArgs = createRuntimeServiceDeployCommandArgs({ allowUnauthenticated: effectiveDeployConfig.allowUnauthenticated === true, deployConfig: effectiveDeployConfig, imageReference: digestPinnedImageReference, projectId: context.projectId, resolvedSecrets: secretResolution.resolvedSecrets, resolvedVariables, resourceName: catalogService.image.imageName }); return { catalogService, catalogVersion: resolvedCatalog.catalogVersion, context, currentBindingConfig, currentRootServiceConfig, deployCommandArgs, digest, digestPinnedImageReference, effectiveDeployConfig, repositoryAccess, resolvedVariables, selectedBindingConfig, secretResolution, serviceName, taggedImageReference }; }; export const runRuntimeServiceDeploy = async (serviceName, options = {}, dependencies = {}, cwd = process.cwd()) => { const loggerImpl = dependencies.logger ?? logger; let spinner; try { const plan = await createRuntimeServiceDeployPlan(serviceName, options, dependencies, cwd); spinner = loggerImpl.spinner(options.dryRun === true ? `Preparing Atlas ${serviceName} runtime service dry run...` : `Deploying Atlas ${serviceName} runtime service...`); const runtimeConfigArtifact = previewGeneratedServiceConfigArtifact(plan.context, cwd); spinner.stop?.(); loggerImpl.summary('Runtime service deploy plan', [{ label: 'Service', value: plan.catalogService.displayName }, { label: 'Project', value: plan.context.projectId }, { label: 'Catalog version', value: plan.catalogVersion }, { label: 'Cloud Run service', value: plan.catalogService.image.imageName }, { label: 'Image', value: plan.digestPinnedImageReference }, { label: 'Region', value: plan.effectiveDeployConfig.region }, { label: 'Service account', value: plan.effectiveDeployConfig.serviceAccountEmail }, { label: 'Unauthenticated', value: plan.effectiveDeployConfig.allowUnauthenticated === true ? 'yes' : 'no' }]); for (const warning of plan.secretResolution.warnings) { loggerImpl.warning(warning); } for (const accessResult of plan.repositoryAccess) { if (accessResult.status === 'would-create') { loggerImpl.warning(`Dry run: would grant ${accessResult.role} to ${accessResult.serviceAgentEmail} on ${accessResult.repositoryUri}.`); } } if (options.dryRun === true) { loggerImpl.info(`Command: gcloud ${plan.deployCommandArgs.join(' ')}`); loggerImpl.info(`Build-time runtime config: ${runtimeConfigArtifact.filePath}`); return { catalogVersion: plan.catalogVersion, image: plan.digestPinnedImageReference, repositoryAccess: plan.repositoryAccess, runtimeConfigArtifact, secrets: plan.secretResolution, serviceName: plan.catalogService.id, status: 'dry-run' }; } runGcloudFileCommand(plan.deployCommandArgs, { stdio: 'inherit' }, dependencies.runCommand); const serviceUrl = resolveRuntimeServiceUrlAfterDeploy({ projectId: plan.context.projectId, region: plan.effectiveDeployConfig.region, resourceName: plan.catalogService.image.imageName }, dependencies); const nextBinding = { ...plan.selectedBindingConfig, enabled: true, release: { ...(plan.selectedBindingConfig.release ?? {}), catalogVersion: plan.catalogVersion, image: plan.digestPinnedImageReference }, runtime: { ...(isPlainObject(plan.selectedBindingConfig.runtime) ? plan.selectedBindingConfig.runtime : {}), serviceUrl } }; const rootConfig = persistRuntimeServiceConfig(plan.context, plan.catalogService.id, nextBinding, dependencies); loggerImpl.info(`Atlas runtime service ${plan.catalogService.displayName} deployed with catalog version ${plan.catalogVersion}.`); loggerImpl.info(`Runtime service URL: ${serviceUrl}`); loggerImpl.info(`Build-time runtime config: ${runtimeConfigArtifact.filePath}`); return { catalogVersion: plan.catalogVersion, image: plan.digestPinnedImageReference, repositoryAccess: plan.repositoryAccess, rootConfig, runtimeConfigArtifact, secrets: plan.secretResolution, serviceName: plan.catalogService.id, status: 'deployed' }; } catch (error) { spinner?.fail?.(`Failed to deploy Atlas runtime service ${serviceName}.`); throw error; } }; export default runRuntimeServiceDeploy;