@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
650 lines • 32.3 kB
JavaScript
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;