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