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