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