UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

251 lines 13.8 kB
import { execFileSync as nodeExecFileSync } from 'child_process'; import { pick } from 'es-toolkit/object'; import { runGcloudFileCommand } from '../../utils/gcloud.js'; import { dedupeMessages, normalizeOptionalString, spreadIf } from '../../utils/value.js'; import { getEnabledSearchEventarcCollectionPlans } from './eventarc.js'; import { resolveSearchFirestoreEventarcTriggerRegion } from './firestoreEventarc.js'; import { getSearchProvider, getSearchProviderDisplayName } from './providers/index.js'; import { discoverSearchProviderTargetsViaSearchApi } from './searchApiAdmin.js'; import { getEnabledScheduledSearchSourcePlans } from './sources/scheduled.js'; import { SEARCH_RESOURCE_NAMES } from './resourceNames.js'; import { resolveSearchCloudRunDeployConfig, resolveSearchTaskQueueConfig } from './deploymentConfig.js'; const DEFAULT_CLOUD_RUN_REGIONS = ['europe-west1', 'europe-west4', 'europe-west3', 'us-central1']; const GCLOUD_LIST_FORMAT_ARGS = ['--quiet', '--format=json']; const runJsonCommand = (args, execute = nodeExecFileSync) => { try { const output = runGcloudFileCommand(args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, execute); return { data: JSON.parse(output), error: null }; } catch (error) { return { data: null, error: error.stderr?.toString().trim() || error.message }; } }; const mergeDiscoveredResources = resources => Array.from(new Map(resources.filter(resource => resource.name).map(resource => [resource.name, resource])).values()); const normalizeCloudRunResource = (resource, fallbackLocation = null) => ({ name: resource?.metadata?.name ?? resource?.name ?? null, location: resource?.metadata?.labels?.['cloud.googleapis.com/location'] ?? resource?.metadata?.labels?.location ?? resource?.location ?? fallbackLocation }); const normalizeLocatedResource = (resource, resourceGroup) => { const fullName = normalizeOptionalString(resource?.name); const resourceName = fullName ? fullName.split('/').pop() : null; const locationMatch = fullName?.match(new RegExp(`/locations/([^/]+)/${resourceGroup}/`, 'i')); return { location: locationMatch?.[1] ?? resource?.location ?? null, name: resourceName }; }; const discoverCloudRunResourceGroup = (resourceType, projectId, execute = nodeExecFileSync) => { const baseArgs = ['run', resourceType, 'list', `--project=${projectId}`]; const platformArgs = resourceType === 'services' ? ['--platform=managed'] : []; const globalResult = runJsonCommand([...baseArgs, ...platformArgs, ...GCLOUD_LIST_FORMAT_ARGS], execute); if (!globalResult.error) { return { resources: mergeDiscoveredResources(globalResult.data.map(resource => normalizeCloudRunResource(resource))), warnings: [] }; } const fallbackResources = []; const successfulRegions = []; for (const region of DEFAULT_CLOUD_RUN_REGIONS) { const regionalResult = runJsonCommand([...baseArgs, ...platformArgs, `--region=${region}`, ...GCLOUD_LIST_FORMAT_ARGS], execute); if (regionalResult.error) { continue; } successfulRegions.push(region); fallbackResources.push(...regionalResult.data.map(resource => normalizeCloudRunResource(resource, region))); } if (successfulRegions.length > 0) { return { resources: mergeDiscoveredResources(fallbackResources), warnings: [`Global Cloud Run ${resourceType} discovery failed. Regional fallback succeeded for: ${successfulRegions.join(', ')}.`] }; } return { resources: null, warnings: [`Could not inspect Cloud Run ${resourceType}. Global lookup failed and no regional fallback succeeded in: ${DEFAULT_CLOUD_RUN_REGIONS.join(', ')}.`] }; }; const discoverResourceGroup = (args, normalizeResource, warningMessage, execute = nodeExecFileSync) => { const result = runJsonCommand([...args, ...GCLOUD_LIST_FORMAT_ARGS], execute); if (!result.error) { return { warnings: [], resources: mergeDiscoveredResources(result.data.map(resource => normalizeResource(resource))) }; } return { resources: null, warnings: [warningMessage(result.error)] }; }; const discoverEventarcTriggerGroup = (projectId, region, execute = nodeExecFileSync) => discoverResourceGroup(['eventarc', 'triggers', 'list', `--project=${projectId}`, `--location=${region}`], resource => normalizeLocatedResource(resource, 'triggers'), error => `Could not inspect Eventarc triggers in ${region}: ${error}`, execute); const discoverTaskQueueGroup = (projectId, location, execute = nodeExecFileSync) => discoverResourceGroup(['tasks', 'queues', 'list', `--project=${projectId}`, `--location=${location}`], resource => normalizeLocatedResource(resource, 'queues'), error => `Could not inspect Cloud Tasks queues in ${location}: ${error}`, execute); const discoverSchedulerJobGroup = (projectId, region, execute = nodeExecFileSync) => discoverResourceGroup(['scheduler', 'jobs', 'list', `--project=${projectId}`, `--location=${region}`], resource => normalizeLocatedResource(resource, 'jobs'), error => `Could not inspect Cloud Scheduler jobs in ${region}: ${error}`, execute); const resolveSearchEventarcDiscoveryRegion = (context, options = {}) => { const fallbackRegion = context.config?.deploy?.cloudRun?.region ?? 'europe-west1'; try { const firestoreEventarc = resolveSearchFirestoreEventarcTriggerRegion(context, options); return { region: firestoreEventarc.triggerRegion, warnings: [] }; } catch (error) { return { region: fallbackRegion, warnings: ['Could not resolve the Firestore Eventarc trigger region for remote discovery. ' + `Falling back to the Cloud Run region ${fallbackRegion}. ${error.message}`] }; } }; const getExpectedSearchEventarcTriggerResources = context => getEnabledSearchEventarcCollectionPlans(context.config).map(collectionPlan => ({ name: collectionPlan.triggerName, projectId: context.projectId, purpose: 'Eventarc Standard trigger that routes Firestore direct events into atlas-search-sync.' })); const getExpectedSearchTaskQueueResources = context => { const taskQueue = resolveSearchTaskQueueConfig(context); return [{ location: taskQueue.location, name: taskQueue.name, projectId: context.projectId, purpose: 'Managed Cloud Tasks queue that durably buffers Atlas search incremental sync work before atlas-search-sync processes it.' }]; }; const getExpectedSearchSchedulerJobResources = context => { const deploymentRegion = resolveSearchCloudRunDeployConfig(context).region; return getEnabledScheduledSearchSourcePlans(context.config).map(sourcePlan => ({ location: deploymentRegion, name: sourcePlan.schedulerJobName, projectId: context.projectId, purpose: 'Cloud Scheduler job that executes atlas-search-source-runner for a single scheduled Atlas search source.' })); }; const toRemoteStatus = (resource, discoveredResources) => { if (!discoveredResources) { return { ...resource, remoteStatus: 'unknown' }; } const match = discoveredResources.find(entry => entry.name === resource.name); if (!match) { return { ...resource, remoteStatus: 'not-found' }; } return { ...resource, remoteLocation: match.location ?? null, remoteStatus: 'found' }; }; const createProviderDiscoveryFallbackWarning = (provider, error) => `Could not inspect ${provider.descriptor.labels.targetGroup.toLowerCase()} through atlas-search-api: ${error.message}`; const createProviderDiscoveryUnavailableWarning = provider => `atlas-search-api did not return ${provider.descriptor.labels.targetGroup.toLowerCase()}, so Atlas fell back to direct provider inspection from this machine.`; const collectDiscoveryWarnings = (...warningGroups) => dedupeMessages(warningGroups.flatMap(warningGroup => warningGroup ?? [])); const createProviderDiscoveryResult = (targets, ...warningGroups) => ({ targets, warnings: collectDiscoveryWarnings(...warningGroups) }); const discoverProviderTargetsWithFallback = async (context, provider, options = {}) => { const discoverProviderTargetsViaSearchApiImpl = options.discoverProviderTargetsViaSearchApiImpl ?? discoverSearchProviderTargetsViaSearchApi; const discoverProviderTargetsDirectImpl = options.discoverProviderTargetsDirectImpl ?? (async (providerContext, directOptions = {}) => provider.discoverRemoteTargets(providerContext, directOptions)); const apiRequestOptions = pick(options, ['fetchImpl', 'getSecretImpl', 'runCommand']); try { const result = await discoverProviderTargetsViaSearchApiImpl(context, apiRequestOptions); if (Array.isArray(result?.targets)) { return result; } const directResult = await discoverProviderTargetsDirectImpl(context, apiRequestOptions); return createProviderDiscoveryResult(directResult.targets, result?.warnings ?? [], createProviderDiscoveryUnavailableWarning(provider), directResult.warnings ?? []); } catch (error) { const directResult = await discoverProviderTargetsDirectImpl(context, apiRequestOptions); return createProviderDiscoveryResult(directResult.targets, createProviderDiscoveryFallbackWarning(provider, error), directResult.warnings ?? []); } }; export const getExpectedSearchResources = context => { const provider = getSearchProvider(context.config.provider); const providerDisplayName = getSearchProviderDisplayName(provider, context.config.provider); const scheduledSourcePlans = getEnabledScheduledSearchSourcePlans(context.config); const providerTargets = Object.keys(context.config.indexes).map(indexName => ({ indexName, name: indexName })); return { cloudRunJobs: [{ name: SEARCH_RESOURCE_NAMES.backfillJob, projectId: context.projectId, purpose: `Batch job that backfills and reindexes Firestore data into the separate ${providerDisplayName} engine.` }, ...spreadIf(scheduledSourcePlans.length > 0, [{ name: SEARCH_RESOURCE_NAMES.sourceRunnerJob, projectId: context.projectId, purpose: 'Cloud Run job that executes scheduled Atlas search source adapters and advances source cursors.' }])], cloudRunServices: [{ name: SEARCH_RESOURCE_NAMES.searchSyncService, projectId: context.projectId, purpose: 'Write-side Atlas search sync service that resolves mappers and applies provider updates centrally.' }, { name: SEARCH_RESOURCE_NAMES.searchApiService, projectId: context.projectId, purpose: `Optional stateless API layer that queries the separate ${providerDisplayName} engine for search requests.` }], computeInstances: [], eventarcTriggers: getExpectedSearchEventarcTriggerResources(context), schedulerJobs: getExpectedSearchSchedulerJobResources(context), taskQueues: getExpectedSearchTaskQueueResources(context), providerTargets: providerTargets.map(target => ({ indexName: target.indexName, name: target.name, projectId: context.projectId, purpose: `Expected ${provider.descriptor.labels.targetGroup.toLowerCase()} entry derived from Atlas search index config.` })) }; }; export const discoverSearchRemoteResources = async (context, options = {}) => { const expectedResources = getExpectedSearchResources(context); const warnings = []; const provider = options.provider ?? getSearchProvider(context.config.provider); const runCommand = options.runCommand ?? nodeExecFileSync; const servicesResult = discoverCloudRunResourceGroup('services', context.projectId, runCommand); const jobsResult = discoverCloudRunResourceGroup('jobs', context.projectId, runCommand); const taskQueueConfig = resolveSearchTaskQueueConfig(context); const taskQueuesResult = discoverTaskQueueGroup(context.projectId, taskQueueConfig.location, runCommand); const schedulerJobsResult = expectedResources.schedulerJobs.length > 0 ? discoverSchedulerJobGroup(context.projectId, resolveSearchCloudRunDeployConfig(context).region, runCommand) : { resources: [], warnings: [] }; const eventarcTriggerRegionResult = resolveSearchEventarcDiscoveryRegion(context, { ...options, runCommand }); const eventarcTriggersResult = discoverEventarcTriggerGroup(context.projectId, eventarcTriggerRegionResult.region, runCommand); const providerTargetsResult = await discoverProviderTargetsWithFallback(context, provider, { discoverProviderTargetsDirectImpl: options.discoverProviderTargetsDirectImpl, discoverProviderTargetsViaSearchApiImpl: options.discoverProviderTargetsImpl, fetchImpl: options.fetchImpl, getSecretImpl: options.getSecretImpl, runCommand }); warnings.push(...collectDiscoveryWarnings(servicesResult.warnings, jobsResult.warnings, taskQueuesResult.warnings, schedulerJobsResult.warnings, eventarcTriggerRegionResult.warnings, eventarcTriggersResult.warnings, providerTargetsResult.warnings)); return { warnings, cloudRunJobs: expectedResources.cloudRunJobs.map(resource => toRemoteStatus(resource, jobsResult.resources)), cloudRunServices: expectedResources.cloudRunServices.map(resource => toRemoteStatus(resource, servicesResult.resources)), eventarcTriggers: expectedResources.eventarcTriggers.map(resource => toRemoteStatus(resource, eventarcTriggersResult.resources)), schedulerJobs: expectedResources.schedulerJobs.map(resource => toRemoteStatus(resource, schedulerJobsResult.resources)), taskQueues: expectedResources.taskQueues.map(resource => toRemoteStatus(resource, taskQueuesResult.resources)), providerTargets: expectedResources.providerTargets.map(resource => toRemoteStatus(resource, providerTargetsResult.targets)) }; }; export default { discoverSearchRemoteResources, getExpectedSearchResources };