UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

213 lines 10.3 kB
import fs from 'fs'; import inquirer from 'inquirer'; import { logger } from '../../utils/index.js'; import { findService } from './serviceRegistry.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 { createCompleteRuntimeEnvironmentConfig, resolveRuntimeBindingEnvironment, resolveRuntimeEnvironmentBinding } from './configShape.js'; import { getCommandErrorMessage, isGcloudResourceNotFoundError, runGcloudFileCommand } from '../../utils/gcloud.js'; const DEFAULT_RUNTIME_SERVICE_DESTROY_REGION = 'europe-west1'; const createBuiltInServiceDestroyError = serviceName => new Error('Atlas service destroy currently supports only runtime Cloud Run services. ' + `"${serviceName}" is a built-in service and must be removed through its dedicated lifecycle.`); const createRuntimeServiceDestroyError = message => { throw new Error(message); }; const getCurrentRuntimeServiceRootConfig = (context, serviceName) => context.config?.services?.[serviceName] ?? {}; const getCurrentRuntimeServiceBinding = (rootConfig, context) => resolveRuntimeEnvironmentBinding(rootConfig, context.environment) ?? {}; const resolveRuntimeServiceCurrentCatalogVersion = (rootConfig, context) => normalizeOptionalString(getCurrentRuntimeServiceBinding(rootConfig, context).release?.catalogVersion); const resolveRequiredRuntimeBindingEnvironment = (context, serviceName) => { const bindingEnvironment = resolveRuntimeBindingEnvironment(context.environment); if (bindingEnvironment !== null) { return bindingEnvironment; } createRuntimeServiceDestroyError(`Atlas could not resolve the runtime service environment binding for ${serviceName} in project ${context.projectId}. Add a development or production mapping to .firebaserc.`); }; const assertProjectScopedRuntimeCatalogService = service => { const scope = normalizeOptionalString(service?.serviceDeploy?.scope); if (scope === 'project') { return; } createRuntimeServiceDestroyError(`Atlas runtime service ${service?.id ?? '(unknown)'} must define serviceDeploy.scope as "project".`); }; const resolveRuntimeServiceDestroyRegion = (bindingConfig, catalogService) => normalizeOptionalString(bindingConfig?.deploy?.region) ?? normalizeOptionalString(catalogService?.image?.registryLocation) ?? DEFAULT_RUNTIME_SERVICE_DESTROY_REGION; const createNextRuntimeServiceRootConfigWithoutSelectedBinding = (context, serviceName, dependencies = {}) => { const readJsonFileImpl = dependencies.readJsonFile ?? readJsonFile; const currentRootConfig = readJsonFileImpl(context.configPath, { allowMissing: true }) ?? context.rootConfig; if (!currentRootConfig) { return null; } const nextRootConfig = { ...currentRootConfig, services: { ...(currentRootConfig.services ?? {}) } }; const bindingEnvironment = resolveRequiredRuntimeBindingEnvironment(context, serviceName); const nextServiceConfig = createCompleteRuntimeEnvironmentConfig(currentRootConfig.services?.[serviceName]); nextServiceConfig[bindingEnvironment] = { enabled: false }; nextRootConfig.services[serviceName] = nextServiceConfig; delete nextRootConfig.projects; return nextRootConfig; }; const persistRuntimeServiceDestroyConfig = (context, serviceName, dependencies = {}) => { const writeJsonFileImpl = dependencies.writeJsonFile ?? writeJsonFile; const nextRootConfig = createNextRuntimeServiceRootConfigWithoutSelectedBinding(context, serviceName, dependencies); if (!nextRootConfig) { return null; } writeJsonFileImpl(context.configPath, nextRootConfig); return nextRootConfig; }; const executeRuntimeServiceDestroy = (destroyPlan, dependencies = {}) => { try { runGcloudFileCommand(destroyPlan.destroyCommandArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); return { status: 'deleted' }; } catch (error) { if (isGcloudResourceNotFoundError(error)) { return { status: 'not-found' }; } const detail = normalizeOptionalString(getCommandErrorMessage(error)) ?? error.message; createRuntimeServiceDestroyError(`Atlas could not destroy Cloud Run service ${destroyPlan.resourceName} in project ${destroyPlan.context.projectId}. ${detail}`); } }; const removeRuntimeServiceGeneratedConfigArtifact = (destroyPlan, dependencies = {}) => { const fsImpl = dependencies.fsImpl ?? fs; fsImpl.rmSync(destroyPlan.runtimeConfigArtifact.filePath, { force: true }); }; export const createRuntimeServiceDestroyPlan = async (serviceName, options = {}, dependencies = {}, cwd = process.cwd()) => { const loadFeatureContextImpl = dependencies.loadFeatureContext ?? loadFeatureContext; const resolveRuntimeServiceCatalogServiceImpl = dependencies.resolveRuntimeServiceCatalogService ?? resolveRuntimeServiceCatalogService; const context = await loadFeatureContextImpl('services', options, { cwd }); const currentRootServiceConfig = getCurrentRuntimeServiceRootConfig(context, serviceName); const currentCatalogVersion = normalizeOptionalString(options.catalogVersion) ?? resolveRuntimeServiceCurrentCatalogVersion(currentRootServiceConfig, context) ?? null; const resolvedCatalog = resolveRuntimeServiceCatalogServiceImpl(serviceName, { ...(currentCatalogVersion ? { catalogVersion: currentCatalogVersion } : {}), runCommand: dependencies.runCommand }); const catalogService = resolvedCatalog.service; assertProjectScopedRuntimeCatalogService(catalogService); const currentBindingConfig = getCurrentRuntimeServiceBinding(currentRootServiceConfig, context); const region = resolveRuntimeServiceDestroyRegion(currentBindingConfig, catalogService); return { catalogService, catalogVersion: resolvedCatalog.catalogVersion, context, currentBindingConfig, currentRootServiceConfig, destroyCommandArgs: ['run', 'services', 'delete', catalogService.image.imageName, `--project=${context.projectId}`, '--platform=managed', `--region=${region}`, '--quiet'], region, resourceName: catalogService.image.imageName, runtimeConfigArtifact: previewGeneratedServiceConfigArtifact(context, cwd), serviceName }; }; export const runRuntimeServiceDestroy = async (serviceName, options = {}, dependencies = {}, cwd = process.cwd()) => { const loggerImpl = dependencies.logger ?? logger; const promptImpl = dependencies.prompt ?? inquirer.prompt; let spinner; try { const destroyPlan = await createRuntimeServiceDestroyPlan(serviceName, options, dependencies, cwd); spinner = loggerImpl.spinner(`Preparing Atlas ${serviceName} runtime service destroy plan...`); spinner.succeed('Atlas runtime service destroy plan is ready.'); loggerImpl.summary('Runtime service destroy plan', [{ label: 'Service', value: destroyPlan.catalogService.displayName }, { label: 'Project', value: destroyPlan.context.projectId }, { label: 'Catalog version', value: destroyPlan.catalogVersion }, { label: 'Cloud Run service', value: destroyPlan.resourceName }, { label: 'Region', value: destroyPlan.region }, { label: 'Config path', value: destroyPlan.context.configPath }, { label: 'Generated config', value: destroyPlan.runtimeConfigArtifact.filePath }]); const confirmation = await promptImpl([{ default: false, type: 'confirm', name: 'shouldDestroy', message: `Continue deleting Cloud Run service ${destroyPlan.resourceName}?` }]); if (!confirmation.shouldDestroy) { loggerImpl.warning('Atlas runtime service destroy aborted.'); return { serviceName: destroyPlan.catalogService.id, status: 'aborted' }; } spinner = loggerImpl.spinner(`Destroying Atlas runtime service ${destroyPlan.catalogService.displayName}...`); const remoteResult = executeRuntimeServiceDestroy(destroyPlan, dependencies); const rootConfig = persistRuntimeServiceDestroyConfig(destroyPlan.context, destroyPlan.catalogService.id, dependencies); removeRuntimeServiceGeneratedConfigArtifact(destroyPlan, dependencies); spinner.succeed('Atlas runtime service destroyed successfully.'); if (remoteResult.status === 'not-found') { loggerImpl.warning(`Cloud Run service ${destroyPlan.resourceName} was not found in project ${destroyPlan.context.projectId}. ` + ' Atlas still cleared the local runtime service binding for the selected project.'); } loggerImpl.info(`Atlas runtime service ${destroyPlan.catalogService.displayName} binding for project ${destroyPlan.context.projectId} cleared in ${destroyPlan.context.configPath}.`); return { remoteStatus: remoteResult.status, rootConfig, runtimeConfigArtifact: destroyPlan.runtimeConfigArtifact, serviceName: destroyPlan.catalogService.id, status: 'destroyed' }; } catch (error) { spinner?.fail('Failed to destroy Atlas runtime service.'); throw error; } }; export const createServiceDestroyHandlers = (dependencies = {}) => { const findConfiguredService = dependencies.findService ?? findService; const runConfiguredRuntimeServiceDestroy = dependencies.runRuntimeServiceDestroy ?? runRuntimeServiceDestroy; return { destroy: (serviceName, options) => { const registeredService = findConfiguredService(serviceName); if (registeredService) { throw createBuiltInServiceDestroyError(registeredService.id); } return runConfiguredRuntimeServiceDestroy(serviceName, options); } }; }; export const destroyService = async (serviceName, options, { exitImpl = code => process.exit(code), loggerImpl = logger, ...dependencies } = {}) => { try { return await createServiceDestroyHandlers(dependencies).destroy(serviceName, options); } catch (error) { loggerImpl.error(error.message, false); exitImpl(1); return undefined; } }; export default async (serviceName, options) => destroyService(serviceName, options);