UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

255 lines 11.7 kB
import * as features from '../../utils/feature.js'; import { logger } from '../../utils/index.js'; import { runSearchReleasePromote } from './releasePromote.js'; import { parseRequestedSearchIndexes } from './indexScope.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { getSearchProvider, getSearchProviderDisplayName } from './providers/index.js'; import { createSearchSchemaLifecycleEntries, createSearchSchemaLifecycleGuidanceEntries, createSearchSchemaLifecycleSummaryRows } from './schemaLifecycle.js'; import { cleanupSearchPromotionViaSearchApi, cutoverSearchPromotionViaSearchApi, rollbackSearchPromotionViaSearchApi } from './searchApiAdmin.js'; const SUPPORTED_PROMOTE_MODES = new Set(['cleanup', 'cutover', 'manual', 'release', 'rollback']); const SUPPORTED_INDEX_SCOPED_PROMOTE_MODES = new Set(['cleanup', 'cutover', 'rollback']); const normalizePromoteMode = mode => normalizeOptionalString(mode)?.toLowerCase() ?? 'cutover'; const normalizePromoteIndexes = indexes => normalizeOptionalString(indexes); const normalizeProviderPromotionResult = (providerResult, context, releaseResult) => ({ message: providerResult?.message ?? 'No provider promotion details were returned by the lifecycle adapter.', releaseId: providerResult?.releaseId ?? releaseResult?.release?.active ?? null, status: providerResult?.status ?? 'not-supported', target: providerResult?.target ?? context.config?.release?.strategy ?? 'provider-runtime' }); const formatCutoverIndexValue = (actions, key) => { const normalizedActions = Array.isArray(actions) ? actions : []; if (normalizedActions.length === 0) { return 'not set'; } return normalizedActions.map(action => `${action.indexName}=${action[key] ?? 'not set'}`).join(', '); }; const normalizeRuntimePromotionResult = (runtimeResult, { idleMessage, status, successfulAction, successMessage }) => { const actions = Array.isArray(runtimeResult?.actions) ? runtimeResult.actions : []; const successCount = actions.filter(action => action.action === successfulAction).length; const skippedCount = actions.length - successCount; const warnings = Array.isArray(runtimeResult?.warnings) ? runtimeResult.warnings : []; return { actions, message: successCount > 0 ? successMessage(successCount) : idleMessage, skippedCount, status: successCount > 0 ? status : 'skipped', successCount, warnings }; }; const resolveRuntimePromotionAuditTitle = mode => { switch (mode) { case 'manual': return 'Promotion audit'; case 'rollback': return 'Runtime rollback audit'; case 'cleanup': return 'Runtime cleanup audit'; case 'cutover': default: return 'Runtime cutover audit'; } }; export const runSearchPromote = async (options = {}, dependencies = {}, cwd = process.cwd()) => { const cleanupSearchPromotionViaSearchApiImpl = dependencies.cleanupSearchPromotionViaSearchApi ?? cleanupSearchPromotionViaSearchApi; const mode = normalizePromoteMode(options.mode); const cutoverSearchPromotionViaSearchApiImpl = dependencies.cutoverSearchPromotionViaSearchApi ?? cutoverSearchPromotionViaSearchApi; const loggerImpl = dependencies.logger ?? logger; const exit = dependencies.exit ?? (code => process.exit(code)); const loadFeatureContextImpl = dependencies.loadFeatureContext ?? features.loadFeatureContext; const getSearchProviderImpl = dependencies.getSearchProvider ?? getSearchProvider; const rollbackSearchPromotionViaSearchApiImpl = dependencies.rollbackSearchPromotionViaSearchApi ?? rollbackSearchPromotionViaSearchApi; const runSearchReleasePromoteImpl = dependencies.runSearchReleasePromote ?? runSearchReleasePromote; if (!SUPPORTED_PROMOTE_MODES.has(mode)) { loggerImpl.error('Atlas search promote supports --mode cutover, --mode rollback, --mode cleanup, --mode release or --mode manual.', false); return exit(1); } let runtimePromotionResult = null; let releasePromotionResult; let providerPromotionResult = null; let context = await loadFeatureContextImpl('search', options, { cwd }); const configuredIndexNames = Object.keys(context.config?.indexes ?? {}); const indexesArgument = normalizePromoteIndexes(options.indexes); const requestedIndexNames = configuredIndexNames.length > 0 || indexesArgument ? parseRequestedSearchIndexes(indexesArgument, configuredIndexNames) : []; const usesIndexScope = requestedIndexNames.length > 0 && requestedIndexNames.length !== configuredIndexNames.length; const targetIndexesSummary = usesIndexScope && requestedIndexNames.length > 0 ? requestedIndexNames.join(', ') : 'all configured indexes'; if (usesIndexScope && !SUPPORTED_INDEX_SCOPED_PROMOTE_MODES.has(mode)) { loggerImpl.error('Atlas search promote supports --indexes only with --mode cutover, --mode rollback or --mode cleanup.', false); return exit(1); } if (mode === 'manual') { loggerImpl.info('Manual promotion mode selected.'); releasePromotionResult = { configPath: context.configPath, previousRelease: context.config.release ?? null, release: context.config.release ?? null, status: 'skipped' }; providerPromotionResult = { message: 'Manual promotion mode does not execute runtime cutover or provider promotion.', releaseId: context.config.release?.active ?? null, status: 'not-supported', target: 'manual' }; } else if (mode === 'cutover' || mode === 'rollback' || mode === 'cleanup') { let runtimeOperation; if (mode === 'cutover') { runtimeOperation = { execute: () => cutoverSearchPromotionViaSearchApiImpl(context, { ...(usesIndexScope ? { indexNames: requestedIndexNames } : {}) }, dependencies), failureMessage: 'Failed to promote Atlas search runtime cutover.', spinnerMessage: 'Promoting Atlas search runtime cutover...', successMessage: 'Atlas search runtime cutover promoted.', transform: result => normalizeRuntimePromotionResult(result, { idleMessage: 'No promotable candidate targets were available for cutover.', status: 'cutover', successfulAction: 'cutover', successMessage: count => `Cut over ${count} search index target(s).` }) }; } else if (mode === 'rollback') { runtimeOperation = { execute: () => rollbackSearchPromotionViaSearchApiImpl(context, { ...(usesIndexScope ? { indexNames: requestedIndexNames } : {}) }, dependencies), failureMessage: 'Failed to roll back Atlas search runtime cutover.', spinnerMessage: 'Rolling back Atlas search runtime cutover...', successMessage: 'Atlas search runtime cutover rolled back.', transform: result => normalizeRuntimePromotionResult(result, { idleMessage: 'No rollback targets were available for rollback.', status: 'rolled-back', successfulAction: 'rolled-back', successMessage: count => `Rolled back ${count} search index target(s).` }) }; } else { runtimeOperation = { execute: () => cleanupSearchPromotionViaSearchApiImpl(context, { force: options.force === true, ...(usesIndexScope ? { indexNames: requestedIndexNames } : {}) }, dependencies), failureMessage: 'Failed to clean up Atlas search rollback targets.', spinnerMessage: 'Cleaning up Atlas search rollback targets...', successMessage: 'Atlas search rollback targets cleaned up.', transform: result => normalizeRuntimePromotionResult(result, { idleMessage: 'No rollback targets were eligible for cleanup.', status: 'cleanup', successfulAction: 'cleaned', successMessage: count => `Cleaned up ${count} rollback target(s).` }) }; } const spinner = loggerImpl.spinner(runtimeOperation.spinnerMessage); try { runtimePromotionResult = runtimeOperation.transform(await runtimeOperation.execute()); spinner.succeed(runtimeOperation.successMessage); } catch (error) { spinner.fail(runtimeOperation.failureMessage); loggerImpl.error(error.message, false); return exit(1); } } else { releasePromotionResult = await runSearchReleasePromoteImpl(options, dependencies, cwd); if (releasePromotionResult?.status === 'failed') { return releasePromotionResult; } context = await loadFeatureContextImpl('search', options, { cwd }); const provider = getSearchProviderImpl(context.config.provider); providerPromotionResult = normalizeProviderPromotionResult(await provider.promoteRelease(context, { mode, releaseResult: releasePromotionResult, runCommand: dependencies.runCommand }), context, releasePromotionResult); loggerImpl.summary('Promotion audit', [{ label: 'Mode', value: mode }, { label: 'Config release promotion', value: releasePromotionResult?.status ?? 'unknown' }, { label: 'Provider', value: getSearchProviderDisplayName(provider, context.config.provider) }, { label: 'Provider promotion status', value: providerPromotionResult.status }, { label: 'Provider target release', value: providerPromotionResult.releaseId ?? 'not set' }, { label: 'Provider details', value: providerPromotionResult.message }]); return { ...releasePromotionResult, mode, providerPromotion: providerPromotionResult }; } const provider = getSearchProviderImpl(context.config.provider); loggerImpl.summary(resolveRuntimePromotionAuditTitle(mode), [{ label: 'Runtime mode', value: mode }, { label: 'Target indexes', value: targetIndexesSummary }, { label: 'Provider', value: getSearchProviderDisplayName(provider, context.config.provider) }, { label: 'Runtime action status', value: runtimePromotionResult?.status ?? releasePromotionResult?.status ?? 'unknown' }, { label: 'Active target', value: formatCutoverIndexValue(runtimePromotionResult?.actions, 'target') }, { label: 'Rollback target', value: formatCutoverIndexValue(runtimePromotionResult?.actions, 'rollbackTarget') }, { label: 'Runtime details', value: runtimePromotionResult?.message ?? 'No runtime promotion action was executed.' }]); const lifecycleSummaryRows = createSearchSchemaLifecycleSummaryRows(provider, runtimePromotionResult?.actions ?? [], { includeAffectedIndexNames: true }); if (lifecycleSummaryRows.length > 0) { loggerImpl.summary('Resulting runtime lifecycle', lifecycleSummaryRows); } const lifecycleEntries = createSearchSchemaLifecycleEntries(provider, runtimePromotionResult?.actions ?? []); if (lifecycleEntries.length > 0) { loggerImpl.summary('Resulting runtime lifecycle details', lifecycleEntries); } const guidanceEntries = createSearchSchemaLifecycleGuidanceEntries(runtimePromotionResult?.actions ?? [], { context }); if (guidanceEntries.length > 0) { loggerImpl.summary('Next steps', guidanceEntries); } for (const warning of runtimePromotionResult?.warnings ?? []) { loggerImpl.warning(warning); } return { ...releasePromotionResult, cutover: mode === 'cutover' ? runtimePromotionResult : null, mode, providerPromotion: providerPromotionResult, runtimePromotion: runtimePromotionResult, status: runtimePromotionResult?.status ?? releasePromotionResult?.status ?? 'skipped' }; }; export default async options => runSearchPromote(options);