862 lines
36 KiB
TypeScript
862 lines
36 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, isAbsolute, join } from "node:path";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { startJob } from "./jobs";
|
|
import type { SshCaptureResult } from "./ssh";
|
|
import { capture, fingerprintValues, parseJsonOutput } from "./platform-infra-public-service";
|
|
import {
|
|
yamlBooleanField,
|
|
yamlIntegerArrayField,
|
|
yamlIntegerField,
|
|
yamlKubernetesNameField,
|
|
yamlObjectField,
|
|
parseEnvFile,
|
|
yamlRecord,
|
|
yamlStringArrayField,
|
|
yamlStringField,
|
|
} from "./platform-infra-ops-library";
|
|
|
|
export { parseEnvFile } from "./platform-infra-ops-library";
|
|
|
|
const defaultConfigPath = "config/secrets-distribution.yaml";
|
|
const fieldManager = "unidesk-secret-distribution";
|
|
|
|
interface SecretsOptions {
|
|
configPath: string;
|
|
scope: string | null;
|
|
targetId: string | null;
|
|
confirm: boolean;
|
|
dryRun: boolean;
|
|
wait: boolean;
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
interface SecretDistributionConfig {
|
|
configPath: string;
|
|
version: number;
|
|
kind: "unidesk-secret-distribution";
|
|
metadata: { id: string; owner: string; relatedIssues: number[] };
|
|
sources: { root: string; files: SourceFileConfig[] };
|
|
targets: DistributionTarget[];
|
|
kubernetesSecrets: KubernetesSecretConfig[];
|
|
}
|
|
|
|
interface SourceFileConfig {
|
|
sourceRef: string;
|
|
type: "env";
|
|
requiredKeys: string[];
|
|
createIfMissing: {
|
|
enabled: boolean;
|
|
values: Record<string, string>;
|
|
randomHex: Record<string, number>;
|
|
randomBase64Url: Record<string, { bytes: number; prefix: string }>;
|
|
};
|
|
}
|
|
|
|
interface DistributionTarget {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
scope: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface KubernetesSecretConfig {
|
|
name: string;
|
|
targetId: string;
|
|
secretName: string;
|
|
type: "Opaque";
|
|
data: SecretDataMapping[];
|
|
}
|
|
|
|
interface SecretDataMapping {
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
targetKey: string;
|
|
}
|
|
|
|
interface SourceMaterial {
|
|
sourceRef: string;
|
|
sourcePath: string;
|
|
exists: boolean;
|
|
requiredKeys: string[];
|
|
presentKeys: string[];
|
|
missingKeys: string[];
|
|
action: "none" | "create" | "update" | "blocked";
|
|
generatedKeys: string[];
|
|
unmaterializedGeneratedKeys: string[];
|
|
values: Record<string, string>;
|
|
fingerprint: string | null;
|
|
}
|
|
|
|
interface SourceInspection {
|
|
ok: boolean;
|
|
root: string;
|
|
entries: Array<Record<string, unknown>>;
|
|
materials: Map<string, SourceMaterial>;
|
|
}
|
|
|
|
interface DesiredSecret {
|
|
name: string;
|
|
target: DistributionTarget;
|
|
secretName: string;
|
|
type: "Opaque";
|
|
data: Record<string, string>;
|
|
keySources: Array<{ sourceRef: string; sourceKey: string; targetKey: string }>;
|
|
missingKeys: Array<{ sourceRef: string; sourceKey: string; targetKey: string }>;
|
|
pendingGeneratedKeys: string[];
|
|
fingerprint: string | null;
|
|
}
|
|
|
|
export interface EnvSourceFileMaterial {
|
|
sourceRef: string;
|
|
sourcePath: string;
|
|
sourcePathRedacted: string;
|
|
values: Record<string, string>;
|
|
valuesPrinted: false;
|
|
}
|
|
|
|
export function secretsHelp(): Record<string, unknown> {
|
|
return {
|
|
command: "secrets plan|sync|status",
|
|
output: "json",
|
|
usage: [
|
|
"bun scripts/cli.ts secrets plan --config config/secrets-distribution.yaml --scope platform-infra",
|
|
"bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm",
|
|
"bun scripts/cli.ts secrets status --config config/secrets-distribution.yaml --scope platform-infra",
|
|
],
|
|
configTruth: defaultConfigPath,
|
|
secretPolicy: "Secret values are never printed or reverse-engineered from runtime. YAML sourceRef files are the authority; sync only pushes declared keys to declared Kubernetes Secret keys.",
|
|
};
|
|
}
|
|
|
|
export async function runSecretsCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const [action = "plan"] = args;
|
|
if (action === "help" || action === "--help" || action === "-h") return secretsHelp();
|
|
if (action === "plan") return plan(parseOptions(args.slice(1)));
|
|
if (action === "sync") return await sync(config, parseOptions(args.slice(1)));
|
|
if (action === "status") return await status(config, parseOptions(args.slice(1)));
|
|
return { ok: false, error: "unsupported-secrets-command", args, help: secretsHelp() };
|
|
}
|
|
|
|
function parseOptions(args: string[]): SecretsOptions {
|
|
let configPath = defaultConfigPath;
|
|
let scope: string | null = null;
|
|
let targetId: string | null = null;
|
|
let confirm = false;
|
|
let dryRun = false;
|
|
let wait = false;
|
|
let full = false;
|
|
let raw = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--config") {
|
|
configPath = readOptionValue(args, index, "--config");
|
|
index += 1;
|
|
} else if (arg.startsWith("--config=")) {
|
|
configPath = arg.slice("--config=".length);
|
|
} else if (arg === "--scope") {
|
|
scope = simpleId(readOptionValue(args, index, "--scope"), "--scope");
|
|
index += 1;
|
|
} else if (arg.startsWith("--scope=")) {
|
|
scope = simpleId(arg.slice("--scope=".length), "--scope");
|
|
} else if (arg === "--target") {
|
|
targetId = simpleId(readOptionValue(args, index, "--target"), "--target");
|
|
index += 1;
|
|
} else if (arg.startsWith("--target=")) {
|
|
targetId = simpleId(arg.slice("--target=".length), "--target");
|
|
} else if (arg === "--confirm") {
|
|
confirm = true;
|
|
} else if (arg === "--dry-run") {
|
|
dryRun = true;
|
|
} else if (arg === "--wait") {
|
|
wait = true;
|
|
} else if (arg === "--full") {
|
|
full = true;
|
|
} else if (arg === "--raw") {
|
|
raw = true;
|
|
full = true;
|
|
} else {
|
|
throw new Error(`unsupported secrets option: ${arg}`);
|
|
}
|
|
}
|
|
if (confirm && dryRun) throw new Error("secrets sync accepts only one of --confirm or --dry-run");
|
|
return { configPath, scope, targetId, confirm, dryRun, wait, full, raw };
|
|
}
|
|
|
|
function plan(options: SecretsOptions): Record<string, unknown> {
|
|
const distribution = readSecretDistributionConfig(options.configPath);
|
|
assertSelectedTargets(distribution, options);
|
|
const sources = inspectSources(distribution, false);
|
|
const desired = desiredSecrets(distribution, options, sources);
|
|
return {
|
|
ok: sources.ok && desired.every((secret) => secret.missingKeys.length === 0),
|
|
action: "secrets-plan",
|
|
mutation: false,
|
|
config: configSummary(distribution, options),
|
|
localSources: sourceSummary(sources),
|
|
desiredSecrets: desired.map(desiredSecretSummary),
|
|
policy: {
|
|
sourceAuthority: "local YAML-declared sourceRef files under sources.root",
|
|
runtimeReverseEngineering: false,
|
|
valuesPrinted: false,
|
|
},
|
|
next: {
|
|
sync: `bun scripts/cli.ts secrets sync --config ${distribution.configPath}${options.scope === null ? "" : ` --scope ${options.scope}`}${options.targetId === null ? "" : ` --target ${options.targetId}`} --confirm`,
|
|
status: `bun scripts/cli.ts secrets status --config ${distribution.configPath}${options.scope === null ? "" : ` --scope ${options.scope}`}${options.targetId === null ? "" : ` --target ${options.targetId}`}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sync(config: UniDeskConfig, options: SecretsOptions): Promise<Record<string, unknown>> {
|
|
const distribution = readSecretDistributionConfig(options.configPath);
|
|
assertSelectedTargets(distribution, options);
|
|
if (!options.confirm || options.dryRun) {
|
|
const planned = plan(options);
|
|
return { ...planned, action: "secrets-sync", mode: "dry-run", mutation: false };
|
|
}
|
|
if (!options.wait) {
|
|
const jobArgs = ["bun", "scripts/cli.ts", "secrets", "sync", "--config", distribution.configPath, "--confirm", "--wait"];
|
|
if (options.scope !== null) jobArgs.push("--scope", options.scope);
|
|
if (options.targetId !== null) jobArgs.push("--target", options.targetId);
|
|
const job = startJob("secrets_sync", jobArgs, "Sync YAML-declared local secret source keys into declared Kubernetes Secrets without printing values");
|
|
return {
|
|
ok: true,
|
|
action: "secrets-sync",
|
|
mode: "async-job",
|
|
mutation: true,
|
|
config: configSummary(distribution, options),
|
|
job,
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
};
|
|
}
|
|
const sources = inspectSources(distribution, true);
|
|
if (!sources.ok) return { ok: false, action: "secrets-sync", mode: "blocked-local-sources", mutation: true, config: configSummary(distribution, options), localSources: sourceSummary(sources) };
|
|
const desired = desiredSecrets(distribution, options, sources);
|
|
const missing = desired.flatMap((secret) => secret.missingKeys);
|
|
if (missing.length > 0) {
|
|
return { ok: false, action: "secrets-sync", mode: "blocked-missing-secret-data", mutation: true, config: configSummary(distribution, options), localSources: sourceSummary(sources), missing, valuesPrinted: false };
|
|
}
|
|
const perTarget = await Promise.all(groupDesiredSecretsByTarget(desired).map(async (group) => await applyTargetSecrets(config, group.target, group.secrets, options)));
|
|
return {
|
|
ok: perTarget.every((item) => item.ok === true),
|
|
action: "secrets-sync",
|
|
mode: "confirmed",
|
|
mutation: true,
|
|
config: configSummary(distribution, options),
|
|
localSources: sourceSummary(sources),
|
|
desiredSecrets: desired.map(desiredSecretSummary),
|
|
targets: perTarget,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
async function status(config: UniDeskConfig, options: SecretsOptions): Promise<Record<string, unknown>> {
|
|
const distribution = readSecretDistributionConfig(options.configPath);
|
|
assertSelectedTargets(distribution, options);
|
|
const sources = inspectSources(distribution, false);
|
|
const desired = desiredSecrets(distribution, options, sources);
|
|
const perTarget = await Promise.all(groupDesiredSecretsByTarget(desired).map(async (group) => await statusTargetSecrets(config, group.target, group.secrets, options)));
|
|
return {
|
|
ok: perTarget.every((item) => item.ok === true),
|
|
action: "secrets-status",
|
|
mutation: false,
|
|
config: configSummary(distribution, options),
|
|
localSources: sourceSummary(sources),
|
|
desiredSecrets: desired.map(desiredSecretSummary),
|
|
targets: perTarget,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig {
|
|
const configPath = resolveConfigPath(pathArg);
|
|
const label = displayConfigPath(pathArg);
|
|
const root = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, label);
|
|
const version = integerField(root, "version", label);
|
|
const kind = stringField(root, "kind", label);
|
|
if (kind !== "unidesk-secret-distribution") throw new Error(`${label}.kind must be unidesk-secret-distribution`);
|
|
const metadata = objectField(root, "metadata", label);
|
|
const sources = objectField(root, "sources", label);
|
|
const config: SecretDistributionConfig = {
|
|
configPath: label,
|
|
version,
|
|
kind,
|
|
metadata: {
|
|
id: stringField(metadata, "id", `${label}.metadata`),
|
|
owner: stringField(metadata, "owner", `${label}.metadata`),
|
|
relatedIssues: numberArrayField(metadata, "relatedIssues", `${label}.metadata`),
|
|
},
|
|
sources: {
|
|
root: stringField(sources, "root", `${label}.sources`),
|
|
files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)),
|
|
},
|
|
targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)),
|
|
kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)),
|
|
};
|
|
validateDistributionConfig(config);
|
|
return config;
|
|
}
|
|
|
|
function parseSourceFile(record: Record<string, unknown>, path: string): SourceFileConfig {
|
|
const type = stringField(record, "type", path);
|
|
if (type !== "env") throw new Error(`${path}.type must be env`);
|
|
const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path);
|
|
const randomBase64UrlRaw = createRaw.randomBase64Url === undefined ? {} : objectField(createRaw, "randomBase64Url", `${path}.createIfMissing`);
|
|
return {
|
|
sourceRef: sourceRefField(record, "sourceRef", path),
|
|
type,
|
|
requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)),
|
|
createIfMissing: {
|
|
enabled: createRaw.enabled === undefined ? false : booleanField(createRaw, "enabled", `${path}.createIfMissing`),
|
|
values: createRaw.values === undefined ? {} : stringMapField(createRaw, "values", `${path}.createIfMissing`),
|
|
randomHex: createRaw.randomHex === undefined ? {} : numberMapField(createRaw, "randomHex", `${path}.createIfMissing`),
|
|
randomBase64Url: Object.fromEntries(Object.entries(randomBase64UrlRaw).map(([key, value]) => [envKeyValue(key, `${path}.createIfMissing.randomBase64Url`), randomBase64UrlSpec(value, `${path}.createIfMissing.randomBase64Url.${key}`)])),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseTarget(record: Record<string, unknown>, path: string): DistributionTarget {
|
|
return {
|
|
id: simpleId(stringField(record, "id", path), `${path}.id`),
|
|
route: stringField(record, "route", path),
|
|
namespace: kubernetesNameField(record, "namespace", path),
|
|
scope: simpleId(stringField(record, "scope", path), `${path}.scope`),
|
|
enabled: booleanField(record, "enabled", path),
|
|
};
|
|
}
|
|
|
|
function parseKubernetesSecret(record: Record<string, unknown>, path: string): KubernetesSecretConfig {
|
|
const type = stringField(record, "type", path);
|
|
if (type !== "Opaque") throw new Error(`${path}.type must be Opaque`);
|
|
return {
|
|
name: simpleId(stringField(record, "name", path), `${path}.name`),
|
|
targetId: simpleId(stringField(record, "targetId", path), `${path}.targetId`),
|
|
secretName: kubernetesNameField(record, "secretName", path),
|
|
type,
|
|
data: arrayOfRecords(record.data, `${path}.data`).map((item, index) => ({
|
|
sourceRef: sourceRefField(item, "sourceRef", `${path}.data[${index}]`),
|
|
sourceKey: envKeyField(item, "sourceKey", `${path}.data[${index}]`),
|
|
targetKey: kubernetesSecretKeyField(item, "targetKey", `${path}.data[${index}]`),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function validateDistributionConfig(config: SecretDistributionConfig): void {
|
|
if (config.sources.files.length === 0) throw new Error(`${config.configPath}.sources.files must not be empty`);
|
|
if (config.targets.length === 0) throw new Error(`${config.configPath}.targets must not be empty`);
|
|
const sources = new Map(config.sources.files.map((item) => [item.sourceRef, item]));
|
|
const targets = new Set(config.targets.map((item) => item.id));
|
|
const targetSecrets = new Set<string>();
|
|
for (const secret of config.kubernetesSecrets) {
|
|
if (!targets.has(secret.targetId)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name}.targetId is not declared in targets`);
|
|
const secretIdentity = `${secret.targetId}/${secret.secretName}`;
|
|
if (targetSecrets.has(secretIdentity)) throw new Error(`${config.configPath} declares duplicate target Secret ${secretIdentity}`);
|
|
targetSecrets.add(secretIdentity);
|
|
const targetKeys = new Set<string>();
|
|
for (const item of secret.data) {
|
|
const source = sources.get(item.sourceRef);
|
|
if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`);
|
|
if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps ${item.sourceRef}.${item.sourceKey}, but that key is not listed in sources.files.requiredKeys`);
|
|
if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`);
|
|
targetKeys.add(item.targetKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectSources(config: SecretDistributionConfig, materialize: boolean): SourceInspection {
|
|
const root = secretRoot(config);
|
|
const materials = new Map<string, SourceMaterial>();
|
|
const entries = config.sources.files.map((source) => {
|
|
const sourcePath = join(root, source.sourceRef);
|
|
const exists = existsSync(sourcePath);
|
|
const existing = exists ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {};
|
|
const next = { ...existing };
|
|
const generatedKeys: string[] = [];
|
|
const unmaterializedGeneratedKeys: string[] = [];
|
|
if (source.createIfMissing.enabled) {
|
|
for (const [key, value] of Object.entries(source.createIfMissing.values)) {
|
|
if (next[key] === undefined || next[key].length === 0) {
|
|
next[key] = value;
|
|
generatedKeys.push(key);
|
|
}
|
|
}
|
|
for (const [key, bytes] of Object.entries(source.createIfMissing.randomHex)) {
|
|
if (next[key] === undefined || next[key].length === 0) {
|
|
next[key] = materialize ? randomBytes(bytes).toString("hex") : `<generated:${key}>`;
|
|
generatedKeys.push(key);
|
|
if (!materialize) unmaterializedGeneratedKeys.push(key);
|
|
}
|
|
}
|
|
for (const [key, spec] of Object.entries(source.createIfMissing.randomBase64Url)) {
|
|
if (next[key] === undefined || next[key].length === 0) {
|
|
next[key] = materialize ? `${spec.prefix}${randomBytes(spec.bytes).toString("base64url")}` : `<generated:${key}>`;
|
|
generatedKeys.push(key);
|
|
if (!materialize) unmaterializedGeneratedKeys.push(key);
|
|
}
|
|
}
|
|
}
|
|
const missingBefore = source.requiredKeys.filter((key) => existing[key] === undefined || existing[key].length === 0);
|
|
const missingKeys = source.requiredKeys.filter((key) => next[key] === undefined || next[key].length === 0);
|
|
const action = missingKeys.length > 0 ? "blocked" : !exists ? "create" : missingBefore.length > 0 ? "update" : "none";
|
|
if (materialize && action !== "blocked" && (action === "create" || action === "update")) writeEnvFile(sourcePath, next);
|
|
const material: SourceMaterial = {
|
|
sourceRef: source.sourceRef,
|
|
sourcePath,
|
|
exists,
|
|
requiredKeys: source.requiredKeys,
|
|
presentKeys: source.requiredKeys.filter((key) => next[key] !== undefined && next[key].length > 0),
|
|
missingKeys,
|
|
action,
|
|
generatedKeys,
|
|
unmaterializedGeneratedKeys,
|
|
values: next,
|
|
fingerprint: missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0 ? fingerprintValues(next, source.requiredKeys) : null,
|
|
};
|
|
materials.set(source.sourceRef, material);
|
|
return {
|
|
sourceRef: source.sourceRef,
|
|
sourcePath: redactRepoPath(sourcePath),
|
|
exists,
|
|
requiredKeys: source.requiredKeys,
|
|
presentKeys: material.presentKeys,
|
|
missingKeys,
|
|
action,
|
|
generatedKeys,
|
|
unmaterializedGeneratedKeys,
|
|
fingerprint: material.fingerprint,
|
|
valuesPrinted: false,
|
|
};
|
|
});
|
|
return {
|
|
ok: entries.every((entry) => (entry.missingKeys as string[]).length === 0),
|
|
root: redactRepoPath(root),
|
|
entries,
|
|
materials,
|
|
};
|
|
}
|
|
|
|
function desiredSecrets(config: SecretDistributionConfig, options: SecretsOptions, sources: SourceInspection): DesiredSecret[] {
|
|
const selectedTargetIds = new Set(selectedTargets(config, options).map((target) => target.id));
|
|
const targets = new Map(config.targets.map((target) => [target.id, target]));
|
|
return config.kubernetesSecrets
|
|
.map((secret) => ({ secret, target: targets.get(secret.targetId) }))
|
|
.filter((item): item is { secret: KubernetesSecretConfig; target: DistributionTarget } => item.target !== undefined && item.target.enabled)
|
|
.filter(({ target }) => selectedTargetIds.has(target.id))
|
|
.map(({ secret, target }) => {
|
|
const data: Record<string, string> = {};
|
|
const missingKeys: DesiredSecret["missingKeys"] = [];
|
|
const pendingGeneratedKeys: string[] = [];
|
|
for (const item of secret.data) {
|
|
const source = sources.materials.get(item.sourceRef);
|
|
const value = source?.values[item.sourceKey];
|
|
if (value === undefined || value.length === 0) {
|
|
missingKeys.push({ sourceRef: item.sourceRef, sourceKey: item.sourceKey, targetKey: item.targetKey });
|
|
} else {
|
|
data[item.targetKey] = value;
|
|
}
|
|
if (source?.unmaterializedGeneratedKeys.includes(item.sourceKey) === true) pendingGeneratedKeys.push(item.targetKey);
|
|
}
|
|
const keys = Object.keys(data);
|
|
return {
|
|
name: secret.name,
|
|
target,
|
|
secretName: secret.secretName,
|
|
type: secret.type,
|
|
data,
|
|
keySources: secret.data,
|
|
missingKeys,
|
|
pendingGeneratedKeys,
|
|
fingerprint: missingKeys.length === 0 && pendingGeneratedKeys.length === 0 ? fingerprintValues(data, keys) : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
function assertSelectedTargets(config: SecretDistributionConfig, options: SecretsOptions): void {
|
|
const targets = selectedTargets(config, options);
|
|
if (targets.length > 0) return;
|
|
const available = config.targets.filter((target) => target.enabled).map((target) => target.id).sort();
|
|
throw new Error(`no enabled secrets target matches scope=${options.scope ?? "*"} target=${options.targetId ?? "*"}; available targets: ${available.length > 0 ? available.join(", ") : "<none>"}`);
|
|
}
|
|
|
|
function selectedTargets(config: SecretDistributionConfig, options: SecretsOptions): DistributionTarget[] {
|
|
return config.targets
|
|
.filter((target) => target.enabled)
|
|
.filter((target) => options.scope === null || target.scope === options.scope)
|
|
.filter((target) => options.targetId === null || target.id === options.targetId);
|
|
}
|
|
|
|
async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise<Record<string, unknown>> {
|
|
if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" };
|
|
const yaml = renderSecretManifest(target, secrets);
|
|
const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
target: targetSummary(target),
|
|
summary: parsed,
|
|
remote: secretCaptureSummary(result),
|
|
...(options.raw ? { rawCaptureOmitted: true, rawPolicy: "Secret distribution never returns raw SSH capture because remote output can contain credential-bearing diagnostics." } : {}),
|
|
};
|
|
}
|
|
|
|
async function statusTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise<Record<string, unknown>> {
|
|
const result = await capture(config, target.route, ["sh"], statusSecretScript(target, secrets));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
target: targetSummary(target),
|
|
summary: parsed,
|
|
remote: secretCaptureSummary(result),
|
|
...(options.raw ? { rawCaptureOmitted: true, rawPolicy: "Secret distribution never returns raw SSH capture because remote output can contain credential-bearing diagnostics." } : {}),
|
|
};
|
|
}
|
|
|
|
function secretCaptureSummary(result: SshCaptureResult): Record<string, unknown> {
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
|
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
|
stdoutTailOmitted: true,
|
|
stderrTailOmitted: true,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function renderSecretManifest(target: DistributionTarget, secrets: DesiredSecret[]): string {
|
|
return secrets.map((secret) => `apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: ${secret.secretName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/managed-by: unidesk
|
|
app.kubernetes.io/part-of: ${target.scope}
|
|
type: ${secret.type}
|
|
data:
|
|
${Object.entries(secret.data).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${key}: ${Buffer.from(value, "utf8").toString("base64")}`).join("\n")}
|
|
`).join("---\n");
|
|
}
|
|
|
|
function applySecretScript(target: DistributionTarget, secrets: DesiredSecret[], yaml: string): string {
|
|
const manifestB64 = Buffer.from(yaml, "utf8").toString("base64");
|
|
const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/secrets.yaml"
|
|
printf '%s' '${manifestB64}' | base64 -d >"$manifest"
|
|
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$tmp/ns.out" 2>"$tmp/ns.err"
|
|
ns_rc=$?
|
|
if [ "$ns_rc" -eq 0 ]; then
|
|
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
|
apply_rc=$?
|
|
else
|
|
: >"$tmp/apply.out"
|
|
printf '%s\\n' 'skipped because namespace sync failed' >"$tmp/apply.err"
|
|
apply_rc=1
|
|
fi
|
|
python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY'
|
|
import base64, json, sys
|
|
ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2])
|
|
def text(path, limit=5000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
payload = {
|
|
"ok": ns_rc == 0 and apply_rc == 0,
|
|
"namespace": "${target.namespace}",
|
|
"secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")),
|
|
"valuesPrinted": False,
|
|
"steps": {
|
|
"namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[3]), "stderr": text(sys.argv[4])},
|
|
"apply": {"exitCode": apply_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])},
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function statusSecretScript(target: DistributionTarget, secrets: DesiredSecret[]): string {
|
|
const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64");
|
|
const commands = secrets.map((secret, index) => [
|
|
`kubectl -n ${target.namespace} get secret ${secret.secretName} -o json >"$tmp/secret.${index}.json" 2>"$tmp/secret.${index}.err"`,
|
|
`printf '%s' "$?" >"$tmp/secret.${index}.rc"`,
|
|
].join("\n")).join("\n");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
${commands}
|
|
python3 - "$tmp" <<'PY'
|
|
import base64, json, os, sys
|
|
tmp = sys.argv[1]
|
|
expected = json.loads(base64.b64decode("${summaryB64}").decode("utf-8"))
|
|
items = []
|
|
ok = True
|
|
for index, item in enumerate(expected):
|
|
try:
|
|
rc = int(open(os.path.join(tmp, f"secret.{index}.rc"), encoding="utf-8").read() or "1")
|
|
except FileNotFoundError:
|
|
rc = 1
|
|
try:
|
|
observed = json.load(open(os.path.join(tmp, f"secret.{index}.json"), encoding="utf-8"))
|
|
except Exception:
|
|
observed = None
|
|
data = (observed or {}).get("data") or {}
|
|
observed_keys = sorted(data.keys())
|
|
expected_keys = item.get("keys") or []
|
|
missing = [key for key in expected_keys if key not in observed_keys]
|
|
exists = rc == 0
|
|
item_ok = exists and len(missing) == 0
|
|
ok = ok and item_ok
|
|
items.append({
|
|
"name": item.get("name"),
|
|
"secretName": item.get("secretName"),
|
|
"exists": exists,
|
|
"keys": observed_keys,
|
|
"expectedKeys": expected_keys,
|
|
"missingKeys": missing,
|
|
"ok": item_ok,
|
|
"valuesPrinted": False,
|
|
})
|
|
payload = {"ok": ok, "namespace": "${target.namespace}", "secrets": items, "valuesPrinted": False}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if ok else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function groupDesiredSecretsByTarget(secrets: DesiredSecret[]): Array<{ target: DistributionTarget; secrets: DesiredSecret[] }> {
|
|
const groups = new Map<string, { target: DistributionTarget; secrets: DesiredSecret[] }>();
|
|
for (const secret of secrets) {
|
|
const existing = groups.get(secret.target.id);
|
|
if (existing === undefined) groups.set(secret.target.id, { target: secret.target, secrets: [secret] });
|
|
else existing.secrets.push(secret);
|
|
}
|
|
return Array.from(groups.values());
|
|
}
|
|
|
|
function configSummary(config: SecretDistributionConfig, options: SecretsOptions): Record<string, unknown> {
|
|
return {
|
|
path: config.configPath,
|
|
metadata: config.metadata,
|
|
root: redactRepoPath(secretRoot(config)),
|
|
scope: options.scope,
|
|
targetId: options.targetId,
|
|
sources: config.sources.files.map((item) => ({ sourceRef: item.sourceRef, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })),
|
|
targets: config.targets.filter((target) => (options.scope === null || target.scope === options.scope) && (options.targetId === null || target.id === options.targetId)).map(targetSummary),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function sourceSummary(sources: SourceInspection): Record<string, unknown> {
|
|
return { ok: sources.ok, root: sources.root, entries: sources.entries, valuesPrinted: false };
|
|
}
|
|
|
|
function desiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
return {
|
|
name: secret.name,
|
|
target: targetSummary(secret.target),
|
|
secretName: secret.secretName,
|
|
keys: Object.keys(secret.data).sort(),
|
|
keySources: secret.keySources,
|
|
missingKeys: secret.missingKeys,
|
|
pendingGeneratedKeys: secret.pendingGeneratedKeys,
|
|
fingerprint: secret.fingerprint,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
return {
|
|
name: secret.name,
|
|
secretName: secret.secretName,
|
|
keys: Object.keys(secret.data).sort(),
|
|
fingerprint: secret.fingerprint,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function targetSummary(target: DistributionTarget): Record<string, unknown> {
|
|
return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope };
|
|
}
|
|
|
|
function readOptionValue(args: string[], index: number, option: string): string {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function resolveConfigPath(pathArg: string): string {
|
|
if (pathArg.startsWith("/") || pathArg.includes("\0")) throw new Error("--config must be a repo-relative YAML path");
|
|
if (pathArg.includes("..")) throw new Error("--config must not contain ..");
|
|
return rootPath(pathArg);
|
|
}
|
|
|
|
function displayConfigPath(pathArg: string): string {
|
|
if (pathArg.startsWith("/") || pathArg.includes("..")) throw new Error("--config must be a repo-relative YAML path without ..");
|
|
return pathArg;
|
|
}
|
|
|
|
function secretRoot(config: SecretDistributionConfig): string {
|
|
const root = config.sources.root;
|
|
return isAbsolute(root) ? root : rootPath(root);
|
|
}
|
|
|
|
export function readTextFile(path: string): string {
|
|
if (!existsSync(path)) throw new Error(`required secret source ${redactRepoPath(path)} is missing`);
|
|
return readFileSync(path, "utf8");
|
|
}
|
|
|
|
export function readEnvSourceFile(params: { root: string; sourceRef: string; missingMessage?: (sourcePath: string) => string }): EnvSourceFileMaterial {
|
|
const sourcePath = join(params.root, params.sourceRef);
|
|
if (!existsSync(sourcePath)) {
|
|
throw new Error(params.missingMessage?.(sourcePath) ?? `required secret source ${redactRepoPath(sourcePath)} is missing`);
|
|
}
|
|
return {
|
|
sourceRef: params.sourceRef,
|
|
sourcePath,
|
|
sourcePathRedacted: redactRepoPath(sourcePath),
|
|
values: parseEnvFile(readFileSync(sourcePath, "utf8")),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function requiredEnvValue(values: Record<string, string>, key: string, sourceRef: string): string {
|
|
const value = values[key];
|
|
if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`);
|
|
return value;
|
|
}
|
|
|
|
export function fingerprintSecretValues(values: Record<string, string>, keys: string[]): string {
|
|
return fingerprintValues(values, keys);
|
|
}
|
|
|
|
function writeEnvFile(path: string, values: Record<string, string>): void {
|
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`);
|
|
writeFileSync(path, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(path, 0o600);
|
|
}
|
|
|
|
function quoteEnv(value: string): string {
|
|
if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value;
|
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
}
|
|
|
|
export function redactRepoPath(path: string): string {
|
|
const root = rootPath();
|
|
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
|
}
|
|
|
|
function boolField(value: Record<string, unknown> | null, key: string, fallback: boolean): boolean {
|
|
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
|
}
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
return yamlRecord(value, path);
|
|
}
|
|
|
|
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
|
return yamlObjectField(obj, key, path, "");
|
|
}
|
|
|
|
function arrayOfRecords(value: unknown, path: string): Record<string, unknown>[] {
|
|
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
|
|
return value.map((item, index) => asRecord(item, `${path}[${index}]`));
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
return yamlStringField(obj, key, path, "");
|
|
}
|
|
|
|
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
return yamlIntegerField(obj, key, path, "");
|
|
}
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
return yamlBooleanField(obj, key, path, "");
|
|
}
|
|
|
|
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
|
return yamlStringArrayField(obj, key, path, "");
|
|
}
|
|
|
|
function numberArrayField(obj: Record<string, unknown>, key: string, path: string): number[] {
|
|
return yamlIntegerArrayField(obj, key, path, "");
|
|
}
|
|
|
|
function stringMapField(obj: Record<string, unknown>, key: string, path: string): Record<string, string> {
|
|
const record = objectField(obj, key, path);
|
|
const result: Record<string, string> = {};
|
|
for (const [itemKey, itemValue] of Object.entries(record)) {
|
|
result[envKeyValue(itemKey, `${path}.${key}`)] = stringValue(itemValue, `${path}.${key}.${itemKey}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function numberMapField(obj: Record<string, unknown>, key: string, path: string): Record<string, number> {
|
|
const record = objectField(obj, key, path);
|
|
const result: Record<string, number> = {};
|
|
for (const [itemKey, itemValue] of Object.entries(record)) {
|
|
result[envKeyValue(itemKey, `${path}.${key}`)] = randomByteCount(itemValue, `${path}.${key}.${itemKey}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function randomBase64UrlSpec(value: unknown, path: string): { bytes: number; prefix: string } {
|
|
const record = asRecord(value, path);
|
|
return {
|
|
bytes: randomByteCount(record.bytes, `${path}.bytes`),
|
|
prefix: record.prefix === undefined ? "" : stringValue(record.prefix, `${path}.prefix`),
|
|
};
|
|
}
|
|
|
|
function stringValue(value: unknown, path: string): string {
|
|
if (typeof value !== "string") throw new Error(`${path} must be a string`);
|
|
return value;
|
|
}
|
|
|
|
function randomByteCount(value: unknown, path: string): number {
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 16 || value > 128) throw new Error(`${path} must be an integer in 16..128`);
|
|
return value;
|
|
}
|
|
|
|
function sourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (value.startsWith("/") || value.includes("..") || value.includes("\0") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${path}.${key} must be a relative source ref without ..`);
|
|
return value;
|
|
}
|
|
|
|
function envKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
return envKeyValue(stringField(obj, key, path), `${path}.${key}`);
|
|
}
|
|
|
|
function envKeyValue(value: string, path: string): string {
|
|
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${path} must be an uppercase env key`);
|
|
return value;
|
|
}
|
|
|
|
function kubernetesNameField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
return yamlKubernetesNameField(obj, key, path, "");
|
|
}
|
|
|
|
function kubernetesSecretKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${key} must be a Kubernetes Secret key`);
|
|
return value;
|
|
}
|
|
|
|
function simpleId(value: string, path: string): string {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path} must be a simple id`);
|
|
return value;
|
|
}
|