UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

260 lines 10.1 kB
import { execFileSync } from 'child_process'; import { isBoolean, isNumber, isPlainObject } from 'es-toolkit/compat'; import jwt from 'jsonwebtoken'; import { getSecret, runGcloudFileCommand } from '../../utils/index.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { SEARCH_API_SERVICE_NAME, SEARCH_SYNC_SERVICE_NAME } from './resourceNames.js'; import { resolveSearchCloudRunDeployConfig, SHARED_REQUEST_AUTH_SECRET } from './deploymentConfig.js'; const MAX_ADMIN_ERROR_DETAIL_LENGTH = 300; export { SEARCH_API_SERVICE_NAME, SEARCH_SYNC_SERVICE_NAME }; const SEARCH_API_ADMIN_OPTION_KEYS = new Set(['force', 'indexNames']); const looksLikeSearchApiAdminOptions = value => value && typeof value === 'object' && Object.keys(value).some(key => SEARCH_API_ADMIN_OPTION_KEYS.has(key)); const hasSearchApiAdminDependencies = value => value && typeof value === 'object' && Object.keys(value).length > 0; const normalizeSearchApiAdminInvocation = (optionsOrDependencies, maybeDependencies) => { if (hasSearchApiAdminDependencies(maybeDependencies)) { return { dependencies: maybeDependencies, options: optionsOrDependencies ?? {} }; } if (looksLikeSearchApiAdminOptions(optionsOrDependencies)) { return { dependencies: maybeDependencies ?? {}, options: optionsOrDependencies ?? {} }; } return { dependencies: optionsOrDependencies ?? {}, options: maybeDependencies ?? {} }; }; const getIssuedAt = now => { const value = typeof now === 'function' ? now() : new Date(); return value instanceof Date ? value : new Date(value); }; const getSearchServiceUrl = (context, serviceName, options = {}) => { const runCommand = options.runCommand ?? execFileSync; const { region } = resolveSearchCloudRunDeployConfig(context); return runGcloudFileCommand(['run', 'services', 'describe', serviceName, `--project=${context.projectId}`, '--platform=managed', `--region=${region}`, '--format="value(status.url)"'], { encoding: 'utf8' }, runCommand).trim(); }; export const getSearchApiServiceUrl = (context, options = {}) => getSearchServiceUrl(context, SEARCH_API_SERVICE_NAME, options); export const getSearchSyncServiceUrl = (context, options = {}) => getSearchServiceUrl(context, SEARCH_SYNC_SERVICE_NAME, options); export const createSearchApiAdminToken = ({ now, path, projectId, requestAuthToken, serviceUrl }) => { const issuedAt = getIssuedAt(now); const serviceHost = new URL(serviceUrl).host; return jwt.sign({ exp: Math.floor(issuedAt.getTime() / 1e3) + 300, host: serviceHost, iss: projectId, path }, requestAuthToken); }; const parseJsonResponse = responseText => { const normalized = normalizeOptionalString(responseText); if (!normalized) { return null; } try { return JSON.parse(normalized); } catch { return normalized; } }; const normalizeAdminErrorDetailValue = value => { if (typeof value === 'string') { return normalizeOptionalString(value); } if (isNumber(value) || isBoolean(value)) { return String(value); } return null; }; const truncateAdminErrorDetailValue = value => { if (typeof value !== 'string') { return null; } if (value.length <= MAX_ADMIN_ERROR_DETAIL_LENGTH) { return value; } return `${value.slice(0, MAX_ADMIN_ERROR_DETAIL_LENGTH - 3)}...`; }; const resolveProviderResponseSummary = responseBody => { const normalizedResponseBody = normalizeAdminErrorDetailValue(responseBody); if (!normalizedResponseBody) { return null; } const parsedResponseBody = parseJsonResponse(normalizedResponseBody); if (parsedResponseBody && isPlainObject(parsedResponseBody)) { const providerMessage = normalizeAdminErrorDetailValue(parsedResponseBody.message); if (providerMessage) { return truncateAdminErrorDetailValue(providerMessage); } } return truncateAdminErrorDetailValue(normalizedResponseBody); }; const summarizeAdminErrorDetails = details => { const directValue = normalizeAdminErrorDetailValue(details); if (directValue) { return directValue; } if (!details || !isPlainObject(details)) { return null; } const summaryParts = []; for (const key of ['cause', 'error', 'code', 'url', 'host', 'status', 'collectionName']) { const value = normalizeAdminErrorDetailValue(details[key]); if (value) { summaryParts.push(`${key}=${value}`); } } const providerResponseSummary = resolveProviderResponseSummary(details.responseBody); if (providerResponseSummary) { summaryParts.push(`providerResponse=${providerResponseSummary}`); } return summaryParts.length > 0 ? summaryParts.join('; ') : null; }; const ensureTrailingPeriod = value => /[^.!?]$/.test(value) ? `${value}.` : value; const createAdminRequestErrorMessage = (responsePayload, status) => { const detailsSummary = summarizeAdminErrorDetails(responsePayload?.details ?? null); const baseMessage = normalizeOptionalString(responsePayload?.message) ?? `Atlas search API admin request failed with status ${status}.`; if (!detailsSummary || baseMessage.includes(detailsSummary)) { return baseMessage; } return `${ensureTrailingPeriod(baseMessage)} Details: ${detailsSummary}.`; }; const executeAdminRequest = async (context, serviceUrlResolver, { method = 'GET', path, payload }, dependencies = {}) => { const fetchImpl = dependencies.fetchImpl ?? fetch; const getSecretImpl = dependencies.getSecretImpl ?? getSecret; const requestAuthToken = await getSecretImpl(SHARED_REQUEST_AUTH_SECRET, 'latest', context.projectId); const serviceUrl = dependencies.serviceUrl ?? serviceUrlResolver(context, dependencies); const token = createSearchApiAdminToken({ now: dependencies.now, path, projectId: context.projectId, requestAuthToken, serviceUrl }); const requestOptions = { headers: { authorization: `Bearer ${token}`, ...(payload !== undefined ? { 'content-type': 'application/json' } : {}) }, method }; if (payload !== undefined) { requestOptions.body = JSON.stringify(payload); } const response = await fetchImpl(`${serviceUrl}${path}`, requestOptions); const responseText = await response.text(); const responsePayload = parseJsonResponse(responseText); if (!response.ok) { const error = new Error(createAdminRequestErrorMessage(responsePayload, response.status)); error.details = responsePayload?.details ?? null; error.status = response.status; throw error; } return responsePayload; }; export const executeSearchApiAdminRequest = (context, options, dependencies = {}) => executeAdminRequest(context, getSearchApiServiceUrl, options, dependencies); export const executeSearchSyncAdminRequest = (context, options, dependencies = {}) => executeAdminRequest(context, getSearchSyncServiceUrl, options, dependencies); export const discoverSearchProviderTargetsViaSearchApi = async (context, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'GET', path: '/admin/provider-targets' }, dependencies); export const getSearchPromotionStatusViaSearchApi = async (context, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'GET', path: '/admin/promotion/status' }, dependencies); export const getSearchStatusViaSearchApi = async (context, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'GET', path: '/admin/status' }, dependencies); export const cutoverSearchPromotionViaSearchApi = async (context, optionsOrDependencies = {}, maybeDependencies = {}) => { const { dependencies, options } = normalizeSearchApiAdminInvocation(optionsOrDependencies, maybeDependencies); return executeSearchApiAdminRequest(context, { method: 'POST', path: '/admin/promotion/cutover', payload: { ...(Array.isArray(options.indexNames) ? { indexNames: options.indexNames } : {}) } }, dependencies); }; export const rollbackSearchPromotionViaSearchApi = async (context, optionsOrDependencies = {}, maybeDependencies = {}) => { const { dependencies, options } = normalizeSearchApiAdminInvocation(optionsOrDependencies, maybeDependencies); return executeSearchApiAdminRequest(context, { method: 'POST', path: '/admin/promotion/rollback', payload: { ...(Array.isArray(options.indexNames) ? { indexNames: options.indexNames } : {}) } }, dependencies); }; export const cleanupSearchPromotionViaSearchApi = async (context, optionsOrDependencies = {}, maybeDependencies = {}) => { const { dependencies, options } = normalizeSearchApiAdminInvocation(optionsOrDependencies, maybeDependencies); return executeSearchApiAdminRequest(context, { method: 'POST', path: '/admin/promotion/cleanup', payload: { force: options.force === true, ...(Array.isArray(options.indexNames) ? { indexNames: options.indexNames } : {}) } }, dependencies); }; export const prepareSourceReindexViaSearchApi = async (context, options, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'POST', path: '/admin/reindex/source/prepare', payload: { indexNames: options.indexNames, reindexSessionId: options.reindexSessionId } }, dependencies); export const finalizeSourceReindexViaSearchApi = async (context, options, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'POST', path: '/admin/reindex/source/finalize', payload: { indexNames: options.indexNames, reindexSessionId: options.reindexSessionId } }, dependencies); export const inspectSearchSchemasViaSearchApi = async (context, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'GET', path: '/admin/schemas' }, dependencies); export const reconcileSearchSchemasViaSearchApi = async (context, options = {}, dependencies = {}) => executeSearchApiAdminRequest(context, { method: 'POST', path: '/admin/schemas/reconcile', payload: { dryRun: options.dryRun === true } }, dependencies);