fix: 补齐 Decision Center 自动交付
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/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 不计入。
|
||||
@@ -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)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user