UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

449 lines 18.5 kB
import { execFileSync } from 'child_process'; import { isPlainObject } from 'es-toolkit/compat'; import * as features from '../../utils/feature.js'; import { compileSyncPlan } from './config/syncConfig.js'; import { executeSyncAdminRequest } from './syncApiAdmin.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { materializeGcloudCommandFlagsFile } from '../../utils/gcloud.js'; import { getCommandErrorMessage, isGcloudResourceNotFoundError, logger, parseGcloudJsonOutput, runGcloudFileCommand } from '../../utils/index.js'; import { createSyncBackfillJobExecutionCommand, ensureSyncBackfillJobExists } from './backfillJob.js'; const SUPPORTED_RUN_ACTIONS = new Set(['backfill', 'incremental']); const DEFAULT_FOLLOW_TIMEOUT_SECONDS = 1800; const DEFAULT_FOLLOW_POLL_INTERVAL_MS = 5000; const EXECUTION_FAILURE_DETAILS_LIMIT = 5; const normalizeRunAction = action => normalizeOptionalString(action)?.toLowerCase() ?? null; const parseRequestedSyncWorkloads = (workloadsArgument, availableWorkloads) => { const normalizedArgument = normalizeOptionalString(workloadsArgument); const normalizedAvailableWorkloads = Array.isArray(availableWorkloads) ? availableWorkloads.filter(Boolean) : []; if (!normalizedArgument) { if (normalizedAvailableWorkloads.length === 0) { throw new Error('Atlas sync run backfill requires at least one enabled workload in the selected project.'); } return normalizedAvailableWorkloads; } const requestedWorkloads = [...new Set(normalizedArgument.split(',').map(value => value.trim()).filter(Boolean))]; const availableWorkloadSet = new Set(normalizedAvailableWorkloads); const unknownWorkloads = requestedWorkloads.filter(workloadKey => !availableWorkloadSet.has(workloadKey)); if (unknownWorkloads.length > 0) { throw new Error(`Atlas sync run backfill received unknown or disabled workload keys: ${unknownWorkloads.join(', ')}.`); } return requestedWorkloads; }; const normalizeTimeoutSeconds = timeout => { if (timeout === undefined || timeout === null || timeout === '') { return DEFAULT_FOLLOW_TIMEOUT_SECONDS; } const parsedValue = Number.parseInt(timeout, 10); if (!Number.isInteger(parsedValue) || parsedValue <= 0) { throw new Error('Atlas sync run requires --timeout <seconds> to be a positive integer.'); } return parsedValue; }; const logSyncOperation = (loggerImpl, context, action, command, workloadKeys) => { loggerImpl.summary('Atlas sync run', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Config', value: context.configPath }, { label: 'Action', value: action }, { label: 'Workloads', value: workloadKeys.join(', ') }, { label: 'Command', value: command.command }]); }; export const runSyncBackfillOperation = async (action, workloadsArgument, commandOptions = {}, dependencies = {}, cwd = process.cwd()) => { const options = commandOptions ?? {}; const compileSyncPlanImpl = dependencies.compileSyncPlanImpl ?? compileSyncPlan; const ensureSyncBackfillJobExistsImpl = dependencies.ensureSyncBackfillJobExists ?? ensureSyncBackfillJobExists; const exit = dependencies.exit ?? (code => process.exit(code)); const loadFeatureContext = dependencies.loadFeatureContext ?? features.loadFeatureContext; const loggerImpl = dependencies.logger ?? logger; const runCommand = dependencies.runCommand ?? execFileSync; let spinner; try { spinner = loggerImpl.spinner(options.dryRun ? 'Preparing Atlas sync backfill command...' : 'Starting Atlas sync backfill...'); const context = await loadFeatureContext('sync', options, { cwd }); const syncPlan = compileSyncPlanImpl(context.config); const workloadKeys = parseRequestedSyncWorkloads(workloadsArgument, syncPlan.pipelines.map(pipeline => pipeline.workloadKey)); if (!options.dryRun) { ensureSyncBackfillJobExistsImpl(context, { runCommand }); } const command = createSyncBackfillJobExecutionCommand({ context, workloadKeys }); if (options.dryRun) { spinner.succeed('Atlas sync backfill command is ready.'); logSyncOperation(loggerImpl, context, action, command, workloadKeys); loggerImpl.info('Dry run requested: no remote changes were made.'); return { ...command, status: 'dry-run', workloadKeys }; } const materializedFlagsFile = materializeGcloudCommandFlagsFile(command); try { runGcloudFileCommand(materializedFlagsFile?.args ?? command.args, { stdio: 'inherit' }, runCommand); } finally { materializedFlagsFile?.cleanup(); } spinner.succeed('Atlas sync backfill started.'); logSyncOperation(loggerImpl, context, action, command, workloadKeys); return { ...command, status: 'started', workloadKeys }; } catch (error) { if (spinner) { spinner.fail('Failed to start Atlas sync backfill.'); } loggerImpl.error(error.message, false); return exit(1); } }; export const runSyncIncrementalOperation = async (action, commandOptions = {}, dependencies = {}, cwd = process.cwd()) => { const options = commandOptions ?? {}; const executeSyncAdminRequestImpl = dependencies.executeSyncAdminRequest ?? executeSyncAdminRequest; const exit = dependencies.exit ?? (code => process.exit(code)); const loadFeatureContext = dependencies.loadFeatureContext ?? features.loadFeatureContext; const loggerImpl = dependencies.logger ?? logger; try { const workloadKey = normalizeOptionalString(options.workloadKey); const documentId = normalizeOptionalString(options.documentId); if (!workloadKey) { throw new Error('Atlas sync run incremental requires --workload-key <workloadKey>.'); } if (!documentId) { throw new Error('Atlas sync run incremental requires --document-id <documentId>.'); } const spinner = loggerImpl.spinner('Triggering Atlas sync incremental run...'); try { const context = await loadFeatureContext('sync', options, { cwd }); const result = await executeSyncAdminRequestImpl(context, { method: 'POST', path: '/admin/run/incremental', payload: { workloadKey, sourceDocumentId: documentId } }); spinner.succeed('Atlas sync incremental run triggered.'); loggerImpl.summary('Incremental run summary', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Workload key', value: result.workloadKey ?? workloadKey }, { label: 'Source document', value: result.sourceDocumentId ?? documentId }, { label: 'Task name', value: result.taskName ?? 'unknown' }]); return result; } catch (error) { spinner.fail('Atlas sync incremental run failed.'); throw error; } } catch (error) { loggerImpl.error(error.message, false); return exit(1); } }; const resolveExecutionCondition = (execution, conditionType) => (execution?.status?.conditions ?? []).find(condition => condition?.type?.toLowerCase() === conditionType.toLowerCase()) ?? null; const createExecutionSummary = execution => { const completedCondition = resolveExecutionCondition(execution, 'Completed'); const cancelledCondition = resolveExecutionCondition(execution, 'Cancelled'); const failedCount = Number.parseInt(execution?.status?.failedCount ?? 0, 10) || 0; const succeededCount = Number.parseInt(execution?.status?.succeededCount ?? 0, 10) || 0; const stateReason = completedCondition?.reason ?? cancelledCondition?.reason ?? null; if (cancelledCondition?.status === 'True') { return { failedCount, reason: stateReason, state: 'failed', succeededCount }; } if (completedCondition?.status === 'True') { if (failedCount > 0 || /fail|error|cancel/i.test(stateReason ?? '')) { return { failedCount, reason: stateReason, state: 'failed', succeededCount }; } return { failedCount, reason: stateReason, state: 'completed', succeededCount }; } return { failedCount, reason: stateReason, state: 'running', succeededCount }; }; const parseExecutionNameFromList = output => { const normalized = normalizeOptionalString(output); if (!normalized) { return null; } return normalized.split(/\r?\n/).map(value => value.trim()).find(Boolean) ?? null; }; const sleep = timeoutInMilliseconds => new Promise(resolve => { setTimeout(resolve, timeoutInMilliseconds); }); const normalizeFollowTimestamp = value => { if (value instanceof Date) { return value.getTime(); } const normalizedValue = Number(value); if (!Number.isFinite(normalizedValue)) { throw new Error('Atlas sync run follow requires a clock that returns a Date or millisecond timestamp.'); } return normalizedValue; }; const normalizeFailureDetailValue = value => { if (value === null || value === undefined) { return []; } if (Array.isArray(value)) { return value.flatMap(normalizeFailureDetailValue); } if (typeof value === 'string') { return [value]; } if (isPlainObject(value)) { if (normalizeOptionalString(value.message)) { return [value.message.trim()]; } try { return [JSON.stringify(value)]; } catch { return []; } } return [String(value)]; }; const extractExecutionFailureDetails = logsPayload => { const details = []; const seen = new Set(); const logEntries = Array.isArray(logsPayload) ? logsPayload : [logsPayload]; for (const logEntry of logEntries) { const severity = normalizeOptionalString(logEntry?.severity); const candidates = [...normalizeFailureDetailValue(logEntry?.jsonPayload?.error?.details), ...normalizeFailureDetailValue(logEntry?.jsonPayload?.error?.message), ...normalizeFailureDetailValue(logEntry?.jsonPayload?.message), ...normalizeFailureDetailValue(logEntry?.textPayload)]; for (const candidate of candidates) { const normalized = candidate.replace(/\s+/g, ' ').trim(); if (normalized.length === 0) { continue; } const formatted = severity ? `${severity}: ${normalized}` : normalized; if (seen.has(formatted)) { continue; } seen.add(formatted); details.push(formatted); if (details.length >= EXECUTION_FAILURE_DETAILS_LIMIT) { return details; } } } return details; }; const listLatestExecutionName = (operationResult, dependencies = {}) => { const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? runGcloudFileCommand; try { const output = runGcloudFileCommandImpl(['run', 'jobs', 'executions', 'list', `--job=${operationResult.jobName}`, `--project=${operationResult.projectId}`, `--region=${operationResult.region}`, '--sort-by=~metadata.creationTimestamp', '--limit=1', '--format="value(metadata.name)"'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); return parseExecutionNameFromList(output); } catch (error) { if (isGcloudResourceNotFoundError(error)) { return null; } throw new Error(`Could not list Atlas sync run executions for ${operationResult.jobName}. ${getCommandErrorMessage(error).trim()}`); } }; const describeExecution = (operationResult, executionName, dependencies = {}) => { const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? runGcloudFileCommand; try { const output = runGcloudFileCommandImpl(['run', 'jobs', 'executions', 'describe', executionName, `--project=${operationResult.projectId}`, `--region=${operationResult.region}`, '--format=json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); return parseGcloudJsonOutput(output, `Cloud Run execution ${executionName}`); } catch (error) { if (isGcloudResourceNotFoundError(error)) { return null; } throw new Error(`Could not inspect Atlas sync run execution ${executionName}. ${getCommandErrorMessage(error).trim()}`); } }; export const resolveSyncRunExecutionFailureDetails = (operationResult, watchResult, dependencies = {}) => { const executionName = normalizeOptionalString(watchResult?.executionName); const runGcloudFileCommandImpl = dependencies.runGcloudFileCommand ?? runGcloudFileCommand; if (!executionName || !operationResult?.jobName || !operationResult?.projectId) { return []; } try { const output = runGcloudFileCommandImpl(['logging', 'read', ['resource.type="cloud_run_job"', `resource.labels.job_name="${operationResult.jobName}"`, `labels."run.googleapis.com/execution_name"="${executionName}"`, 'severity>=ERROR'].join(' AND '), `--project=${operationResult.projectId}`, '--limit=20', '--order=desc', '--format=json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, dependencies.runCommand); const parsedOutput = parseGcloudJsonOutput(output, `Cloud Run execution logs for ${executionName}`); return extractExecutionFailureDetails(parsedOutput); } catch { return []; } }; export const watchSyncRunExecution = async (operationResult, options = {}, dependencies = {}) => { const loggerImpl = dependencies.logger ?? logger; const sleepImpl = dependencies.sleep ?? sleep; const now = dependencies.now ?? (() => Date.now()); const timeoutSeconds = normalizeTimeoutSeconds(options.timeout); const timeoutInMilliseconds = timeoutSeconds * 1000; const pollIntervalMilliseconds = options.pollIntervalMs ?? DEFAULT_FOLLOW_POLL_INTERVAL_MS; const getCurrentTimestamp = () => normalizeFollowTimestamp(now()); const deadline = getCurrentTimestamp() + timeoutInMilliseconds; let executionName = null; let hasLoggedExecutionDiscoveryWait = false; let lastLoggedState = null; loggerImpl.info(`Following Atlas sync execution for up to ${timeoutSeconds} seconds (job: ${operationResult.jobName}).`); while (getCurrentTimestamp() <= deadline) { if (!executionName) { executionName = listLatestExecutionName(operationResult, dependencies); if (!executionName) { if (!hasLoggedExecutionDiscoveryWait) { loggerImpl.info(`Waiting for Cloud Run to report the new execution for job ${operationResult.jobName}.`); hasLoggedExecutionDiscoveryWait = true; } await sleepImpl(pollIntervalMilliseconds); continue; } loggerImpl.info(`Following execution: ${executionName}`); } const execution = describeExecution(operationResult, executionName, dependencies); if (!execution) { await sleepImpl(pollIntervalMilliseconds); continue; } const executionSummary = createExecutionSummary(execution); if (lastLoggedState !== executionSummary.state) { loggerImpl.summary('Execution state', [{ label: 'State', value: executionSummary.state }, { label: 'Succeeded', value: executionSummary.succeededCount }, { label: 'Failed', value: executionSummary.failedCount }]); lastLoggedState = executionSummary.state; } if (executionSummary.state === 'completed') { loggerImpl.success?.('Atlas sync run completed successfully.'); return { executionName, status: 'completed', summary: executionSummary }; } if (executionSummary.state === 'failed') { return { executionName, status: 'failed', summary: executionSummary }; } await sleepImpl(pollIntervalMilliseconds); } return { executionName, status: 'timeout', timeoutSeconds }; }; export const runSync = async (options = {}, dependencies = {}, cwd = process.cwd()) => { const action = normalizeRunAction(options.action); const exit = dependencies.exit ?? (code => process.exit(code)); const loggerImpl = dependencies.logger ?? logger; const resolveSyncRunExecutionFailureDetailsImpl = dependencies.resolveSyncRunExecutionFailureDetails ?? resolveSyncRunExecutionFailureDetails; const runSyncBackfillOperationImpl = dependencies.runSyncBackfillOperation ?? runSyncBackfillOperation; const runSyncIncrementalOperationImpl = dependencies.runSyncIncrementalOperation ?? runSyncIncrementalOperation; const shouldFollowExecution = options.dryRun !== true && options.detached !== true && options.follow !== false; const watchSyncRunExecutionImpl = dependencies.watchSyncRunExecution ?? watchSyncRunExecution; if (!action || !SUPPORTED_RUN_ACTIONS.has(action)) { loggerImpl.error('Atlas sync run requires <action> to be backfill or incremental.', false); return exit(1); } if (action === 'incremental') { return runSyncIncrementalOperationImpl(action, options, dependencies, cwd); } const operationResult = await runSyncBackfillOperationImpl(action, normalizeOptionalString(options.workloads), options, dependencies, cwd); if (!shouldFollowExecution || operationResult?.status !== 'started') { return operationResult; } let watchResult; try { watchResult = await watchSyncRunExecutionImpl(operationResult, options, { ...dependencies, logger: loggerImpl }); } catch (error) { loggerImpl.error(error.message, false); return exit(1); } if (watchResult.status === 'completed') { return { ...operationResult, watch: watchResult }; } if (watchResult.status === 'timeout') { loggerImpl.error(`Atlas sync run timed out after ${watchResult.timeoutSeconds} seconds while waiting for the execution to complete.`, false); } else { loggerImpl.error(`Atlas sync run execution failed${watchResult.summary?.reason ? `: ${watchResult.summary.reason}` : '.'}`, false); const failureDetails = resolveSyncRunExecutionFailureDetailsImpl(operationResult, watchResult, dependencies); if (failureDetails.length > 0) { loggerImpl.summary('Recent execution error details', failureDetails.map((detail, index) => ({ label: `Detail ${index + 1}`, value: detail }))); } } return exit(1); }; export default async (action, options = {}) => runSync({ ...(options ?? {}), action });