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