UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

251 lines 14.8 kB
import { logger } from '../../utils/index.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { getSearchProvider } from './providers/index.js'; import { inspectSearchSchemasViaSearchApi } from './searchApiAdmin.js'; import { createSearchNonBackfillableIndexGuidanceEntry, resolveSearchBackfillSupportEntries } from './planning.js'; const SCHEMA_MISMATCH_PREVIEW_LIMIT = 3; const SEARCH_API_SCHEMA_INSPECTION_WARNING_PATTERN = /^Could not inspect .* through atlas-search-api:/i; const SEARCH_SCHEMA_LIFECYCLE_STATUS_ALIASES = { backfilling: 'candidate-active', 'bootstrap-required': 'bootstrap-required', 'candidate-active': 'candidate-active', 'cleanup-pending': 'cleanup-pending', cleanup_pending: 'cleanup-pending', failed: 'invalid-state', 'invalid-state': 'invalid-state', match: 'match', mismatch: 'reindex-required', 'not-found': 'bootstrap-required', promotable: 'promotable', 'reindex-required': 'reindex-required', steady: 'steady', 'unmanaged-drift': 'unmanaged-drift', unconfigured: 'unconfigured', unknown: 'unknown' }; const SEARCH_SCHEMA_LIFECYCLE_STATUS_LABELS = { 'bootstrap-required': 'bootstrap required', 'candidate-active': 'candidate active', 'cleanup-pending': 'cleanup pending', 'invalid-state': 'invalid state', match: 'ready', promotable: 'promotable', 'reindex-required': 'reindex required', steady: 'steady', 'unmanaged-drift': 'unmanaged drift', unconfigured: 'unconfigured', unknown: 'unknown' }; const SEARCH_SCHEMA_LIFECYCLE_STATUS_ORDER = ['match', 'steady', 'bootstrap-required', 'reindex-required', 'candidate-active', 'promotable', 'cleanup-pending', 'unmanaged-drift', 'unconfigured', 'invalid-state', 'unknown']; const SEARCH_SCHEMA_LIFECYCLE_VERIFICATION_ISSUES = new Set(['bootstrap-required', 'invalid-state', 'reindex-required', 'unmanaged-drift', 'unknown']); const SEARCH_SCHEMA_LIFECYCLE_SUCCESS_STATES = new Set(['match', 'steady']); const SEARCH_SCHEMA_LIFECYCLE_DETAIL_EXCLUDED_STATES = new Set(['match', 'steady']); const normalizeOmittedLifecycleStatuses = omittedStatuses => { if (omittedStatuses instanceof Set) { return omittedStatuses; } return new Set(Array.isArray(omittedStatuses) ? omittedStatuses : []); }; const getEntryName = entry => normalizeOptionalString(entry?.indexName) ?? normalizeOptionalString(entry?.name) ?? 'unknown'; const getEntryMismatchDetails = entry => { const details = Array.isArray(entry?.mismatchDetails) ? entry.mismatchDetails : Array.isArray(entry?.details) ? entry.details : []; return details.map(detail => normalizeOptionalString(detail)).filter(Boolean); }; const createMismatchPreview = entry => { const mismatchDetails = getEntryMismatchDetails(entry); if (mismatchDetails.length === 0) { return ''; } const previewDetails = mismatchDetails.slice(0, SCHEMA_MISMATCH_PREVIEW_LIMIT); const remainingCount = mismatchDetails.length - previewDetails.length; return ` Differences: ${previewDetails.join('; ')}${remainingCount > 0 ? `; ${remainingCount} more` : ''}`; }; const formatIndexList = indexNames => [...new Set(indexNames.filter(Boolean))].sort((left, right) => left.localeCompare(right)).join(', '); const getLifecycleLabel = status => SEARCH_SCHEMA_LIFECYCLE_STATUS_LABELS[status] ?? status; const createReindexGuidanceEntry = (indexName, context = null) => { const defaultGuidance = `Run "atlas search run reindex --indexes ${indexName}" to build the updated candidate target.`; if (!context) { return defaultGuidance; } const [backfillSupportEntry] = resolveSearchBackfillSupportEntries(context, [indexName]); if (!backfillSupportEntry || backfillSupportEntry.supportsBackfill || backfillSupportEntry.enabledSourceNames.length === 0) { return defaultGuidance; } return createSearchNonBackfillableIndexGuidanceEntry(backfillSupportEntry); }; const normalizeSearchSchemaLifecycleStatusValue = value => { const normalizedValue = normalizeOptionalString(value); return normalizedValue ? SEARCH_SCHEMA_LIFECYCLE_STATUS_ALIASES[normalizedValue] ?? null : null; }; const resolveExplicitSearchSchemaLifecycleStatus = entry => normalizeSearchSchemaLifecycleStatusValue(entry?.reconciliationStatus) ?? normalizeSearchSchemaLifecycleStatusValue(entry?.action) ?? normalizeSearchSchemaLifecycleStatusValue(entry?.status); const getLifecycleIndexNames = (entries, status) => entries.filter(entry => resolveSearchSchemaLifecycleStatus(entry) === status).map(getEntryName).filter(Boolean); export const hasSearchSchemaLifecycleState = entry => { const explicitStatus = resolveExplicitSearchSchemaLifecycleStatus(entry) ?? normalizeSearchSchemaLifecycleStatusValue(entry?.schemaStatus); return Boolean(explicitStatus); }; export const resolveSearchSchemaLifecycleStatus = entry => { const explicitStatus = resolveExplicitSearchSchemaLifecycleStatus(entry); if (explicitStatus) { return explicitStatus; } return normalizeSearchSchemaLifecycleStatusValue(entry?.schemaStatus) ?? 'unknown'; }; export const formatSearchSchemaLifecycleStatus = status => getLifecycleLabel(status); export const isSearchSchemaLifecycleVerificationIssue = status => SEARCH_SCHEMA_LIFECYCLE_VERIFICATION_ISSUES.has(status); export const isPrivateComputeProviderRuntime = context => { const providerRuntimeConfig = context?.config?.deploy?.providerRuntime; return providerRuntimeConfig?.platform === 'compute' && providerRuntimeConfig?.compute?.assignPublicIp !== true; }; export const createUnknownSchemaInspection = (context, warning = null) => ({ collections: Object.keys(context?.config?.indexes ?? {}).map(indexName => ({ indexName, mismatchDetails: [], name: indexName, reconciliationStatus: 'unknown', schemaStatus: 'unknown' })), warnings: warning ? [warning] : [] }); export const logSearchSchemaInspectionWarnings = (schemaInspection, loggerImpl = logger) => { const warnings = Array.isArray(schemaInspection?.warnings) ? schemaInspection.warnings : []; const dedupedWarnings = [...new Set(warnings.filter(warning => typeof warning === 'string').map(warning => warning.trim()).filter(Boolean).filter(warning => SEARCH_API_SCHEMA_INSPECTION_WARNING_PATTERN.test(warning)))]; for (const warning of dedupedWarnings) { loggerImpl.warning(warning); } }; export const inspectSearchSchemasWithFallback = async (context, options = {}) => { const provider = options.provider ?? (options.getSearchProviderImpl ?? getSearchProvider)(context.config.provider); const inspectSearchSchemasViaSearchApiImpl = options.inspectSearchSchemasViaSearchApiImpl ?? inspectSearchSchemasViaSearchApi; const inspectSearchSchemasDirectImpl = options.inspectSearchSchemasDirectImpl ?? (typeof provider?.inspectSchemas === 'function' ? async providerContext => provider.inspectSchemas(providerContext) : null); try { return await inspectSearchSchemasViaSearchApiImpl(context); } catch (error) { const warning = `Could not inspect ${provider.descriptor.labels.targetGroup.toLowerCase()} ` + `through atlas-search-api: ${error.message}`; if (isPrivateComputeProviderRuntime(context) || !inspectSearchSchemasDirectImpl) { return createUnknownSchemaInspection(context, warning); } const directInspection = await inspectSearchSchemasDirectImpl(context); return { ...directInspection, warnings: [warning].concat(directInspection.warnings ?? []) }; } }; export const createSearchSchemaLifecycleEntryMessage = (provider, entry, options = {}) => { const indexName = getEntryName(entry); const label = provider.descriptor.labels.targetGroup; const lifecycleStatus = resolveSearchSchemaLifecycleStatus(entry); const { compact = false, includeMismatchPreview = true } = options; switch (lifecycleStatus) { case 'bootstrap-required': if (compact) { return `${label}: bootstrap required for "${indexName}".`; } return `${label}: required schema target "${indexName}" was not found.`; case 'reindex-required': { if (compact) { return `${label}: reindex required for "${indexName}".`; } const baseMessage = normalizeOptionalString(entry?.message) ? entry.message : `${label}: "${indexName}" requires a reindex before the configured schema can go live.`; return `${baseMessage}${includeMismatchPreview ? createMismatchPreview(entry) : ''}`; } case 'candidate-active': return `${label}: "${indexName}" has an active candidate target` + `${entry?.candidateTarget ? ` (${entry.candidateTarget})` : ''}.`; case 'promotable': return `${label}: "${indexName}" is promotable` + `${entry?.candidateTarget ? ` (${entry.candidateTarget})` : ''}.`; case 'cleanup-pending': return `${label}: "${indexName}" is waiting for rollback cleanup` + `${entry?.rollbackTarget ? ` (${entry.rollbackTarget})` : ''}` + `${entry?.cleanupAfter ? ` after ${entry.cleanupAfter}` : ''}.`; case 'steady': return `${label}: "${indexName}" is steady` + `${entry?.activeReadTarget ? ` on ${entry.activeReadTarget}` : ''}.`; case 'unconfigured': return `${label}: "${indexName}" does not have runtime routing metadata yet.`; case 'unmanaged-drift': { const baseMessage = normalizeOptionalString(entry?.message) ? entry.message : `${label}: "${indexName}" has unmanaged drift${entry?.unmanagedReason ? `: ${entry.unmanagedReason}` : '.'}`; return `${baseMessage}${includeMismatchPreview ? createMismatchPreview(entry) : ''}`; } case 'invalid-state': return normalizeOptionalString(entry?.message) ? entry.message : `${label}: "${indexName}" has an invalid runtime routing state${entry?.invalidReason ? `: ${entry.invalidReason}` : ''}.`; case 'unknown': return `${label}: schema for "${indexName}" could not be verified from this machine.`; case 'match': return `${label}: "${indexName}" is ready.`; default: return `${label}: "${indexName}" is in an unknown schema lifecycle state.`; } }; export const createSearchSchemaLifecycleEntries = (provider, entries = [], options = {}) => { const omittedStatuses = normalizeOmittedLifecycleStatuses(options.omittedStatuses); return entries.filter(entry => hasSearchSchemaLifecycleState(entry)).filter(entry => { const lifecycleStatus = resolveSearchSchemaLifecycleStatus(entry); return !SEARCH_SCHEMA_LIFECYCLE_DETAIL_EXCLUDED_STATES.has(lifecycleStatus) && !omittedStatuses.has(lifecycleStatus); }).map(entry => createSearchSchemaLifecycleEntryMessage(provider, entry, options)); }; export const createSearchSchemaLifecycleSummaryRows = (provider, entries = [], options = {}) => { const lifecycleEntries = entries.filter(entry => hasSearchSchemaLifecycleState(entry)); const { includeAffectedIndexNames = false } = options; if (lifecycleEntries.length === 0) { return []; } const counts = lifecycleEntries.reduce((accumulator, entry) => { const status = resolveSearchSchemaLifecycleStatus(entry); accumulator[status] = (accumulator[status] ?? 0) + 1; return accumulator; }, {}); const value = SEARCH_SCHEMA_LIFECYCLE_STATUS_ORDER.filter(status => (counts[status] ?? 0) > 0).map(status => { const summaryValue = `${counts[status]} ${getLifecycleLabel(status)}`; if (!includeAffectedIndexNames || SEARCH_SCHEMA_LIFECYCLE_SUCCESS_STATES.has(status)) { return summaryValue; } const affectedIndexNames = formatIndexList(getLifecycleIndexNames(lifecycleEntries, status)); return affectedIndexNames ? `${summaryValue} (${affectedIndexNames})` : summaryValue; }).join(', '); const hasVerificationIssue = Object.keys(counts).some(status => isSearchSchemaLifecycleVerificationIssue(status)); const tone = hasVerificationIssue ? 'warning' : lifecycleEntries.every(entry => SEARCH_SCHEMA_LIFECYCLE_SUCCESS_STATES.has(resolveSearchSchemaLifecycleStatus(entry))) ? 'success' : 'info'; return [{ label: provider.descriptor.labels.targetGroup, tone, value: `${value}.` }]; }; export const collectSearchSchemaLifecycleVerificationIssues = (provider, entries = [], options = {}) => entries.filter(entry => hasSearchSchemaLifecycleState(entry)).filter(entry => isSearchSchemaLifecycleVerificationIssue(resolveSearchSchemaLifecycleStatus(entry))).map(entry => createSearchSchemaLifecycleEntryMessage(provider, entry, options)); export const createSearchSchemaLifecycleGuidanceEntries = (entries = [], options = {}) => { const lifecycleEntries = entries.filter(entry => hasSearchSchemaLifecycleState(entry)); const context = options.context ?? null; const guidance = []; const bootstrapRequiredIndexNames = getLifecycleIndexNames(lifecycleEntries, 'bootstrap-required'); const reindexRequiredIndexNames = getLifecycleIndexNames(lifecycleEntries, 'reindex-required'); const candidateActiveIndexNames = getLifecycleIndexNames(lifecycleEntries, 'candidate-active'); const promotableIndexNames = getLifecycleIndexNames(lifecycleEntries, 'promotable'); const cleanupPendingIndexNames = getLifecycleIndexNames(lifecycleEntries, 'cleanup-pending'); const unmanagedDriftIndexNames = getLifecycleIndexNames(lifecycleEntries, 'unmanaged-drift'); const invalidStateIndexNames = getLifecycleIndexNames(lifecycleEntries, 'invalid-state'); if (bootstrapRequiredIndexNames.length > 0) { guidance.push(`Run "atlas search apply" if Atlas should create the missing schema targets for ${formatIndexList(bootstrapRequiredIndexNames)}.`); } for (const indexName of reindexRequiredIndexNames) { guidance.push(createReindexGuidanceEntry(indexName, context)); } if (candidateActiveIndexNames.length > 0) { guidance.push(`Use "atlas search promote status" to follow backfill progress for ${formatIndexList(candidateActiveIndexNames)} until Atlas reports them as promotable.`); } if (promotableIndexNames.length > 0) { guidance.push(`Run "atlas search promote --mode cutover" to switch all promotable indexes, or add "--indexes <index1,index2>" when you only want to switch ${formatIndexList(promotableIndexNames)}.`); } if (cleanupPendingIndexNames.length > 0) { guidance.push(`Run "atlas search promote --mode cleanup" after the rollback grace period for ${formatIndexList(cleanupPendingIndexNames)}.`); } if (unmanagedDriftIndexNames.length > 0) { guidance.push(`Run "atlas search verify" and inspect unmanaged provider state for ${formatIndexList(unmanagedDriftIndexNames)} before rerunning "atlas search apply".`); } if (invalidStateIndexNames.length > 0) { guidance.push(`Run "atlas search verify" and inspect atlas-search-api routing state for ${formatIndexList(invalidStateIndexNames)} before continuing.`); } return guidance; };