UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

199 lines 7.22 kB
import { pick } from 'es-toolkit/object'; import { isString, isPlainObject } from 'es-toolkit/compat'; import { normalizeOptionalString } from '../../../../utils/value.js'; import { getSecret, upsertSecret } from '../../../../utils/secrets.js'; const DEFAULT_ELASTICSEARCH_CONFIG_SECRET = 'puls-atlas-elasticsearch-config'; const NULL_ACCESS_CONFIG = { apiKey: null, node: null, password: null, username: null }; export const normalizeElasticsearchNode = node => { const normalizedNode = normalizeOptionalString(node); if (!normalizedNode) { return null; } const urlValue = normalizedNode.startsWith('http://') || normalizedNode.startsWith('https://') ? normalizedNode : `https://${normalizedNode}`; try { const url = new URL(urlValue); if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('Unsupported protocol'); } return url.toString().replace(/\/$/, ''); } catch { throw new Error('Elasticsearch node must be a valid http(s) URL or hostname, for example https://elasticsearch.example.com.'); } }; export const hasResolvedAuth = access => Boolean(access?.apiKey) || Boolean(access?.username && access?.password); export const createAuthorizationHeader = access => { if (access?.apiKey) { return `ApiKey ${access.apiKey}`; } if (access?.username && access?.password) { const credentials = Buffer.from(`${access.username}:${access.password}`, 'utf8').toString('base64'); return `Basic ${credentials}`; } return null; }; export const createRequestHeaders = access => { const authorizationHeader = createAuthorizationHeader(access); return { 'Content-Type': 'application/json', ...(authorizationHeader ? { Authorization: authorizationHeader } : {}) }; }; export const getConfigSecretName = config => config.elasticsearch?.configSecret ?? DEFAULT_ELASTICSEARCH_CONFIG_SECRET; const parseConfigSecret = (secretValue, secretName) => { if (!isString(secretValue) || secretValue.length === 0) { return { apiKey: null, node: null, password: null, username: null }; } let parsedSecret = null; try { parsedSecret = JSON.parse(secretValue); } catch { throw new Error(`Secret ${secretName} must contain valid JSON with Elasticsearch connection properties.`); } if (!isPlainObject(parsedSecret)) { throw new Error(`Secret ${secretName} must contain a JSON object.`); } for (const propertyName of ['node', 'apiKey', 'username', 'password']) { if (parsedSecret[propertyName] !== undefined && !isString(parsedSecret[propertyName])) { throw new Error(`Secret ${secretName} must define "${propertyName}" as a string.`); } } return { apiKey: normalizeOptionalString(parsedSecret.apiKey), node: normalizeElasticsearchNode(parsedSecret.node), password: normalizeOptionalString(parsedSecret.password), username: normalizeOptionalString(parsedSecret.username) }; }; const loadSecretConfig = async (secretName, projectId) => { try { const secretValue = await getSecret(secretName, 'latest', projectId); return { config: parseConfigSecret(secretValue, secretName), error: null, exists: true, value: secretValue }; } catch (error) { if (error.code === 5) { return { config: NULL_ACCESS_CONFIG, error: null, exists: false, value: null }; } return { config: NULL_ACCESS_CONFIG, exists: false, value: null }; } }; const serializeSecretConfig = config => JSON.stringify(pick(config, ['apiKey', 'node', 'password', 'username']), null, 4); const getResolvedAccess = secretConfig => ({ apiKey: normalizeOptionalString(process.env.ELASTICSEARCH_API_KEY) ?? secretConfig.apiKey ?? null, node: normalizeElasticsearchNode(process.env.ELASTICSEARCH_NODE) ?? secretConfig.node ?? null, password: normalizeOptionalString(process.env.ELASTICSEARCH_PASSWORD) ?? secretConfig.password ?? null, username: normalizeOptionalString(process.env.ELASTICSEARCH_USERNAME) ?? secretConfig.username ?? null }); const getAccessWarnings = access => { const warnings = []; if (!access.node) { warnings.push('Elasticsearch node is still unresolved. Set ELASTICSEARCH_NODE before provisioning or update the configured Elasticsearch secret with a reachable node URL.'); } if (!hasResolvedAuth(access)) { warnings.push('Elasticsearch authentication is still unresolved. Set ELASTICSEARCH_API_KEY or ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD, or update the configured Elasticsearch secret.'); } return warnings; }; export const ensureManagedAccess = async (context, options = {}) => { const secretName = getConfigSecretName(context.config); const loadedSecret = await loadSecretConfig(secretName, context.projectId); const warnings = []; if (loadedSecret.error) { if (!options.dryRun) { throw loadedSecret.error; } warnings.push(`Could not inspect Elasticsearch config secret ${secretName}: ${loadedSecret.error.message}`); } const access = getResolvedAccess(loadedSecret.config); const payload = serializeSecretConfig(access); const hasResolvedValues = Object.values(access).some(Boolean); const previousPayload = loadedSecret.exists ? loadedSecret.value : null; const hasChanges = previousPayload !== payload; warnings.push(...getAccessWarnings(access)); if (!hasResolvedValues && !loadedSecret.exists) { return { ...access, payload: null, secretName, status: 'missing', warnings }; } if (options.dryRun) { return { ...access, payload, secretName, status: loadedSecret.error ? 'unverified' : loadedSecret.exists ? hasChanges ? 'would-update' : 'unchanged' : 'would-create', warnings }; } if (!hasChanges || !hasResolvedValues) { return { ...access, payload, secretName, status: loadedSecret.exists ? 'unchanged' : 'missing', warnings }; } const secretResult = await upsertSecret(secretName, payload, context.projectId); return { ...access, payload, secretName, status: secretResult.status, warnings }; }; export const resolveAccess = async context => { const warnings = []; const secretName = getConfigSecretName(context.config); const access = { apiKey: normalizeOptionalString(process.env.ELASTICSEARCH_API_KEY), node: normalizeElasticsearchNode(process.env.ELASTICSEARCH_NODE), password: normalizeOptionalString(process.env.ELASTICSEARCH_PASSWORD), username: normalizeOptionalString(process.env.ELASTICSEARCH_USERNAME) }; if (!access.node || !hasResolvedAuth(access)) { try { const secretValue = await getSecret(secretName, 'latest', context.projectId); const secretConfig = parseConfigSecret(secretValue, secretName); for (const key of ['node', 'apiKey', 'username', 'password']) { if (!access[key]) { access[key] = secretConfig[key]; } } } catch (error) { warnings.push(`Could not resolve Elasticsearch access from Secret Manager secret ${secretName}: ${error.message}`); } } return { ...access, warnings }; };