fix: 补齐 Decision Center 自动交付

This commit is contained in:
Codex
2026-07-16 07:18:10 +02:00
parent 3262082707
commit 1ccb8c0839
11 changed files with 367 additions and 241 deletions
+2 -2
View File
@@ -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
+31 -11
View File
@@ -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:
@@ -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/DeploymentDecision 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 不计入。
@@ -22,6 +22,9 @@
#### R1.2.2 [in_progress]
完成 [UniDesk #1710](https://github.com/pikasTech/unidesk/issues/1710) 的 Decision Center 子进程泄露与 GC 诊断收敛:修复周期性 Git 子进程未回收并把远程同步移出健康轮询;增强 `gc remote <node> 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)。
+47 -35
View File
@@ -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"
@@ -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();
@@ -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,
@@ -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,
@@ -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
@@ -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) {
@@ -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<string, any>;
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<Record<string, any>>)
.flatMap((service) => service.runtimePaths as string[]),
]);
for (const inputPath of [
"bun.lock",
"config/hwlab-node-lanes.yaml",