UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

159 lines 6.58 kB
import fs from 'fs'; import path from 'path'; import inquirer from 'inquirer'; import { execFileSync as nodeExecFileSync } from 'child_process'; import * as features from '../../utils/feature.js'; import { SEARCH_RESOURCE_NAMES } from './resourceNames.js'; import { getSearchProvider } from './providers/index.js'; import { resolveSearchCloudRunDeployConfig } from './planning.js'; import { deleteFirestorePathRecursively } from '../../utils/firestore.js'; import { formatShellCommand, isGcloudResourceNotFoundError, logger, runGcloudFileCommand } from '../../utils/index.js'; import { getAtlasFeatureCachePath, getAtlasGeneratedArtifactsDir, getAtlasGeneratedFeatureConfigPath } from '../../utils/atlas.js'; import { getSearchTerraformBackfillJobArtifactPath, writeSearchTerraformBackfillJobArtifact } from './terraformAdapter.js'; import { createSearchTerraformWorkflowSummary, runSearchTerraformWorkflow } from './terraformWorkflow.js'; const SEARCH_STATE_DOCUMENT_PATH = 'sync/search'; export const createSearchDestroyPlan = (context, cwd = process.cwd()) => { const { region } = resolveSearchCloudRunDeployConfig(context); const commands = []; for (const serviceName of [SEARCH_RESOURCE_NAMES.searchSyncService, SEARCH_RESOURCE_NAMES.searchApiService]) { const args = ['run', 'services', 'delete', serviceName, `--project=${context.projectId}`, '--platform=managed', `--region=${region}`, '--quiet']; commands.push({ args, command: formatShellCommand(['gcloud', ...args]), description: `Delete Cloud Run service ${serviceName}`, executable: 'gcloud', kind: 'service', tolerateNotFound: true }); } return { commands, firestoreCleanupPaths: [SEARCH_STATE_DOCUMENT_PATH], projectId: context.projectId, localCleanupPaths: [getAtlasFeatureCachePath('search', context.projectId, cwd), getAtlasGeneratedFeatureConfigPath('search', context.projectId, cwd), path.join(getAtlasGeneratedArtifactsDir(cwd), 'search', 'mappers', context.projectId), path.join(cwd, '.atlas', 'artifacts', 'search', 'mappers', context.projectId), getSearchTerraformBackfillJobArtifactPath(context.projectId, cwd), path.join(cwd, '.atlas', 'terraform', 'search', `search.${context.projectId}.tfvars.json`)], terraform: createSearchTerraformWorkflowSummary(context.config, cwd) }; }; export const executeSearchDestroyPlan = async (destroyPlan, options = {}) => { const { runCommand = nodeExecFileSync } = options; const deleteFirestorePathRecursivelyImpl = options.deleteFirestorePathRecursivelyImpl ?? deleteFirestorePathRecursively; const results = []; for (const command of destroyPlan.commands) { try { if (command.executable === 'gcloud') { runGcloudFileCommand(command.args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, runCommand); } else { runCommand(command.executable, command.args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } results.push({ kind: command.kind, status: 'deleted' }); } catch (error) { if (command.tolerateNotFound && isGcloudResourceNotFoundError(error)) { results.push({ kind: command.kind, status: 'not-found' }); continue; } throw error; } } for (const firestorePath of destroyPlan.firestoreCleanupPaths ?? []) { await deleteFirestorePathRecursivelyImpl({ firestorePath, projectId: destroyPlan.projectId }); results.push({ kind: 'firestore', status: 'deleted' }); } for (const cleanupPath of destroyPlan.localCleanupPaths) { if (!fs.existsSync(cleanupPath)) { continue; } fs.rmSync(cleanupPath, { force: true, recursive: true }); } return results; }; const logDestroyPlan = destroyPlan => { logger.summary('Destroy summary', [{ label: 'Project', value: destroyPlan.projectId }, { label: 'Terraform root', value: destroyPlan.terraform.rootPath }, { label: 'Terraform module source', value: destroyPlan.terraform.moduleSource }, { label: 'Terraform-managed job', value: SEARCH_RESOURCE_NAMES.backfillJob }, { label: 'Firestore cleanup', value: (destroyPlan.firestoreCleanupPaths ?? []).join(', ') || 'none' }]); logger.summary('Planned remote destroy commands', destroyPlan.commands.map(command => chalk => chalk.gray(command.command)), { emptyMessage: 'No remote Atlas search commands need to run.' }); logger.summary('Local cleanup paths', destroyPlan.localCleanupPaths.map(cleanupPath => chalk => chalk.gray(cleanupPath)), { emptyMessage: 'No local cleanup paths are configured.' }); logger.summary('Firestore cleanup paths', (destroyPlan.firestoreCleanupPaths ?? []).map(firestorePath => chalk => chalk.gray(firestorePath)), { emptyMessage: 'No Firestore cleanup paths are configured.' }); }; export default async options => { let spinner; try { const context = await features.loadFeatureContext('search', options, { cwd: process.cwd() }); spinner = logger.spinner('Preparing Atlas search destroy plan...'); const provider = getSearchProvider(context.config.provider); const destroyPlan = createSearchDestroyPlan(context, process.cwd()); spinner.succeed('Atlas search destroy plan is ready.'); logDestroyPlan(destroyPlan); const confirmation = await inquirer.prompt([{ default: false, type: 'confirm', name: 'shouldDestroy', message: 'Continue destroying Atlas search Terraform, Eventarc-managed Cloud Run resources, persistent Firestore search state, and local generated search artifacts?' }]); if (!confirmation.shouldDestroy) { logger.warning('Atlas search destroy aborted.'); return; } const destroySpinner = logger.spinner('Destroying Atlas search resources...'); const terraformArtifact = writeSearchTerraformBackfillJobArtifact(context, {}, process.cwd()); await runSearchTerraformWorkflow(context.config, terraformArtifact, { mode: 'destroy' }, {}, process.cwd()); await executeSearchDestroyPlan(destroyPlan); destroySpinner.succeed('Atlas search resources destroyed successfully.'); if (provider.getDestroyNotice) { logger.warning(provider.getDestroyNotice(context)); } } catch (error) { if (spinner) { spinner.fail('Failed to destroy Atlas search resources.'); } logger.error(error.message, false); process.exit(1); } };