@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
238 lines (237 loc) • 8.84 kB
JavaScript
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);
}
}