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