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