UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

382 lines (381 loc) 15.1 kB
import { SSMClient, GetParametersByPathCommand, } from "@aws-sdk/client-ssm"; import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, CreateSecretCommand, } from "@aws-sdk/client-secrets-manager"; import input from "@inquirer/input"; import password from "@inquirer/password"; import { buildInstanceIndexValue, instanceIndexSecretId, isInstanceKey, parseInstanceIndex, } from "@mesh-tech/secrets"; import { detectContext } from "../../utils/context.js"; import { logInfo, logSuccess, logError } from "../../utils/log.js"; import { resolveStackOption } from "../../utils/stack-flag.js"; import { findAppRoot, readStackConfig } from "../../utils/pulumi.js"; import { resolveAwsCredentials } from "../../utils/aws-auth.js"; export async function ensureAwsCredentialsForStack(stack) { if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SESSION_TOKEN) return; if (!stack) return; const appRoot = findAppRoot(process.cwd()); if (!appRoot) return; const roleArn = readStackConfig(appRoot, stack, "mesh:deployerRole"); if (!roleArn) return; const resolved = await resolveAwsCredentials(roleArn, appRoot, stack); if (resolved) { Object.assign(process.env, resolved.env); delete process.env.AWS_PROFILE; } } function normalizeEntry(raw) { if (raw.type === "group" && raw.fields) { return { type: "group", name: raw.name ?? "", description: raw.description ?? "", fields: normalizeFields(raw.fields), }; } return { type: "field", name: raw.name ?? "", description: raw.description ?? "", secret: raw.secret ?? false, optional: raw.optional ?? false, }; } function normalizeFields(raw) { return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, normalizeEntry(value)])); } export async function discoverExternalServices(ssmClient, tenant, platformEnv) { const services = new Map(); const basePath = `/mesh-platform/${tenant}/${platformEnv}`; let nextToken; do { const response = await ssmClient.send(new GetParametersByPathCommand({ Path: basePath, Recursive: true, NextToken: nextToken, })); for (const param of response.Parameters ?? []) { if (!param.Name || !param.Value) continue; const metaMatch = param.Name.match(/\/external-services\/([^/]+)\/meta$/); if (metaMatch?.[1]) { const serviceName = metaMatch[1]; const meta = JSON.parse(param.Value); const existing = services.get(serviceName); services.set(serviceName, { ...existing, meta }); } const credMatch = param.Name.match(/\/external-services\/([^/]+)\/credentials$/); if (credMatch?.[1]) { const serviceName = credMatch[1]; const raw = JSON.parse(param.Value); const schema = { fields: normalizeFields(raw.fields), keyedBy: raw.keyedBy, }; const existing = services.get(serviceName); if (existing) { existing.schema = schema; } else { services.set(serviceName, { meta: {}, schema }); } } } nextToken = response.NextToken; } while (nextToken); return services; } async function promptFields(fields, existing, result, opts, depth = 0) { for (const [fieldKey, entry] of Object.entries(fields)) { if (entry.type === "group") { const indent = " ".repeat(depth); console.error(""); console.error(`${indent}── ${entry.name} ──`); const existingGroup = (existing[fieldKey] ?? {}); const resultGroup = {}; await promptFields(entry.fields, existingGroup, resultGroup, opts, depth + 1); result[fieldKey] = resultGroup; } else { if (entry.optional && !opts.all) { const existingVal = existing[fieldKey]; if (existingVal !== undefined && existingVal !== "") { result[fieldKey] = existingVal; } continue; } const existingVal = existing[fieldKey]; const hasExisting = !!existingVal && existingVal !== ""; const message = entry.description ? `${entry.name} (${entry.description})` : entry.name; const label = entry.optional ? `${message} [optional]` : message; let value; if (entry.secret) { value = await password({ message: label, mask: "*" }); if (!value && hasExisting) { value = existingVal; } } else { value = await input({ message: label, default: hasExisting ? existingVal : undefined, }); } result[fieldKey] = value || (hasExisting ? existingVal : ""); } } } function displaySummary(fields, values, depth = 0) { const indent = " ".repeat(depth); for (const [key, entry] of Object.entries(fields)) { if (entry.type === "group") { console.error(`${indent}${entry.name}:`); const group = (values[key] ?? {}); displaySummary(entry.fields, group, depth + 1); } else { const val = values[key]; if (!val) continue; const display = entry.secret ? "********" : String(val); console.error(`${indent}${entry.name}: ${display}`); } } } export async function setCommand(servicePath, opts) { const stackOpt = resolveStackOption(opts); const context = detectContext(stackOpt); const region = opts.region ?? process.env.AWS_REGION ?? "us-east-2"; await ensureAwsCredentialsForStack(stackOpt ?? context.stage); const ssmClient = new SSMClient({ region }); const smClient = new SecretsManagerClient({ region }); const services = await discoverExternalServices(ssmClient, context.tenant, context.platformEnv); if (services.size === 0) { logError("No external services found. Deploy an ExternalService first:\n" + " new mesh.apps.ExternalService(\"symitar\", { env, credentials: { ... } })"); process.exit(1); } let serviceName; if (servicePath) { serviceName = servicePath.replace(/^external\//, ""); } if (!serviceName || !services.has(serviceName)) { if (serviceName) { logError(`External service "${serviceName}" not found.`); } console.error("\nAvailable external services:"); for (const [name, svc] of services) { const desc = svc.meta?.description ? ` — ${svc.meta.description}` : ""; const type = svc.meta?.type ? ` (${svc.meta.type})` : ""; console.error(` ${name}${type}${desc}`); } console.error(`\nUsage: mesh secrets set external/<name>`); process.exit(serviceName ? 1 : 0); } const svc = services.get(serviceName); const schema = svc.schema; if (!schema) { logError(`No credential schema found for "${serviceName}".`); process.exit(1); } const secretPrefix = svc.meta.secretPrefix; const secretId = opts.key ? `${secretPrefix}/${opts.key}` : secretPrefix; if (schema.keyedBy && !opts.key) { logError(`"${serviceName}" is multi-instance (keyed by ${schema.keyedBy}). ` + `Specify an instance:\n mesh secrets set external/${serviceName} --key=<${schema.keyedBy}>`); process.exit(1); } if (opts.key && !isInstanceKey(opts.key)) { logError(`Invalid instance key "${opts.key}": keys must not contain "/", start with ".", ` + `or end with ".config" — those names are reserved for the index and config mirrors.`); process.exit(1); } let existing = {}; try { const response = await smClient.send(new GetSecretValueCommand({ SecretId: secretId })); if (response.SecretString) { existing = JSON.parse(response.SecretString); } } catch (err) { if (!isResourceNotFound(err)) { throw err; } } if (opts.json) { let values; try { values = JSON.parse(opts.json); } catch { logError("Invalid JSON input. Provide valid JSON, e.g.: --json='{\"key\": \"value\"}'"); return; } const merged = deepMerge(existing, values); await writeSecret(smClient, secretId, merged, svc.meta); await writeConfigMirror(smClient, secretId, schema, merged, svc.meta); if (opts.key) { await addKeyToInstanceIndex(smClient, secretPrefix, opts.key, serviceName); } logSuccess(`Credentials written to ${secretId}`); return; } console.error(""); logInfo(`Setting credentials for: ${svc.meta.description || serviceName}`); if (opts.key) { logInfo(`Instance: ${opts.key}`); } logInfo(`Secret: ${secretId}`); const values = {}; await promptFields(schema.fields, existing, values, { all: !!opts.all }); console.error(""); const merged = deepMerge(existing, values); await writeSecret(smClient, secretId, merged, svc.meta); await writeConfigMirror(smClient, secretId, schema, merged, svc.meta); if (opts.key) { await addKeyToInstanceIndex(smClient, secretPrefix, opts.key, serviceName); } logSuccess(`Credentials written to ${secretId}`); console.error("\nStored fields:"); displaySummary(schema.fields, merged); console.error(""); } export function isResourceNotFound(err) { const e = err; return (e?.name === "ResourceNotFoundException" || e?.__type === "ResourceNotFoundException" || e?.Code === "ResourceNotFoundException" || e?.code === "ResourceNotFoundException" || (typeof e?.message === "string" && e.message.includes("ResourceNotFoundException"))); } function deepMerge(target, source) { const result = { ...target }; for (const [key, value] of Object.entries(source)) { if (value !== null && typeof value === "object" && !Array.isArray(value) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) { result[key] = deepMerge(result[key], value); } else { result[key] = value; } } return result; } async function writeSecret(client, secretId, values, meta) { const secretString = JSON.stringify(values); try { await client.send(new PutSecretValueCommand({ SecretId: secretId, SecretString: secretString, })); } catch (err) { if (isResourceNotFound(err)) { await client.send(new CreateSecretCommand({ Name: secretId, Description: `External service credentials for ${meta.name} (instance)`, SecretString: secretString, Tags: [ { Key: "mesh:type", Value: "external-service" }, { Key: "mesh:service", Value: meta.name }, ], })); } else { throw err; } } } function extractNonSecretFields(fields, values) { const result = {}; for (const [key, entry] of Object.entries(fields)) { if (entry.type === "group") { const groupValues = (values[key] ?? {}); const filtered = extractNonSecretFields(entry.fields, groupValues); if (Object.keys(filtered).length > 0) { result[key] = filtered; } } else if (!entry.secret && values[key] !== undefined) { result[key] = values[key]; } } return result; } export async function addKeyToInstanceIndex(client, secretPrefix, key, serviceName) { const indexId = instanceIndexSecretId(secretPrefix); try { let keys = []; try { const res = await client.send(new GetSecretValueCommand({ SecretId: indexId })); keys = parseInstanceIndex(res.SecretString ?? ""); } catch (err) { if (!isResourceNotFound(err)) throw err; } if (keys.includes(key)) return; const secretString = buildInstanceIndexValue([...keys, key]); try { await client.send(new PutSecretValueCommand({ SecretId: indexId, SecretString: secretString })); } catch (err) { if (!isResourceNotFound(err)) throw err; await client.send(new CreateSecretCommand({ Name: indexId, Description: `Instance-key index for ${serviceName} (read by listInstances(); maintained by mesh secrets set/reindex and the Hub)`, SecretString: secretString, Tags: [ { Key: "mesh:type", Value: "external-service-index" }, { Key: "mesh:service", Value: serviceName }, ], })); logInfo(`Created ${indexId} OUT OF BAND — the declaring stack does not manage it. ` + `Deploy the declaring stack (it creates the index) before writing keys, or ` + `import this secret into the stack before its next deploy.`); } } catch (err) { logInfo(`Warning: failed to record instance "${key}" in ${indexId} — ` + `listInstances() will not see it until you run: ` + `mesh secrets reindex external/${serviceName} (${err})`); } } async function writeConfigMirror(client, secretId, schema, merged, meta) { const configId = `${secretId}/.config`; const configValues = extractNonSecretFields(schema.fields, merged); const secretString = JSON.stringify(configValues); try { try { await client.send(new PutSecretValueCommand({ SecretId: configId, SecretString: secretString, })); } catch (err) { if (isResourceNotFound(err)) { await client.send(new CreateSecretCommand({ Name: configId, Description: `Non-secret credential fields for ${meta.name} (Hub display)`, SecretString: secretString, Tags: [{ Key: "mesh:type", Value: "external-service-config" }], })); } else { throw err; } } } catch (err) { logInfo(`Warning: failed to write .config mirror to ${configId}: ${err}`); } }