UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

242 lines 8.76 kB
import { isString } from 'es-toolkit/compat'; import { fetchWithTimeout } from '../request.js'; import { createRequestHeaders, getConfigSecretName, hasResolvedAuth, resolveAccess } from './access.js'; const normalizeFieldType = fieldType => { switch (fieldType) { case 'bool': return 'boolean'; case 'float': return 'float'; case 'geopoint': return 'geo_point'; case 'int32': case 'int64': return 'long'; case 'object': return 'object'; case 'object[]': return 'nested'; case 'string': case 'string[]': case 'auto': default: return 'text'; } }; const sortFieldEntries = entries => [...entries].sort(([left], [right]) => left.localeCompare(right)); const normalizeExpectedProperty = fieldConfig => { const elasticsearchType = normalizeFieldType(fieldConfig.type); if (elasticsearchType === 'text') { return { fields: { keyword: { type: 'keyword' } }, type: 'text' }; } return { type: elasticsearchType }; }; const normalizeRemoteProperty = property => ({ hasKeywordSubfield: property?.fields?.keyword?.type === 'keyword', type: isString(property?.type) ? property.type : null }); export const createIndexSchema = (indexName, indexConfig) => ({ mappings: { properties: Object.fromEntries(sortFieldEntries((indexConfig.fields ?? []).map(fieldConfig => [fieldConfig.name, normalizeExpectedProperty(fieldConfig)]))) }, name: indexName }); const executeRequest = async (access, requestPath, options = {}) => { const response = await fetchWithTimeout(`${access.node}${requestPath}`, { ...options, headers: { ...createRequestHeaders(access), ...(options.headers ?? {}) } }); return response; }; const createElasticsearchError = (context, verb, indexName, access, error) => { const secretName = getConfigSecretName(context.config); return new Error(`Could not ${verb} Elasticsearch index ${indexName} at ${access.node}. ${error.message} ` + `Check the configured Elasticsearch node and authentication in ${secretName}, verify that this machine can reach the Elasticsearch endpoint, and retry "atlas search apply".`); }; const readIndex = async (context, access, indexName) => { let response; try { response = await executeRequest(access, `/${encodeURIComponent(indexName)}`, { method: 'GET' }); } catch (error) { throw createElasticsearchError(context, 'inspect', indexName, access, error); } if (response.status === 404) { return null; } if (!response.ok) { throw new Error(`Elasticsearch index lookup failed for ${indexName} at ${access.node} with status ${response.status}. ` + 'Check the configured Elasticsearch node and authentication before retrying "atlas search apply".'); } const payload = await response.json(); return payload?.[indexName] ?? null; }; const createIndex = async (context, access, schema) => { let response; try { response = await executeRequest(access, `/${encodeURIComponent(schema.name)}`, { body: JSON.stringify({ mappings: schema.mappings }), method: 'PUT' }); } catch (error) { throw createElasticsearchError(context, 'create', schema.name, access, error); } const responseText = await response.text(); if (!response.ok) { throw new Error((`Elasticsearch index creation failed for ${schema.name} at ${access.node} with status ${response.status}. ${responseText} ` + 'Check the configured Elasticsearch node and authentication before retrying "atlas search apply".').trim()); } return responseText.length > 0 ? JSON.parse(responseText) : null; }; const compareIndexSchema = (expectedSchema, remoteIndex) => { const mismatches = []; const expectedProperties = sortFieldEntries(Object.entries(expectedSchema.mappings.properties)); const remoteProperties = sortFieldEntries(Object.entries(remoteIndex?.mappings?.properties ?? {}).map(([name, property]) => [name, normalizeRemoteProperty(property)])); if (expectedProperties.length !== remoteProperties.length) { mismatches.push(`field count differs (expected ${expectedProperties.length}, received ${remoteProperties.length})`); } const remotePropertyMap = new Map(remoteProperties); for (const [name, expectedProperty] of expectedProperties) { const remoteProperty = remotePropertyMap.get(name); if (!remoteProperty) { mismatches.push(`field ${name}: expected property is missing from the remote mapping`); continue; } const expectedType = expectedProperty.type ?? null; const remoteType = remoteProperty.type ?? null; if (expectedType !== remoteType) { mismatches.push(`field ${name}: expected type=${expectedType}, received ${remoteType}`); } const expectedHasKeywordSubfield = expectedProperty.fields?.keyword?.type === 'keyword'; if (expectedHasKeywordSubfield !== remoteProperty.hasKeywordSubfield) { mismatches.push(`field ${name}: expected keyword subfield=${expectedHasKeywordSubfield}, received ${remoteProperty.hasKeywordSubfield}`); } } for (const [name] of remoteProperties) { if (!expectedSchema.mappings.properties[name]) { mismatches.push(`field ${name}: remote mapping contains an unexpected property`); } } return { mismatches, schemaStatus: mismatches.length === 0 ? 'match' : 'mismatch' }; }; export const inspectIndexSchemas = async context => { const access = await resolveAccess(context); if (!access.node || !hasResolvedAuth(access)) { return { collections: Object.entries(context.config.indexes).map(([indexName, indexConfig]) => ({ expectedSchema: createIndexSchema(indexName, indexConfig), indexName, name: indexName, remoteCollection: null, remoteStatus: 'unknown', schemaStatus: 'unknown' })), warnings: [...access.warnings, 'Elasticsearch schema inspection is unavailable because node or authentication could not be resolved.'] }; } const collections = []; for (const [indexName, indexConfig] of Object.entries(context.config.indexes)) { const expectedSchema = createIndexSchema(indexName, indexConfig); const remoteCollection = await readIndex(context, access, indexName); if (!remoteCollection) { collections.push({ expectedSchema, indexName, mismatchDetails: [], name: indexName, remoteCollection: null, remoteStatus: 'not-found', schemaStatus: 'not-found' }); continue; } const comparison = compareIndexSchema(expectedSchema, remoteCollection); collections.push({ expectedSchema, indexName, mismatchDetails: comparison.mismatches, name: indexName, remoteCollection, remoteStatus: 'found', schemaStatus: comparison.schemaStatus }); } return { collections, warnings: access.warnings }; }; export const reconcileIndexSchemas = async (context, options = {}) => { const { dryRun = false } = options; const access = await resolveAccess(context); if (!access.node || !hasResolvedAuth(access)) { if (dryRun) { return { actions: [], blockingIssues: [], warnings: [...access.warnings, 'Dry run skipped Elasticsearch schema reconciliation because node or authentication could not be resolved.'] }; } return { actions: [], blockingIssues: ['Elasticsearch schema reconciliation requires node access and authentication for the selected project.'], warnings: access.warnings }; } const inspection = await inspectIndexSchemas(context); const actions = []; const blockingIssues = []; for (const collection of inspection.collections) { if (collection.schemaStatus === 'match') { actions.push({ action: 'unchanged', indexName: collection.indexName, name: collection.name, schemaStatus: collection.schemaStatus }); continue; } if (collection.schemaStatus === 'not-found') { actions.push({ action: dryRun ? 'create-planned' : 'created', indexName: collection.indexName, name: collection.name, schemaStatus: collection.schemaStatus }); if (!dryRun) { await createIndex(context, access, collection.expectedSchema); } continue; } actions.push({ action: 'reindex-required', indexName: collection.indexName, mismatchDetails: collection.mismatchDetails, name: collection.name, reconciliationStatus: 'reindex-required', schemaStatus: collection.schemaStatus }); } return { actions, blockingIssues, warnings: inspection.warnings }; };