refactor: share platform secret source helpers
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { isAbsolute, join } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
import type { UniDeskConfig } from "./config";
|
import type { UniDeskConfig } from "./config";
|
||||||
import { rootPath } from "./config";
|
import { rootPath } from "./config";
|
||||||
import { startJob } from "./jobs";
|
import { startJob } from "./jobs";
|
||||||
import { applyPk01CaddyBlock } from "./platform-infra-public-service";
|
import { applyPk01CaddyBlock, prepareFrpcSecret, shQuote, type FrpcSecretMaterial } from "./platform-infra-public-service";
|
||||||
|
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
|
||||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||||
|
|
||||||
const configFile = rootPath("config", "platform-infra", "langbot.yaml");
|
const configFile = rootPath("config", "platform-infra", "langbot.yaml");
|
||||||
@@ -129,16 +130,6 @@ interface SecretMaterial {
|
|||||||
valuesPrinted: false;
|
valuesPrinted: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FrpcSecretMaterial {
|
|
||||||
sourceRef: string;
|
|
||||||
sourcePath: string;
|
|
||||||
secretName: string;
|
|
||||||
secretKey: string;
|
|
||||||
frpcToml: string;
|
|
||||||
fingerprint: string;
|
|
||||||
valuesPrinted: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function langBotHelp(): Record<string, unknown> {
|
export function langBotHelp(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
command: "platform-infra langbot plan|apply|status|logs|validate|bootstrap-api-key|query",
|
command: "platform-infra langbot plan|apply|status|logs|validate|bootstrap-api-key|query",
|
||||||
@@ -474,7 +465,14 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const secretMaterial = prepareSecretMaterial(langbot);
|
const secretMaterial = prepareSecretMaterial(langbot);
|
||||||
const frpcSecret = prepareFrpcSecret(langbot, target);
|
const frpcSecret = prepareFrpcSecret({
|
||||||
|
secretRoot: secretRoot(langbot),
|
||||||
|
exposure: target.publicExposure,
|
||||||
|
sourcePathRedactor: redactRepoPath,
|
||||||
|
parseEnvFile,
|
||||||
|
requiredEnvValue,
|
||||||
|
readTextFile,
|
||||||
|
});
|
||||||
const confirmedYaml = renderManifest(langbot, target, secretMaterial.fingerprint);
|
const confirmedYaml = renderManifest(langbot, target, secretMaterial.fingerprint);
|
||||||
const result = await capture(config, target.route, ["script"], applyScript(confirmedYaml, langbot, target, secretMaterial, frpcSecret));
|
const result = await capture(config, target.route, ["script"], applyScript(confirmedYaml, langbot, target, secretMaterial, frpcSecret));
|
||||||
const parsed = parseJsonOutput(result.stdout);
|
const parsed = parseJsonOutput(result.stdout);
|
||||||
@@ -596,7 +594,7 @@ function bootstrapApiKey(options: ApplyOptions): Record<string, unknown> {
|
|||||||
name: langbot.apiKey.name,
|
name: langbot.apiKey.name,
|
||||||
keySourceRef: langbot.apiKey.sourceRef,
|
keySourceRef: langbot.apiKey.sourceRef,
|
||||||
keyName: langbot.apiKey.key,
|
keyName: langbot.apiKey.key,
|
||||||
fingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]),
|
fingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]),
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
},
|
},
|
||||||
database: {
|
database: {
|
||||||
@@ -633,7 +631,7 @@ function query(options: QueryOptions): Record<string, unknown> {
|
|||||||
baseUrl: target.publicExposure.publicBaseUrl,
|
baseUrl: target.publicExposure.publicBaseUrl,
|
||||||
path: options.path,
|
path: options.path,
|
||||||
auth: "X-API-Key",
|
auth: "X-API-Key",
|
||||||
apiKeyFingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]),
|
apiKeyFingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]),
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
},
|
},
|
||||||
response: probe,
|
response: probe,
|
||||||
@@ -992,17 +990,23 @@ export function readLangBotPostgresConninfo(): string {
|
|||||||
|
|
||||||
function prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial {
|
function prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial {
|
||||||
const root = secretRoot(langbot);
|
const root = secretRoot(langbot);
|
||||||
const dbSourcePath = join(root, langbot.runtime.database.sourceRef);
|
const dbSource = readEnvSourceFile({
|
||||||
if (!existsSync(dbSourcePath)) throw new Error(`LangBot database source ${redactRepoPath(dbSourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`);
|
root,
|
||||||
const dbValues = parseEnvFile(readFileSync(dbSourcePath, "utf8"));
|
sourceRef: langbot.runtime.database.sourceRef,
|
||||||
|
missingMessage: (sourcePath) => `LangBot database source ${redactRepoPath(sourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`,
|
||||||
|
});
|
||||||
|
const dbValues = dbSource.values;
|
||||||
const dbUser = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.user, langbot.runtime.database.sourceRef);
|
const dbUser = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.user, langbot.runtime.database.sourceRef);
|
||||||
const dbPassword = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.password, langbot.runtime.database.sourceRef);
|
const dbPassword = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.password, langbot.runtime.database.sourceRef);
|
||||||
const dbName = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.dbName, langbot.runtime.database.sourceRef);
|
const dbName = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.dbName, langbot.runtime.database.sourceRef);
|
||||||
if (dbUser !== langbot.runtime.database.user) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.user} does not match langbot runtime.database.user`);
|
if (dbUser !== langbot.runtime.database.user) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.user} does not match langbot runtime.database.user`);
|
||||||
if (dbName !== langbot.runtime.database.dbName) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.dbName} does not match langbot runtime.database.dbName`);
|
if (dbName !== langbot.runtime.database.dbName) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.dbName} does not match langbot runtime.database.dbName`);
|
||||||
const appSourcePath = join(root, langbot.runtime.secrets.appSourceRef);
|
const appSource = readEnvSourceFile({
|
||||||
if (!existsSync(appSourcePath)) throw new Error(`LangBot app secret source ${redactRepoPath(appSourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`);
|
root,
|
||||||
const appValues = parseEnvFile(readFileSync(appSourcePath, "utf8"));
|
sourceRef: langbot.runtime.secrets.appSourceRef,
|
||||||
|
missingMessage: (sourcePath) => `LangBot app secret source ${redactRepoPath(sourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`,
|
||||||
|
});
|
||||||
|
const appValues = appSource.values;
|
||||||
const jwtSecret = requiredEnvValue(appValues, "LANGBOT_JWT_SECRET", langbot.runtime.secrets.appSourceRef);
|
const jwtSecret = requiredEnvValue(appValues, "LANGBOT_JWT_SECRET", langbot.runtime.secrets.appSourceRef);
|
||||||
const apiKey = requiredEnvValue(appValues, langbot.apiKey.key, langbot.apiKey.sourceRef);
|
const apiKey = requiredEnvValue(appValues, langbot.apiKey.key, langbot.apiKey.sourceRef);
|
||||||
const values = {
|
const values = {
|
||||||
@@ -1014,12 +1018,12 @@ function prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
dbSourceRef: langbot.runtime.database.sourceRef,
|
dbSourceRef: langbot.runtime.database.sourceRef,
|
||||||
dbSourcePath: redactRepoPath(dbSourcePath),
|
dbSourcePath: dbSource.sourcePathRedacted,
|
||||||
appSourceRef: langbot.runtime.secrets.appSourceRef,
|
appSourceRef: langbot.runtime.secrets.appSourceRef,
|
||||||
appSourcePath: redactRepoPath(appSourcePath),
|
appSourcePath: appSource.sourcePathRedacted,
|
||||||
action: "read",
|
action: "read",
|
||||||
values,
|
values,
|
||||||
fingerprint: fingerprintValues({ dbPassword, jwtSecret: values.jwtSecret, apiKey: values.apiKey }, ["dbPassword", "jwtSecret", "apiKey"]),
|
fingerprint: fingerprintSecretValues({ dbPassword, jwtSecret: values.jwtSecret, apiKey: values.apiKey }, ["dbPassword", "jwtSecret", "apiKey"]),
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1040,44 +1044,13 @@ export function readLangBotSecretMaterial(): Record<string, unknown> {
|
|||||||
const secret = prepareSecretMaterial(langbot);
|
const secret = prepareSecretMaterial(langbot);
|
||||||
return {
|
return {
|
||||||
apiKey: secret.values.apiKey,
|
apiKey: secret.values.apiKey,
|
||||||
apiKeyFingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]),
|
apiKeyFingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]),
|
||||||
sourceRef: langbot.apiKey.sourceRef,
|
sourceRef: langbot.apiKey.sourceRef,
|
||||||
keyName: langbot.apiKey.key,
|
keyName: langbot.apiKey.key,
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareFrpcSecret(langbot: LangBotConfig, target: LangBotTarget): FrpcSecretMaterial {
|
|
||||||
const exposure = target.publicExposure;
|
|
||||||
const sourcePath = join(secretRoot(langbot), exposure.frpc.tokenSourceRef);
|
|
||||||
if (!existsSync(sourcePath)) throw new Error(`LangBot public exposure requires ${redactRepoPath(sourcePath)} with ${exposure.frpc.tokenSourceKey}`);
|
|
||||||
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
|
||||||
const token = requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef);
|
|
||||||
const frpcToml = [
|
|
||||||
`serverAddr = "${exposure.frpc.serverAddr}"`,
|
|
||||||
`serverPort = ${exposure.frpc.serverPort}`,
|
|
||||||
"loginFailExit = true",
|
|
||||||
`auth.token = "${escapeTomlString(token)}"`,
|
|
||||||
"",
|
|
||||||
"[[proxies]]",
|
|
||||||
`name = "${exposure.frpc.proxyName}"`,
|
|
||||||
'type = "tcp"',
|
|
||||||
`localIP = "${exposure.frpc.localIP}"`,
|
|
||||||
`localPort = ${exposure.frpc.localPort}`,
|
|
||||||
`remotePort = ${exposure.frpc.remotePort}`,
|
|
||||||
"",
|
|
||||||
].join("\n");
|
|
||||||
return {
|
|
||||||
sourceRef: exposure.frpc.tokenSourceRef,
|
|
||||||
sourcePath: redactRepoPath(sourcePath),
|
|
||||||
secretName: exposure.frpc.secretName,
|
|
||||||
secretKey: exposure.frpc.secretKey,
|
|
||||||
frpcToml,
|
|
||||||
fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]),
|
|
||||||
valuesPrinted: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function dryRunScript(yaml: string, target: LangBotTarget): string {
|
function dryRunScript(yaml: string, target: LangBotTarget): string {
|
||||||
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
||||||
return `
|
return `
|
||||||
@@ -1582,46 +1555,10 @@ function secretRoot(langbot: LangBotConfig): string {
|
|||||||
return isAbsolute(root) ? root : rootPath(root);
|
return isAbsolute(root) ? root : rootPath(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEnvFile(text: string): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const rawLine of text.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (line.length === 0 || line.startsWith("#")) continue;
|
|
||||||
const eq = line.indexOf("=");
|
|
||||||
if (eq <= 0) continue;
|
|
||||||
const key = line.slice(0, eq).trim();
|
|
||||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
|
||||||
result[key] = unquoteEnvValue(line.slice(eq + 1).trim());
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unquoteEnvValue(value: string): string {
|
|
||||||
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sqlLiteral(value: string): string {
|
function sqlLiteral(value: string): string {
|
||||||
return `'${value.replaceAll("'", "''")}'`;
|
return `'${value.replaceAll("'", "''")}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fingerprintValues(values: Record<string, string>, keys: string[]): string {
|
|
||||||
const hash = createHash("sha256");
|
|
||||||
for (const key of keys.slice().sort()) {
|
|
||||||
hash.update(key);
|
|
||||||
hash.update("\0");
|
|
||||||
hash.update(values[key] ?? "");
|
|
||||||
hash.update("\0");
|
|
||||||
}
|
|
||||||
return `sha256:${hash.digest("hex")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
||||||
const trimmed = stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
if (trimmed.length === 0) return null;
|
if (trimmed.length === 0) return null;
|
||||||
@@ -1655,23 +1592,10 @@ function redactText(text: string): string {
|
|||||||
.replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$2");
|
.replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$2");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function boolField(value: Record<string, unknown> | null, key: string, fallback: boolean): boolean {
|
||||||
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeTomlString(value: string): string {
|
|
||||||
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
function shQuote(value: string): string {
|
|
||||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { isAbsolute, join } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
import type { UniDeskConfig } from "./config";
|
import type { UniDeskConfig } from "./config";
|
||||||
import { rootPath } from "./config";
|
import { rootPath } from "./config";
|
||||||
import { startJob } from "./jobs";
|
import { startJob } from "./jobs";
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
capture,
|
capture,
|
||||||
compactCapture,
|
compactCapture,
|
||||||
dryRunManifestScript,
|
dryRunManifestScript,
|
||||||
fingerprintValues,
|
|
||||||
parseJsonOutput,
|
parseJsonOutput,
|
||||||
prepareFrpcSecret,
|
prepareFrpcSecret,
|
||||||
publicHttpProbe,
|
publicHttpProbe,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
type PublicServiceExposure,
|
type PublicServiceExposure,
|
||||||
type PublicServiceTarget,
|
type PublicServiceTarget,
|
||||||
} from "./platform-infra-public-service";
|
} from "./platform-infra-public-service";
|
||||||
|
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
|
||||||
|
|
||||||
const configFile = rootPath("config", "platform-infra", "n8n.yaml");
|
const configFile = rootPath("config", "platform-infra", "n8n.yaml");
|
||||||
const configLabel = "config/platform-infra/n8n.yaml";
|
const configLabel = "config/platform-infra/n8n.yaml";
|
||||||
@@ -817,28 +817,34 @@ PY
|
|||||||
|
|
||||||
function prepareSecretMaterial(n8n: N8nConfig): SecretMaterial {
|
function prepareSecretMaterial(n8n: N8nConfig): SecretMaterial {
|
||||||
const root = secretRoot(n8n);
|
const root = secretRoot(n8n);
|
||||||
const dbSourcePath = join(root, n8n.runtime.database.sourceRef);
|
const dbSource = readEnvSourceFile({
|
||||||
if (!existsSync(dbSourcePath)) throw new Error(`n8n database source ${redactRepoPath(dbSourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`);
|
root,
|
||||||
const dbValues = parseEnvFile(readTextFile(dbSourcePath));
|
sourceRef: n8n.runtime.database.sourceRef,
|
||||||
|
missingMessage: (sourcePath) => `n8n database source ${redactRepoPath(sourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`,
|
||||||
|
});
|
||||||
|
const dbValues = dbSource.values;
|
||||||
const dbUser = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.user, n8n.runtime.database.sourceRef);
|
const dbUser = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.user, n8n.runtime.database.sourceRef);
|
||||||
const dbPassword = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.password, n8n.runtime.database.sourceRef);
|
const dbPassword = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.password, n8n.runtime.database.sourceRef);
|
||||||
const dbName = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.dbName, n8n.runtime.database.sourceRef);
|
const dbName = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.dbName, n8n.runtime.database.sourceRef);
|
||||||
if (dbUser !== n8n.runtime.database.user) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.user} does not match n8n runtime.database.user`);
|
if (dbUser !== n8n.runtime.database.user) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.user} does not match n8n runtime.database.user`);
|
||||||
if (dbName !== n8n.runtime.database.dbName) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.dbName} does not match n8n runtime.database.dbName`);
|
if (dbName !== n8n.runtime.database.dbName) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.dbName} does not match n8n runtime.database.dbName`);
|
||||||
const appSourcePath = join(root, n8n.runtime.secrets.appSourceRef);
|
const appSource = readEnvSourceFile({
|
||||||
if (!existsSync(appSourcePath)) throw new Error(`n8n app secret source ${redactRepoPath(appSourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`);
|
root,
|
||||||
const appValues = parseEnvFile(readTextFile(appSourcePath));
|
sourceRef: n8n.runtime.secrets.appSourceRef,
|
||||||
|
missingMessage: (sourcePath) => `n8n app secret source ${redactRepoPath(sourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`,
|
||||||
|
});
|
||||||
|
const appValues = appSource.values;
|
||||||
const encryptionKeyName = n8n.runtime.secrets.encryptionKey;
|
const encryptionKeyName = n8n.runtime.secrets.encryptionKey;
|
||||||
const encryptionKey = requiredEnvValue(appValues, encryptionKeyName, n8n.runtime.secrets.appSourceRef);
|
const encryptionKey = requiredEnvValue(appValues, encryptionKeyName, n8n.runtime.secrets.appSourceRef);
|
||||||
const values = { dbUser, dbPassword, dbName, encryptionKey };
|
const values = { dbUser, dbPassword, dbName, encryptionKey };
|
||||||
return {
|
return {
|
||||||
dbSourceRef: n8n.runtime.database.sourceRef,
|
dbSourceRef: n8n.runtime.database.sourceRef,
|
||||||
dbSourcePath: redactRepoPath(dbSourcePath),
|
dbSourcePath: dbSource.sourcePathRedacted,
|
||||||
appSourceRef: n8n.runtime.secrets.appSourceRef,
|
appSourceRef: n8n.runtime.secrets.appSourceRef,
|
||||||
appSourcePath: redactRepoPath(appSourcePath),
|
appSourcePath: appSource.sourcePathRedacted,
|
||||||
action: "read",
|
action: "read",
|
||||||
values,
|
values,
|
||||||
fingerprint: fingerprintValues({ dbPassword, encryptionKey: values.encryptionKey }, ["dbPassword", "encryptionKey"]),
|
fingerprint: fingerprintSecretValues({ dbPassword, encryptionKey: values.encryptionKey }, ["dbPassword", "encryptionKey"]),
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1038,41 +1044,6 @@ function secretRoot(n8n: N8nConfig): string {
|
|||||||
return isAbsolute(root) ? root : rootPath(root);
|
return isAbsolute(root) ? root : rootPath(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTextFile(path: string): string {
|
|
||||||
if (!existsSync(path)) throw new Error(`required secret source ${redactRepoPath(path)} is missing`);
|
|
||||||
return readFileSync(path, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvFile(text: string): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const rawLine of text.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (line.length === 0 || line.startsWith("#")) continue;
|
|
||||||
const eq = line.indexOf("=");
|
|
||||||
if (eq <= 0) continue;
|
|
||||||
const key = line.slice(0, eq).trim();
|
|
||||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
|
||||||
result[key] = unquoteEnvValue(line.slice(eq + 1).trim());
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unquoteEnvValue(value: string): string {
|
|
||||||
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
function boolField(value: Record<string, unknown> | null, key: string, fallback: boolean): boolean {
|
||||||
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-2
@@ -98,6 +98,14 @@ interface DesiredSecret {
|
|||||||
fingerprint: string | null;
|
fingerprint: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnvSourceFileMaterial {
|
||||||
|
sourceRef: string;
|
||||||
|
sourcePath: string;
|
||||||
|
sourcePathRedacted: string;
|
||||||
|
values: Record<string, string>;
|
||||||
|
valuesPrinted: false;
|
||||||
|
}
|
||||||
|
|
||||||
export function secretsHelp(): Record<string, unknown> {
|
export function secretsHelp(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
command: "secrets plan|sync|status",
|
command: "secrets plan|sync|status",
|
||||||
@@ -673,7 +681,26 @@ function secretRoot(config: SecretDistributionConfig): string {
|
|||||||
return isAbsolute(root) ? root : rootPath(root);
|
return isAbsolute(root) ? root : rootPath(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEnvFile(text: string): Record<string, string> {
|
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 parseEnvFile(text: string): Record<string, string> {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const rawLine of text.split(/\r?\n/u)) {
|
for (const rawLine of text.split(/\r?\n/u)) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
@@ -692,6 +719,16 @@ function unquoteEnvValue(value: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function writeEnvFile(path: string, values: Record<string, string>): void {
|
||||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||||
const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`);
|
const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`);
|
||||||
@@ -704,7 +741,7 @@ function quoteEnv(value: string): string {
|
|||||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redactRepoPath(path: string): string {
|
export function redactRepoPath(path: string): string {
|
||||||
const root = rootPath();
|
const root = rootPath();
|
||||||
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user