From 40d03621c59e0ffe4be5ebede75b0a0fab236a73 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 17 May 2026 17:33:36 +0000 Subject: [PATCH] feat: add d601 dev environment foundation --- AGENTS.md | 1 + docs/reference/cli.md | 1 + docs/reference/deploy.md | 17 + docs/reference/microservices.md | 8 + scripts/cli.ts | 10 + scripts/src/check.ts | 1 + scripts/src/dev-env.ts | 196 ++++++ .../k3s/dev/unidesk-dev-foundation.k8s.yaml | 568 ++++++++++++++++++ 8 files changed, 802 insertions(+) create mode 100644 scripts/src/dev-env.ts create mode 100644 src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml diff --git a/AGENTS.md b/AGENTS.md index 5593d651..56833185 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json] [--service ]`:按根目录 `deploy.json` 的服务 repo 和 commit 期望状态校验或更新用户服务,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`。 +- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]`:离线校验 D601 `unidesk-dev` namespace 与 dev PostgreSQL 底座 manifest 的生产隔离护栏,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 - `bun scripts/cli.ts ci install/status/run/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,只做每 commit 检查和 Code Queue 只读性能门禁,不部署 CD;规则见 `docs/reference/ci.md`。 - `bun scripts/cli.ts codex deploy `:Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build 与 live commit 验证路径;规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 57005bb9..31c56693 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -22,6 +22,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health`、`diagnostics`、`tunnel-self-test` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路,`proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;它不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。 - `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;`deploy plan --env dev|prod` 在 Phase 0 只从固定 Git ref 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;规则见 `docs/reference/deploy.md`。 +- `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace 和 dev PostgreSQL 底座 manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml` 中所有 namespaced 对象都只落到 `unidesk-dev`,存在 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,且 dev `DATABASE_URL` 只能指向 `postgres-dev.unidesk-dev.../unidesk_dev`。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源。 - `codex deploy ` 是 Code Queue 兼容部署入口,会生成临时 desired manifest 并调用 `deploy apply --service code-queue` 的同一条 target-side build、k3s import、rollout 和 live commit 验证路径;详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 - `codex task ` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index d2a29402..339e0fc2 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -33,6 +33,23 @@ The current Phase 0 implementation enables only dry-run `check` and `plan` for ` `config.json.microservices[].repository.commitId` is retained for catalog compatibility, but `deploy.json` is the deployment version authority for the reconciler. +## D601 Dev Foundation + +Phase 2 of the D601 dev environment creates only the isolated namespace and database foundation. The authoritative manifest is `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`. + +It may create resources only in `unidesk-dev`: + +- `Namespace unidesk-dev`, plus quota and default limits. +- `Secret unidesk-dev-runtime-secrets` as a dev-only template for DB credentials, provider token, auth/session secret, and Code Queue model secret placeholders. +- `ConfigMap unidesk-dev-runtime-config` for dev identity, fixed deploy ref `origin/deploy/dev`, provider id `D601-dev`, Code Queue dev paths, and non-secret runtime defaults. +- `ConfigMap unidesk-dev-db-guard` with an executable guard script that rejects production-looking `DATABASE_URL` values. +- `StatefulSet/Service postgres-dev` with a 5Gi persistent volume claim and bounded CPU/memory requests/limits. +- `Job unidesk-dev-db-migrate`, which waits for `postgres-dev`, runs the guard, then prepares backend-core and Code Queue tables in the independent `unidesk_dev` database. + +The manifest must not create, update, or delete production namespace resources, production DB objects, production PVCs, production Deployments/Services/Secrets, or main server Docker Compose services. Static validation is available through `bun scripts/cli.ts dev-env validate`; Kubernetes client dry-run is `bun scripts/cli.ts dev-env validate --kubectl-dry-run`. If applying manually during Phase 2, the only allowed apply target is this manifest and the post-check must prove production resources are unchanged, for example by comparing `kubectl -n unidesk get deploy,sts,svc,secret,pvc -o name` before and after. + +Phase 2 guardrails are deliberately limited to the dev manifest and CLI validator. Runtime startup guards for dev backend-core, Code Queue and Code Queue Manager must be reviewed and shipped as a separate change before dev workloads are exposed beyond dry-run or controlled apply. + ## CLI `bun scripts/cli.ts deploy check [--file deploy.json] [--service ]` checks the live runtime against the desired repo and commit without changing the system. diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 8d39e021..2045c544 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -151,6 +151,14 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度 - 拓扑健康:`expectedNodeIds` 负责展示计划内节点;当前 Code Queue 目标拓扑为 D601 原生 k3s 单节点多服务,`presentNodeIds` 应包含 `D601`、`missingNodeIds=[]`、`topologyComplete=true`、`status=healthy`。不能把未完成原生 k3s 接入或仍依赖 Docker 化 k3s 的节点列为 expected node;只有显式 `requireAllInstancesHealthy=true` 的服务才允许把缺失 standby/worker 节点提升为整体不健康。 - 前端:`用户服务 / k3s Control` React 页面必须只通过 `/api/microservices/k3sctl-adapter/proxy/api/control-plane` 通信,展示控制面状态、manifest、D601 scheduler/read/write 实例、active instance、Kubernetes API service proxy/no-fallback 路径和显式原始 JSON 按钮;页面不得直接访问 provider-gateway、D601/D518 业务容器端口、NodePort 或 raw k3s/kubectl API。 +### D601 Dev Namespace Foundation + +D601 开发环境底座只允许创建 `unidesk-dev` namespace 与 dev 专用对象,manifest 固定为 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`。该 manifest 包含 `postgres-dev` 独立 PostgreSQL StatefulSet/Service/PVC、dev-only secret/config 模板、dev DB 初始化 SQL 和迁移 Job、ResourceQuota/LimitRange,以及 `unidesk-dev-db-guard`。它不得修改生产 `unidesk` namespace、生产 PostgreSQL、生产 PVC、生产 Deployment/Service/Secret 或主 server Docker Compose。 + +`postgres-dev` 是 dev backend-core 与 dev Code Queue 状态的默认唯一数据库。dev 运行时必须使用 `postgres-dev.unidesk-dev.svc.cluster.local:5432/unidesk_dev` 和 dev Provider 身份 `D601-dev`;不得共享生产 `d601-tcp-egress-gateway.../unidesk`。当前 Phase 2 只提供 manifest 脚本和 `dev-env validate` 的静态护栏;backend-core、Code Queue 和 Code Queue Manager 的运行时启动护栏需在后续阶段单独评审后接入。 + +验收入口:先运行 `bun scripts/cli.ts dev-env validate` 做静态资源与 DB URL 护栏检查;具备 D601 kubeconfig 时运行 `bun scripts/cli.ts dev-env validate --kubectl-dry-run` 做 Kubernetes client dry-run。若实际 apply,只能 apply 到 `unidesk-dev`,随后用 `kubectl -n unidesk-dev get pods,svc,pvc` 验证 dev DB ready,并对比 apply 前后的 `kubectl -n unidesk get deploy,sts,svc,secret,pvc -o name` 证明生产 workload 未变化。 + ### Code Queue k3s-Managed 当前对外 `id=code-queue` 是稳定用户服务 ID,实际按 master 控制面与 D601 执行面拆分。队列管理、提交、历史摘要、已读状态和轻量 Trace 读取默认由主 server `code-queue-mgr` 直管 PostgreSQL;D601 k3s Code Queue 作为执行面代管,负责 scheduler/runner、dev-container、active run steer/interrupt、judge、输出/attempt/通知写回,并接入统一 `oa-event-flow` 发布 Trace/STEP 事实事件与读取统计中心: diff --git a/scripts/cli.ts b/scripts/cli.ts index 84685f9f..240ef6f2 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -16,6 +16,7 @@ import { runScheduleCommand } from "./src/schedules"; import { parseNetworkPerfOptions, runNetworkPerf } from "./src/network-perf"; import { runCiCommand } from "./src/ci"; import { runSwapCommand } from "./src/swap"; +import { runDevEnvCommand } from "./src/dev-env"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; @@ -54,6 +55,7 @@ function help(): unknown { { command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." }, { command: "decision show ", description: "Show one Decision Center record." }, { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env uses fixed environment refs for dry-run planning in Phase 0." }, + { command: "dev-env validate [--manifest path] [--kubectl-dry-run]", description: "Validate the D601 unidesk-dev namespace/database foundation manifest and production DB URL guardrails without applying resources." }, { command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run supports --wait-ms N." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, { command: "codex deploy [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." }, @@ -155,6 +157,14 @@ async function main(): Promise { return; } + if (top === "dev-env") { + const result = runDevEnvCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + const config = readConfig(); if (top === "ssh") { diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 49a62bf8..096473c8 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -16,6 +16,7 @@ const syntaxFiles = [ "scripts/src/code-queue.ts", "scripts/src/command.ts", "scripts/src/decision-center.ts", + "scripts/src/dev-env.ts", "scripts/src/deploy.ts", "scripts/src/docker.ts", "scripts/src/e2e.ts", diff --git a/scripts/src/dev-env.ts b/scripts/src/dev-env.ts new file mode 100644 index 00000000..d518070a --- /dev/null +++ b/scripts/src/dev-env.ts @@ -0,0 +1,196 @@ +import { readFileSync } from "node:fs"; +import { runCommand } from "./command"; +import { repoRoot, rootPath } from "./config"; + +const defaultManifest = "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml"; +const devNamespace = "unidesk-dev"; +const prodNamespace = "unidesk"; +const requiredKinds = new Set([ + "Namespace/unidesk-dev", + "Secret/unidesk-dev-runtime-secrets", + "ConfigMap/unidesk-dev-runtime-config", + "ConfigMap/unidesk-dev-db-guard", + "ConfigMap/unidesk-dev-db-init", + "Service/postgres-dev", + "StatefulSet/postgres-dev", + "Job/unidesk-dev-db-migrate", +]); + +interface ManifestDocument { + index: number; + raw: string; + kind: string; + name: string; + namespace: string | null; +} + +interface DevEnvOptions { + manifestPath: string; + kubectlDryRun: boolean; +} + +function isHelpArg(arg: string | undefined): boolean { + return arg === "help" || arg === "--help" || arg === "-h"; +} + +function parseOptions(args: string[]): DevEnvOptions { + const options: DevEnvOptions = { manifestPath: defaultManifest, kubectlDryRun: false }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--manifest") { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error("--manifest requires a path"); + options.manifestPath = value; + index += 1; + } else if (arg === "--kubectl-dry-run") { + options.kubectlDryRun = true; + } else if (!isHelpArg(arg)) { + throw new Error(`unknown dev-env option: ${arg}`); + } + } + return options; +} + +function scalarAfter(text: string, key: string): string | null { + const match = text.match(new RegExp(`^\\s*${key}:\\s*"?([^"\\n#]+)"?\\s*(?:#.*)?$`, "mu")); + return match?.[1]?.trim() ?? null; +} + +function namespaceFromDoc(text: string): string | null { + const metadataIndex = text.search(/^metadata:\s*$/mu); + if (metadataIndex < 0) return null; + const metadataBlock = text.slice(metadataIndex); + const match = metadataBlock.match(/^ {2}namespace:\s*"?([^"\n#]+)"?\s*(?:#.*)?$/mu); + return match?.[1]?.trim() ?? null; +} + +function parseManifestDocuments(text: string): ManifestDocument[] { + return text.split(/^---\s*$/mu) + .map((raw, index) => ({ raw: raw.trim(), index })) + .filter((doc) => doc.raw.length > 0) + .map(({ raw, index }) => { + const kind = scalarAfter(raw, "kind") ?? ""; + const name = (() => { + const metadataIndex = raw.search(/^metadata:\s*$/mu); + if (metadataIndex < 0) return ""; + const metadataBlock = raw.slice(metadataIndex); + const match = metadataBlock.match(/^ {2}name:\s*"?([^"\n#]+)"?\s*(?:#.*)?$/mu); + return match?.[1]?.trim() ?? ""; + })(); + return { index, raw, kind, name, namespace: namespaceFromDoc(raw) }; + }); +} + +function databaseUrls(text: string): string[] { + const urls: string[] = []; + const pattern = /postgres(?:ql)?:\/\/[^\s"']+/gu; + for (const match of text.matchAll(pattern)) urls.push(match[0] ?? ""); + return urls.filter((url) => url.length > 0 && !url.includes("*") && !url.includes("$")); +} + +function validateDatabaseUrl(url: string): { ok: boolean; url: string; reason: string | null } { + if (url.includes("d601-tcp-egress-gateway") || url.includes("74.48.78.17:15432") || url.includes("database:5432/unidesk")) { + return { ok: false, url, reason: "matches production database route" }; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, url, reason: "invalid URL" }; + } + const hostOk = [ + "postgres-dev", + "postgres-dev.unidesk-dev", + "postgres-dev.unidesk-dev.svc", + "postgres-dev.unidesk-dev.svc.cluster.local", + ].includes(parsed.hostname); + const database = parsed.pathname.replace(/^\/+/u, ""); + if (!hostOk) return { ok: false, url, reason: `host ${parsed.hostname} is not postgres-dev` }; + if (database !== "unidesk_dev") return { ok: false, url, reason: `database ${database} is not unidesk_dev` }; + return { ok: true, url, reason: null }; +} + +function kubectlDryRun(manifestPath: string): unknown { + const result = runCommand(["kubectl", "apply", "--dry-run=client", "--validate=false", "-f", manifestPath], repoRoot, { timeoutMs: 60_000 }); + return { + command: result.command, + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + ok: result.exitCode === 0, + stdoutTail: result.stdout.slice(-4000), + stderrTail: result.stderr.slice(-4000), + }; +} + +function devEnvHelp(): Record { + return { + ok: true, + command: "dev-env validate", + usage: "bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]", + defaultManifest, + checks: [ + "all namespaced resources must target unidesk-dev", + "required dev namespace, postgres-dev, secret/config, guard, migration resources must exist", + "dev DATABASE_URL values must target postgres-dev/unidesk_dev and not production routes", + "--kubectl-dry-run optionally asks kubectl to client-dry-run the manifest without applying it", + ], + }; +} + +export function runDevEnvCommand(args: string[]): unknown { + const action = args[0]; + if (action === undefined || isHelpArg(action)) return devEnvHelp(); + if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]"); + + const options = parseOptions(args.slice(1)); + const manifestPath = rootPath(options.manifestPath); + const manifestText = readFileSync(manifestPath, "utf8"); + const docs = parseManifestDocuments(manifestText); + const resources = docs.map((doc) => `${doc.kind}/${doc.name}`); + const namespacedViolations = docs + .filter((doc) => doc.kind !== "Namespace") + .filter((doc) => doc.namespace !== devNamespace) + .map((doc) => ({ index: doc.index, kind: doc.kind, name: doc.name, namespace: doc.namespace })); + const namespaceObjectViolations = docs + .filter((doc) => doc.kind === "Namespace") + .filter((doc) => doc.name !== devNamespace) + .map((doc) => ({ index: doc.index, kind: doc.kind, name: doc.name })); + const productionNamespaceTouches = docs + .filter((doc) => doc.namespace === prodNamespace || (doc.kind === "Namespace" && doc.name === prodNamespace)) + .map((doc) => ({ kind: doc.kind, name: doc.name })); + const missingRequiredResources = Array.from(requiredKinds).filter((resource) => !resources.includes(resource)); + const urlChecks = databaseUrls(manifestText).map(validateDatabaseUrl); + const badUrls = urlChecks.filter((check) => !check.ok); + const forbiddenProductionTextHits = [ + "namespace: unidesk\n", + "d601-tcp-egress-gateway.unidesk.svc.cluster.local:15432/unidesk", + "74.48.78.17:15432/unidesk", + ].filter((needle) => manifestText.includes(needle)); + + const staticOk = namespacedViolations.length === 0 + && namespaceObjectViolations.length === 0 + && productionNamespaceTouches.length === 0 + && missingRequiredResources.length === 0 + && badUrls.length === 0 + && forbiddenProductionTextHits.length === 0; + const kubectl = options.kubectlDryRun ? kubectlDryRun(manifestPath) : { skipped: true, enableWith: "--kubectl-dry-run" }; + const kubectlOk = typeof kubectl === "object" && kubectl !== null && "ok" in kubectl ? (kubectl as { ok: boolean }).ok : true; + + return { + ok: staticOk && kubectlOk, + manifest: options.manifestPath, + namespace: devNamespace, + staticChecks: { + ok: staticOk, + resources, + namespacedViolations, + namespaceObjectViolations, + productionNamespaceTouches, + missingRequiredResources, + databaseUrlChecks: urlChecks, + forbiddenProductionTextHits, + }, + kubectlDryRun: kubectl, + }; +} diff --git a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml new file mode 100644 index 00000000..ee9aae8f --- /dev/null +++ b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml @@ -0,0 +1,568 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev + unidesk.ai/k3s-cluster: d601-native-k3s +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: unidesk-dev-quota + namespace: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +spec: + hard: + pods: "30" + requests.cpu: "4" + requests.memory: 8Gi + requests.storage: 30Gi + limits.cpu: "8" + limits.memory: 12Gi + persistentvolumeclaims: "8" +--- +apiVersion: v1 +kind: LimitRange +metadata: + name: unidesk-dev-default-limits + namespace: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +spec: + limits: + - type: Container + defaultRequest: + cpu: 50m + memory: 96Mi + default: + memory: 512Mi + max: + cpu: "2" + memory: 2Gi +--- +apiVersion: v1 +kind: Secret +metadata: + name: unidesk-dev-runtime-secrets + namespace: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev + annotations: + unidesk.ai/template: "true" + unidesk.ai/description: "Dev-only placeholder secret; replace values on D601 before exposing dev services." +type: Opaque +stringData: + POSTGRES_USER: unidesk_dev + POSTGRES_PASSWORD: change-me-dev-postgres-password + POSTGRES_DB: unidesk_dev + DATABASE_URL: postgres://unidesk_dev:change-me-dev-postgres-password@postgres-dev.unidesk-dev.svc.cluster.local:5432/unidesk_dev + PROVIDER_TOKEN: change-me-D601-dev-provider-token + AUTH_USERNAME: admin-dev + AUTH_PASSWORD: change-me-dev-auth-password + SESSION_SECRET: change-me-dev-session-secret-minimum-32-characters + OPENAI_API_KEY: replace-me-openai-api-key + CRS_OAI_KEY: replace-me-crs-oai-key + MINIMAX_API_KEY: replace-me-minimax-api-key +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: unidesk-dev-runtime-config + namespace: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +data: + UNIDESK_ENV: dev + UNIDESK_NAMESPACE: unidesk-dev + UNIDESK_DEPLOY_REF: origin/deploy/dev + UNIDESK_PROVIDER_ID: D601-dev + UNIDESK_NODE_ID: D601 + UNIDESK_DEV_DATABASE_NAME: unidesk_dev + UNIDESK_DEV_DATABASE_ALLOWED_HOSTS: postgres-dev,postgres-dev.unidesk-dev,postgres-dev.unidesk-dev.svc,postgres-dev.unidesk-dev.svc.cluster.local + UNIDESK_DEV_DATABASE_FORBIDDEN_PATTERNS: d601-tcp-egress-gateway,database:5432/unidesk,74.48.78.17:15432 + DATABASE_VOLUME_NAME: postgres-dev-data + DATABASE_VOLUME_SIZE: 5Gi + HEARTBEAT_TIMEOUT_MS: "30000" + TASK_PENDING_TIMEOUT_MS: "600000" + DATABASE_POOL_MAX: "2" + MICROSERVICES_JSON: "[]" + SESSION_TTL_SECONDS: "28800" + CODE_QUEUE_MAIN_PROVIDER_ID: D601-dev + CODE_QUEUE_EXECUTION_PROVIDER_IDS: D601-dev + CODE_QUEUE_WORKDIR: /workspace-dev + CODE_QUEUE_REMOTE_WORKDIR: /home/ubuntu/unidesk-dev-workspace + CODE_QUEUE_DATA_DIR: /var/lib/unidesk-dev/code-queue + CODE_QUEUE_CODEX_HOME: /var/lib/unidesk-dev/code-queue/codex-home + CODE_QUEUE_OPENCODE_XDG_DIR: /var/lib/unidesk-dev/code-queue/opencode-xdg + CODE_QUEUE_DEFAULT_MODEL: gpt-5.5 + CODE_QUEUE_MODELS: gpt-5.5,gpt-5.4-mini,gpt-5.4,minimax-m2.7 + CODE_QUEUE_MODEL_REASONING_EFFORTS: gpt-5.5=xhigh + CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: "false" + CODE_QUEUE_STARTUP_OA_BACKFILL_ENABLED: "false" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: unidesk-dev-db-guard + namespace: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +data: + guard-dev-db-url.sh: | + #!/bin/sh + set -eu + + database_url="${1:-${DATABASE_URL:-}}" + expected_name="${UNIDESK_DEV_DATABASE_NAME:-unidesk_dev}" + + if [ -z "$database_url" ]; then + echo "DATABASE_URL is required for dev DB guard" >&2 + exit 42 + fi + + case "$database_url" in + *d601-tcp-egress-gateway*|*74.48.78.17:15432*|*database:5432/unidesk*|*/unidesk|*/unidesk\?*) + echo "refusing production-looking DATABASE_URL in unidesk-dev: $database_url" >&2 + exit 42 + ;; + esac + + case "$database_url" in + postgres://*@postgres-dev:5432/"$expected_name"|\ + postgresql://*@postgres-dev:5432/"$expected_name"|\ + postgres://*@postgres-dev.unidesk-dev:5432/"$expected_name"|\ + postgresql://*@postgres-dev.unidesk-dev:5432/"$expected_name"|\ + postgres://*@postgres-dev.unidesk-dev.svc:5432/"$expected_name"|\ + postgresql://*@postgres-dev.unidesk-dev.svc:5432/"$expected_name"|\ + postgres://*@postgres-dev.unidesk-dev.svc.cluster.local:5432/"$expected_name"|\ + postgresql://*@postgres-dev.unidesk-dev.svc.cluster.local:5432/"$expected_name") + exit 0 + ;; + *) + echo "DATABASE_URL must target postgres-dev in unidesk-dev and database $expected_name: $database_url" >&2 + exit 42 + ;; + esac +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: unidesk-dev-db-init + namespace: unidesk-dev + labels: + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +data: + 001_unidesk_dev_schema.sql: | + BEGIN; + + CREATE TABLE IF NOT EXISTS unidesk_environment_identity ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + INSERT INTO unidesk_environment_identity (key, value, updated_at) + VALUES + ('environment', 'dev', now()), + ('namespace', 'unidesk-dev', now()), + ('database', 'unidesk_dev', now()), + ('provider_id', 'D601-dev', now()), + ('deploy_ref', 'origin/deploy/dev', now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(); + + CREATE TABLE IF NOT EXISTS unidesk_nodes ( + provider_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + labels JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL, + connected_at TIMESTAMPTZ, + last_heartbeat TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_events ( + id BIGSERIAL PRIMARY KEY, + type TEXT NOT NULL, + source TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_tasks ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + command TEXT NOT NULL, + status TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + result JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_scheduled_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT true, + schedule_json JSONB NOT NULL DEFAULT '{}'::jsonb, + action_json JSONB NOT NULL DEFAULT '{}'::jsonb, + concurrency_policy TEXT NOT NULL DEFAULT 'skip', + next_run_at TIMESTAMPTZ, + last_run_at TIMESTAMPTZ, + last_run_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_scheduled_task_runs ( + id TEXT PRIMARY KEY, + schedule_id TEXT NOT NULL REFERENCES unidesk_scheduled_tasks(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL, + status TEXT NOT NULL, + task_id TEXT, + result JSONB, + error TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + duration_ms BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_node_docker_status ( + provider_id TEXT PRIMARY KEY, + status JSONB NOT NULL DEFAULT '{}'::jsonb, + collected_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_node_system_status ( + provider_id TEXT PRIMARY KEY, + status JSONB NOT NULL DEFAULT '{}'::jsonb, + collected_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_node_metric_samples ( + id BIGSERIAL PRIMARY KEY, + provider_id TEXT NOT NULL, + collected_at TIMESTAMPTZ NOT NULL, + cpu_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + sample JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS unidesk_code_queue_tasks ( + id TEXT PRIMARY KEY, + queue_id TEXT NOT NULL DEFAULT 'default', + status TEXT NOT NULL, + provider_id TEXT NOT NULL DEFAULT 'D601-dev', + execution_mode TEXT NOT NULL DEFAULT 'default', + model TEXT NOT NULL, + cwd TEXT NOT NULL, + prompt TEXT NOT NULL, + base_prompt TEXT NOT NULL DEFAULT '', + reference_task_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + reference_injection JSONB, + reasoning_effort TEXT, + max_attempts INTEGER NOT NULL, + current_attempt INTEGER NOT NULL DEFAULT 0, + current_mode TEXT, + codex_thread_id TEXT, + active_turn_id TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + read_at TIMESTAMPTZ, + last_error TEXT, + last_judge JSONB, + output_count INTEGER NOT NULL DEFAULT 0, + event_count INTEGER NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_output_seq BIGINT NOT NULL DEFAULT 0, + task_json JSONB NOT NULL + ); + + CREATE TABLE IF NOT EXISTS unidesk_code_queue_queues ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + + CREATE TABLE IF NOT EXISTS unidesk_code_queue_workdirs ( + provider_id TEXT NOT NULL, + execution_mode TEXT NOT NULL DEFAULT 'default', + path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (provider_id, execution_mode, path) + ); + + CREATE TABLE IF NOT EXISTS unidesk_code_queue_notifications ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + dedup_key TEXT NOT NULL, + target TEXT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL, + last_error TEXT, + sent_at TIMESTAMPTZ + ); + + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS queue_id TEXT NOT NULL DEFAULT 'default'; + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS provider_id TEXT NOT NULL DEFAULT 'D601-dev'; + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'; + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS base_prompt TEXT NOT NULL DEFAULT ''; + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS reference_task_ids JSONB NOT NULL DEFAULT '[]'::jsonb; + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS reference_injection JSONB; + ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ; + ALTER TABLE unidesk_code_queue_queues ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; + ALTER TABLE unidesk_code_queue_workdirs ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'; + + CREATE INDEX IF NOT EXISTS idx_unidesk_nodes_status ON unidesk_nodes(status); + CREATE INDEX IF NOT EXISTS idx_unidesk_events_created_at ON unidesk_events(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_provider_status ON unidesk_tasks(provider_id, status); + CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_updated_at ON unidesk_tasks(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_status_updated_at ON unidesk_tasks(status, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_scheduled_tasks_next_run ON unidesk_scheduled_tasks(enabled, next_run_at); + CREATE INDEX IF NOT EXISTS idx_unidesk_scheduled_task_runs_schedule_updated ON unidesk_scheduled_task_runs(schedule_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_scheduled_task_runs_status_updated ON unidesk_scheduled_task_runs(status, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_node_docker_status_updated_at ON unidesk_node_docker_status(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_node_system_status_updated_at ON unidesk_node_system_status(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_node_metric_samples_provider_time ON unidesk_node_metric_samples(provider_id, collected_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_status_updated ON unidesk_code_queue_tasks(status, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_queue_status_updated ON unidesk_code_queue_tasks(queue_id, status, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_provider_updated ON unidesk_code_queue_tasks(provider_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_execution_mode_updated ON unidesk_code_queue_tasks(execution_mode, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_created ON unidesk_code_queue_tasks(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_queue_created ON unidesk_code_queue_tasks(queue_id, created_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_unread_terminal ON unidesk_code_queue_tasks(queue_id, updated_at DESC) WHERE read_at IS NULL AND status IN ('succeeded', 'failed', 'canceled'); + CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_model_updated ON unidesk_code_queue_tasks(model, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_code_queue_tasks_list ON unidesk_code_queue_tasks(status, updated_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_code_queue_tasks_queue_updated ON unidesk_code_queue_tasks(queue_id, updated_at DESC, id DESC); + + INSERT INTO unidesk_code_queue_queues (id, name, created_at, updated_at) + VALUES ('default', 'default', now(), now()) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = GREATEST(unidesk_code_queue_queues.updated_at, EXCLUDED.updated_at); + + COMMIT; +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-dev + namespace: unidesk-dev + labels: + app.kubernetes.io/name: postgres-dev + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: postgres-dev + unidesk.ai/environment: dev + ports: + - name: pg + port: 5432 + targetPort: pg +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres-dev + namespace: unidesk-dev + labels: + app.kubernetes.io/name: postgres-dev + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +spec: + serviceName: postgres-dev + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: postgres-dev + unidesk.ai/environment: dev + template: + metadata: + labels: + app.kubernetes.io/name: postgres-dev + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev + unidesk.ai/node-id: D601 + spec: + nodeSelector: + unidesk.ai/node-id: D601 + terminationGracePeriodSeconds: 30 + securityContext: + fsGroup: 70 + containers: + - name: postgres + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + args: + - -c + - shared_buffers=64MB + - -c + - max_connections=30 + - -c + - work_mem=4MB + - -c + - maintenance_work_mem=64MB + - -c + - effective_cache_size=256MB + ports: + - name: pg + containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: unidesk-dev-runtime-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: unidesk-dev-runtime-secrets + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: unidesk-dev-runtime-secrets + key: POSTGRES_DB + volumeMounts: + - name: postgres-dev-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: + - sh + - -ec + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 18 + livenessProbe: + exec: + command: + - sh + - -ec + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + startupProbe: + exec: + command: + - sh + - -ec + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 60 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumeClaimTemplates: + - metadata: + name: postgres-dev-data + labels: + app.kubernetes.io/name: postgres-dev + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: unidesk-dev-db-migrate + namespace: unidesk-dev + labels: + app.kubernetes.io/name: unidesk-dev-db-migrate + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev +spec: + backoffLimit: 6 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: unidesk-dev-db-migrate + app.kubernetes.io/part-of: unidesk + unidesk.ai/environment: dev + spec: + restartPolicy: OnFailure + nodeSelector: + unidesk.ai/node-id: D601 + containers: + - name: migrate + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: unidesk-dev-runtime-secrets + key: DATABASE_URL + - name: UNIDESK_DEV_DATABASE_NAME + valueFrom: + configMapKeyRef: + name: unidesk-dev-runtime-config + key: UNIDESK_DEV_DATABASE_NAME + command: + - /bin/sh + - -ec + args: + - | + /etc/unidesk-dev-guard/guard-dev-db-url.sh "$DATABASE_URL" + until pg_isready -d "$DATABASE_URL"; do + echo "waiting for postgres-dev" + sleep 2 + done + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f /migrations/001_unidesk_dev_schema.sql + volumeMounts: + - name: db-guard + mountPath: /etc/unidesk-dev-guard + readOnly: true + - name: db-init + mountPath: /migrations + readOnly: true + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + volumes: + - name: db-guard + configMap: + name: unidesk-dev-db-guard + defaultMode: 0555 + - name: db-init + configMap: + name: unidesk-dev-db-init