UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

879 lines 38.3 kB
import { execFileSync } from 'child_process'; import { isPlainObject } from 'es-toolkit/compat'; import * as features from '../../utils/feature.js'; import { parseRequestedSearchIndexes } from './indexScope.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { createSearchReindexSessionId } from './reindexSession.js'; import { materializeGcloudCommandFlagsFile } from '../../utils/gcloud.js'; import { PULS_ATLAS_SEARCH_REINDEX_SESSION_ID_ENV } from './runtimeEnv.js'; import { getCommandErrorMessage, isGcloudResourceNotFoundError, logger, parseGcloudJsonOutput, runGcloudFileCommand } from '../../utils/index.js'; import { createSearchBackfillJobExecutionCommand, ensureSearchBackfillJobExists } from './backfillJob.js'; import { createUnsupportedSearchBackfillIndexMessage, createUnsupportedSearchReindexIndexMessage, resolveSearchBackfillSupportEntries } from './planning.js'; import { createSearchSourceRunnerJobExecutionCommand, ensureSearchSourceRunnerJobExists } from './sources/index.js'; import { finalizeSourceReindexViaSearchApi, prepareSourceReindexViaSearchApi } from './searchApiAdmin.js'; const SUPPORTED_RUN_ACTIONS = new Set(['backfill', 'reindex', 'source']); const DEFAULT_FOLLOW_TIMEOUT_SECONDS = 1800; const DEFAULT_FOLLOW_POLL_INTERVAL_MS = 5000; const EXECUTION_FAILURE_DETAILS_LIMIT = 5; const SEARCH_RUN_EXECUTION_NAME_FORMAT = 'value(metadata.name)'; const FOLLOW_INTERRUPT_HINT = 'Press Ctrl+C to stop following. Atlas will leave the Cloud Run execution running.'; const normalizeRunAction = action => normalizeOptionalString(action)?.toLowerCase() ?? null; const normalizeRunIndexes = indexes => normalizeOptionalString(indexes); const normalizeTimeoutSeconds = timeout => { if (timeout === undefined || timeout === null || timeout === '') { return DEFAULT_FOLLOW_TIMEOUT_SECONDS; } const parsedValue = Number.parseInt(timeout, 10); if (!Number.isInteger(parsedValue) || parsedValue <= 0) { throw new Error('Atlas search run requires --timeout <seconds> to be a positive integer.'); } return parsedValue; }; const logSearchOperation = (loggerImpl, context, action, command, indexNames) => { loggerImpl.summary('Atlas search run', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Config', value: context.configPath }, { label: 'Action', value: action }, { label: 'Indexes', value: indexNames.join(', ') }, { label: 'Command', value: command.command }, command.environmentVariables[PULS_ATLAS_SEARCH_REINDEX_SESSION_ID_ENV] ? { label: 'Reindex session id', value: command.environmentVariables[PULS_ATLAS_SEARCH_REINDEX_SESSION_ID_ENV] } : null]); }; const executeSearchRunCommand = (command, options = {}) => { const { captureExecutionName = false, runCommand = execFileSync } = options; const materializedFlagsFile = materializeGcloudCommandFlagsFile(command); try { const output = runGcloudFileCommand(captureExecutionName ? [...(materializedFlagsFile?.args ?? command.args), `--format=${SEARCH_RUN_EXECUTION_NAME_FORMAT}`] : materializedFlagsFile?.args ?? command.args, captureExecutionName ? { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } : { stdio: 'inherit' }, runCommand); return { executionName: captureExecutionName ? parseExecutionNameFromList(output) : null }; } finally { materializedFlagsFile?.cleanup(); } }; const resolveStartedExecutionName = executionResult => normalizeOptionalString(isPlainObject(executionResult) ? executionResult.executionName : executionResult); const summarizeSourceReindexSkippedActions = actions => (Array.isArray(actions) ? actions : []).filter(action => action?.action === 'skipped').map(action => `${action.indexName ?? 'unknown'} (${action.reason ?? 'skipped'})`); const ensureCompletedSourceReindexStage = (stageLabel, response) => { const skippedActions = summarizeSourceReindexSkippedActions(response?.actions); if (skippedActions.length > 0) { throw new Error(`Atlas search source-backed reindex ${stageLabel} did not complete for: ${skippedActions.join(', ')}.`); } }; const resolveSourceBackedReindexPlan = (context, indexesArgument, now) => { const indexNames = parseRequestedSearchIndexes(indexesArgument, Object.keys(context.config.indexes)); const supportEntries = resolveSearchBackfillSupportEntries(context, indexNames); const backfillEntries = supportEntries.filter(indexEntry => indexEntry.supportsBackfill); const sourceReindexEntries = supportEntries.filter(indexEntry => !indexEntry.supportsBackfill && indexEntry.supportsSourceReindex); const unsupportedEntries = supportEntries.filter(indexEntry => !indexEntry.supportsBackfill && indexEntry.enabledSourceNames.length > 0 && !indexEntry.supportsSourceReindex); if (sourceReindexEntries.length === 0) { return null; } if (backfillEntries.length > 0) { throw new Error(['Atlas search reindex currently requires separate runs for Firestore-backed and source-backed indexes.', `Rerun "atlas search run reindex --indexes ${sourceReindexEntries.map(indexEntry => indexEntry.indexName).join(',')}" for the source-backed indexes.`, `Rerun "atlas search run reindex --indexes ${backfillEntries.map(indexEntry => indexEntry.indexName).join(',')}" for the Firestore-backed indexes.`].join(' ')); } if (unsupportedEntries.length > 0) { throw new Error(unsupportedEntries.map(indexEntry => createUnsupportedSearchReindexIndexMessage(indexEntry)).join(' ')); } const sourceGroups = Object.entries(sourceReindexEntries.reduce((groups, indexEntry) => { const sourceName = indexEntry.sourceReindexSourceNames[0]; groups[sourceName] = groups[sourceName] ?? []; groups[sourceName].push(indexEntry.indexName); return groups; }, {})).map(([sourceName, scopedIndexNames]) => ({ indexNames: scopedIndexNames.sort((left, right) => left.localeCompare(right)), sourceName })).sort((left, right) => left.sourceName.localeCompare(right.sourceName)); return { indexNames: sourceReindexEntries.map(indexEntry => indexEntry.indexName), sessionId: createSearchReindexSessionId(now), sourceGroups }; }; const logSourceBackedReindexPlan = (loggerImpl, context, plan, commands = []) => { loggerImpl.summary('Atlas search source-backed reindex', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Config', value: context.configPath }, { label: 'Indexes', value: plan.indexNames.join(', ') }, { label: 'Sources', value: plan.sourceGroups.map(group => group.sourceName).join(', ') }, { label: 'Reindex session id', value: plan.sessionId }]); for (const command of commands) { loggerImpl.info(`${command.phase} (${command.sourceName}): ${command.command}`); } }; const logWatchedSearchRunFailure = (loggerImpl, operationLabel, operationResult, watchResult, resolveSearchRunExecutionFailureDetailsImpl, dependencies) => { if (watchResult.status === 'timeout') { loggerImpl.error(`${operationLabel} timed out after ${watchResult.timeoutSeconds} seconds.`, false); return; } loggerImpl.error(`${operationLabel} failed${watchResult.summary?.reason ? `: ${watchResult.summary.reason}` : '.'}`, false); const failureDetails = resolveSearchRunExecutionFailureDetailsImpl(operationResult, watchResult, dependencies); if (failureDetails.length > 0) { loggerImpl.summary('Recent execution error details', failureDetails.map((detail, index) => ({ label: `Detail ${index + 1}`, value: detail }))); } }; export const runSearchOperation = async (action, indexesArgument, commandOptions = {}, dependencies = {}, cwd = process.cwd()) => { const options = commandOptions ?? {}; const loadFeatureContext = dependencies.loadFeatureContext ?? features.loadFeatureContext; const loggerImpl = dependencies.logger ?? logger; const now = dependencies.now ?? (() => new Date()); const runCommand = dependencies.runCommand ?? execFileSync; const ensureSearchBackfillJobExistsImpl = dependencies.ensureSearchBackfillJobExists ?? ensureSearchBackfillJobExists; const exit = dependencies.exit ?? (code => process.exit(code)); let spinner; try { spinner = loggerImpl.spinner(options.dryRun ? `Preparing Atlas search ${action} command...` : `Starting Atlas search ${action}...`); const context = await loadFeatureContext('search', options, { cwd }); const indexNames = parseRequestedSearchIndexes(indexesArgument, Object.keys(context.config.indexes)); const backfillSupportEntries = resolveSearchBackfillSupportEntries(context, indexNames); const unsupportedIndexEntries = backfillSupportEntries.filter(indexEntry => !indexEntry.supportsBackfill && indexEntry.enabledSourceNames.length > 0); if (unsupportedIndexEntries.length > 0) { const supportedIndexScope = backfillSupportEntries.filter(indexEntry => indexEntry.supportsBackfill).map(indexEntry => indexEntry.indexName).join(','); throw new Error([...unsupportedIndexEntries.map(indexEntry => createUnsupportedSearchBackfillIndexMessage(indexEntry, action)), supportedIndexScope ? `Rerun "atlas search run ${action} --indexes ${supportedIndexScope}" for the Firestore-backed indexes only.` : null].filter(Boolean).join(' ')); } if (!options.dryRun) { ensureSearchBackfillJobExistsImpl(context, { runCommand }); } const command = createSearchBackfillJobExecutionCommand({ action, context, indexNames, now }); if (options.dryRun) { spinner.succeed(`Atlas search ${action} command is ready.`); logSearchOperation(loggerImpl, context, action, command, indexNames); loggerImpl.info('Dry run requested: no remote changes were made.'); return { ...command, indexNames, status: 'dry-run' }; } const startResult = executeSearchRunCommand(command, { captureExecutionName: true, runCommand }); spinner.succeed(`Atlas search ${action} started.`); logSearchOperation(loggerImpl, context, action, command, indexNames); return { ...command, executionName: resolveStartedExecutionName(startResult), indexNames, status: 'started' }; } catch (error) { if (spinner) { spinner.fail(`Failed to start Atlas search ${action}.`); } loggerImpl.error(error.message, false); return exit(1); } }; export const runSearchSourceOperation = async (sourceName, commandOptions = {}, dependencies = {}, cwd = process.cwd()) => { const options = commandOptions ?? {}; const loadFeatureContext = dependencies.loadFeatureContext ?? features.loadFeatureContext; const loggerImpl = dependencies.logger ?? logger; const runCommand = dependencies.runCommand ?? execFileSync; const ensureSearchSourceRunnerJobExistsImpl = dependencies.ensureSearchSourceRunnerJobExists ?? ensureSearchSourceRunnerJobExists; const exit = dependencies.exit ?? (code => process.exit(code)); let spinner; try { spinner = loggerImpl.spinner(options.dryRun ? 'Preparing Atlas search source runner command...' : `Starting Atlas search source runner for source "${sourceName}"...`); const context = await loadFeatureContext('search', options, { cwd }); const searchSources = context.config?.sources ?? {}; if (!searchSources[sourceName]) { throw new Error(`Atlas search source "${sourceName}" is not defined in the search config. ` + `Available sources: ${Object.keys(searchSources).join(', ') || '(none)'}.`); } if (!options.dryRun) { ensureSearchSourceRunnerJobExistsImpl(context, { runCommand }); } const command = createSearchSourceRunnerJobExecutionCommand({ context, sourceName }); if (options.dryRun) { spinner.succeed('Atlas search source runner command is ready.'); loggerImpl.summary('Atlas search run source', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Config', value: context.configPath }, { label: 'Source', value: sourceName }, { label: 'Command', value: command.command }]); loggerImpl.info('Dry run requested: no remote changes were made.'); return { ...command, status: 'dry-run' }; } const startResult = executeSearchRunCommand(command, { captureExecutionName: true, runCommand }); spinner.succeed(`Atlas search source runner started for source "${sourceName}".`); loggerImpl.summary('Atlas search run source', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Config', value: context.configPath }, { label: 'Source', value: sourceName }, { label: 'Command', value: command.command }, { label: 'Note', value: 'The source runner processes all pending pages and enqueues sync requests. Use "atlas search status" to check index state.' }]); return { ...command, executionName: resolveStartedExecutionName(startResult), status: 'started' }; } catch (error) { if (spinner) { spinner.fail('Failed to start Atlas search source runner.'); } loggerImpl.error(error.message, false); return exit(1); } }; const resolveExecutionCondition = (execution, conditionType) => (execution?.status?.conditions ?? []).find(condition => condition?.type?.toLowerCase() === conditionType.toLowerCase()) ?? null; const createExecutionSummary = execution => { const completedCondition = resolveExecutionCondition(execution, 'Completed'); const cancelledCondition = resolveExecutionCondition(execution, 'Cancelled'); const failedCount = Number.parseInt(execution?.status?.failedCount ?? 0, 10) || 0; const succeededCount = Number.parseInt(execution?.status?.succeededCount ?? 0, 10) || 0; const stateReason = completedCondition?.reason ?? cancelledCondition?.reason ?? null; if (cancelledCondition?.status === 'True') { return { failedCount, reason: stateReason, state: 'failed', succeededCount }; } if (completedCondition?.status === 'True') { if (failedCount > 0 || /fail|error|cancel/i.test(stateReason ?? '')) { return { failedCount, reason: stateReason, state: 'failed', succeededCount }; } return { failedCount, reason: stateReason, state: 'completed', succeededCount }; } return { failedCount, reason: stateReason, state: 'running', succeededCount }; }; const parseExecutionNameFromList = output => { const normalized = normalizeOptionalString(output); if (!normalized) { return null; } return normalized.split(/\r?\n/).map(value => value.trim()).find(Boolean) ?? null; }; const sleep = timeoutInMilliseconds => new Promise(resolve => { setTimeout(resolve, timeoutInMilliseconds); }); const createProcessInterruptController = (processObject = process) => { let interrupted = false; let resolveInterrupt = () => {}; const promise = new Promise(resolve => { resolveInterrupt = () => { interrupted = true; resolve(); }; }); const handler = () => { resolveInterrupt(); }; processObject.once('SIGINT', handler); return { cleanup() { if (typeof processObject.off === 'function') { processObject.off('SIGINT', handler); return; } if (typeof processObject.removeListener === 'function') { processObject.removeListener('SIGINT', handler); } }, get interrupted() { return interrupted; }, promise }; }; const waitForFollowPoll = async (sleepImpl, pollIntervalMilliseconds, interruptController) => { if (!interruptController) { await sleepImpl(pollIntervalMilliseconds); return 'slept'; } const result = await Promise.race([sleepImpl(pollIntervalMilliseconds).then(() => 'slept'), interruptController.promise.then(() => 'interrupted')]); return result; }; const createInterruptedFollowResult = executionName => ({ executionName, status: 'interrupted' }); const createInterruptedFollowMessage = ({ executionName = null, label = 'Atlas search run execution', note = 'The Cloud Run execution keeps running.' } = {}) => { const normalizedExecutionName = normalizeOptionalString(executionName); return `${label}${normalizedExecutionName ? ` ${normalizedExecutionName}` : ''} is no longer being followed. ${note}`; }; const normalizeFollowTimestamp = value => { if (value instanceof Date) { return value.getTime(); } const normalizedValue = Number(value); if (!Number.isFinite(normalizedValue)) { throw new Error('Atlas search run follow requires a clock that returns a Date or millisecond timestamp.'); } return normalizedValue; }; const normalizeFailureDetailValue = value => { if (value === null || value === undefined) { return []; } if (Array.isArray(value)) { return value.flatMap(normalizeFailureDetailValue); } if (typeof value === 'string') { return [value]; } if (isPlainObject(value)) { if (normalizeOptionalString(value.message)) { return [value.message.trim()]; } try { return [JSON.stringify(value)]; } catch { return []; } } return [String(value)]; }; const extractExecutionFailureDetails = logsPayload => { const details = []; const seen = new Set(); const logEntries = Array.isArray(logsPayload) ? logsPayload : [logsPayload]; for (const logEntry of logEntries) { const severity = normalizeOptionalString(logEntry?.severity); const candidates = [...normalizeFailureDetailValue(logEntry?.jsonPayload?.error?.details), ...normalizeFailureDetailValue(logEntry?.jsonPayload?.error?.message), ...normalizeFailureDetailValue(logEntry?.jsonPayload?.message), ...normalizeFailureDetailValue(logEntry?.textPayload)]; for (const candidate of candidates) { const normalized = candidate.replace(/\s+/g, ' ').trim(); if (normalized.length === 0) { continue; } const formatted = severity ? `${severity}: ${normalized}` : normalized; if (seen.has(formatted)) { continue; } seen.add(formatted); details.push(formatted); if (details.length >= EXECUTION_FAILURE_DETAILS_LIMIT) { return details; } } } return details; }; const listLatestExecutionName = (operationResult, dependencies = {}) => { const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? runGcloudFileCommand; try { const output = runGcloudFileCommandImpl(['run', 'jobs', 'executions', 'list', `--job=${operationResult.jobName}`, `--project=${operationResult.projectId}`, `--region=${operationResult.region}`, '--sort-by=~metadata.creationTimestamp', '--limit=1', '--format="value(metadata.name)"'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); return parseExecutionNameFromList(output); } catch (error) { if (isGcloudResourceNotFoundError(error)) { return null; } throw new Error(`Could not list Atlas search run executions for ${operationResult.jobName}. ${getCommandErrorMessage(error).trim()}`); } }; const describeExecution = (operationResult, executionName, dependencies = {}) => { const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? runGcloudFileCommand; try { const output = runGcloudFileCommandImpl(['run', 'jobs', 'executions', 'describe', executionName, `--project=${operationResult.projectId}`, `--region=${operationResult.region}`, '--format=json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); return parseGcloudJsonOutput(output, `Cloud Run execution ${executionName}`); } catch (error) { if (isGcloudResourceNotFoundError(error)) { return null; } throw new Error(`Could not inspect Atlas search run execution ${executionName}. ${getCommandErrorMessage(error).trim()}`); } }; export const resolveSearchRunExecutionFailureDetails = (operationResult, watchResult, dependencies = {}) => { const executionName = normalizeOptionalString(watchResult?.executionName); const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? runGcloudFileCommand; if (!executionName || !operationResult?.jobName || !operationResult?.projectId) { return []; } try { const output = runGcloudFileCommandImpl(['logging', 'read', ['resource.type="cloud_run_job"', `resource.labels.job_name="${operationResult.jobName}"`, `labels."run.googleapis.com/execution_name"="${executionName}"`, 'severity>=ERROR'].join(' AND '), `--project=${operationResult.projectId}`, '--limit=20', '--order=desc', '--format=json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); const parsedOutput = parseGcloudJsonOutput(output, `Cloud Run execution logs for ${executionName}`); return extractExecutionFailureDetails(parsedOutput); } catch { return []; } }; export const watchSearchRunExecution = async (operationResult, options = {}, dependencies = {}) => { const loggerImpl = dependencies.logger ?? logger; const sleepImpl = dependencies.sleep ?? sleep; const now = dependencies.now ?? (() => Date.now()); const createInterruptControllerImpl = dependencies.createInterruptController ?? createProcessInterruptController; const timeoutSeconds = normalizeTimeoutSeconds(options.timeout); const timeoutInMilliseconds = timeoutSeconds * 1000; const pollIntervalMilliseconds = options.pollIntervalMs ?? DEFAULT_FOLLOW_POLL_INTERVAL_MS; const getCurrentTimestamp = () => normalizeFollowTimestamp(now()); const deadline = getCurrentTimestamp() + timeoutInMilliseconds; const interruptController = createInterruptControllerImpl(); let executionName = normalizeOptionalString(operationResult?.executionName); let hasLoggedExecutionDiscoveryWait = false; let hasLoggedExecutionName = false; let lastLoggedState = null; loggerImpl.info(`Following Atlas search execution for up to ${timeoutSeconds} seconds (job: ${operationResult.jobName}).`); loggerImpl.info(FOLLOW_INTERRUPT_HINT); try { while (getCurrentTimestamp() <= deadline) { if (interruptController?.interrupted) { return createInterruptedFollowResult(executionName); } if (!executionName) { executionName = listLatestExecutionName(operationResult, dependencies); if (!executionName) { if (!hasLoggedExecutionDiscoveryWait) { loggerImpl.info(`Waiting for Cloud Run to report the new execution for job ${operationResult.jobName}.`); hasLoggedExecutionDiscoveryWait = true; } if ((await waitForFollowPoll(sleepImpl, pollIntervalMilliseconds, interruptController)) === 'interrupted') { return createInterruptedFollowResult(executionName); } continue; } } if (!hasLoggedExecutionName) { loggerImpl.info(`Following execution: ${executionName}`); hasLoggedExecutionName = true; } const execution = describeExecution(operationResult, executionName, dependencies); if (!execution) { if ((await waitForFollowPoll(sleepImpl, pollIntervalMilliseconds, interruptController)) === 'interrupted') { return createInterruptedFollowResult(executionName); } continue; } const executionSummary = createExecutionSummary(execution); if (lastLoggedState !== executionSummary.state) { loggerImpl.summary('Execution state', [{ label: 'State', value: executionSummary.state }, { label: 'Succeeded', value: executionSummary.succeededCount }, { label: 'Failed', value: executionSummary.failedCount }]); lastLoggedState = executionSummary.state; } if (executionSummary.state === 'completed') { loggerImpl.success('Atlas search run completed successfully.'); return { executionName, status: 'completed', summary: executionSummary }; } if (executionSummary.state === 'failed') { return { executionName, status: 'failed', summary: executionSummary }; } if ((await waitForFollowPoll(sleepImpl, pollIntervalMilliseconds, interruptController)) === 'interrupted') { return createInterruptedFollowResult(executionName); } } return { executionName, status: 'timeout', timeoutSeconds }; } finally { interruptController?.cleanup?.(); } }; export const runSearch = async (options = {}, dependencies = {}, cwd = process.cwd()) => { const loggerImpl = dependencies.logger ?? logger; const exit = dependencies.exit ?? (code => process.exit(code)); const loadFeatureContext = dependencies.loadFeatureContext ?? features.loadFeatureContext; const createSearchSourceRunnerJobExecutionCommandImpl = dependencies.createSearchSourceRunnerJobExecutionCommand ?? createSearchSourceRunnerJobExecutionCommand; const ensureSearchSourceRunnerJobExistsImpl = dependencies.ensureSearchSourceRunnerJobExists ?? ensureSearchSourceRunnerJobExists; const executeSearchRunCommandImpl = dependencies.executeSearchRunCommand ?? executeSearchRunCommand; const finalizeSourceReindexViaSearchApiImpl = dependencies.finalizeSourceReindexViaSearchApi ?? finalizeSourceReindexViaSearchApi; const now = dependencies.now ?? (() => new Date()); const prepareSourceReindexViaSearchApiImpl = dependencies.prepareSourceReindexViaSearchApi ?? prepareSourceReindexViaSearchApi; const runSearchOperationImpl = dependencies.runSearchOperation ?? runSearchOperation; const watchSearchRunExecutionImpl = dependencies.watchSearchRunExecution ?? watchSearchRunExecution; const resolveSearchRunExecutionFailureDetailsImpl = dependencies.resolveSearchRunExecutionFailureDetails ?? resolveSearchRunExecutionFailureDetails; const action = normalizeRunAction(options.action); const indexesArgument = normalizeRunIndexes(options.indexes); const shouldFollowExecution = options.dryRun !== true && options.detached !== true && options.follow !== false; if (!action || !SUPPORTED_RUN_ACTIONS.has(action)) { loggerImpl.error('Atlas search run requires <action> to be backfill, reindex, or source.', false); return exit(1); } if (action === 'source') { const sourceName = normalizeOptionalString(options.source); if (!sourceName) { loggerImpl.error('Atlas search run source requires --source <name> to identify the source to run.', false); return exit(1); } const runSearchSourceOperationImpl = dependencies.runSearchSourceOperation ?? runSearchSourceOperation; const operationResult = await runSearchSourceOperationImpl(sourceName, options, dependencies, cwd); if (!shouldFollowExecution || operationResult?.status !== 'started') { return operationResult; } let watchResult; try { watchResult = await watchSearchRunExecutionImpl(operationResult, options, { ...dependencies, logger: loggerImpl }); } catch (error) { loggerImpl.error(error.message, false); return exit(1); } if (watchResult.status === 'completed') { return { ...operationResult, watch: watchResult }; } if (watchResult.status === 'interrupted') { loggerImpl.warning(createInterruptedFollowMessage({ executionName: watchResult.executionName, label: 'Stopped following Atlas search run source execution' })); return { ...operationResult, status: 'interrupted', watch: watchResult }; } if (watchResult.status === 'timeout') { loggerImpl.error(`Atlas search run source timed out after ${watchResult.timeoutSeconds} seconds.`, false); } else { loggerImpl.error(`Atlas search run source execution failed${watchResult.summary?.reason ? `: ${watchResult.summary.reason}` : '.'}`, false); const failureDetails = resolveSearchRunExecutionFailureDetailsImpl(operationResult, watchResult, dependencies); if (failureDetails.length > 0) { loggerImpl.summary('Recent execution error details', failureDetails.map((detail, index) => ({ label: `Detail ${index + 1}`, value: detail }))); } } return exit(1); } if (action === 'reindex') { let sourceBackedReindexPlan = null; try { const context = await loadFeatureContext('search', options, { cwd }); sourceBackedReindexPlan = resolveSourceBackedReindexPlan(context, indexesArgument, now); if (sourceBackedReindexPlan) { if (!options.dryRun && !shouldFollowExecution) { loggerImpl.error('Atlas search source-backed reindex currently requires follow mode. Detached execution is not supported.', false); return exit(1); } if (options.dryRun) { const commandPlans = sourceBackedReindexPlan.sourceGroups.flatMap(group => [{ phase: 'Replay', sourceName: group.sourceName, ...createSearchSourceRunnerJobExecutionCommandImpl({ context, indexNames: group.indexNames, reindexSessionId: sourceBackedReindexPlan.sessionId, sourceName: group.sourceName }) }, { phase: 'Catch-up', sourceName: group.sourceName, ...createSearchSourceRunnerJobExecutionCommandImpl({ context, indexNames: group.indexNames, sourceName: group.sourceName }) }]); logSourceBackedReindexPlan(loggerImpl, context, sourceBackedReindexPlan, commandPlans); loggerImpl.info('Dry run requested: no remote changes were made.'); return { commands: commandPlans, context, indexNames: sourceBackedReindexPlan.indexNames, sessionId: sourceBackedReindexPlan.sessionId, sourceGroups: sourceBackedReindexPlan.sourceGroups, status: 'dry-run' }; } ensureSearchSourceRunnerJobExistsImpl(context, { runCommand: dependencies.runCommand ?? execFileSync }); const preparationResult = await prepareSourceReindexViaSearchApiImpl(context, { indexNames: sourceBackedReindexPlan.indexNames, reindexSessionId: sourceBackedReindexPlan.sessionId }, dependencies); ensureCompletedSourceReindexStage('prepare', preparationResult); const steps = []; for (const sourceGroup of sourceBackedReindexPlan.sourceGroups) { const replayCommand = createSearchSourceRunnerJobExecutionCommandImpl({ context, indexNames: sourceGroup.indexNames, reindexSessionId: sourceBackedReindexPlan.sessionId, sourceName: sourceGroup.sourceName }); const replayStartResult = executeSearchRunCommandImpl(replayCommand, { captureExecutionName: true, runCommand: dependencies.runCommand ?? execFileSync }); const replayOperationResult = { ...replayCommand, executionName: resolveStartedExecutionName(replayStartResult), status: 'started' }; const replayWatch = await watchSearchRunExecutionImpl(replayOperationResult, options, { ...dependencies, logger: loggerImpl }); if (replayWatch.status !== 'completed') { if (replayWatch.status === 'interrupted') { loggerImpl.warning(createInterruptedFollowMessage({ executionName: replayWatch.executionName, label: `Stopped following Atlas search source-backed reindex replay for ${sourceGroup.sourceName}`, note: 'The Cloud Run execution keeps running, but Atlas did not start the remaining catch-up or finalize steps.' })); steps.push({ phase: 'replay', sourceName: sourceGroup.sourceName, watch: replayWatch }); return { indexNames: sourceBackedReindexPlan.indexNames, prepare: preparationResult, sessionId: sourceBackedReindexPlan.sessionId, sourceGroups: sourceBackedReindexPlan.sourceGroups, status: 'interrupted', steps }; } logWatchedSearchRunFailure(loggerImpl, `Atlas search source-backed reindex replay for ${sourceGroup.sourceName}`, replayOperationResult, replayWatch, resolveSearchRunExecutionFailureDetailsImpl, dependencies); return exit(1); } steps.push({ phase: 'replay', sourceName: sourceGroup.sourceName, watch: replayWatch }); const catchUpCommand = createSearchSourceRunnerJobExecutionCommandImpl({ context, indexNames: sourceGroup.indexNames, sourceName: sourceGroup.sourceName }); const catchUpStartResult = executeSearchRunCommandImpl(catchUpCommand, { captureExecutionName: true, runCommand: dependencies.runCommand ?? execFileSync }); const catchUpOperationResult = { ...catchUpCommand, executionName: resolveStartedExecutionName(catchUpStartResult), status: 'started' }; const catchUpWatch = await watchSearchRunExecutionImpl(catchUpOperationResult, options, { ...dependencies, logger: loggerImpl }); if (catchUpWatch.status !== 'completed') { if (catchUpWatch.status === 'interrupted') { loggerImpl.warning(createInterruptedFollowMessage({ executionName: catchUpWatch.executionName, label: `Stopped following Atlas search source-backed reindex catch-up for ${sourceGroup.sourceName}`, note: 'The Cloud Run execution keeps running, but Atlas did not start the remaining finalize step.' })); steps.push({ phase: 'catch-up', sourceName: sourceGroup.sourceName, watch: catchUpWatch }); return { indexNames: sourceBackedReindexPlan.indexNames, prepare: preparationResult, sessionId: sourceBackedReindexPlan.sessionId, sourceGroups: sourceBackedReindexPlan.sourceGroups, status: 'interrupted', steps }; } logWatchedSearchRunFailure(loggerImpl, `Atlas search source-backed reindex catch-up for ${sourceGroup.sourceName}`, catchUpOperationResult, catchUpWatch, resolveSearchRunExecutionFailureDetailsImpl, dependencies); return exit(1); } steps.push({ phase: 'catch-up', sourceName: sourceGroup.sourceName, watch: catchUpWatch }); } const finalizeResult = await finalizeSourceReindexViaSearchApiImpl(context, { indexNames: sourceBackedReindexPlan.indexNames, reindexSessionId: sourceBackedReindexPlan.sessionId }, dependencies); ensureCompletedSourceReindexStage('finalize', finalizeResult); loggerImpl.success('Atlas search source-backed reindex completed. Run "atlas search promote --mode cutover" when you are ready to switch traffic.'); return { finalize: finalizeResult, indexNames: sourceBackedReindexPlan.indexNames, prepare: preparationResult, sessionId: sourceBackedReindexPlan.sessionId, sourceGroups: sourceBackedReindexPlan.sourceGroups, status: 'completed', steps }; } } catch (error) { loggerImpl.error(error.message, false); return exit(1); } } const operationResult = await runSearchOperationImpl(action, indexesArgument, options, dependencies, cwd); if (!shouldFollowExecution || operationResult?.status !== 'started') { return operationResult; } let watchResult; try { watchResult = await watchSearchRunExecutionImpl(operationResult, options, { ...dependencies, logger: loggerImpl }); } catch (error) { loggerImpl.error(error.message, false); return exit(1); } if (watchResult.status === 'completed') { return { ...operationResult, watch: watchResult }; } if (watchResult.status === 'interrupted') { loggerImpl.warning(createInterruptedFollowMessage({ executionName: watchResult.executionName, label: 'Stopped following Atlas search run execution' })); return { ...operationResult, status: 'interrupted', watch: watchResult }; } if (watchResult.status === 'timeout') { loggerImpl.error(`Atlas search run timed out after ${watchResult.timeoutSeconds} seconds while waiting for the execution to complete.`, false); } else { loggerImpl.error(`Atlas search run execution failed${watchResult.summary?.reason ? `: ${watchResult.summary.reason}` : '.'}`, false); const failureDetails = resolveSearchRunExecutionFailureDetailsImpl(operationResult, watchResult, dependencies); if (failureDetails.length > 0) { loggerImpl.summary('Recent execution error details', failureDetails.map((detail, index) => ({ label: `Detail ${index + 1}`, value: detail }))); } } return exit(1); }; export default async (action, options = {}) => runSearch({ ...(options ?? {}), action });