diff --git a/.tekton/unidesk-host-pac.yaml b/.tekton/unidesk-host-pac.yaml index ad032a8f..8e94d834 100644 --- a/.tekton/unidesk-host-pac.yaml +++ b/.tekton/unidesk-host-pac.yaml @@ -12,7 +12,7 @@ metadata: unidesk.ai/source-commit: "{{revision}}" spec: timeouts: - pipeline: 300s + pipeline: 3600s taskRunTemplate: serviceAccountName: default podTemplate: @@ -110,7 +110,7 @@ spec: exec bun /workspace/source/scripts/native/cicd/publish-unidesk-host-gitops.mjs \ --config /workspace/source/config/unidesk-host-k8s.yaml \ --source-root /workspace/source \ - --metadata /workspace/build-metadata.json \ + --metadata /workspace/build-metadata \ --release-dir /workspace/release \ --source-commit "$SOURCE_COMMIT" \ --worktree /workspace/unidesk-host-gitops diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index 5048143a..1a4fa44b 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -19,9 +19,8 @@ runtime: delivery: enabled: true - serviceRef: services.todoNote changeDetection: - runtimePaths: + sharedRuntimePaths: - .tekton/unidesk-host-pac.yaml - config/feature-config.schema.json - config/hwlab-node-lanes.yaml @@ -55,12 +54,33 @@ delivery: - scripts/src/config.ts - scripts/src/hwlab-node-lanes.ts - scripts/src/yaml-composition.ts - - src/components/microservices/todo-note/Dockerfile - - src/components/microservices/todo-note/bun.lock - - src/components/microservices/todo-note/package.json - - src/components/microservices/todo-note/src - - src/components/microservices/todo-note/tsconfig.json - src/components/shared + services: + - id: todo-note + serviceRef: services.todoNote + runtimePaths: + - src/components/microservices/todo-note/Dockerfile + - src/components/microservices/todo-note/bun.lock + - src/components/microservices/todo-note/package.json + - src/components/microservices/todo-note/src + - src/components/microservices/todo-note/tsconfig.json + image: + repository: 127.0.0.1:5000/unidesk/todo-note + gitops: + manifestPath: deploy/gitops/unidesk-host/todo-note.yaml + releaseStatePath: deploy/gitops-state/unidesk-host/todo-note.json + - id: decision-center + serviceRef: services.decisionCenter + runtimePaths: + - src/components/microservices/decision-center/Dockerfile + - src/components/microservices/decision-center/package.json + - src/components/microservices/decision-center/src + - src/components/microservices/decision-center/tsconfig.json + image: + repository: 127.0.0.1:5000/unidesk/decision-center + gitops: + manifestPath: deploy/gitops/unidesk-host/decision-center.yaml + releaseStatePath: deploy/gitops-state/unidesk-host/decision-center.json build: networkMode: host proxy: @@ -76,14 +96,10 @@ delivery: - .svc - .svc.cluster.local - .cluster.local - image: - repository: 127.0.0.1:5000/unidesk/todo-note gitops: readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git branch: unidesk-host-gitops - manifestPath: deploy/gitops/unidesk-host/todo-note.yaml - releaseStatePath: deploy/gitops-state/unidesk-host/todo-note.json resources: - id: hwlab-nc01-v03-runtime-gitops-scripts renderer: hwlab-runtime-gitops-scripts @@ -147,12 +163,16 @@ services: limits: memory: 512Mi decisionCenter: + serviceId: decision-center + imageRef: images.decisionCenter + storageEnvPrefix: DECISION_CENTER replicas: 1 deploymentName: decision-center serviceName: decision-center containerName: decision-center containerPort: 4277 healthPath: /health + livePath: /live logFile: /var/log/unidesk/decision-center.jsonl database: sourceRef: diff --git a/docs/MDTODO/details/web-observe-runtime-reliability/R1.2.3_Task_Report.md b/docs/MDTODO/details/web-observe-runtime-reliability/R1.2.3_Task_Report.md new file mode 100644 index 00000000..98a7ba68 --- /dev/null +++ b/docs/MDTODO/details/web-observe-runtime-reliability/R1.2.3_Task_Report.md @@ -0,0 +1,23 @@ +# R1.2.3 任务报告 + +## 根因 + +Decision Center 的源代码泄露修复已经合并,但 `config/unidesk-host-k8s.yaml#delivery` 只声明 `services.todoNote`。自动 PaC 因此把 Decision Center 源码变化判定为 `runtime-inputs-unchanged`,没有构建镜像、写入 GitOps manifest 或触发滚动,导致运行面继续使用旧进程并持续积累 zombie。 + +## 实现 + +- 把 UniDesk Host 交付配置改为 YAML-first 多服务清单;Todo Note 与 Decision Center 各自声明 source paths、镜像仓库、manifest 和 release state,共享输入独立声明。 +- 发布准备、BuildKit 构建和 GitOps 发布按服务生成独立状态与元数据;单次 source PR 合并仍是唯一触发,多个服务在一个 GitOps commit 中原子发布。 +- 通用服务 renderer 支持没有 Todo Note 提醒配置的服务,并保持数据库、GitHub storage、Secret 和 digest pinning 契约。 +- Pipeline hard timeout 调整为 3600 秒,满足不得低于一小时的运行约束。 + +## 静态验证 + +- `bun scripts/cli.ts check --syntax-only`:11/11 通过。 +- `sh -n scripts/native/cicd/build-unidesk-host-image.sh` 与 `git diff --check` 通过。 +- Todo Note 和 Decision Center 均可独立渲染 Service/Deployment;Decision Center 不包含 Todo Note 提醒变量。 +- Bun 单文件 publisher smoke 通过,未使用 Vitest。 + +## 待验收 + +通过正常 PR 合并事件观察 PaC、GitOps 与 Argo 自动收敛;确认 Decision Center 运行 commit 更新、旧 zombie 随 Pod 滚动清除,并跨 300 秒健康周期不再增长。随后重新获取全局内存分布,根据实际 PSS、swap、cgroup 与对象压力执行一次受控 GC,目标是物理 `MemAvailable > 4 GiB`,swap 不计入。 diff --git a/docs/MDTODO/web-observe-runtime-reliability.md b/docs/MDTODO/web-observe-runtime-reliability.md index 20857b9a..2927b3e8 100644 --- a/docs/MDTODO/web-observe-runtime-reliability.md +++ b/docs/MDTODO/web-observe-runtime-reliability.md @@ -22,6 +22,9 @@ #### R1.2.2 [in_progress] 完成 [UniDesk #1710](https://github.com/pikasTech/unidesk/issues/1710) 的 Decision Center 子进程泄露与 GC 诊断收敛:修复周期性 Git 子进程未回收并把远程同步移出健康轮询;增强 `gc remote memory-distribution`,一次披露物理内存、PSS、swap、僵尸 Pod 归属、Kubernetes 对象压力和处置分类;经自动交付后验证僵尸不再增长并记录内存变化,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R1.2.2_Task_Report.md)。 +#### R1.2.3 [in_progress] + +补齐 [UniDesk #1710](https://github.com/pikasTech/unidesk/issues/1710) 的 Decision Center YAML-first 自动交付:把 `config/unidesk-host-k8s.yaml#delivery` 从 todoNote 单服务模型泛化为显式多服务清单,使各服务独立声明 source paths、镜像、manifest 和 release state;复用同一 PaC 事件完成受影响服务的并行/顺序构建与 GitOps 发布,不修改共享 controller、不人工补链,最终滚动 Decision Center 并跨 300 秒验证 zombie 不再增长,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R1.2.3_Task_Report.md)。 ### R1.3 测试与验收:https://github.com/pikasTech/unidesk/issues/1842;覆盖生命周期、stale/protected 分类和失败路径,提交独立 PR;合并后只等待自动 CI/CD 发布链,使用正式入口验证 observer/Chrome 退出、内存压力缓解且健康运行面不受扰动,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R1.3_Task_Report.md)。 diff --git a/scripts/native/cicd/build-unidesk-host-image.sh b/scripts/native/cicd/build-unidesk-host-image.sh index 9b60dd0d..2e4ddb08 100755 --- a/scripts/native/cicd/build-unidesk-host-image.sh +++ b/scripts/native/cicd/build-unidesk-host-image.sh @@ -3,46 +3,58 @@ set -eu release_dir=${UNIDESK_HOST_RELEASE_DIR:-/workspace/release} source_dir=${UNIDESK_HOST_SOURCE_DIR:-/workspace/source} -digest_file=${UNIDESK_HOST_DIGEST_FILE:-/workspace/image-digest-ref} -metadata_file=${UNIDESK_HOST_BUILD_METADATA_FILE:-/workspace/build-metadata.json} -build_log=${UNIDESK_HOST_BUILD_LOG:-/workspace/image-build.log} +metadata_dir=${UNIDESK_HOST_BUILD_METADATA_DIR:-/workspace/build-metadata} +build_log_dir=${UNIDESK_HOST_BUILD_LOG_DIR:-/workspace/image-build-logs} source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required} -action=$(cat "$release_dir/action") -reason=$(cat "$release_dir/reason") -if [ "$action" != build ]; then - : >"$digest_file" - : >"$metadata_file" - printf '{"ok":true,"phase":"image-build","status":"skipped","action":"%s","reason":"%s","sourceCommit":"%s","valuesPrinted":false}\n' "$action" "$reason" "$source_commit" - exit 0 -fi - -image_repository=$(cat "$release_dir/image-repository") -dockerfile=$(cat "$release_dir/dockerfile") network_mode=$(cat "$release_dir/network-mode") http_proxy=$(cat "$release_dir/http-proxy") https_proxy=$(cat "$release_dir/https-proxy") all_proxy=$(cat "$release_dir/all-proxy") no_proxy=$(cat "$release_dir/no-proxy") -image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)" +mkdir -p "$metadata_dir" "$build_log_dir" +rm -f "$metadata_dir"/*.json "$build_log_dir"/*.log -rm -f "$metadata_file" "$build_log" "$digest_file" -env HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" ALL_PROXY="$all_proxy" NO_PROXY="$no_proxy" \ - http_proxy="$http_proxy" https_proxy="$https_proxy" all_proxy="$all_proxy" no_proxy="$no_proxy" \ - buildctl-daemonless.sh build \ - --allow network.host \ - --frontend dockerfile.v0 \ - --local context="$source_dir" \ - --local dockerfile="$source_dir" \ - --opt "filename=$dockerfile" \ - --opt "network=$network_mode" \ - --opt "build-arg:HTTP_PROXY=$http_proxy" \ - --opt "build-arg:HTTPS_PROXY=$https_proxy" \ - --opt "build-arg:ALL_PROXY=$all_proxy" \ - --opt "build-arg:NO_PROXY=$no_proxy" \ - --metadata-file "$metadata_file" \ - --output "type=image,name=$image_ref,push=true,registry.insecure=true" >"$build_log" 2>&1 -cat "$build_log" -test -s "$metadata_file" -printf '%s\n' "$image_ref" >"$digest_file" -printf '{"ok":true,"phase":"image-build","status":"built","sourceCommit":"%s","imageRef":"%s","metadataReady":true,"valuesPrinted":false}\n' "$source_commit" "$image_ref" +built_count=0 +skipped_count=0 +for service_dir in "$release_dir"/services/*; do + [ -d "$service_dir" ] || continue + service_id=$(basename "$service_dir") + action=$(cat "$service_dir/action") + reason=$(cat "$service_dir/reason") + if [ "$action" != build ]; then + skipped_count=$((skipped_count + 1)) + printf '{"ok":true,"phase":"image-build-service","serviceId":"%s","status":"skipped","action":"%s","reason":"%s","sourceCommit":"%s","valuesPrinted":false}\n' \ + "$service_id" "$action" "$reason" "$source_commit" + continue + fi + + image_repository=$(cat "$service_dir/image-repository") + dockerfile=$(cat "$service_dir/dockerfile") + image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)" + metadata_file="$metadata_dir/$service_id.json" + build_log="$build_log_dir/$service_id.log" + env HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" ALL_PROXY="$all_proxy" NO_PROXY="$no_proxy" \ + http_proxy="$http_proxy" https_proxy="$https_proxy" all_proxy="$all_proxy" no_proxy="$no_proxy" \ + buildctl-daemonless.sh build \ + --allow network.host \ + --frontend dockerfile.v0 \ + --local context="$source_dir" \ + --local dockerfile="$source_dir" \ + --opt "filename=$dockerfile" \ + --opt "network=$network_mode" \ + --opt "build-arg:HTTP_PROXY=$http_proxy" \ + --opt "build-arg:HTTPS_PROXY=$https_proxy" \ + --opt "build-arg:ALL_PROXY=$all_proxy" \ + --opt "build-arg:NO_PROXY=$no_proxy" \ + --metadata-file "$metadata_file" \ + --output "type=image,name=$image_ref,push=true,registry.insecure=true" >"$build_log" 2>&1 + cat "$build_log" + test -s "$metadata_file" + built_count=$((built_count + 1)) + printf '{"ok":true,"phase":"image-build-service","serviceId":"%s","status":"built","sourceCommit":"%s","imageRef":"%s","metadataReady":true,"valuesPrinted":false}\n' \ + "$service_id" "$source_commit" "$image_ref" +done + +printf '{"ok":true,"phase":"image-build","status":"completed","sourceCommit":"%s","builtCount":%s,"skippedCount":%s,"valuesPrinted":false}\n' \ + "$source_commit" "$built_count" "$skipped_count" diff --git a/scripts/native/cicd/prepare-unidesk-host-release.mjs b/scripts/native/cicd/prepare-unidesk-host-release.mjs index 20c02d57..5dff5295 100755 --- a/scripts/native/cicd/prepare-unidesk-host-release.mjs +++ b/scripts/native/cicd/prepare-unidesk-host-release.mjs @@ -50,75 +50,125 @@ function writeValue(outputDir, name, value) { writeFileSync(resolve(outputDir, name), `${value}\n`, { encoding: "utf8", mode: 0o644 }); } -function main() { -const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml"; -const sourceRoot = resolve(option("--source-root") ?? process.cwd()); -const sourceCommit = required(option("--source-commit"), "--source-commit"); -const sourceSnapshotPrefix = required(option("--source-snapshot-prefix"), "--source-snapshot-prefix"); -const outputDir = resolve(required(option("--output-dir"), "--output-dir")); -if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA"); -if (run("git", ["check-ref-format", sourceSnapshotPrefix], sourceRoot, true).status !== 0) throw new Error("--source-snapshot-prefix must be a valid Git ref prefix"); -const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath); -const delivery = record(config.delivery, "delivery"); -const serviceRef = required(delivery.serviceRef, "delivery.serviceRef"); -const service = record(valueAt(config, serviceRef), serviceRef); -const changeDetection = record(delivery.changeDetection, "delivery.changeDetection"); -const runtimePaths = stringArray(changeDetection.runtimePaths, "delivery.changeDetection.runtimePaths"); -const build = record(delivery.build, "delivery.build"); -const proxy = record(build.proxy, "delivery.build.proxy"); -const image = record(delivery.image, "delivery.image"); -const gitops = record(delivery.gitops, "delivery.gitops"); -const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl"); -const branch = required(gitops.branch, "delivery.gitops.branch"); -const releaseStatePath = safeRelativePath(gitops.releaseStatePath, "delivery.gitops.releaseStatePath"); -const enabled = delivery.enabled === true; -if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean"); -const noProxy = proxy.noProxy; -if (!Array.isArray(noProxy) || noProxy.some((value) => typeof value !== "string" || value.length === 0)) throw new Error("delivery.build.proxy.noProxy must be a non-empty string array"); -let action = enabled ? "build" : "disabled"; -let reason = enabled ? "gitops-baseline-unavailable" : "delivery-disabled"; -let baselineSourceCommit = null; -let changedPaths = []; -if (enabled) { - const gitopsRef = "refs/unidesk/release-baseline/unidesk-host"; - const gitopsFetch = run("git", ["fetch", "--depth=1", readUrl, `+refs/heads/${branch}:${gitopsRef}`], sourceRoot, true); - if (gitopsFetch.status === 0) { - const stateResult = run("git", ["show", `${gitopsRef}:${releaseStatePath}`], sourceRoot, true); - if (stateResult.status === 0) { - const state = record(JSON.parse(stateResult.stdout), releaseStatePath); - if (state.kind !== "UniDeskHostReleaseState") throw new Error(`${releaseStatePath}.kind must be UniDeskHostReleaseState`); - if (state.serviceRef !== serviceRef) throw new Error(`${releaseStatePath}.serviceRef must match delivery.serviceRef`); - baselineSourceCommit = required(state.sourceCommit, `${releaseStatePath}.sourceCommit`); - if (!/^[0-9a-f]{40}$/u.test(baselineSourceCommit)) throw new Error(`${releaseStatePath}.sourceCommit must be a full Git commit SHA`); - const sourceRef = "refs/unidesk/release-baseline/unidesk-host-source"; - const sourceFetch = run("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", `+${sourceSnapshotPrefix}/${baselineSourceCommit}:${sourceRef}`], sourceRoot, true); - if (sourceFetch.status === 0) { - const diff = run("git", ["diff", "--name-only", "--no-renames", sourceRef, sourceCommit, "--", ...runtimePaths], sourceRoot); - changedPaths = diff.stdout.split(/\r?\n/u).filter(Boolean); - action = changedPaths.length === 0 ? "skip" : "build"; - reason = changedPaths.length === 0 ? "runtime-inputs-unchanged" : "runtime-inputs-changed"; - } else { - reason = "source-baseline-unavailable"; - } - } else { - reason = "release-state-missing"; - } - } +function deliveryServices(config, delivery) { + if (!Array.isArray(delivery.services) || delivery.services.length === 0) throw new Error("delivery.services must be a non-empty array"); + const ids = new Set(); + return delivery.services.map((value, index) => { + const path = `delivery.services[${index}]`; + const item = record(value, path); + const id = required(item.id, `${path}.id`); + if (!/^[a-z0-9](?:[-a-z0-9]{0,62}[a-z0-9])?$/u.test(id) || ids.has(id)) throw new Error(`${path}.id must be unique DNS label`); + ids.add(id); + const serviceRef = required(item.serviceRef, `${path}.serviceRef`); + const service = record(valueAt(config, serviceRef), serviceRef); + const image = record(item.image, `${path}.image`); + const gitops = record(item.gitops, `${path}.gitops`); + return { + id, + serviceRef, + service, + runtimePaths: stringArray(item.runtimePaths, `${path}.runtimePaths`), + imageRepository: required(image.repository, `${path}.image.repository`), + manifestPath: safeRelativePath(gitops.manifestPath, `${path}.gitops.manifestPath`), + releaseStatePath: safeRelativePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`), + }; + }); } -mkdirSync(outputDir, { recursive: true }); -writeValue(outputDir, "enabled", enabled ? "true" : "false"); -writeValue(outputDir, "action", action); -writeValue(outputDir, "reason", reason); -writeValue(outputDir, "baseline-source-commit", baselineSourceCommit ?? ""); -writeFileSync(resolve(outputDir, "changed-paths.json"), `${JSON.stringify(changedPaths)}\n`, { encoding: "utf8", mode: 0o644 }); -writeValue(outputDir, "dockerfile", required(record(service.repository, `${serviceRef}.repository`).dockerfile, `${serviceRef}.repository.dockerfile`)); -writeValue(outputDir, "image-repository", required(image.repository, "delivery.image.repository")); -writeValue(outputDir, "network-mode", required(build.networkMode, "delivery.build.networkMode")); -writeValue(outputDir, "http-proxy", required(proxy.http, "delivery.build.proxy.http")); -writeValue(outputDir, "https-proxy", required(proxy.https, "delivery.build.proxy.https")); -writeValue(outputDir, "all-proxy", required(proxy.all, "delivery.build.proxy.all")); -writeValue(outputDir, "no-proxy", noProxy.join(",")); -process.stdout.write(`${JSON.stringify({ ok: true, phase: "release-prepare", action, reason, enabled, serviceRef, sourceCommit, baselineSourceCommit, changedPaths, valuesPrinted: false })}\n`); + +function baselineForService(sourceRoot, gitopsRef, service) { + const result = run("git", ["show", `${gitopsRef}:${service.releaseStatePath}`], sourceRoot, true); + if (result.status !== 0) return null; + const state = record(JSON.parse(result.stdout), service.releaseStatePath); + if (state.kind !== "UniDeskHostReleaseState") throw new Error(`${service.releaseStatePath}.kind must be UniDeskHostReleaseState`); + if (state.serviceRef !== service.serviceRef) throw new Error(`${service.releaseStatePath}.serviceRef must match ${service.serviceRef}`); + const sourceCommit = required(state.sourceCommit, `${service.releaseStatePath}.sourceCommit`); + if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error(`${service.releaseStatePath}.sourceCommit must be a full Git commit SHA`); + return sourceCommit; +} + +function main() { + const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml"; + const sourceRoot = resolve(option("--source-root") ?? process.cwd()); + const sourceCommit = required(option("--source-commit"), "--source-commit"); + const sourceSnapshotPrefix = required(option("--source-snapshot-prefix"), "--source-snapshot-prefix"); + const outputDir = resolve(required(option("--output-dir"), "--output-dir")); + if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA"); + if (run("git", ["check-ref-format", sourceSnapshotPrefix], sourceRoot, true).status !== 0) throw new Error("--source-snapshot-prefix must be a valid Git ref prefix"); + + const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath); + const delivery = record(config.delivery, "delivery"); + if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean"); + const enabled = delivery.enabled === true; + const changeDetection = record(delivery.changeDetection, "delivery.changeDetection"); + const sharedRuntimePaths = stringArray(changeDetection.sharedRuntimePaths, "delivery.changeDetection.sharedRuntimePaths"); + const services = deliveryServices(config, delivery); + const build = record(delivery.build, "delivery.build"); + const proxy = record(build.proxy, "delivery.build.proxy"); + const gitops = record(delivery.gitops, "delivery.gitops"); + const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl"); + const branch = required(gitops.branch, "delivery.gitops.branch"); + const noProxy = proxy.noProxy; + if (!Array.isArray(noProxy) || noProxy.some((value) => typeof value !== "string" || value.length === 0)) throw new Error("delivery.build.proxy.noProxy must be a non-empty string array"); + + const gitopsRef = "refs/unidesk/release-baseline/unidesk-host"; + const gitopsReady = enabled && run("git", ["fetch", "--depth=1", readUrl, `+refs/heads/${branch}:${gitopsRef}`], sourceRoot, true).status === 0; + const plans = []; + for (const service of services) { + let action = enabled ? "build" : "disabled"; + let reason = enabled ? "gitops-baseline-unavailable" : "delivery-disabled"; + let baselineSourceCommit = null; + let changedPaths = []; + if (enabled && gitopsReady) { + baselineSourceCommit = baselineForService(sourceRoot, gitopsRef, service); + if (baselineSourceCommit === null) { + reason = "release-state-missing"; + } else { + const sourceRef = `refs/unidesk/release-baseline/unidesk-host-source-${service.id}`; + const sourceReady = run("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", `+${sourceSnapshotPrefix}/${baselineSourceCommit}:${sourceRef}`], sourceRoot, true).status === 0; + if (sourceReady) { + const paths = [...new Set([...sharedRuntimePaths, ...service.runtimePaths])]; + const diff = run("git", ["diff", "--name-only", "--no-renames", sourceRef, sourceCommit, "--", ...paths], sourceRoot); + changedPaths = diff.stdout.split(/\r?\n/u).filter(Boolean); + action = changedPaths.length === 0 ? "skip" : "build"; + reason = changedPaths.length === 0 ? "runtime-inputs-unchanged" : "runtime-inputs-changed"; + } else { + reason = "source-baseline-unavailable"; + } + } + } + plans.push({ ...service, action, reason, baselineSourceCommit, changedPaths }); + } + + mkdirSync(resolve(outputDir, "services"), { recursive: true }); + for (const plan of plans) { + const serviceDir = resolve(outputDir, "services", plan.id); + mkdirSync(serviceDir, { recursive: true }); + writeValue(serviceDir, "action", plan.action); + writeValue(serviceDir, "reason", plan.reason); + writeValue(serviceDir, "service-ref", plan.serviceRef); + writeValue(serviceDir, "baseline-source-commit", plan.baselineSourceCommit ?? ""); + writeValue(serviceDir, "dockerfile", required(record(plan.service.repository, `${plan.serviceRef}.repository`).dockerfile, `${plan.serviceRef}.repository.dockerfile`)); + writeValue(serviceDir, "image-repository", plan.imageRepository); + writeFileSync(resolve(serviceDir, "changed-paths.json"), `${JSON.stringify(plan.changedPaths)}\n`, { encoding: "utf8", mode: 0o644 }); + } + writeValue(outputDir, "action", plans.some((item) => item.action === "build") ? "build" : enabled ? "skip" : "disabled"); + writeValue(outputDir, "network-mode", required(build.networkMode, "delivery.build.networkMode")); + writeValue(outputDir, "http-proxy", required(proxy.http, "delivery.build.proxy.http")); + writeValue(outputDir, "https-proxy", required(proxy.https, "delivery.build.proxy.https")); + writeValue(outputDir, "all-proxy", required(proxy.all, "delivery.build.proxy.all")); + writeValue(outputDir, "no-proxy", noProxy.join(",")); + writeFileSync(resolve(outputDir, "plan.json"), `${JSON.stringify(plans.map(({ service: _service, ...item }) => item))}\n`, { encoding: "utf8", mode: 0o644 }); + process.stdout.write(`${JSON.stringify({ + ok: true, + phase: "release-prepare", + action: plans.some((item) => item.action === "build") ? "build" : enabled ? "skip" : "disabled", + sourceCommit, + affectedServices: plans.filter((item) => item.action === "build").map((item) => item.id), + buildServices: plans.filter((item) => item.action === "build").map((item) => item.id), + reusedServices: plans.filter((item) => item.action === "skip").map((item) => item.id), + services: plans.map((item) => ({ id: item.id, serviceRef: item.serviceRef, action: item.action, reason: item.reason, baselineSourceCommit: item.baselineSourceCommit, changedPaths: item.changedPaths })), + valuesPrinted: false, + })}\n`); } if (!process.execArgv.includes("--check")) main(); diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.mjs b/scripts/native/cicd/publish-unidesk-host-gitops.mjs index 62efe49a..bb1a5747 100755 --- a/scripts/native/cicd/publish-unidesk-host-gitops.mjs +++ b/scripts/native/cicd/publish-unidesk-host-gitops.mjs @@ -33,15 +33,15 @@ function run(command, args, cwd, allowFailure = false) { return result; } -function safeManifestPath(value) { - const path = required(value, "delivery.gitops.manifestPath"); - if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.manifestPath must be a safe relative path"); +function safeManifestPath(value, pathLabel) { + const path = required(value, pathLabel); + if (path.startsWith("/") || path.split("/").includes("..")) throw new Error(`${pathLabel} must be a safe relative path`); return path; } -function safeReleaseStatePath(value) { - const path = required(value, "delivery.gitops.releaseStatePath"); - if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.releaseStatePath must be a safe relative path"); +function safeReleaseStatePath(value, pathLabel) { + const path = required(value, pathLabel); + if (path.startsWith("/") || path.split("/").includes("..")) throw new Error(`${pathLabel} must be a safe relative path`); return path; } @@ -92,6 +92,33 @@ function releaseValue(releaseDir, name) { return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`); } +function valueAt(root, reference) { + return reference.split(".").reduce((value, key) => record(value, reference)[key], root); +} + +function deliveryServices(config, delivery) { + if (!Array.isArray(delivery.services) || delivery.services.length === 0) throw new Error("delivery.services must be a non-empty array"); + const ids = new Set(); + return delivery.services.map((value, index) => { + const path = `delivery.services[${index}]`; + const item = record(value, path); + const id = required(item.id, `${path}.id`); + if (!/^[a-z0-9](?:[-a-z0-9]{0,62}[a-z0-9])?$/u.test(id) || ids.has(id)) throw new Error(`${path}.id must be unique DNS label`); + ids.add(id); + const serviceRef = required(item.serviceRef, `${path}.serviceRef`); + record(valueAt(config, serviceRef), serviceRef); + const image = record(item.image, `${path}.image`); + const gitops = record(item.gitops, `${path}.gitops`); + return { + id, + serviceRef, + imageRepository: required(image.repository, `${path}.image.repository`), + manifestPath: safeManifestPath(gitops.manifestPath, `${path}.gitops.manifestPath`), + releaseStatePath: safeReleaseStatePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`), + }; + }); +} + function removeFile(path) { try { unlinkSync(path); @@ -109,7 +136,7 @@ function readReleaseState(path, serviceRef) { throw error; } if (value.kind !== "UniDeskHostReleaseState") throw new Error(`${path}.kind must be UniDeskHostReleaseState`); - if (value.serviceRef !== serviceRef) throw new Error(`${path}.serviceRef must match delivery.serviceRef`); + if (value.serviceRef !== serviceRef) throw new Error(`${path}.serviceRef must match ${serviceRef}`); const sourceCommit = required(value.sourceCommit, `${path}.sourceCommit`); const digest = required(value.digest, `${path}.digest`); const digestRef = required(value.digestRef, `${path}.digestRef`); @@ -121,7 +148,7 @@ function readReleaseState(path, serviceRef) { function main() { const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml"); const sourceRoot = resolve(option("--source-root") ?? process.cwd()); -const metadataPath = resolve(required(option("--metadata"), "--metadata")); +const metadataDir = resolve(required(option("--metadata"), "--metadata")); const releaseDir = resolve(required(option("--release-dir"), "--release-dir")); const sourceCommit = required(option("--source-commit"), "--source-commit"); const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops"); @@ -131,46 +158,12 @@ const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPa const delivery = record(config.delivery, "delivery"); if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean"); const enabled = delivery.enabled === true; -const action = releaseValue(releaseDir, "action"); -const reason = releaseValue(releaseDir, "reason"); -const baselineSourceCommit = readFileSync(resolve(releaseDir, "baseline-source-commit"), "utf8").trim() || null; -if (!new Set(["build", "skip", "disabled"]).has(action)) throw new Error(`${releaseDir}/action must be build, skip, or disabled`); -if ((enabled && action === "disabled") || (!enabled && action !== "disabled")) throw new Error("release action does not match delivery.enabled"); -const serviceRef = required(delivery.serviceRef, "delivery.serviceRef"); -const imageConfig = record(delivery.image, "delivery.image"); +const services = deliveryServices(config, delivery); const gitops = record(delivery.gitops, "delivery.gitops"); const author = record(gitops.author, "delivery.gitops.author"); const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl"); const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl"); const branch = required(gitops.branch, "delivery.gitops.branch"); -const manifestPath = safeManifestPath(gitops.manifestPath); -const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath); -const gitopsResources = action === "skip" ? [] : renderGitOpsResources(gitops, sourceRoot); -let digest = null; -let digestRef = null; -let manifest = null; -let runtimeSourceCommit = null; - -if (action === "build") { - const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath); - digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`); - if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("containerimage.digest must be sha256:<64 hex>"); - digestRef = `${required(imageConfig.repository, "delivery.image.repository")}@${digest}`; - runtimeSourceCommit = sourceCommit; - const render = run("bun", [ - "scripts/native/deploy/render-unidesk-host-service.mjs", - "--config", - configPath, - "--service-ref", - serviceRef, - "--image", - digestRef, - "--source-commit", - sourceCommit, - ], sourceRoot); - manifest = render.stdout; - if (!manifest.includes("kind: Deployment") || !manifest.includes("kind: Service")) throw new Error("service renderer did not emit Service and Deployment objects"); -} rmSync(worktree, { recursive: true, force: true }); run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot); @@ -182,53 +175,53 @@ if (fetched) { run("git", ["rm", "-rf", "."], worktree, true); } -const targetPath = resolve(worktree, manifestPath); -const statePath = resolve(worktree, releaseStatePath); -if (!targetPath.startsWith(`${worktree}/`)) throw new Error("resolved manifest path escaped the GitOps worktree"); -if (!statePath.startsWith(`${worktree}/`)) throw new Error("resolved release state path escaped the GitOps worktree"); -const existingState = readReleaseState(statePath, serviceRef); -if (action === "skip") { - if (existingState === null) throw new Error("skip requires an existing GitOps release state"); - digest = existingState.digest; - digestRef = existingState.digestRef; - runtimeSourceCommit = existingState.sourceCommit; - const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim(); - process.stdout.write(`${JSON.stringify({ - ok: true, - phase: "gitops-publish", - action, - reason, - status: "skipped", - imageStatus: "skipped", - sourceCommit, - baselineSourceCommit, - runtimeSourceCommit, - digest, - digestRef, - gitopsCommit, - resources: [], - changed: false, - pushAttempts: 0, - valuesPrinted: false, - })}\n`); - return; -} else if (action === "build" && manifest !== null) { - mkdirSync(dirname(targetPath), { recursive: true }); - writeFileSync(targetPath, manifest, "utf8"); - mkdirSync(dirname(statePath), { recursive: true }); - writeFileSync(statePath, `${JSON.stringify({ - version: 1, - kind: "UniDeskHostReleaseState", - serviceRef, - sourceCommit, - digest, - digestRef, - }, null, 2)}\n`, "utf8"); -} else { - removeFile(targetPath); - removeFile(statePath); +const serviceResults = []; +for (const service of services) { + const serviceDir = resolve(releaseDir, "services", service.id); + const action = releaseValue(serviceDir, "action"); + const reason = releaseValue(serviceDir, "reason"); + const baselineSourceCommit = readFileSync(resolve(serviceDir, "baseline-source-commit"), "utf8").trim() || null; + if (!new Set(["build", "skip", "disabled"]).has(action)) throw new Error(`${serviceDir}/action must be build, skip, or disabled`); + if ((enabled && action === "disabled") || (!enabled && action !== "disabled")) throw new Error(`${service.id} release action does not match delivery.enabled`); + const targetPath = resolve(worktree, service.manifestPath); + const statePath = resolve(worktree, service.releaseStatePath); + if (!targetPath.startsWith(`${worktree}/`) || !statePath.startsWith(`${worktree}/`)) throw new Error(`${service.id} resolved path escaped the GitOps worktree`); + const existingState = readReleaseState(statePath, service.serviceRef); + let digest = null; + let digestRef = null; + let runtimeSourceCommit = null; + if (action === "build") { + const metadataPath = resolve(metadataDir, `${service.id}.json`); + const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath); + digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`); + if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error(`${service.id} digest must be sha256:<64 hex>`); + digestRef = `${service.imageRepository}@${digest}`; + runtimeSourceCommit = sourceCommit; + const render = run("bun", [ + "scripts/native/deploy/render-unidesk-host-service.mjs", + "--config", configPath, + "--service-ref", service.serviceRef, + "--image", digestRef, + "--source-commit", sourceCommit, + ], sourceRoot); + if (!render.stdout.includes("kind: Deployment") || !render.stdout.includes("kind: Service")) throw new Error(`${service.id} renderer did not emit Service and Deployment objects`); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, render.stdout, "utf8"); + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, `${JSON.stringify({ version: 1, kind: "UniDeskHostReleaseState", serviceRef: service.serviceRef, sourceCommit, digest, digestRef }, null, 2)}\n`, "utf8"); + } else if (action === "skip") { + if (existingState === null) throw new Error(`${service.id} skip requires an existing GitOps release state`); + ({ digest, digestRef, sourceCommit: runtimeSourceCommit } = existingState); + } else { + removeFile(targetPath); + removeFile(statePath); + } + serviceResults.push({ id: service.id, serviceRef: service.serviceRef, action, reason, baselineSourceCommit, runtimeSourceCommit, digest, digestRef, manifestPath: service.manifestPath, releaseStatePath: service.releaseStatePath }); } +const action = serviceResults.some((item) => item.action === "build") ? "build" : enabled ? "skip" : "disabled"; +const gitopsResources = action === "skip" ? [] : renderGitOpsResources(gitops, sourceRoot); + for (const resource of gitopsResources) { const resourcePath = resolve(worktree, resource.path); if (!resourcePath.startsWith(`${worktree}/`)) throw new Error(`resolved resource path escaped the GitOps worktree: ${resource.id}`); @@ -246,7 +239,8 @@ run("git", ["add", "-A"], worktree); const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0; if (changed) { const commitAction = action === "disabled" ? "remove" : "deploy"; - run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree); + const changedServices = serviceResults.filter((item) => item.action !== "skip").map((item) => item.id).join(","); + run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${changedServices} ${sourceCommit.slice(0, 12)}`], worktree); } run("git", ["remote", "set-url", "origin", writeUrl], worktree); @@ -276,15 +270,15 @@ process.stdout.write(`${JSON.stringify({ ok: true, phase: "gitops-publish", action, - reason, status: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled", imageStatus: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled", sourceCommit, - baselineSourceCommit, - runtimeSourceCommit, - digest, - digestRef, gitopsCommit, + affectedServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id), + buildServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id), + rolloutServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id), + reusedServices: serviceResults.filter((item) => item.action === "skip").map((item) => item.id), + services: serviceResults, resources: gitopsResources.map((resource) => ({ id: resource.id, path: resource.path })), changed, pushAttempts, diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.test.ts b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts index 98c66a9d..5fb5def3 100644 --- a/scripts/native/cicd/publish-unidesk-host-gitops.test.ts +++ b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts @@ -47,21 +47,29 @@ describe("UniDesk Host GitOps publisher", () => { git(["remote", "add", "origin", `file://${bare}`], seed); git(["push", "origin", "unidesk-host-gitops"], seed); - mkdirSync(releaseDir); - writeFileSync(join(releaseDir, "action"), "skip\n"); - writeFileSync(join(releaseDir, "reason"), "runtime-inputs-unchanged\n"); - writeFileSync(join(releaseDir, "baseline-source-commit"), `${runtimeSourceCommit}\n`); + const serviceReleaseDir = join(releaseDir, "services", "todo-note"); + mkdirSync(serviceReleaseDir, { recursive: true }); + writeFileSync(join(serviceReleaseDir, "action"), "skip\n"); + writeFileSync(join(serviceReleaseDir, "reason"), "runtime-inputs-unchanged\n"); + writeFileSync(join(serviceReleaseDir, "baseline-source-commit"), `${runtimeSourceCommit}\n`); writeFileSync(configPath, Bun.YAML.stringify({ + services: { todoNote: {} }, delivery: { enabled: true, - serviceRef: "services.todoNote", - image: { repository: "registry.example/todo-note" }, + services: [{ + id: "todo-note", + serviceRef: "services.todoNote", + runtimePaths: ["src/components/microservices/todo-note/src"], + image: { repository: "registry.example/todo-note" }, + gitops: { + manifestPath: "deploy/gitops/unidesk-host/todo-note.yaml", + releaseStatePath: statePath, + }, + }], gitops: { readUrl: `file://${bare}`, writeUrl: `file://${bare}`, branch: "unidesk-host-gitops", - manifestPath: "deploy/gitops/unidesk-host/todo-note.yaml", - releaseStatePath: statePath, resources: [{ id: "hwlab-runtime-gitops-scripts", renderer: "hwlab-runtime-gitops-scripts", @@ -78,7 +86,7 @@ describe("UniDesk Host GitOps publisher", () => { "scripts/native/cicd/publish-unidesk-host-gitops.mjs", "--config", configPath, "--source-root", rootPath(), - "--metadata", join(root, "unused-metadata.json"), + "--metadata", join(root, "unused-metadata"), "--release-dir", releaseDir, "--source-commit", sourceCommit, "--worktree", publisherWorktree, diff --git a/scripts/native/deploy/render-unidesk-host-k8s.mjs b/scripts/native/deploy/render-unidesk-host-k8s.mjs index ef002c42..510329a2 100755 --- a/scripts/native/deploy/render-unidesk-host-k8s.mjs +++ b/scripts/native/deploy/render-unidesk-host-k8s.mjs @@ -154,7 +154,14 @@ 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"); +const deliveryServiceRefs = new Set(); +if (delivery !== null) { + if (!Array.isArray(delivery.services) || delivery.services.length === 0) throw new Error("delivery.services must be a non-empty array"); + for (const [index, value] of delivery.services.entries()) { + const item = record(value, `delivery.services[${index}]`); + deliveryServiceRefs.add(required(item.serviceRef, `delivery.services[${index}].serviceRef`)); + } +} if (delivery !== null && delivery.enabled !== true && delivery.enabled !== false) { throw new Error("delivery.enabled must be boolean"); } @@ -668,7 +675,7 @@ function managedStorageEnv(envPrefix, storage) { function appendManagedServiceDeployment({ service, servicePath, serviceId, imageKey, envPrefix, databaseConfig, storageConfig }) { if (service === null) return; -if (deliveryServiceRef === servicePath) return; +if (deliveryServiceRefs.has(servicePath)) return; if (databaseConfig === null) throw new Error(`${servicePath}.database is required`); const github = storageConfig?.github; docs.push(`apiVersion: v1 diff --git a/scripts/native/deploy/render-unidesk-host-service.mjs b/scripts/native/deploy/render-unidesk-host-service.mjs index 653671c7..b334d57c 100755 --- a/scripts/native/deploy/render-unidesk-host-service.mjs +++ b/scripts/native/deploy/render-unidesk-host-service.mjs @@ -64,8 +64,8 @@ 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 reminder = service.reminder === undefined ? null : record(service.reminder, `${serviceRef}.reminder`); +const reminderTransport = reminder === null ? null : 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`); @@ -80,14 +80,6 @@ const containerPort = positiveInteger(service.containerPort, `${serviceRef}.cont 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 = { @@ -109,17 +101,30 @@ const env = [ { 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`)) }, ]; +if (reminder !== null && reminderTransport !== null) { + 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`); + env.push( + { 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) { diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index 00e9525f..5e50f91c 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -412,7 +412,11 @@ describe("HWLAB YAML-owned Pipeline provenance", () => { "runtime-gitops-verify.mjs", ]); const hostConfig = Bun.YAML.parse(readFileSync(rootPath("config", "unidesk-host-k8s.yaml"), "utf8")) as Record; - const runtimePaths = new Set(hostConfig.delivery.changeDetection.runtimePaths as string[]); + const runtimePaths = new Set([ + ...(hostConfig.delivery.changeDetection.sharedRuntimePaths as string[]), + ...(hostConfig.delivery.services as Array>) + .flatMap((service) => service.runtimePaths as string[]), + ]); for (const inputPath of [ "bun.lock", "config/hwlab-node-lanes.yaml",