UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

238 lines (237 loc) 8.84 kB
import { SSMClient, GetParametersByPathCommand, } from "@aws-sdk/client-ssm"; import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, CreateSecretCommand, ListSecretsCommand, } from "@aws-sdk/client-secrets-manager"; import { detectContext } from "../../utils/context.js"; import { logInfo, logSuccess, logError } from "../../utils/log.js"; import { resolveStackOption } from "../../utils/stack-flag.js"; 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)])); } 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; } 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; } 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"))); } async function readSecret(client, secretId) { try { const res = await client.send(new GetSecretValueCommand({ SecretId: secretId })); return res.SecretString ? JSON.parse(res.SecretString) : null; } catch (err) { if (isResourceNotFound(err)) return null; throw err; } } async function writeSecret(client, secretId, values, description) { 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: description, SecretString: secretString, Tags: [{ Key: "mesh:type", Value: "external-service-config" }], })); } else { throw err; } } } async function secretExists(client, secretId) { try { await client.send(new GetSecretValueCommand({ SecretId: secretId })); return true; } catch (err) { if (isResourceNotFound(err)) return false; throw err; } } async function listInstanceKeys(client, secretPrefix) { const prefix = `${secretPrefix}/`; const keys = []; let nextToken; do { const response = await client.send(new ListSecretsCommand({ Filters: [{ Key: "name", Values: [prefix] }], NextToken: nextToken, })); for (const secret of response.SecretList ?? []) { if (secret.Name?.startsWith(prefix)) { const key = secret.Name.slice(prefix.length); if (key && !key.includes("/") && !key.endsWith(".config")) { keys.push(key); } } } nextToken = response.NextToken; } while (nextToken); return keys; } async function migrateService(smClient, meta, schema, opts) { let migrated = 0; let skipped = 0; let errors = 0; async function migrateOneSecret(secretId, label) { const configId = `${secretId}/.config`; try { if (!opts.force) { const exists = await secretExists(smClient, configId); if (exists) { logInfo(` SKIP ${label} — .config already exists`); skipped++; return; } } const values = await readSecret(smClient, secretId); if (!values) { logInfo(` SKIP ${label} — main secret empty or missing`); skipped++; return; } const configValues = extractNonSecretFields(schema.fields, values); if (opts.dryRun) { const fieldCount = Object.keys(configValues).length; logInfo(` DRY-RUN ${label}${configId} (${fieldCount} non-secret fields)`); migrated++; return; } const desc = `Non-secret credential fields for ${meta.name} (Hub display)`; await writeSecret(smClient, configId, configValues, desc); logSuccess(` OK ${label}${configId}`); migrated++; } catch (err) { logError(` FAIL ${label}: ${err}`); errors++; } } await migrateOneSecret(meta.secretPrefix, meta.name); if (schema.keyedBy) { const keys = await listInstanceKeys(smClient, meta.secretPrefix); for (const key of keys) { await migrateOneSecret(`${meta.secretPrefix}/${key}`, `${meta.name}/${key}`); } } return { migrated, skipped, errors }; } export async function migrateConfigCommand(opts) { const context = detectContext(resolveStackOption(opts)); const region = opts.region ?? process.env.AWS_REGION ?? "us-east-2"; const ssmClient = new SSMClient({ region }); const smClient = new SecretsManagerClient({ region }); logInfo(`Migrating .config mirrors for ${context.tenant}/${context.platformEnv}`); if (opts.dryRun) logInfo("DRY-RUN mode — no writes"); if (opts.force) logInfo("FORCE mode — overwrite existing .config"); const services = await discoverExternalServices(ssmClient, context.tenant, context.platformEnv); if (services.size === 0) { logInfo("No external services found."); return; } let totalMigrated = 0; let totalSkipped = 0; let totalErrors = 0; for (const [name, svc] of services) { if (!svc.schema) { logInfo(`Skipping ${name}: no credential schema`); continue; } logInfo(`\nMigrating: ${name}`); const result = await migrateService(smClient, svc.meta, svc.schema, { force: !!opts.force, dryRun: !!opts.dryRun, }); totalMigrated += result.migrated; totalSkipped += result.skipped; totalErrors += result.errors; } console.error(""); logInfo(`Done: ${totalMigrated} migrated, ${totalSkipped} skipped, ${totalErrors} errors`); if (totalErrors > 0) { process.exit(1); } }