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