feat: migrate todo note to nc01 github storage

This commit is contained in:
Codex
2026-07-10 03:47:30 +02:00
parent e11c140b09
commit 0184bd7334
57 changed files with 3738 additions and 350 deletions
+229 -158
View File
@@ -122,12 +122,14 @@ const configPath = process.argv[2] ?? "config/unidesk-host-k8s.yaml";
const config = Bun.YAML.parse(readFileSync(configPath, "utf8"));
const target = record(config.target, "target");
const runtime = record(config.runtime, "runtime");
const delivery = config.delivery === undefined ? null : record(config.delivery, "delivery");
const images = record(config.images, "images");
const database = record(config.database, "database");
const services = record(config.services, "services");
const backend = record(services.backendCore, "services.backendCore");
const frontend = record(services.frontend, "services.frontend");
const decisionCenter = optionalRecord(services.decisionCenter, "services.decisionCenter");
const todoNote = optionalRecord(services.todoNote, "services.todoNote");
const runtimeConfig = record(config.runtimeConfig, "runtimeConfig");
const runtimeAuth = record(config.runtimeAuth, "runtimeAuth");
const runtimeAuthSourceRef = record(runtimeAuth.sourceRef, "runtimeAuth.sourceRef");
@@ -152,6 +154,10 @@ for (const key of requiredSecretKeys) {
}
const secretFingerprint = sha(requiredSecretKeys.map((key) => `${key}=${secrets[key]}`).join("\n"));
const runtimeConfigName = "unidesk-runtime-config";
const deliveryServiceRef = delivery === null ? null : required(delivery.serviceRef, "delivery.serviceRef");
if (delivery !== null && delivery.enabled !== true && delivery.enabled !== false) {
throw new Error("delivery.enabled must be boolean");
}
const databaseMode = database.mode === undefined ? "k8s-postgres" : required(database.mode, "database.mode");
if (databaseMode !== "k8s-postgres" && databaseMode !== "host-native") throw new Error("database.mode must be k8s-postgres or host-native");
const dbService = required(database.serviceName, "database.serviceName");
@@ -162,21 +168,21 @@ if (secrets[secretKeys.databaseUrl] !== `postgres://${secrets[secretKeys.postgre
throw new Error(`DATABASE_URL in ${sourcePath} must target ${dbHost}:${dbPort}/${dbName}`);
}
function readDecisionCenterDatabase(decision) {
if (decision === null) return null;
const database = record(decision.database, "services.decisionCenter.database");
const sourceRef = record(database.sourceRef, "services.decisionCenter.database.sourceRef");
const sourcePath = resolve(required(sourceRef.path, "services.decisionCenter.database.sourceRef.path"));
const sourceKey = required(sourceRef.key, "services.decisionCenter.database.sourceRef.key");
function readServiceDatabase(service, servicePath) {
if (service === null) return null;
const database = record(service.database, `${servicePath}.database`);
const sourceRef = record(database.sourceRef, `${servicePath}.database.sourceRef`);
const sourcePath = resolve(required(sourceRef.path, `${servicePath}.database.sourceRef.path`));
const sourceKey = required(sourceRef.key, `${servicePath}.database.sourceRef.key`);
const sourceValues = readEnvFile(sourcePath);
const value = sourceValues[sourceKey];
if (typeof value !== "string" || value.length === 0) throw new Error(`missing ${sourceKey} in ${sourcePath}`);
return {
sourcePath,
sourceRefPath: required(sourceRef.path, "services.decisionCenter.database.sourceRef.path"),
sourceRefPath: required(sourceRef.path, `${servicePath}.database.sourceRef.path`),
sourceKey,
secretName: required(database.secretName, "services.decisionCenter.database.secretName"),
secretKey: required(database.secretKey, "services.decisionCenter.database.secretKey"),
secretName: required(database.secretName, `${servicePath}.database.secretName`),
secretKey: required(database.secretKey, `${servicePath}.database.secretKey`),
value: removeUrlSearchParam(value, "uselibpqcompat"),
fingerprint: sha(`${sourceKey}=${removeUrlSearchParam(value, "uselibpqcompat")}`),
};
@@ -194,37 +200,37 @@ function readFileSource(sourceRef, path) {
};
}
function readDecisionCenterStorage(decision) {
if (decision === null) return null;
const storage = optionalRecord(decision.storage, "services.decisionCenter.storage");
function readServiceStorage(service, servicePath) {
if (service === null) return null;
const storage = optionalRecord(service.storage, `${servicePath}.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;
const primary = required(storage.primary, `${servicePath}.storage.primary`);
if (primary !== "postgres" && primary !== "github-repo") throw new Error(`${servicePath}.storage.primary must be postgres or github-repo`);
const github = primary === "github-repo" ? record(storage.github, `${servicePath}.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");
const ssh = record(github.ssh, `${servicePath}.storage.github.ssh`);
const privateKey = record(ssh.privateKey, `${servicePath}.storage.github.ssh.privateKey`);
const knownHosts = record(ssh.knownHosts, `${servicePath}.storage.github.ssh.knownHosts`);
const privateKeySource = readFileSource(record(privateKey.sourceRef, `${servicePath}.storage.github.ssh.privateKey.sourceRef`), `${servicePath}.storage.github.ssh.privateKey.sourceRef`);
const knownHostsSource = readFileSource(record(knownHosts.sourceRef, `${servicePath}.storage.github.ssh.knownHosts.sourceRef`), `${servicePath}.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"),
repo: required(github.repo, `${servicePath}.storage.github.repo`),
sshUrl: required(github.sshUrl, `${servicePath}.storage.github.sshUrl`),
branch: required(github.branch, `${servicePath}.storage.github.branch`),
basePath: required(github.basePath, `${servicePath}.storage.github.basePath`),
worktreePath: required(github.worktreePath, `${servicePath}.storage.github.worktreePath`),
commitMessagePrefix: required(github.commitMessagePrefix, `${servicePath}.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"),
name: required(record(github.author, `${servicePath}.storage.github.author`).name, `${servicePath}.storage.github.author.name`),
email: required(record(github.author, `${servicePath}.storage.github.author`).email, `${servicePath}.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"),
secretName: required(ssh.secretName, `${servicePath}.storage.github.ssh.secretName`),
mountPath: required(ssh.mountPath, `${servicePath}.storage.github.ssh.mountPath`),
privateKeyKey: required(privateKey.secretKey, `${servicePath}.storage.github.ssh.privateKey.secretKey`),
knownHostsKey: required(knownHosts.secretKey, `${servicePath}.storage.github.ssh.knownHosts.secretKey`),
privateKeySource,
knownHostsSource,
fingerprint: sha(`${privateKeySource.fingerprint}\n${knownHostsSource.fingerprint}`),
@@ -233,53 +239,65 @@ function readDecisionCenterStorage(decision) {
};
}
const decisionCenterDatabase = readDecisionCenterDatabase(decisionCenter);
const decisionCenterStorage = readDecisionCenterStorage(decisionCenter);
const effectiveMicroservicesJson = decisionCenter === null
? required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson")
: JSON.stringify([
{
id: "decision-center",
name: "Decision Center",
providerId: required(target.id, "target.id"),
description: "Decision Center is managed by the NC01 YAML-first k8s runtime for UniDesk decisions, requirements, meetings, and work diaries.",
repository: {
url: required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"),
commitId: required(runtime.commit, "runtime.commit"),
dockerfile: required(record(decisionCenter.repository, "services.decisionCenter.repository").dockerfile, "services.decisionCenter.repository.dockerfile"),
composeFile: required(runtime.deployRef, "runtime.deployRef"),
composeService: required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName"),
containerName: required(decisionCenter.containerName, "services.decisionCenter.containerName"),
},
backend: {
nodeBaseUrl: `http://${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local:${required(String(decisionCenter.containerPort), "services.decisionCenter.containerPort")}`,
nodeBindHost: `${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local`,
nodePort: positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort"),
proxyMode: "cluster-service-http",
frontendOnly: true,
public: false,
allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"],
allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"],
healthPath: required(decisionCenter.healthPath, "services.decisionCenter.healthPath"),
timeoutMs: 30000,
},
development: {
providerId: required(target.id, "target.id"),
sshPassthrough: true,
worktreePath: required(record(decisionCenter.repository, "services.decisionCenter.repository").worktreePath, "services.decisionCenter.repository.worktreePath"),
},
frontend: {
route: required(record(decisionCenter.frontend, "services.decisionCenter.frontend").route, "services.decisionCenter.frontend.route"),
integrated: record(decisionCenter.frontend, "services.decisionCenter.frontend").integrated !== false,
},
deployment: {
mode: "internal-sidecar",
namespace,
expectedNodeIds: [required(target.id, "target.id")],
activeNodeId: required(target.id, "target.id"),
},
},
]);
function managedMicroservice(service, servicePath, id, name, description) {
return {
id,
name,
providerId: required(target.id, "target.id"),
description,
repository: {
url: required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`),
commitId: required(runtime.commit, "runtime.commit"),
dockerfile: required(record(service.repository, `${servicePath}.repository`).dockerfile, `${servicePath}.repository.dockerfile`),
composeFile: required(runtime.deployRef, "runtime.deployRef"),
composeService: required(service.deploymentName, `${servicePath}.deploymentName`),
containerName: required(service.containerName, `${servicePath}.containerName`),
},
backend: {
nodeBaseUrl: `http://${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local:${required(String(service.containerPort), `${servicePath}.containerPort`)}`,
nodeBindHost: `${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local`,
nodePort: positiveInteger(service.containerPort, `${servicePath}.containerPort`),
proxyMode: "cluster-service-http",
frontendOnly: true,
public: false,
allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"],
allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"],
healthPath: required(service.healthPath, `${servicePath}.healthPath`),
timeoutMs: 30000,
},
development: {
providerId: required(target.id, "target.id"),
sshPassthrough: true,
worktreePath: required(record(service.repository, `${servicePath}.repository`).worktreePath, `${servicePath}.repository.worktreePath`),
},
frontend: {
route: required(record(service.frontend, `${servicePath}.frontend`).route, `${servicePath}.frontend.route`),
integrated: record(service.frontend, `${servicePath}.frontend`).integrated !== false,
},
deployment: {
mode: "internal-sidecar",
namespace,
expectedNodeIds: [required(target.id, "target.id")],
activeNodeId: required(target.id, "target.id"),
},
};
}
const decisionCenterDatabase = readServiceDatabase(decisionCenter, "services.decisionCenter");
const decisionCenterStorage = readServiceStorage(decisionCenter, "services.decisionCenter");
const todoNoteDatabase = readServiceDatabase(todoNote, "services.todoNote");
const todoNoteStorage = readServiceStorage(todoNote, "services.todoNote");
const configuredMicroservices = JSON.parse(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson"));
if (!Array.isArray(configuredMicroservices)) throw new Error("runtimeConfig.microservicesJson must encode an array");
const managedMicroservices = [
...(decisionCenter === null ? [] : [managedMicroservice(decisionCenter, "services.decisionCenter", "decision-center", "Decision Center", "NC01 YAML-first Decision Center for decisions, requirements, meetings, and work diaries.")]),
...(todoNote === null ? [] : [managedMicroservice(todoNote, "services.todoNote", "todo-note", "Todo Note", "NC01 YAML-first Todo Note backed by the shared decision-center-data GitHub repository.")]),
];
const managedIds = new Set(managedMicroservices.map((service) => service.id));
const effectiveMicroservicesJson = JSON.stringify([
...configuredMicroservices.filter((service) => typeof service !== "object" || service === null || !managedIds.has(service.id)),
...managedMicroservices,
]);
const runtimeConfigFingerprint = sha(JSON.stringify({ ...runtimeConfig, microservicesJson: effectiveMicroservicesJson }));
const docs = [];
@@ -331,39 +349,60 @@ data:
MICROSERVICES_JSON: ${yamlScalar(effectiveMicroservicesJson)}
NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}
no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`);
if (decisionCenter !== null && decisionCenterDatabase !== null) {
docs.push(`apiVersion: v1
kind: Secret
metadata:
name: ${decisionCenterDatabase.secretName}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
app.kubernetes.io/name: decision-center
annotations:
unidesk.ai/source-ref: ${yamlScalar(decisionCenterDatabase.sourceRefPath)}
unidesk.ai/fingerprint: ${yamlScalar(decisionCenterDatabase.fingerprint)}
type: Opaque
stringData:
${decisionCenterDatabase.secretKey}: ${yamlScalar(decisionCenterDatabase.value)}`);
const serviceDatabaseSecrets = new Map();
for (const databaseSecret of [decisionCenterDatabase, todoNoteDatabase].filter(Boolean)) {
const existing = serviceDatabaseSecrets.get(databaseSecret.secretName);
if (existing !== undefined && (existing.secretKey !== databaseSecret.secretKey || existing.value !== databaseSecret.value)) {
throw new Error(`conflicting managed service database Secret: ${databaseSecret.secretName}`);
}
serviceDatabaseSecrets.set(databaseSecret.secretName, databaseSecret);
}
if (decisionCenterStorage?.github !== undefined) {
docs.push(`apiVersion: v1
for (const databaseSecret of serviceDatabaseSecrets.values()) {
docs.push(`apiVersion: v1
kind: Secret
metadata:
name: ${decisionCenterStorage.github.ssh.secretName}
name: ${databaseSecret.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)}
unidesk.ai/source-ref: ${yamlScalar(databaseSecret.sourceRefPath)}
unidesk.ai/fingerprint: ${yamlScalar(databaseSecret.fingerprint)}
type: Opaque
stringData:
${decisionCenterStorage.github.ssh.privateKeyKey}: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.value)}
${decisionCenterStorage.github.ssh.knownHostsKey}: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.value)}`);
${databaseSecret.secretKey}: ${yamlScalar(databaseSecret.value)}`);
}
const serviceGitSecrets = new Map();
for (const storage of [decisionCenterStorage, todoNoteStorage]) {
if (storage?.github === undefined) continue;
const gitSecret = storage.github.ssh;
const existing = serviceGitSecrets.get(gitSecret.secretName);
if (existing !== undefined && (
existing.privateKeyKey !== gitSecret.privateKeyKey
|| existing.knownHostsKey !== gitSecret.knownHostsKey
|| existing.privateKeySource.value !== gitSecret.privateKeySource.value
|| existing.knownHostsSource.value !== gitSecret.knownHostsSource.value
)) {
throw new Error(`conflicting managed service Git SSH Secret: ${gitSecret.secretName}`);
}
serviceGitSecrets.set(gitSecret.secretName, gitSecret);
}
for (const gitSecret of serviceGitSecrets.values()) {
docs.push(`apiVersion: v1
kind: Secret
metadata:
name: ${gitSecret.secretName}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
annotations:
unidesk.ai/private-key-source-ref: ${yamlScalar(gitSecret.privateKeySource.sourceRefPath)}
unidesk.ai/known-hosts-source-ref: ${yamlScalar(gitSecret.knownHostsSource.sourceRefPath)}
unidesk.ai/fingerprint: ${yamlScalar(gitSecret.fingerprint)}
type: Opaque
stringData:
${gitSecret.privateKeyKey}: ${yamlScalar(gitSecret.privateKeySource.value)}
${gitSecret.knownHostsKey}: ${yamlScalar(gitSecret.knownHostsSource.value)}`);
}
if (databaseMode === "k8s-postgres") {
docs.push(`apiVersion: v1
@@ -585,44 +624,77 @@ ${indent(resourceBlock(backend.resources), 10)}
volumes:
- name: logs
emptyDir: {}`);
if (decisionCenter !== null) {
function managedStorageEnv(envPrefix, storage) {
const lines = [
`- name: ${envPrefix}_STORAGE_PRIMARY`,
` value: ${yamlScalar(storage?.primary ?? "postgres")}`,
];
if (storage?.github === undefined) return lines.join("\n");
lines.push(
`- name: ${envPrefix}_GIT_REPO`,
` value: ${yamlScalar(storage.github.repo)}`,
`- name: ${envPrefix}_GIT_REPO_SSH_URL`,
` value: ${yamlScalar(storage.github.sshUrl)}`,
`- name: ${envPrefix}_GIT_BRANCH`,
` value: ${yamlScalar(storage.github.branch)}`,
`- name: ${envPrefix}_GIT_BASE_PATH`,
` value: ${yamlScalar(storage.github.basePath)}`,
`- name: ${envPrefix}_GIT_WORKTREE`,
` value: ${yamlScalar(storage.github.worktreePath)}`,
`- name: ${envPrefix}_GIT_AUTHOR_NAME`,
` value: ${yamlScalar(storage.github.author.name)}`,
`- name: ${envPrefix}_GIT_AUTHOR_EMAIL`,
` value: ${yamlScalar(storage.github.author.email)}`,
`- name: ${envPrefix}_GIT_COMMIT_PREFIX`,
` value: ${yamlScalar(storage.github.commitMessagePrefix)}`,
"- name: GIT_SSH_COMMAND",
` value: ${yamlScalar(`ssh -i ${storage.github.ssh.mountPath}/${storage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${storage.github.ssh.mountPath}/${storage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`,
);
return lines.join("\n");
}
function appendManagedServiceDeployment({ service, servicePath, serviceId, imageKey, envPrefix, databaseConfig, storageConfig }) {
if (service === null) return;
if (deliveryServiceRef === servicePath) return;
if (databaseConfig === null) throw new Error(`${servicePath}.database is required`);
const github = storageConfig?.github;
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}
name: ${required(service.serviceName, `${servicePath}.serviceName`)}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
app.kubernetes.io/name: decision-center
app.kubernetes.io/name: ${serviceId}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: decision-center
app.kubernetes.io/name: ${serviceId}
ports:
- name: http
port: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")}
port: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)}
targetPort: http`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: ${required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName")}
name: ${required(service.deploymentName, `${servicePath}.deploymentName`)}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
app.kubernetes.io/name: decision-center
app.kubernetes.io/name: ${serviceId}
spec:
replicas: 1
replicas: ${positiveInteger(service.replicas, `${servicePath}.replicas`)}
selector:
matchLabels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/name: ${serviceId}
template:
metadata:
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/name: ${serviceId}
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/secret-fingerprint: ${yamlScalar(databaseConfig.fingerprint)}
unidesk.ai/storage-secret-fingerprint: ${yamlScalar(github?.ssh.fingerprint ?? "")}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
initContainers:
@@ -633,65 +705,48 @@ spec:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")}
key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")}
name: ${databaseConfig.secretName}
key: ${databaseConfig.secretKey}
command: ["sh", "-ec", "db_url=\\"$(printf '%s' \\"$DATABASE_URL\\" | sed 's/[?&]uselibpqcompat=true//g')\\"; until psql \\"$db_url\\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"]
containers:
- name: ${required(decisionCenter.containerName, "services.decisionCenter.containerName")}
image: ${required(images.decisionCenter, "images.decisionCenter")}
- name: ${required(service.containerName, `${servicePath}.containerName`)}
image: ${required(images[imageKey], `images.${imageKey}`)}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")}
containerPort: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)}
env:
- name: HOST
value: "0.0.0.0"
- name: PORT
value: ${yamlScalar(decisionCenter.containerPort)}
value: ${yamlScalar(service.containerPort)}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")}
key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")}
name: ${databaseConfig.secretName}
key: ${databaseConfig.secretKey}
- name: DATABASE_POOL_MAX
value: "2"
value: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))}
- name: UNIDESK_DEPLOY_SERVICE_ID
value: "decision-center"
value: ${yamlScalar(serviceId)}
- name: UNIDESK_DEPLOY_REPO
value: ${yamlScalar(required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"))}
value: ${yamlScalar(required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`))}
- name: UNIDESK_DEPLOY_COMMIT
value: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
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) : ""}
value: ${yamlScalar(required(service.logFile, `${servicePath}.logFile`))}
${indent(managedStorageEnv(envPrefix, storageConfig), 12)}
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh
mountPath: ${yamlScalar(decisionCenterStorage.github.ssh.mountPath)}
readOnly: true`, 12) : ""}
${github === undefined ? "" : indent(`- name: github-ssh
mountPath: ${yamlScalar(github.ssh.mountPath)}
readOnly: true`, 12)}
readinessProbe:
httpGet:
path: ${required(decisionCenter.healthPath, "services.decisionCenter.healthPath")}
path: ${required(service.healthPath, `${servicePath}.healthPath`)}
port: http
periodSeconds: 5
timeoutSeconds: 3
@@ -710,18 +765,34 @@ ${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
${indent(resourceBlock(decisionCenter.resources), 10)}
${indent(resourceBlock(service.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`;
}
emptyDir: {}
${github === undefined ? "" : indent(`- name: github-ssh
secret:
secretName: ${github.ssh.secretName}
defaultMode: 0400`, 8)}`);
}
appendManagedServiceDeployment({
service: decisionCenter,
servicePath: "services.decisionCenter",
serviceId: "decision-center",
imageKey: "decisionCenter",
envPrefix: "DECISION_CENTER",
databaseConfig: decisionCenterDatabase,
storageConfig: decisionCenterStorage,
});
appendManagedServiceDeployment({
service: todoNote,
servicePath: "services.todoNote",
serviceId: "todo-note",
imageKey: "todoNote",
envPrefix: "TODO_NOTE",
databaseConfig: todoNoteDatabase,
storageConfig: todoNoteStorage,
});
docs.push(`apiVersion: v1
kind: Service
metadata:
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bun
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
function option(name) {
const index = process.argv.indexOf(name);
if (index === -1) return null;
const value = process.argv[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function record(value, path) {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value;
}
function required(value, path) {
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
return value;
}
function positiveInteger(value, path) {
if (!Number.isInteger(value) || value < 1) throw new Error(`${path} must be a positive integer`);
return value;
}
function booleanValue(value, path) {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
}
function stringValue(value, path) {
if (typeof value !== "string") throw new Error(`${path} must be a string`);
return value;
}
function valueAt(root, reference) {
return reference.split(".").reduce((value, key) => record(value, reference)[key], root);
}
function sha(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function secretEnv(name, secretName, secretKey) {
return { name, valueFrom: { secretKeyRef: { name: secretName, key: secretKey } } };
}
function main() {
const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml";
const serviceRef = required(option("--service-ref"), "--service-ref");
const image = required(option("--image"), "--image");
const sourceCommit = required(option("--source-commit"), "--source-commit");
if (!/^services\.[A-Za-z][A-Za-z0-9]*$/u.test(serviceRef)) throw new Error("--service-ref must identify one services.<key> entry");
if (!/@sha256:[0-9a-f]{64}$/u.test(image)) throw new Error("--image must be digest pinned");
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");
const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
const target = record(config.target, "target");
const runtime = record(config.runtime, "runtime");
const images = record(config.images, "images");
const service = record(valueAt(config, serviceRef), serviceRef);
const database = record(service.database, `${serviceRef}.database`);
const storage = record(service.storage, `${serviceRef}.storage`);
const repository = record(service.repository, `${serviceRef}.repository`);
const reminder = record(service.reminder, `${serviceRef}.reminder`);
const reminderTransport = record(reminder.transport, `${serviceRef}.reminder.transport`);
const resources = record(service.resources, `${serviceRef}.resources`);
const github = storage.primary === "github-repo" ? record(storage.github, `${serviceRef}.storage.github`) : null;
const githubSsh = github === null ? null : record(github.ssh, `${serviceRef}.storage.github.ssh`);
const privateKey = githubSsh === null ? null : record(githubSsh.privateKey, `${serviceRef}.storage.github.ssh.privateKey`);
const knownHosts = githubSsh === null ? null : record(githubSsh.knownHosts, `${serviceRef}.storage.github.ssh.knownHosts`);
const namespace = required(target.namespace, "target.namespace");
const serviceId = required(service.serviceId, `${serviceRef}.serviceId`);
const serviceName = required(service.serviceName, `${serviceRef}.serviceName`);
const deploymentName = required(service.deploymentName, `${serviceRef}.deploymentName`);
const containerName = required(service.containerName, `${serviceRef}.containerName`);
const containerPort = positiveInteger(service.containerPort, `${serviceRef}.containerPort`);
const databaseSecretName = required(database.secretName, `${serviceRef}.database.secretName`);
const databaseSecretKey = required(database.secretKey, `${serviceRef}.database.secretKey`);
const storageEnvPrefix = required(service.storageEnvPrefix, `${serviceRef}.storageEnvPrefix`);
const reminderTransportType = required(reminderTransport.type, `${serviceRef}.reminder.transport.type`);
if (reminderTransportType !== "claudeqq") throw new Error(`${serviceRef}.reminder.transport.type must be claudeqq`);
const reminderTargetType = required(reminderTransport.targetType, `${serviceRef}.reminder.transport.targetType`);
if (reminderTargetType !== "private" && reminderTargetType !== "group") throw new Error(`${serviceRef}.reminder.transport.targetType must be private or group`);
const reminderUserId = stringValue(reminderTransport.userId, `${serviceRef}.reminder.transport.userId`);
const reminderGroupId = stringValue(reminderTransport.groupId, `${serviceRef}.reminder.transport.groupId`);
if (booleanValue(reminder.enabled, `${serviceRef}.reminder.enabled`) && reminderTargetType === "private" && reminderUserId.length === 0) throw new Error(`${serviceRef}.reminder.transport.userId is required for private reminders`);
if (reminder.enabled && reminderTargetType === "group" && reminderGroupId.length === 0) throw new Error(`${serviceRef}.reminder.transport.groupId is required for group reminders`);
const configuredImage = required(valueAt(config, required(service.imageRef, `${serviceRef}.imageRef`)), service.imageRef);
const configFingerprint = sha(JSON.stringify({ target, runtime, images, serviceRef, service, configuredImage }));
const commonLabels = {
"app.kubernetes.io/name": serviceId,
"app.kubernetes.io/part-of": "unidesk",
"app.kubernetes.io/managed-by": "argocd",
"unidesk.ai/deployment": required(record(config.metadata, "metadata").name, "metadata.name"),
"unidesk.ai/target": required(target.id, "target.id"),
};
const env = [
{ name: "HOST", value: "0.0.0.0" },
{ name: "PORT", value: String(containerPort) },
secretEnv("DATABASE_URL", databaseSecretName, databaseSecretKey),
{ name: "DATABASE_POOL_MAX", value: required(record(config.runtimeConfig, "runtimeConfig").databasePoolMax, "runtimeConfig.databasePoolMax") },
{ name: "UNIDESK_DEPLOY_SERVICE_ID", value: serviceId },
{ name: "UNIDESK_DEPLOY_REPO", value: required(repository.url, `${serviceRef}.repository.url`) },
{ name: "UNIDESK_DEPLOY_COMMIT", value: sourceCommit },
{ name: "UNIDESK_DEPLOY_REQUESTED_COMMIT", value: sourceCommit },
{ name: "LOG_FILE", value: required(service.logFile, `${serviceRef}.logFile`) },
{ name: `${storageEnvPrefix}_STORAGE_PRIMARY`, value: required(storage.primary, `${serviceRef}.storage.primary`) },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED", value: String(reminder.enabled) },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL", value: required(reminderTransport.baseUrl, `${serviceRef}.reminder.transport.baseUrl`) },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE", value: reminderTargetType },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID", value: reminderUserId },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID", value: reminderGroupId },
{ name: "TODO_NOTE_REMINDER_LEAD_MINUTES", value: String(positiveInteger(reminder.leadMinutes, `${serviceRef}.reminder.leadMinutes`)) },
{ name: "TODO_NOTE_REMINDER_SCAN_INTERVAL_MS", value: String(positiveInteger(reminder.scanIntervalMs, `${serviceRef}.reminder.scanIntervalMs`)) },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS", value: String(positiveInteger(reminderTransport.timeoutMs, `${serviceRef}.reminder.transport.timeoutMs`)) },
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS", value: String(positiveInteger(reminderTransport.sendAttempts, `${serviceRef}.reminder.transport.sendAttempts`)) },
];
const volumeMounts = [{ name: "logs", mountPath: "/var/log/unidesk" }];
const volumes = [{ name: "logs", emptyDir: {} }];
if (github !== null && githubSsh !== null && privateKey !== null && knownHosts !== null) {
const mountPath = required(githubSsh.mountPath, `${serviceRef}.storage.github.ssh.mountPath`);
const privateKeyName = required(privateKey.secretKey, `${serviceRef}.storage.github.ssh.privateKey.secretKey`);
const knownHostsName = required(knownHosts.secretKey, `${serviceRef}.storage.github.ssh.knownHosts.secretKey`);
env.push(
{ name: `${storageEnvPrefix}_GIT_REPO`, value: required(github.repo, `${serviceRef}.storage.github.repo`) },
{ name: `${storageEnvPrefix}_GIT_REPO_SSH_URL`, value: required(github.sshUrl, `${serviceRef}.storage.github.sshUrl`) },
{ name: `${storageEnvPrefix}_GIT_BRANCH`, value: required(github.branch, `${serviceRef}.storage.github.branch`) },
{ name: `${storageEnvPrefix}_GIT_BASE_PATH`, value: required(github.basePath, `${serviceRef}.storage.github.basePath`) },
{ name: `${storageEnvPrefix}_GIT_WORKTREE`, value: required(github.worktreePath, `${serviceRef}.storage.github.worktreePath`) },
{ name: `${storageEnvPrefix}_GIT_AUTHOR_NAME`, value: required(record(github.author, `${serviceRef}.storage.github.author`).name, `${serviceRef}.storage.github.author.name`) },
{ name: `${storageEnvPrefix}_GIT_AUTHOR_EMAIL`, value: required(record(github.author, `${serviceRef}.storage.github.author`).email, `${serviceRef}.storage.github.author.email`) },
{ name: `${storageEnvPrefix}_GIT_COMMIT_PREFIX`, value: required(github.commitMessagePrefix, `${serviceRef}.storage.github.commitMessagePrefix`) },
{ name: "GIT_SSH_COMMAND", value: `ssh -i ${mountPath}/${privateKeyName} -o UserKnownHostsFile=${mountPath}/${knownHostsName} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes` },
);
volumeMounts.push({ name: "github-ssh", mountPath, readOnly: true });
volumes.push({ name: "github-ssh", secret: { secretName: required(githubSsh.secretName, `${serviceRef}.storage.github.ssh.secretName`), defaultMode: 256 } });
}
const objects = [
{
apiVersion: "v1",
kind: "Service",
metadata: { name: serviceName, namespace, labels: commonLabels },
spec: {
type: "ClusterIP",
selector: { "app.kubernetes.io/name": serviceId },
ports: [{ name: "http", port: containerPort, targetPort: "http" }],
},
},
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: deploymentName, namespace, labels: commonLabels },
spec: {
replicas: positiveInteger(service.replicas, `${serviceRef}.replicas`),
selector: { matchLabels: { "app.kubernetes.io/name": serviceId } },
template: {
metadata: {
labels: commonLabels,
annotations: {
"unidesk.ai/source-commit": sourceCommit,
"unidesk.ai/config-fingerprint": `sha256:${configFingerprint}`,
},
},
spec: {
initContainers: [{
name: "wait-database",
image: required(images.postgres, "images.postgres"),
imagePullPolicy: "IfNotPresent",
env: [secretEnv("DATABASE_URL", databaseSecretName, databaseSecretKey)],
command: ["sh", "-ec", "db_url=\"$(printf '%s' \"$DATABASE_URL\" | sed 's/[?&]uselibpqcompat=true//g')\"; until psql \"$db_url\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"],
}],
containers: [{
name: containerName,
image,
imagePullPolicy: "IfNotPresent",
ports: [{ name: "http", containerPort }],
env,
volumeMounts,
readinessProbe: { httpGet: { path: required(service.healthPath, `${serviceRef}.healthPath`), port: "http" }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 18 },
livenessProbe: { httpGet: { path: required(service.livePath, `${serviceRef}.livePath`), port: "http" }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 6 },
startupProbe: { httpGet: { path: required(service.livePath, `${serviceRef}.livePath`), port: "http" }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 30 },
resources,
}],
volumes,
},
},
},
},
];
process.stdout.write(`${objects.map((object) => Bun.YAML.stringify(object).trim()).join("\n---\n")}\n`);
}
if (!process.execArgv.includes("--check")) main();