UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

451 lines 24.4 kB
import { isBoolean, isString } from 'es-toolkit/compat'; import { isPlainObject } from 'es-toolkit/predicate'; import { isStringArray } from '../../../utils/value.js'; import { SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES, validateSearchSourceBindingConfig, validateSearchSourceConfig, validateSearchSourceDefinitionConfig } from './sources.js'; import { getSearchProvider, listSearchProviders } from '../providers/index.js'; const BASE_SEARCH_ROOT_SECTION_KEYS = ['deploy', 'projects', 'provider', 'release', 'workloads']; const SEARCH_WORKLOAD_FILE_KEYS = ['index', 'mapper', 'source']; const SEARCH_WORKLOAD_MAPPER_KEYS = ['export', 'name', 'source']; const SEARCH_WORKLOAD_SOURCE_KEYS = ['description', 'enabled', 'firestore', 'http', 'name', 'routeTemplate', 'sql', 'syncClass', 'type']; const SEARCH_WORKLOAD_FIRESTORE_KEYS = ['database', 'documentPathPattern', 'triggerRegion']; const SUPPORTED_SEARCH_CONFIG_TRANSPORTS = ['artifact']; const SUPPORTED_CLOUD_RUN_VPC_EGRESS_SETTINGS = ['all-traffic', 'private-ranges-only']; const SEARCH_COLLECTION_PATH_SEGMENT_PATTERN = /^[a-z][a-z0-9_]*$/; const SUPPORTED_SEARCH_FIELD_TYPES = ['auto', 'bool', 'float', 'geopoint', 'int32', 'int64', 'object', 'object[]', 'string', 'string[]']; const getRegisteredSearchProviders = () => listSearchProviders().map(getSearchProvider); export const getProviderSectionEntries = () => getRegisteredSearchProviders().filter(provider => isString(provider.configSectionKey)).map(provider => [provider.configSectionKey, provider]); const SEARCH_ROOT_SECTION_KEYS = [...BASE_SEARCH_ROOT_SECTION_KEYS, ...getProviderSectionEntries().map(([sectionKey]) => sectionKey)]; const SEARCH_PROJECT_SECTION_KEYS = ['deploy', 'environment', 'release', 'sourceBindings', ...getProviderSectionEntries().map(([sectionKey]) => sectionKey)]; const throwSearchConfigError = message => { throw new Error(`Invalid Atlas search config. ${message}`); }; const assertObjectWhenProvided = (value, propertyPath) => { if (value !== undefined && !isPlainObject(value)) { throwSearchConfigError(`The "${propertyPath}" property must be an object when provided.`); } }; const assertStringWhenProvided = (value, propertyPath) => { if (value !== undefined && !isString(value)) { throwSearchConfigError(`The "${propertyPath}" property must be a string when provided.`); } }; const assertStringOrNullWhenProvided = (value, propertyPath) => { if (value !== undefined && value !== null && !isString(value)) { throwSearchConfigError(`The "${propertyPath}" property must be a string or null when provided.`); } }; const assertSnakeCaseCollectionPathWhenProvided = (value, propertyPath) => { if (!isString(value)) { return; } const normalizedValue = value.trim().replace(/^\/+|\/+$/g, ''); if (!normalizedValue) { return; } const segments = normalizedValue.split('/').filter(Boolean); for (let index = 0; index < segments.length; index += 2) { if (!SEARCH_COLLECTION_PATH_SEGMENT_PATTERN.test(segments[index])) { throwSearchConfigError(`The "${propertyPath}" property must use snake_case collection names. Invalid collection segment: "${segments[index]}".`); } } }; const assertPositiveIntegerWhenProvided = (value, propertyPath) => { if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { throwSearchConfigError(`The "${propertyPath}" property must be a positive integer when provided.`); } }; const assertAllowedKeys = (value, propertyPath, allowedKeys) => { if (!isPlainObject(value)) { throwSearchConfigError(`The "${propertyPath}" property must be an object.`); } const invalidKeys = Object.keys(value).filter(key => !allowedKeys.includes(key)); if (invalidKeys.length > 0) { throwSearchConfigError(`Unsupported properties found on "${propertyPath}": ${invalidKeys.join(', ')}.`); } }; const assertNonEmptyString = (value, propertyPath) => { if (!isString(value) || value.trim().length === 0) { throwSearchConfigError(`The "${propertyPath}" property must be a non-empty string.`); } }; const validateSearchRuntimeSettings = config => { if (config.provider !== undefined && !isString(config.provider)) { throwSearchConfigError('The "provider" property must be a string when provided.'); } if (config.provider !== undefined) { getSearchProvider(config.provider); } for (const [sectionKey, provider] of getProviderSectionEntries()) { if (config[sectionKey] === undefined) { continue; } if (provider.validateConfigSection) { provider.validateConfigSection(config[sectionKey]); continue; } assertObjectWhenProvided(config[sectionKey], sectionKey); } if (config.deploy !== undefined) { assertObjectWhenProvided(config.deploy, 'deploy'); if (config.deploy.cloudRun !== undefined) { assertObjectWhenProvided(config.deploy.cloudRun, 'deploy.cloudRun'); const cloudRunConfig = config.deploy.cloudRun; assertStringWhenProvided(cloudRunConfig.artifactRegistryProject, 'deploy.cloudRun.artifactRegistryProject'); assertStringWhenProvided(cloudRunConfig.artifactRegistryLocation, 'deploy.cloudRun.artifactRegistryLocation'); assertStringOrNullWhenProvided(cloudRunConfig.mapperManifestUri, 'deploy.cloudRun.mapperManifestUri'); assertStringOrNullWhenProvided(cloudRunConfig.runtimeConfigUri, 'deploy.cloudRun.runtimeConfigUri'); if (cloudRunConfig.searchConfigTransport !== undefined && !SUPPORTED_SEARCH_CONFIG_TRANSPORTS.includes(cloudRunConfig.searchConfigTransport)) { throwSearchConfigError('The "deploy.cloudRun.searchConfigTransport" property must be "artifact" when provided.'); } assertStringWhenProvided(cloudRunConfig.tag, 'deploy.cloudRun.tag'); assertStringWhenProvided(cloudRunConfig.region, 'deploy.cloudRun.region'); assertStringWhenProvided(cloudRunConfig.repository, 'deploy.cloudRun.repository'); assertStringWhenProvided(cloudRunConfig.serviceImage, 'deploy.cloudRun.serviceImage'); assertStringWhenProvided(cloudRunConfig.jobImage, 'deploy.cloudRun.jobImage'); assertStringWhenProvided(cloudRunConfig.serviceAccountEmail, 'deploy.cloudRun.serviceAccountEmail'); if (cloudRunConfig.vpcAccess !== undefined) { assertObjectWhenProvided(cloudRunConfig.vpcAccess, 'deploy.cloudRun.vpcAccess'); assertStringWhenProvided(cloudRunConfig.vpcAccess.network, 'deploy.cloudRun.vpcAccess.network'); assertStringOrNullWhenProvided(cloudRunConfig.vpcAccess.subnetwork, 'deploy.cloudRun.vpcAccess.subnetwork'); if (cloudRunConfig.vpcAccess.egress !== undefined && !SUPPORTED_CLOUD_RUN_VPC_EGRESS_SETTINGS.includes(cloudRunConfig.vpcAccess.egress)) { throwSearchConfigError('The "deploy.cloudRun.vpcAccess.egress" property must be "all-traffic" or "private-ranges-only" when provided.'); } } } if (config.deploy.terraform !== undefined) { assertObjectWhenProvided(config.deploy.terraform, 'deploy.terraform'); assertStringWhenProvided(config.deploy.terraform.rootDir, 'deploy.terraform.rootDir'); assertStringWhenProvided(config.deploy.terraform.moduleSource, 'deploy.terraform.moduleSource'); } if (config.deploy.syncState !== undefined) { assertObjectWhenProvided(config.deploy.syncState, 'deploy.syncState'); assertStringOrNullWhenProvided(config.deploy.syncState.collectionPath, 'deploy.syncState.collectionPath'); assertSnakeCaseCollectionPathWhenProvided(config.deploy.syncState.collectionPath, 'deploy.syncState.collectionPath'); assertPositiveIntegerWhenProvided(config.deploy.syncState.recentLimit, 'deploy.syncState.recentLimit'); } } if (config.release !== undefined) { assertObjectWhenProvided(config.release, 'release'); if (config.release.strategy !== undefined && !['direct', 'managed'].includes(config.release.strategy)) { throwSearchConfigError('The "release.strategy" property must be "direct" or "managed" when provided.'); } for (const key of ['active', 'candidate']) { assertStringOrNullWhenProvided(config.release[key], `release.${key}`); } } return config; }; const validateSearchIndexFields = (indexName, indexConfig) => { if (!Array.isArray(indexConfig.fields) || indexConfig.fields.length === 0) { throwSearchConfigError(`The index "${indexName}.fields" property must be a non-empty array of field definitions.`); } const fieldNames = new Set(); for (const fieldConfig of indexConfig.fields) { if (!isPlainObject(fieldConfig)) { throwSearchConfigError(`The index "${indexName}.fields" entries must be objects.`); } if (!isString(fieldConfig.name) || fieldConfig.name.trim().length === 0) { throwSearchConfigError(`The index "${indexName}.fields[].name" property must be a non-empty string.`); } if (!isString(fieldConfig.type) || fieldConfig.type.trim().length === 0) { throwSearchConfigError(`The index "${indexName}.fields[].type" property must be a non-empty string.`); } if (!SUPPORTED_SEARCH_FIELD_TYPES.includes(fieldConfig.type)) { throwSearchConfigError(`The index "${indexName}.fields[].type" value "${fieldConfig.type}" is not supported.`); } for (const boolField of ['optional', 'sort', 'facet', 'filter', 'group']) { if (fieldConfig[boolField] !== undefined && !isBoolean(fieldConfig[boolField])) { throwSearchConfigError(`The index "${indexName}.fields[].${boolField}" property must be a boolean when provided.`); } } if (fieldNames.has(fieldConfig.name)) { throwSearchConfigError(`The index "${indexName}" defines the field "${fieldConfig.name}" more than once.`); } fieldNames.add(fieldConfig.name); } for (const refProp of ['queryBy', 'facets', 'filterBy', 'groupBy']) { if ((indexConfig[refProp] ?? []).some(fieldName => !fieldNames.has(fieldName))) { throwSearchConfigError(`The index "${indexName}.${refProp}" properties must reference fields declared in "${indexName}.fields".`); } } if ((indexConfig.defaultSort ?? []).some(entry => !isPlainObject(entry) || !fieldNames.has(entry.field))) { throwSearchConfigError(`The index "${indexName}.defaultSort" properties must reference fields declared in "${indexName}.fields".`); } }; const validateSearchSourceBindingRegistry = (sourceBindings, propertyPath) => { if (sourceBindings === undefined) { return sourceBindings; } if (!isPlainObject(sourceBindings)) { throwSearchConfigError(`The "${propertyPath}" property must be an object.`); } for (const [sourceName, sourceBinding] of Object.entries(sourceBindings)) { validateSearchSourceBindingConfig(sourceName, sourceBinding, { sourceRegistryPath: propertyPath }); } return sourceBindings; }; const validateSearchWorkloadMapperConfig = (mapperConfig, propertyPath) => { assertAllowedKeys(mapperConfig, propertyPath, SEARCH_WORKLOAD_MAPPER_KEYS); assertNonEmptyString(mapperConfig.name, `${propertyPath}.name`); if (mapperConfig.source !== undefined) { assertStringWhenProvided(mapperConfig.source, `${propertyPath}.source`); } if (mapperConfig.export !== undefined) { assertStringWhenProvided(mapperConfig.export, `${propertyPath}.export`); } return mapperConfig; }; const validateSearchWorkloadIndexConfig = (indexConfig, propertyPath) => { assertObjectWhenProvided(indexConfig, propertyPath); assertNonEmptyString(indexConfig.name, `${propertyPath}.name`); validateSearchIndexFields(indexConfig.name, indexConfig); for (const arrayProp of ['queryBy', 'facets', 'filterBy', 'groupBy']) { if (indexConfig[arrayProp] !== undefined && !isStringArray(indexConfig[arrayProp])) { throwSearchConfigError(`The "${propertyPath}.${arrayProp}" property must be an array of strings when provided.`); } } if (indexConfig.defaultSort !== undefined) { if (!Array.isArray(indexConfig.defaultSort)) { throwSearchConfigError(`The "${propertyPath}.defaultSort" property must be an array when provided.`); } for (const sortEntry of indexConfig.defaultSort) { if (!isPlainObject(sortEntry)) { throwSearchConfigError(`The "${propertyPath}.defaultSort" entries must be objects.`); } if (!isString(sortEntry.field) || sortEntry.field.trim().length === 0) { throwSearchConfigError(`The "${propertyPath}.defaultSort[].field" property must be a non-empty string.`); } if (sortEntry.direction !== undefined && (!isString(sortEntry.direction) || !['asc', 'desc'].includes(sortEntry.direction.trim().toLowerCase()))) { throwSearchConfigError(`The "${propertyPath}.defaultSort[].direction" property must be either "asc" or "desc" when provided.`); } } } return indexConfig; }; const validateSearchFirestoreWorkloadSourceConfig = (sourceConfig, propertyPath) => { assertAllowedKeys(sourceConfig, propertyPath, SEARCH_WORKLOAD_SOURCE_KEYS); assertNonEmptyString(sourceConfig.name, `${propertyPath}.name`); assertNonEmptyString(sourceConfig.type, `${propertyPath}.type`); assertNonEmptyString(sourceConfig.syncClass, `${propertyPath}.syncClass`); assertStringWhenProvided(sourceConfig.description, `${propertyPath}.description`); assertStringWhenProvided(sourceConfig.routeTemplate, `${propertyPath}.routeTemplate`); if (sourceConfig.enabled !== undefined && !isBoolean(sourceConfig.enabled)) { throwSearchConfigError(`The "${propertyPath}.enabled" property must be a boolean when provided.`); } if (sourceConfig.type.trim().toLowerCase() !== 'firestore') { throwSearchConfigError(`The "${propertyPath}.type" property must be "firestore" for Firestore workloads.`); } if (!SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES.includes(sourceConfig.syncClass.trim().toLowerCase())) { throwSearchConfigError(`The "${propertyPath}.syncClass" property must be one of: ${SUPPORTED_SEARCH_SOURCE_SYNC_CLASSES.join(', ')}.`); } if (sourceConfig.syncClass.trim().toLowerCase() !== 'delta-merge') { throwSearchConfigError(`The "${propertyPath}" entry must use syncClass "delta-merge" when type is "firestore".`); } if (sourceConfig.http !== undefined || sourceConfig.sql !== undefined) { throwSearchConfigError(`The "${propertyPath}" entry cannot define "http" or "sql" when type is "firestore".`); } assertObjectWhenProvided(sourceConfig.firestore, `${propertyPath}.firestore`); if (sourceConfig.firestore === undefined) { throwSearchConfigError(`The "${propertyPath}.firestore" property is required for Firestore workloads.`); } assertAllowedKeys(sourceConfig.firestore, `${propertyPath}.firestore`, SEARCH_WORKLOAD_FIRESTORE_KEYS); assertNonEmptyString(sourceConfig.firestore.documentPathPattern, `${propertyPath}.firestore.documentPathPattern`); assertStringWhenProvided(sourceConfig.firestore.database, `${propertyPath}.firestore.database`); assertStringWhenProvided(sourceConfig.firestore.triggerRegion, `${propertyPath}.firestore.triggerRegion`); return sourceConfig; }; const validateSearchWorkloadSourceConfig = (sourceConfig, mapperConfig, indexConfig, propertyPath) => { assertObjectWhenProvided(sourceConfig, propertyPath); if (sourceConfig.type?.trim().toLowerCase() === 'firestore') { return validateSearchFirestoreWorkloadSourceConfig(sourceConfig, propertyPath); } if (sourceConfig.enabled !== undefined) { throwSearchConfigError(`The "${propertyPath}.enabled" property is only supported for Firestore workloads.`); } if (sourceConfig.firestore !== undefined) { throwSearchConfigError(`The "${propertyPath}.firestore" property is only supported when type is "firestore".`); } const sharedSourceDefinition = { ...sourceConfig }; delete sharedSourceDefinition.enabled; delete sharedSourceDefinition.firestore; delete sharedSourceDefinition.name; validateSearchSourceDefinitionConfig(sourceConfig.name, { ...sharedSourceDefinition, indexes: [indexConfig.name], mapper: mapperConfig.name }, { sourceRegistryPath: propertyPath }); return sourceConfig; }; export const validateSearchWorkloadConfig = (config, filePath) => { if (!isPlainObject(config)) { throw new Error(`Invalid Atlas search workload: ${filePath}. Expected a JSON object.`); } const invalidKeys = Object.keys(config).filter(key => !SEARCH_WORKLOAD_FILE_KEYS.includes(key)); if (invalidKeys.length > 0) { throw new Error(`Invalid Atlas search workload: ${filePath}. Unsupported properties: ${invalidKeys.join(', ')}.`); } if (!isPlainObject(config.source)) { throw new Error(`Invalid Atlas search workload: ${filePath}. The "source" property must be an object.`); } if (!isPlainObject(config.mapper)) { throw new Error(`Invalid Atlas search workload: ${filePath}. The "mapper" property must be an object.`); } if (!isPlainObject(config.index)) { throw new Error(`Invalid Atlas search workload: ${filePath}. The "index" property must be an object.`); } validateSearchWorkloadMapperConfig(config.mapper, `${filePath}.mapper`); validateSearchWorkloadIndexConfig(config.index, `${filePath}.index`); validateSearchWorkloadSourceConfig(config.source, config.mapper, config.index, `${filePath}.source`); return config; }; const validateSearchRegistries = (config, options = {}) => { const { requireCollections = false, requireIndexes = false, validateSources = false } = options; if (config.collections === undefined ? requireCollections : !isPlainObject(config.collections)) { throwSearchConfigError('The "collections" property must be an object.'); } if (config.indexes === undefined ? requireIndexes : !isPlainObject(config.indexes)) { throwSearchConfigError('The "indexes" property must be an object.'); } if (config.mappers !== undefined && !isPlainObject(config.mappers)) { throwSearchConfigError('The "mappers" property must be an object.'); } if (validateSources) { if (config.sources !== undefined && !isPlainObject(config.sources)) { throwSearchConfigError('The "sources" property must be an object.'); } for (const [sourceName, sourceConfig] of Object.entries(config.sources ?? {})) { validateSearchSourceConfig(sourceName, sourceConfig); } } for (const [mapperName, mapperConfig] of Object.entries(config.mappers ?? {})) { if (!isPlainObject(mapperConfig)) { throwSearchConfigError(`The mapper "${mapperName}" must be an object.`); } if (mapperConfig.source !== undefined && !isString(mapperConfig.source)) { throwSearchConfigError(`The mapper "${mapperName}.source" property must be a string when provided.`); } if (mapperConfig.export !== undefined && !isString(mapperConfig.export)) { throwSearchConfigError(`The mapper "${mapperName}.export" property must be a string when provided.`); } } for (const [collectionName, collectionConfig] of Object.entries(config.collections ?? {})) { if (!isPlainObject(collectionConfig)) { throwSearchConfigError(`The collection "${collectionName}" must be an object.`); } if (collectionConfig.enabled !== undefined && !isBoolean(collectionConfig.enabled)) { throwSearchConfigError(`The collection "${collectionName}.enabled" property must be a boolean when provided.`); } if (collectionConfig.indexes !== undefined && !isStringArray(collectionConfig.indexes)) { throwSearchConfigError(`The collection "${collectionName}.indexes" property must be an array of strings when provided.`); } if (collectionConfig.mapper !== undefined && !isString(collectionConfig.mapper)) { throwSearchConfigError(`The collection "${collectionName}.mapper" property must be a string when provided.`); } if (collectionConfig.sourcePathPattern !== undefined && !isString(collectionConfig.sourcePathPattern)) { throwSearchConfigError(`The collection "${collectionName}.sourcePathPattern" property must be a string when provided.`); } if (collectionConfig.routeTemplate !== undefined && !isString(collectionConfig.routeTemplate)) { throwSearchConfigError(`The collection "${collectionName}.routeTemplate" property must be a string when provided.`); } } for (const [indexName, indexConfig] of Object.entries(config.indexes ?? {})) { if (!isPlainObject(indexConfig)) { throwSearchConfigError(`The index "${indexName}" must be an object.`); } validateSearchIndexFields(indexName, indexConfig); for (const arrayProp of ['queryBy', 'facets', 'filterBy', 'groupBy']) { if (indexConfig[arrayProp] !== undefined && !isStringArray(indexConfig[arrayProp])) { throwSearchConfigError(`The index "${indexName}.${arrayProp}" property must be an array of strings when provided.`); } } if (indexConfig.defaultSort !== undefined) { if (!Array.isArray(indexConfig.defaultSort)) { throwSearchConfigError(`The index "${indexName}.defaultSort" property must be an array when provided.`); } for (const sortEntry of indexConfig.defaultSort) { if (!isPlainObject(sortEntry)) { throwSearchConfigError(`The index "${indexName}.defaultSort" entries must be objects.`); } if (!isString(sortEntry.field) || sortEntry.field.trim().length === 0) { throwSearchConfigError(`The index "${indexName}.defaultSort[].field" property must be a non-empty string.`); } if (sortEntry.direction !== undefined && (!isString(sortEntry.direction) || !['asc', 'desc'].includes(sortEntry.direction.trim().toLowerCase()))) { throwSearchConfigError(`The index "${indexName}.defaultSort[].direction" property must be either "asc" or "desc" when provided.`); } } } } return config; }; export const validateSearchRootSection = config => { if (!isPlainObject(config)) { throw new Error('Invalid Atlas config. The "search" section must be an object.'); } const invalidKeys = Object.keys(config).filter(key => !SEARCH_ROOT_SECTION_KEYS.includes(key)); if (invalidKeys.length > 0) { throw new Error(`Invalid Atlas search config. Unsupported root search properties: ${invalidKeys.join(', ')}.`); } if (!isStringArray(config.workloads) || config.workloads.length === 0) { throw new Error('Invalid Atlas search config. The "workloads" property must be a non-empty array of strings.'); } if (config.projects !== undefined) { assertObjectWhenProvided(config.projects, 'projects'); for (const [projectId, projectConfig] of Object.entries(config.projects ?? {})) { validateSearchProjectSection(projectConfig, projectId); } } return validateSearchRuntimeSettings(config); }; export const validateSearchProjectSection = (config, projectId = 'project') => { if (!isPlainObject(config)) { throw new Error(`Invalid Atlas search config. The "projects.${projectId}" property must be an object.`); } const invalidKeys = Object.keys(config).filter(key => !SEARCH_PROJECT_SECTION_KEYS.includes(key)); if (invalidKeys.length > 0) { throw new Error(`Invalid Atlas search config. Unsupported project search properties for "${projectId}": ${invalidKeys.join(', ')}.`); } if (config.environment !== undefined && !isString(config.environment)) { throw new Error(`Invalid Atlas search config. The "projects.${projectId}.environment" property must be a string when provided.`); } validateSearchSourceBindingRegistry(config.sourceBindings, `projects.${projectId}.sourceBindings`); const { environment, sourceBindings, ...runtimeConfig } = config; return { ...(environment !== undefined ? { environment } : {}), ...(sourceBindings !== undefined ? { sourceBindings } : {}), ...validateSearchRuntimeSettings(runtimeConfig) }; }; export const validateResolvedSearchConfig = config => { if (!isPlainObject(config)) { throw new Error('Invalid Atlas search config. Expected a resolved search config object.'); } if (!Number.isInteger(config.version) || config.version < 1) { throw new Error('Invalid Atlas search config. The resolved "version" property must be a positive integer.'); } validateSearchRuntimeSettings(config); validateSearchRegistries(config, { requireCollections: true, requireIndexes: true, validateSources: true }); return config; }; export const validateSearchFragmentConfig = validateSearchWorkloadConfig;