feat: add github-backed decision storage
This commit is contained in:
@@ -182,7 +182,59 @@ function readDecisionCenterDatabase(decision) {
|
||||
};
|
||||
}
|
||||
|
||||
function readFileSource(sourceRef, path) {
|
||||
const sourcePath = resolve(required(sourceRef.path, `${path}.path`));
|
||||
const value = readFileSync(sourcePath, "utf8");
|
||||
if (value.length === 0) throw new Error(`missing file content in ${sourcePath}`);
|
||||
return {
|
||||
sourcePath,
|
||||
sourceRefPath: required(sourceRef.path, `${path}.path`),
|
||||
value,
|
||||
fingerprint: sha(value),
|
||||
};
|
||||
}
|
||||
|
||||
function readDecisionCenterStorage(decision) {
|
||||
if (decision === null) return null;
|
||||
const storage = optionalRecord(decision.storage, "services.decisionCenter.storage");
|
||||
if (storage === null) return null;
|
||||
const primary = required(storage.primary, "services.decisionCenter.storage.primary");
|
||||
if (primary !== "postgres" && primary !== "github-repo") throw new Error("services.decisionCenter.storage.primary must be postgres or github-repo");
|
||||
const github = primary === "github-repo" ? record(storage.github, "services.decisionCenter.storage.github") : null;
|
||||
if (github === null) return { primary };
|
||||
const ssh = record(github.ssh, "services.decisionCenter.storage.github.ssh");
|
||||
const privateKey = record(ssh.privateKey, "services.decisionCenter.storage.github.ssh.privateKey");
|
||||
const knownHosts = record(ssh.knownHosts, "services.decisionCenter.storage.github.ssh.knownHosts");
|
||||
const privateKeySource = readFileSource(record(privateKey.sourceRef, "services.decisionCenter.storage.github.ssh.privateKey.sourceRef"), "services.decisionCenter.storage.github.ssh.privateKey.sourceRef");
|
||||
const knownHostsSource = readFileSource(record(knownHosts.sourceRef, "services.decisionCenter.storage.github.ssh.knownHosts.sourceRef"), "services.decisionCenter.storage.github.ssh.knownHosts.sourceRef");
|
||||
return {
|
||||
primary,
|
||||
github: {
|
||||
repo: required(github.repo, "services.decisionCenter.storage.github.repo"),
|
||||
sshUrl: required(github.sshUrl, "services.decisionCenter.storage.github.sshUrl"),
|
||||
branch: required(github.branch, "services.decisionCenter.storage.github.branch"),
|
||||
basePath: required(github.basePath, "services.decisionCenter.storage.github.basePath"),
|
||||
worktreePath: required(github.worktreePath, "services.decisionCenter.storage.github.worktreePath"),
|
||||
commitMessagePrefix: required(github.commitMessagePrefix, "services.decisionCenter.storage.github.commitMessagePrefix"),
|
||||
author: {
|
||||
name: required(record(github.author, "services.decisionCenter.storage.github.author").name, "services.decisionCenter.storage.github.author.name"),
|
||||
email: required(record(github.author, "services.decisionCenter.storage.github.author").email, "services.decisionCenter.storage.github.author.email"),
|
||||
},
|
||||
ssh: {
|
||||
secretName: required(ssh.secretName, "services.decisionCenter.storage.github.ssh.secretName"),
|
||||
mountPath: required(ssh.mountPath, "services.decisionCenter.storage.github.ssh.mountPath"),
|
||||
privateKeyKey: required(privateKey.secretKey, "services.decisionCenter.storage.github.ssh.privateKey.secretKey"),
|
||||
knownHostsKey: required(knownHosts.secretKey, "services.decisionCenter.storage.github.ssh.knownHosts.secretKey"),
|
||||
privateKeySource,
|
||||
knownHostsSource,
|
||||
fingerprint: sha(`${privateKeySource.fingerprint}\n${knownHostsSource.fingerprint}`),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const decisionCenterDatabase = readDecisionCenterDatabase(decisionCenter);
|
||||
const decisionCenterStorage = readDecisionCenterStorage(decisionCenter);
|
||||
const effectiveMicroservicesJson = decisionCenter === null
|
||||
? required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson")
|
||||
: JSON.stringify([
|
||||
@@ -295,6 +347,24 @@ type: Opaque
|
||||
stringData:
|
||||
${decisionCenterDatabase.secretKey}: ${yamlScalar(decisionCenterDatabase.value)}`);
|
||||
}
|
||||
if (decisionCenterStorage?.github !== undefined) {
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${decisionCenterStorage.github.ssh.secretName}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
${indent(labels(config), 4)}
|
||||
app.kubernetes.io/name: decision-center
|
||||
annotations:
|
||||
unidesk.ai/private-key-source-ref: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.sourceRefPath)}
|
||||
unidesk.ai/known-hosts-source-ref: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.sourceRefPath)}
|
||||
unidesk.ai/fingerprint: ${yamlScalar(decisionCenterStorage.github.ssh.fingerprint)}
|
||||
type: Opaque
|
||||
stringData:
|
||||
${decisionCenterStorage.github.ssh.privateKeyKey}: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.value)}
|
||||
${decisionCenterStorage.github.ssh.knownHostsKey}: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.value)}`);
|
||||
}
|
||||
if (databaseMode === "k8s-postgres") {
|
||||
docs.push(`apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
@@ -552,6 +622,7 @@ spec:
|
||||
app.kubernetes.io/part-of: unidesk
|
||||
annotations:
|
||||
unidesk.ai/secret-fingerprint: ${yamlScalar(decisionCenterDatabase?.fingerprint ?? "")}
|
||||
unidesk.ai/storage-secret-fingerprint: ${yamlScalar(decisionCenterStorage?.github?.ssh.fingerprint ?? "")}
|
||||
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
|
||||
spec:
|
||||
initContainers:
|
||||
@@ -594,9 +665,30 @@ spec:
|
||||
value: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
|
||||
- name: LOG_FILE
|
||||
value: ${yamlScalar(required(decisionCenter.logFile, "services.decisionCenter.logFile"))}
|
||||
- name: DECISION_CENTER_STORAGE_PRIMARY
|
||||
value: ${yamlScalar(decisionCenterStorage?.primary ?? "postgres")}
|
||||
${decisionCenterStorage?.github !== undefined ? indent(`- name: DECISION_CENTER_GIT_REPO_SSH_URL
|
||||
value: ${yamlScalar(decisionCenterStorage.github.sshUrl)}
|
||||
- name: DECISION_CENTER_GIT_BRANCH
|
||||
value: ${yamlScalar(decisionCenterStorage.github.branch)}
|
||||
- name: DECISION_CENTER_GIT_BASE_PATH
|
||||
value: ${yamlScalar(decisionCenterStorage.github.basePath)}
|
||||
- name: DECISION_CENTER_GIT_WORKTREE
|
||||
value: ${yamlScalar(decisionCenterStorage.github.worktreePath)}
|
||||
- name: DECISION_CENTER_GIT_AUTHOR_NAME
|
||||
value: ${yamlScalar(decisionCenterStorage.github.author.name)}
|
||||
- name: DECISION_CENTER_GIT_AUTHOR_EMAIL
|
||||
value: ${yamlScalar(decisionCenterStorage.github.author.email)}
|
||||
- name: DECISION_CENTER_GIT_COMMIT_PREFIX
|
||||
value: ${yamlScalar(decisionCenterStorage.github.commitMessagePrefix)}
|
||||
- name: GIT_SSH_COMMAND
|
||||
value: ${yamlScalar(`ssh -i ${decisionCenterStorage.github.ssh.mountPath}/${decisionCenterStorage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${decisionCenterStorage.github.ssh.mountPath}/${decisionCenterStorage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`, 12) : ""}
|
||||
volumeMounts:
|
||||
- name: logs
|
||||
mountPath: /var/log/unidesk
|
||||
${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh
|
||||
mountPath: ${yamlScalar(decisionCenterStorage.github.ssh.mountPath)}
|
||||
readOnly: true`, 12) : ""}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: ${required(decisionCenter.healthPath, "services.decisionCenter.healthPath")}
|
||||
@@ -622,6 +714,13 @@ ${indent(resourceBlock(decisionCenter.resources), 10)}
|
||||
volumes:
|
||||
- name: logs
|
||||
emptyDir: {}`);
|
||||
if (decisionCenterStorage?.github !== undefined) {
|
||||
docs[docs.length - 1] += `
|
||||
- name: github-ssh
|
||||
secret:
|
||||
secretName: ${decisionCenterStorage.github.ssh.secretName}
|
||||
defaultMode: 0400`;
|
||||
}
|
||||
}
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -76,6 +76,17 @@ function secretSourceSummary(sourcePath: string, key: string): Record<string, un
|
||||
};
|
||||
}
|
||||
|
||||
function fileSourceSummary(sourcePath: string): Record<string, unknown> {
|
||||
const absolute = sourcePath.startsWith("/") ? sourcePath : resolve(repoRoot, sourcePath);
|
||||
if (!existsSync(absolute)) return { sourceRef: sourcePath, present: false, fingerprint: null };
|
||||
const value = readFileSync(absolute, "utf8");
|
||||
return {
|
||||
sourceRef: sourcePath,
|
||||
present: value.length > 0,
|
||||
fingerprint: value.length > 0 ? `sha256:${sha256Short(value)}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function decisionDeploymentConfigSummary(configPath = hostK8sConfigPath): Record<string, unknown> {
|
||||
const parsed = readYamlConfig(configPath);
|
||||
const target = asRecord(parsed.target, `${configPath}.target`);
|
||||
@@ -85,6 +96,13 @@ function decisionDeploymentConfigSummary(configPath = hostK8sConfigPath): Record
|
||||
const decision = asRecord(services.decisionCenter, `${configPath}.services.decisionCenter`);
|
||||
const database = asRecord(decision.database, `${configPath}.services.decisionCenter.database`);
|
||||
const sourceRef = asRecord(database.sourceRef, `${configPath}.services.decisionCenter.database.sourceRef`);
|
||||
const storage = asRecord(decision.storage, `${configPath}.services.decisionCenter.storage`);
|
||||
const github = asRecord(storage.github, `${configPath}.services.decisionCenter.storage.github`);
|
||||
const ssh = asRecord(github.ssh, `${configPath}.services.decisionCenter.storage.github.ssh`);
|
||||
const privateKey = asRecord(ssh.privateKey, `${configPath}.services.decisionCenter.storage.github.ssh.privateKey`);
|
||||
const knownHosts = asRecord(ssh.knownHosts, `${configPath}.services.decisionCenter.storage.github.ssh.knownHosts`);
|
||||
const privateKeySourceRef = asRecord(privateKey.sourceRef, `${configPath}.services.decisionCenter.storage.github.ssh.privateKey.sourceRef`);
|
||||
const knownHostsSourceRef = asRecord(knownHosts.sourceRef, `${configPath}.services.decisionCenter.storage.github.ssh.knownHosts.sourceRef`);
|
||||
return {
|
||||
configPath,
|
||||
target: {
|
||||
@@ -113,6 +131,19 @@ function decisionDeploymentConfigSummary(configPath = hostK8sConfigPath): Record
|
||||
stringField(sourceRef, "key", `${configPath}.services.decisionCenter.database.sourceRef`),
|
||||
),
|
||||
},
|
||||
storage: {
|
||||
primary: stringField(storage, "primary", `${configPath}.services.decisionCenter.storage`),
|
||||
github: {
|
||||
repo: stringField(github, "repo", `${configPath}.services.decisionCenter.storage.github`),
|
||||
sshUrl: stringField(github, "sshUrl", `${configPath}.services.decisionCenter.storage.github`),
|
||||
branch: stringField(github, "branch", `${configPath}.services.decisionCenter.storage.github`),
|
||||
basePath: stringField(github, "basePath", `${configPath}.services.decisionCenter.storage.github`),
|
||||
worktreePath: stringField(github, "worktreePath", `${configPath}.services.decisionCenter.storage.github`),
|
||||
sshSecret: stringField(ssh, "secretName", `${configPath}.services.decisionCenter.storage.github.ssh`),
|
||||
privateKey: fileSourceSummary(stringField(privateKeySourceRef, "path", `${configPath}.services.decisionCenter.storage.github.ssh.privateKey.sourceRef`)),
|
||||
knownHosts: fileSourceSummary(stringField(knownHostsSourceRef, "path", `${configPath}.services.decisionCenter.storage.github.ssh.knownHosts.sourceRef`)),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,6 +164,23 @@ function manifestObjects(manifest: string): Array<Record<string, string>> {
|
||||
.filter((item) => item.kind.length > 0 && item.name.length > 0);
|
||||
}
|
||||
|
||||
function redactSecretManifestValues(manifest: string): string {
|
||||
return manifest.split(/^---$/mu).map((doc) => {
|
||||
if (!/^kind:\s*Secret\s*$/mu.test(doc)) return doc;
|
||||
const lines = doc.split("\n");
|
||||
let inStringData = false;
|
||||
return lines.map((line) => {
|
||||
if (/^stringData:\s*$/u.test(line)) {
|
||||
inStringData = true;
|
||||
return line;
|
||||
}
|
||||
if (inStringData && /^\S/u.test(line)) inStringData = false;
|
||||
if (inStringData && /^ [A-Za-z0-9_.-]+:\s*/u.test(line)) return line.replace(/:\s*.*$/u, ': "<redacted>"');
|
||||
return line;
|
||||
}).join("\n");
|
||||
}).join("---");
|
||||
}
|
||||
|
||||
function deploymentOptions(args: string[]): { configPath: string; confirm: boolean; dryRun: boolean; wait: boolean; full: boolean; raw: boolean } {
|
||||
return {
|
||||
configPath: optionValue(args, ["--config"]) ?? hostK8sConfigPath,
|
||||
@@ -175,7 +223,7 @@ function deploymentPlan(args: string[]): Record<string, unknown> {
|
||||
function deploymentRender(args: string[]): Record<string, unknown> | string {
|
||||
const options = deploymentOptions(args);
|
||||
const manifest = renderDecisionDeploymentManifest(options.configPath);
|
||||
if (options.raw || options.full) return manifest;
|
||||
if (options.raw || options.full) return redactSecretManifestValues(manifest);
|
||||
return {
|
||||
ok: true,
|
||||
action: "decision-center-deployment-render",
|
||||
@@ -315,6 +363,62 @@ async function runDeploymentCommand(config: UniDeskConfig, args: string[]): Prom
|
||||
throw new Error("decision deployment command must be one of: plan, render, apply, status");
|
||||
}
|
||||
|
||||
function storageOptions(args: string[]): { configPath: string; confirm: boolean; dryRun: boolean } {
|
||||
return {
|
||||
configPath: optionValue(args, ["--config"]) ?? hostK8sConfigPath,
|
||||
confirm: hasFlag(args, "--confirm"),
|
||||
dryRun: hasFlag(args, "--dry-run"),
|
||||
};
|
||||
}
|
||||
|
||||
function storagePlan(args: string[]): Record<string, unknown> {
|
||||
const options = storageOptions(args);
|
||||
const summary = decisionDeploymentConfigSummary(options.configPath);
|
||||
return {
|
||||
ok: true,
|
||||
action: "decision-center-storage-plan",
|
||||
mutation: false,
|
||||
config: {
|
||||
configPath: options.configPath,
|
||||
target: asRecord(summary.target, "summary.target"),
|
||||
storage: asRecord(summary.storage, "summary.storage"),
|
||||
database: asRecord(summary.database, "summary.database"),
|
||||
},
|
||||
next: {
|
||||
servicePlan: "bun scripts/cli.ts decision storage status",
|
||||
migrateDryRun: "bun scripts/cli.ts decision storage migrate --dry-run",
|
||||
migrateConfirm: "bun scripts/cli.ts decision storage migrate --confirm",
|
||||
verify: "bun scripts/cli.ts decision storage verify",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function storageProxy(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
return unwrapProxyResponse(decisionProxy(path, init));
|
||||
}
|
||||
|
||||
async function storageProxyAsync(fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>, path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, path, init));
|
||||
}
|
||||
|
||||
async function runStorageCommand(args: string[], fetcher?: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return storagePlan(args.slice(1));
|
||||
if (action === "status") {
|
||||
return fetcher === undefined ? storageProxy("/api/storage/status") : storageProxyAsync(fetcher, "/api/storage/status");
|
||||
}
|
||||
if (action === "migrate") {
|
||||
const options = storageOptions(args.slice(1));
|
||||
if (!options.confirm && !options.dryRun) throw new Error("decision storage migrate requires --dry-run or --confirm");
|
||||
const init = { method: "POST", body: { confirm: options.confirm } };
|
||||
return fetcher === undefined ? storageProxy("/api/storage/migrate", init) : storageProxyAsync(fetcher, "/api/storage/migrate", init);
|
||||
}
|
||||
if (action === "verify") {
|
||||
return fetcher === undefined ? storageProxy("/api/storage/verify") : storageProxyAsync(fetcher, "/api/storage/verify");
|
||||
}
|
||||
throw new Error("decision storage command must be one of: plan, status, migrate, verify");
|
||||
}
|
||||
|
||||
function optionValues(args: string[], names: string[]): string[] {
|
||||
const values: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -951,6 +1055,7 @@ export async function runDecisionCenterCommandAsync(
|
||||
): Promise<unknown> {
|
||||
const [action = "list", id] = args;
|
||||
if (action === "deployment" || action === "deploy") return runDeploymentCommand(_config, args.slice(1));
|
||||
if (action === "storage") return runStorageCommand(args.slice(1), fetcher);
|
||||
if (action === "diary") {
|
||||
const [diaryAction = "list", diaryId] = args.slice(1);
|
||||
if (diaryAction === "import") return importDiaryAsync(args.slice(2), fetcher);
|
||||
@@ -978,5 +1083,5 @@ export async function runDecisionCenterCommandAsync(
|
||||
if (action === "list") return listRecordsAsync(args.slice(1), fetcher);
|
||||
if (action === "show") return showRecordAsync(id, fetcher);
|
||||
if (action === "health") return unwrapProxyResponse(await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/health`));
|
||||
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary, deployment");
|
||||
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary, deployment, storage");
|
||||
}
|
||||
|
||||
+3
-1
@@ -278,7 +278,7 @@ function microserviceHelp(): unknown {
|
||||
|
||||
function decisionHelp(): unknown {
|
||||
return {
|
||||
command: "decision upload|list|show|health|diary|requirement|deployment",
|
||||
command: "decision upload|list|show|health|diary|requirement|deployment|storage",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts decision upload <markdown-file> [--title text] [--type meeting|decision] [--doc-no DC-...]",
|
||||
@@ -289,6 +289,8 @@ function decisionHelp(): unknown {
|
||||
"bun scripts/cli.ts decision requirement list|create|show|update|upsert ... [--doc-no DC-...] [--doc-type GOAL] [--doc-priority P0] [--signer text] [--issued-at ISO]",
|
||||
"bun scripts/cli.ts decision deployment plan|render|status [--config config/unidesk-host-k8s.yaml]",
|
||||
"bun scripts/cli.ts decision deployment apply --dry-run|--confirm [--config config/unidesk-host-k8s.yaml]",
|
||||
"bun scripts/cli.ts decision storage plan|status|verify [--config config/unidesk-host-k8s.yaml]",
|
||||
"bun scripts/cli.ts decision storage migrate --dry-run|--confirm [--config config/unidesk-host-k8s.yaml]",
|
||||
],
|
||||
description: "Operate Decision Center through the registered user-service proxy and manage its NC01 YAML-first k8s deployment.",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user