feat: add JD01 YAML-first deployment support

This commit is contained in:
Codex
2026-06-29 08:13:34 +00:00
parent fe917bec4a
commit 076c4b643d
49 changed files with 10909 additions and 5223 deletions
+1
View File
@@ -26,6 +26,7 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/
- 源码、配置、部署类正规化默认在独立 `.worktree/<task>` 中做;轻量 skill/docs/reference 收敛可按项目规则直接在主 worktree 做。
- YAML 是 source of truth。不得新增隐藏代码默认值、schema 数值硬限制、合同测试或测试硬编码策略。
- 代码校验只保证字段能被正确读取和渲染:类型、必填、枚举键名、引用存在性。版本号、namespace、endpoint、容量、冷却时间、回退窗口等数值以 YAML 为准。
- YAML 文件名、YAML 解析函数名和 YAML 渲染函数名不得携带具体 node/lane 名称或版本实例(例如 `JD01``D601``v03``jd01-v03`)。node/lane/version 只能作为 YAML 变量、selector、target key、template value 或 CLI 参数参与渲染;可复用文件和代码入口必须按职责命名。
- 避免“超级配置”。当一个能力同时涉及 target/lane、runtime、scenario、prompt、report、publicExposure、Secret、CI/CD 等不同职责时,按职责拆分到 owning YAMLroot YAML 只保存归属和 `configRefs`/path 引用,不承载全部细节。
- 跨 YAML 引用应使用稳定的 `path/to/file.yaml#object.path` 或当前 domain parser 明确支持的等价语法。parser 只解析引用、校验存在性/类型/形状和冲突,不生成隐藏默认值,也不把合并后的大对象写成新的 source of truth。
- CLI `plan/status` 应输出 redacted 配置引用图:每个 ref 的文件、path、presence、摘要 hash、缺失字段和下一步 drill-down 命令。不要默认 dump 展开后的完整 YAML 或 Secret。
+262
View File
@@ -64,6 +64,9 @@ controlPlane:
D518:
route: D518
kubeRoute: D518:k3s
JD01:
route: JD01
kubeRoute: JD01:k3s
lanes:
v01:
@@ -212,6 +215,10 @@ controlPlane:
image: postgres:16-alpine
storage: 5Gi
port: 5432
database: agentrun_v01
user: agentrun_v01
passwordSourceRef: agentrun/v01-local-postgres.env
passwordSourceKey: POSTGRES_PASSWORD
gitMirror:
namespace: devops-infra
readService: git-mirror-http
@@ -557,6 +564,261 @@ controlPlane:
name: agentrun-v01-tool-unidesk-ssh
key: UNIDESK_SSH_CLIENT_TOKEN
jd01-v02:
node: JD01
version: v0.2
source:
statusMode: k3s-git-mirror
repository: pikasTech/agentrun
branch: v0.2
bootstrapFromBranch: v0.1
bootstrapTimeoutSeconds: 900
bootstrapPollSeconds: 15
remote: git@github.com:pikasTech/agentrun.git
workspace: /root/workspace/agentrun-v02
runtime:
namespace: agentrun-v02
managerDeployment: agentrun-mgr
managerService: agentrun-mgr
managerPort: 8080
internalBaseUrl: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080
ci:
namespace: agentrun-ci
pipeline: agentrun-jd01-v02-ci-image-publish
pipelineRunPrefix: agentrun-jd01-v02-ci
serviceAccountName: agentrun-jd01-v02-tekton-runner
registryPrefix: 127.0.0.1:5000/agentrun
toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless
gitops:
branch: jd01-v0.2-gitops
path: deploy/gitops/node/jd01/runtime-v02
argoNamespace: argocd
argoApplication: agentrun-jd01-v02
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
deployment:
format: unidesk-yaml-only
gitopsRoot: deploy/gitops/node/jd01
runtimeRenderDir: runtime-v02
artifactCatalogPath: deploy/artifact-catalog.jd01-v02.json
argocd:
project: agentrun-jd01-v02
applicationFile: application-v02.yaml
manager:
serviceAccount: agentrun-jd01-v02-mgr
apiKeySecretRef:
name: agentrun-v02-api-key
key: HWLAB_API_KEY
env:
AGENTRUN_POSTGRES_POOL_MAX: "4"
AGENTRUN_MANAGER_RECONCILER_BATCH_SIZE: "20"
AGENTRUN_MANAGER_RECONCILER_ENABLED: "true"
AGENTRUN_MANAGER_RECONCILER_INTERVAL_MS: "30000"
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces
OTEL_SERVICE_NAME: agentrun-manager
UNIDESK_NODE_ID: JD01
HWLAB_RUNTIME_LANE: v0.3
unideskSshEndpointEnv:
name: UNIDESK_MAIN_SERVER_IP
value: 74.48.78.17
bootRepoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
imageBuild:
context: .
containerfile: deploy/container/Containerfile
repository: agentrun-mgr-env
network: host
buildArgs:
BUN_IMAGE: oven/bun:1-alpine
httpProxy: http://127.0.0.1:10808
httpsProxy: http://127.0.0.1:10808
noProxy:
- localhost
- 127.0.0.1
- ::1
- 127.0.0.1:5000
- localhost:5000
- .svc
- .svc.cluster.local
- .cluster.local
- hyueapi.com
- .hyueapi.com
buildContainerProxy:
httpProxy: http://127.0.0.1:10808
httpsProxy: http://127.0.0.1:10808
noProxy:
- localhost
- 127.0.0.1
- ::1
- 127.0.0.1:5000
- localhost:5000
- .svc
- .svc.cluster.local
- .cluster.local
- hyueapi.com
- .hyueapi.com
envIdentityFiles:
- deploy/container/Containerfile
- deploy/runtime/boot/agentrun-boot.sh
- deploy/runtime/boot/agentrun-mgr.sh
- deploy/runtime/boot/agentrun-runner.sh
- src
- scripts
- package.json
- bun.lock
- tsconfig.json
timeoutSeconds: 1800
pollSeconds: 15
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 800m
memory: 1Gi
runner:
serviceAccount: agentrun-jd01-v02-runner
jobNamePrefix: agentrun-jd01-v02-runner
idleTimeoutMs: 600000
backendRetry:
maxAttempts: 5
initialBackoffMs: 1000
maxBackoffMs: 30000
apiKeySecretRef:
name: agentrun-v02-api-key
key: HWLAB_API_KEY
egressProxyUrl: http://127.0.0.1:10808
noProxyExtra:
- localhost
- 127.0.0.1
- ::1
- .svc
- .svc.cluster.local
- .cluster.local
- hyueapi.com
- .hyueapi.com
retention:
maxRunners: 20
cleanupOrder: oldest-inactive-last-active-first
activeHeartbeatMaxAgeMs: 900000
selectors:
matchLabels:
app.kubernetes.io/part-of: agentrun
app.kubernetes.io/name: agentrun-runner
app.kubernetes.io/component: runner
jobNamePrefixes:
- agentrun-jd01-v02-runner
- agentrun-v02-runner
- agentrun-v01-runner
ageBasedCleanup:
enabled: false
maxAgeHours: 48
cancelLifecycle:
deliveryMode: manager-epoch
gracefulAbortMs: 15000
killEscalationMs: 30000
staleHeartbeatFencingMs: 900000
lateWriteFencing:
enabled: true
eventStages:
- accepted
- persisted
- delivered
- aborting
- terminalized
- fenced
- late-write-rejected
localPostgres:
enabled: true
serviceName: agentrun-v02-postgres
image: postgres:16-alpine
storage: 5Gi
port: 5432
database: agentrun_v02
user: agentrun_v02
passwordSourceRef: agentrun/jd01-v02-local-postgres.env
passwordSourceKey: POSTGRES_PASSWORD
gitMirror:
namespace: devops-infra
readService: git-mirror-http
readDeployment: git-mirror-http
writeService: git-mirror-write
writeDeployment: git-mirror-write
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
cachePvc: hwlab-git-mirror-cache
cacheHostPath: /var/lib/rancher/k3s/storage/hwlab-jd01-v03-git-mirror-cache
sshSecretName: git-mirror-github-ssh
githubProxy:
host: 127.0.0.1
port: 10808
toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
syncJobPrefix: git-mirror-agentrun-jd01-v02-sync-manual
flushJobPrefix: git-mirror-agentrun-jd01-v02-flush-manual
repositories:
- key: agentrun
repository: pikasTech/agentrun
sourceBranch: v0.2
gitopsBranch: jd01-v0.2-gitops
- key: unidesk
repository: pikasTech/unidesk
sourceBranch: master
- key: agent_skills
repository: pikasTech/agent_skills
sourceBranch: master
database:
mode: local-postgres
secretRef:
name: agentrun-v02-mgr-db
key: DATABASE_URL
localPostgresExpectedAbsent: false
secrets:
- id: manager-api-key
sourceRef: /root/.config/hwlab-v03/master-server-admin-api-key.env
sourceKey: HWLAB_API_KEY
targetRef:
namespace: agentrun-v02
name: agentrun-v02-api-key
key: HWLAB_API_KEY
- id: runner-api-key-legacy-name
sourceRef: /root/.config/hwlab-v03/master-server-admin-api-key.env
sourceKey: HWLAB_API_KEY
targetRef:
namespace: agentrun-v02
name: agentrun-v01-api-key
key: HWLAB_API_KEY
- id: provider-codex-auth-json
sourceMode: file
sourceRef: /root/.codex/auth.json
targetRef:
namespace: agentrun-v02
name: agentrun-v01-provider-codex
key: auth.json
providerCredential:
profile: codex
- id: provider-codex-config
sourceMode: file
sourceRef: agentrun/jd01-v02-provider-codex-config.toml
targetRef:
namespace: agentrun-v02
name: agentrun-v01-provider-codex
key: config.toml
providerCredential:
profile: codex
- id: tool-github-pr-token
sourceRef: /root/.config/unidesk/github.env
sourceKey: GH_TOKEN
targetRef:
namespace: agentrun-v02
name: agentrun-v01-tool-github-pr
key: GH_TOKEN
- id: tool-unidesk-ssh-token
sourceRef: /root/unidesk/.state/docker-compose.env
sourceKey: UNIDESK_SSH_CLIENT_TOKEN
targetRef:
namespace: agentrun-v02
name: agentrun-v01-tool-unidesk-ssh
key: UNIDESK_SSH_CLIENT_TOKEN
d518-v02:
node: D518
version: v0.2
+8
View File
@@ -49,6 +49,14 @@ targets:
mode: direct
identities:
- github.com
JD01:
providerId: JD01
route: JD01
homeDir: /root
egress:
mode: direct
identities:
- github.com
G14:
providerId: G14
route: G14
+367
View File
@@ -8,6 +8,7 @@ metadata:
- 1010
- 1119
- 1148
- 1234
imagePolicy:
requireReproducibleBuildSource: true
forbidPrivateOrNodeLocalImagesAsInputs: true
@@ -152,6 +153,104 @@ nodes:
- 74.48.78.17
- hyueapi.com
- .hyueapi.com
JD01:
route: JD01
kubeRoute: JD01:k3s
k3s:
serviceName: k3s
dropInPath: /etc/systemd/system/k3s.service.d/20-unidesk-node-config.conf
nodeStatusName: jd01
execStartPre: []
install:
enabled: true
channel: stable
version: v1.36.2+k3s1
installScriptUrl: https://get.k3s.io
binaryUrl: https://github.com/k3s-io/k3s/releases/download/v1.36.2%2Bk3s1/k3s
sha256Url: https://github.com/k3s-io/k3s/releases/download/v1.36.2%2Bk3s1/sha256sum-amd64.txt
expectedSha256: 65a55ec56c24eab44383086166ec620a491952b7e23941a49ddca6e8a4c4b4de
hostProxyConfigRef: config/platform-infra/host-proxy.yaml#targets.JD01
proxyEnvPath: /etc/unidesk/proxy.env
registriesYamlPath: /etc/rancher/k3s/registries.yaml
localRegistry:
containerName: registry
image: docker.m.daocloud.io/library/registry:2
canonicalImage: registry:2
bind: 127.0.0.1:5000:5000
state:
dir: /root/.unidesk/k3s-install
logPath: /root/.unidesk/k3s-install/install.log
statusPath: /root/.unidesk/k3s-install/status.json
downloads:
connectTimeoutSeconds: 15
maxTimeSeconds: 1200
retry: 8
retryDelaySeconds: 5
serverArgs:
- server
- --disable
- traefik
- --disable
- servicelb
- --disable
- metrics-server
- --node-name
- JD01
- --node-label
- unidesk.ai/node-id=JD01
- --node-label
- unidesk.ai/provider-id=JD01
- --tls-san
- 127.0.0.1
- --tls-san
- host.docker.internal
- --write-kubeconfig-mode
- "644"
- --kubelet-arg
- image-gc-high-threshold=95
- --kubelet-arg
- image-gc-low-threshold=90
- --kubelet-arg
- max-pods=500
kubelet:
maxPods: 500
registry:
endpoint: 127.0.0.1:5000
egressProxy:
mode: host-route
clientName: jd01-host-proxy
hostProxyConfigRef: config/platform-infra/host-proxy.yaml#targets.JD01
proxyEnvPath: /etc/unidesk/proxy.env
proxyUrl: http://10.42.0.1:10808
noProxy:
- localhost
- 127.0.0.1
- ::1
- 127.0.0.1:5000
- localhost:5000
- .svc
- .svc.cluster.local
- .cluster.local
- kubernetes
- kubernetes.default
- kubernetes.default.svc
- argocd-repo-server
- argocd-repo-server.argocd
- argocd-redis
- argocd-redis.argocd
- git-mirror-http
- git-mirror-http.devops-infra
- git-mirror-write
- git-mirror-write.devops-infra
- 10.0.0.0/8
- 10.42.0.0/16
- 10.43.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- 82.156.23.220
- 74.48.78.17
- hyueapi.com
- .hyueapi.com
targets:
- id: d601-v03
@@ -174,6 +273,8 @@ targets:
cachePvcStorage: 20Gi
cacheHostPath: /var/lib/rancher/k3s/storage/hwlab-d601-v03-git-mirror-cache
servicePort: 8080
readContainerPort: 8080
writeContainerPort: 8080
deploymentReplicas: 1
secretName: git-mirror-github-ssh
syncConfigMapName: git-mirror-sync-script
@@ -184,6 +285,8 @@ targets:
egressProxy:
mode: node-global
required: true
podHostNetwork: false
injectPodEnv: false
githubTransport:
mode: ssh
privateKeySecretKey: ssh-privatekey
@@ -195,6 +298,29 @@ targets:
knownHostsSourceKey: GITHUB_KNOWN_HOSTS_B64
knownHostsSourceEncoding: base64
tekton:
install:
enabled: true
sourceKind: url
version: pipeline-v1.12.0-triggers-v0.34.0
fieldManager: unidesk-hwlab-node-tekton
manifests:
- name: pipeline
url: https://infra.tekton.dev/tekton-releases/pipeline/previous/v1.12.0/release.yaml
- name: triggers
url: https://infra.tekton.dev/tekton-releases/triggers/previous/v0.34.0/release.yaml
- name: triggers-interceptors
url: https://infra.tekton.dev/tekton-releases/triggers/previous/v0.34.0/interceptors.yaml
requiredCrds:
- pipelines.tekton.dev
- pipelineruns.tekton.dev
- tasks.tekton.dev
- taskruns.tekton.dev
expectedDeploymentNamespaces:
- tekton-pipelines
- tekton-pipelines-resolvers
readinessTimeoutSeconds: 900
runtimeProxy:
enabled: false
pipelineName: hwlab-d601-v03-ci-image-publish
serviceAccountName: hwlab-d601-v03-tekton-runner
pipelineRunPrefix: hwlab-d601-v03-ci-poll
@@ -319,6 +445,218 @@ targets:
expectedStatefulSets:
- argocd-application-controller
readinessTimeoutSeconds: 600
runtimeProxy:
enabled: false
- id: jd01-v03
node: JD01
lane: v03
enabled: true
ciNamespace: hwlab-ci
runtimeNamespace: hwlab-v03
source:
repository: pikasTech/HWLAB
branch: v0.3
gitops:
branch: v0.3-gitops
path: deploy/gitops/node/jd01/runtime-v03
gitMirror:
namespace: devops-infra
serviceReadName: git-mirror-http
serviceWriteName: git-mirror-write
cachePvcName: hwlab-git-mirror-cache
cachePvcStorage: 20Gi
cacheHostPath: /var/lib/rancher/k3s/storage/hwlab-jd01-v03-git-mirror-cache
servicePort: 8080
readContainerPort: 8080
writeContainerPort: 8081
deploymentReplicas: 1
secretName: git-mirror-github-ssh
syncConfigMapName: git-mirror-sync-script
syncJobPrefix: git-mirror-hwlab-jd01-v03-sync-manual
flushJobPrefix: git-mirror-hwlab-jd01-v03-flush-manual
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
egressProxy:
mode: host-route
required: true
podHostNetwork: false
injectPodEnv: true
githubTransport:
mode: ssh
privateKeySecretKey: ssh-privatekey
privateKeySourceRef: github/hwlab-git-mirror-ssh.env
privateKeySourceKey: GITHUB_SSH_PRIVATE_KEY_B64
privateKeySourceEncoding: base64
knownHostsSecretKey: known_hosts
knownHostsSourceRef: github/hwlab-git-mirror-ssh.env
knownHostsSourceKey: GITHUB_KNOWN_HOSTS_B64
knownHostsSourceEncoding: base64
tekton:
install:
enabled: true
sourceKind: url
version: pipeline-v1.12.0-triggers-v0.34.0
fieldManager: unidesk-hwlab-node-tekton
manifests:
- name: pipeline
url: https://infra.tekton.dev/tekton-releases/pipeline/previous/v1.12.0/release.yaml
- name: triggers
url: https://infra.tekton.dev/tekton-releases/triggers/previous/v0.34.0/release.yaml
- name: triggers-interceptors
url: https://infra.tekton.dev/tekton-releases/triggers/previous/v0.34.0/interceptors.yaml
requiredCrds:
- pipelines.tekton.dev
- pipelineruns.tekton.dev
- tasks.tekton.dev
- taskruns.tekton.dev
expectedDeploymentNamespaces:
- tekton-pipelines
- tekton-pipelines-resolvers
readinessTimeoutSeconds: 900
runtimeProxy:
enabled: false
pipelineName: hwlab-jd01-v03-ci-image-publish
serviceAccountName: hwlab-jd01-v03-tekton-runner
pipelineRunPrefix: hwlab-jd01-v03-ci-poll
gitWorkspaceSecret:
name: hwlab-git-ssh
namespace: hwlab-ci
sourceRefFrom: gitMirror.githubTransport
privateKeySecretKey: ssh-privatekey
knownHostsSecretKey: known_hosts
runtimeObserverRbac:
namespace: hwlab-v03
roleName: hwlab-jd01-v03-runtime-observer
roleBindingName: hwlab-jd01-v03-runtime-observer
argoObserverRbac:
namespace: argocd
roleName: hwlab-jd01-v03-argo-observer
roleBindingName: hwlab-jd01-v03-argo-observer
toolsImage:
output: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
imagePullPolicy: Always
sourceKind: dockerfile
context: .
dockerfileInline:
filename: hwlab-ci-node-tools.public.Dockerfile
lines:
- FROM docker.io/oven/bun:1.3.13 AS bun-runtime
- FROM docker.io/docker:29-cli AS docker-cli
- FROM docker.io/library/golang:1.24-bookworm AS golang-toolchain
- FROM docker.io/library/node:22-bookworm-slim AS node-runtime
- FROM docker.io/library/python:3.12-bookworm
- ARG HTTP_PROXY
- ARG HTTPS_PROXY
- ARG ALL_PROXY
- ARG NO_PROXY
- ARG http_proxy
- ARG https_proxy
- ARG all_proxy
- ARG no_proxy
- COPY --from=golang-toolchain /usr/local/go /usr/local/go
- COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
- COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules
- COPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun
- COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
- ENV PATH=/usr/local/go/bin:$PATH
- RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
- RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
- ENV HWLAB_CI_NODE_DEPS=/opt/hwlab-ci-node-deps/node_modules
- RUN set -eu; export HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}"; export HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$HTTP_PROXY}}"; export ALL_PROXY="${ALL_PROXY:-${all_proxy:-}}"; export NO_PROXY="${NO_PROXY:-${no_proxy:-}}"; export http_proxy="$HTTP_PROXY"; export https_proxy="$HTTPS_PROXY"; export all_proxy="$ALL_PROXY"; export no_proxy="$NO_PROXY"; export npm_config_registry="https://registry.npmmirror.com/"; export BUN_CONFIG_REGISTRY="https://registry.npmmirror.com/"; export npm_config_noproxy="$NO_PROXY"; if [ -n "$HTTP_PROXY" ]; then export npm_config_proxy="$HTTP_PROXY"; fi; if [ -n "$HTTPS_PROXY" ]; then export npm_config_https_proxy="$HTTPS_PROXY"; fi; export npm_config_fetch_retries=2; export npm_config_fetch_retry_mintimeout=2000; export npm_config_fetch_retry_maxtimeout=16000; export npm_config_fetch_timeout=120000; proxy_label="${HTTP_PROXY:+HTTP_PROXY}"; proxy_label="${proxy_label:-none}"; mkdir -p /opt/hwlab-ci-node-deps; cd /opt/hwlab-ci-node-deps; printf '{"private":true,"dependencies":{}}\n' > package.json; ok=0; delay=2; for attempt in 1 2 3 4 5; do echo "{\"event\":\"tools-yaml-node-npm-install\",\"attempt\":\"$attempt/5\",\"registry\":\"$npm_config_registry\",\"proxy\":\"$proxy_label\"}" >&2; if timeout 180s npm install --package-lock=false --no-save --ignore-scripts --no-audit --no-fund --omit=dev yaml@2.8.3; then ok=1; break; fi; if [ "$attempt" = 5 ]; then break; fi; echo "{\"event\":\"tools-yaml-node-npm-install\",\"status\":\"retrying\",\"attempt\":\"$attempt/5\",\"sleepSeconds\":$delay}" >&2; sleep "$delay"; delay=$((delay * 2)); done; test "$ok" = 1; node --input-type=module -e 'import("/opt/hwlab-ci-node-deps/node_modules/yaml/browser/dist/index.js").then((yaml)=>console.log("yaml-ok", typeof yaml.parse))'
- RUN node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V && go version
buildArgs: {}
buildNetwork: host
publicBaseImages:
- docker.io/library/node:22-bookworm-slim
- docker.io/library/golang:1.24-bookworm
- docker.io/oven/bun:1.3.13
- docker.io/buildpack-deps:bookworm-scm
- docker.io/library/python:3.12-bookworm
- docker.io/docker:29-cli
buildOwner: JD01
buildMode: node-local
ciBuildBenchmarks:
- profile: no-mirror-full
runtimeLaneConfigRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01
pipelineRunPrefix: hwlab-jd01-v03-ci-bench
catalogPathTemplate: .unidesk/ci-build-benchmark/{profile}/{pipelineRun}/artifact-catalog.json
imageTagMode: full
pipelineTimeoutSeconds: 7200
cachePolicy:
noPipelineRunReuse: true
forceFullBuild: true
forbidGitopsCatalogReuse: true
forbidDependencyCache: true
forbidBuildkitCache: true
forbidRegistryMirror: true
forbidLocalPreheatedImages: true
timings:
requiredStages:
- source-fetch
- dependency-install
- base-image-pull
- service-image-build
- registry-push
- pipeline-total
failureFamilies:
- dns
- proxy-connect
- tls-timeout
- rate-limit
- auth
- cache-hit-forbidden
- image-policy
- build-script
- registry-push
argo:
namespace: argocd
projectName: hwlab-jd01
applicationName: hwlab-node-v03
applicationFile: application-v03.yaml
install:
enabled: true
sourceKind: url
version: v3.4.2
manifestUrl: https://raw.githubusercontent.com/argoproj/argo-cd/v3.4.2/manifests/install.yaml
fieldManager: unidesk-hwlab-node-argocd
imagePullPolicy: IfNotPresent
preloadImages:
- 127.0.0.1:5000/hwlab/argocd:v3.4.2
- 127.0.0.1:5000/hwlab/dex:v2.45.0
- 127.0.0.1:5000/hwlab/redis:8.2.3-alpine
imageRewrites:
- source: quay.io/argoproj/argocd:v3.4.2
pullImage: quay.m.daocloud.io/argoproj/argocd:v3.4.2
target: 127.0.0.1:5000/hwlab/argocd:v3.4.2
- source: ghcr.io/dexidp/dex:v2.45.0
pullImage: ghcr.m.daocloud.io/dexidp/dex:v2.45.0
target: 127.0.0.1:5000/hwlab/dex:v2.45.0
- source: public.ecr.aws/docker/library/redis:8.2.3-alpine
pullImage: docker.m.daocloud.io/library/redis:8.2.3-alpine
target: 127.0.0.1:5000/hwlab/redis:8.2.3-alpine
requiredCrds:
- applications.argoproj.io
- appprojects.argoproj.io
expectedDeployments:
- argocd-applicationset-controller
- argocd-dex-server
- argocd-notifications-controller
- argocd-redis
- argocd-repo-server
- argocd-server
expectedStatefulSets:
- argocd-application-controller
readinessTimeoutSeconds: 600
runtimeProxy:
enabled: true
mode: host-route
configRef: nodes.JD01.egressProxy
hostNetwork: false
injectEnv: true
deployments:
- argocd-repo-server
statefulSets:
- argocd-application-controller
- id: d518-v03
node: D518
lane: v03
@@ -339,6 +677,8 @@ targets:
cachePvcStorage: 20Gi
cacheHostPath: /var/lib/rancher/k3s/storage/hwlab-d518-v03-git-mirror-cache
servicePort: 8080
readContainerPort: 8080
writeContainerPort: 8080
deploymentReplicas: 1
secretName: git-mirror-github-ssh
syncConfigMapName: git-mirror-sync-script
@@ -349,6 +689,8 @@ targets:
egressProxy:
mode: node-global
required: true
podHostNetwork: false
injectPodEnv: false
githubTransport:
mode: ssh
privateKeySecretKey: ssh-privatekey
@@ -360,6 +702,29 @@ targets:
knownHostsSourceKey: GITHUB_KNOWN_HOSTS_B64
knownHostsSourceEncoding: base64
tekton:
install:
enabled: true
sourceKind: url
version: pipeline-v1.12.0-triggers-v0.34.0
fieldManager: unidesk-hwlab-node-tekton
manifests:
- name: pipeline
url: https://infra.tekton.dev/tekton-releases/pipeline/previous/v1.12.0/release.yaml
- name: triggers
url: https://infra.tekton.dev/tekton-releases/triggers/previous/v0.34.0/release.yaml
- name: triggers-interceptors
url: https://infra.tekton.dev/tekton-releases/triggers/previous/v0.34.0/interceptors.yaml
requiredCrds:
- pipelines.tekton.dev
- pipelineruns.tekton.dev
- tasks.tekton.dev
- taskruns.tekton.dev
expectedDeploymentNamespaces:
- tekton-pipelines
- tekton-pipelines-resolvers
readinessTimeoutSeconds: 900
runtimeProxy:
enabled: false
pipelineName: hwlab-d518-v03-ci-image-publish
serviceAccountName: hwlab-d518-v03-tekton-runner
pipelineRunPrefix: hwlab-d518-v03-ci-poll
@@ -492,3 +857,5 @@ targets:
expectedStatefulSets:
- argocd-application-controller
readinessTimeoutSeconds: 600
runtimeProxy:
enabled: false
+257 -1
View File
@@ -34,6 +34,13 @@ nodes:
gitopsRoot: deploy/gitops/node
networkProfile: d518-node-ci-egress
downloadProfile: d518-node-default
JD01:
route: JD01
kubeRoute: JD01:k3s
sourceWorkspace: /root/workspace/hwlab-v03
gitopsRoot: deploy/gitops/node
networkProfile: jd01-node-ci-egress
downloadProfile: jd01-node-default
lanes:
v02:
@@ -76,7 +83,7 @@ lanes:
apiUrl: http://74.48.78.17:19667
v03:
node: G14
activeTarget: D518
activeTarget: JD01
minor: 3
version: v0.3
sourceBranch: v0.3
@@ -773,6 +780,198 @@ lanes:
envKey: DATASTORE_URI
authnKey: authn-preshared-key
role: hwlab_d518_v03_app
JD01:
node: JD01
workspace: /root/workspace/hwlab-v03
sourceWorkspace:
requiredCommands:
- git
- node
- npm
- npx
requiredFiles:
- AGENTS.md
- package.json
- package-lock.json
- scripts/src/browser-launcher.mjs
- scripts/web-live-dom-probe.mjs
install:
dependencyCommand: npm ci
browserCommand: PLAYWRIGHT_BROWSERS_PATH=0 npx playwright install chromium
timeoutSeconds: 900
cicdRepo: /root/workspace/hwlab-v03-cicd.git
cicdRepoLock: /tmp/hwlab-v03-cicd-repo.lock
app: hwlab-node-v03
pipeline: hwlab-jd01-v03-ci-image-publish
pipelineRunPrefix: hwlab-jd01-v03-ci-poll
serviceAccountName: hwlab-jd01-v03-tekton-runner
controlPlaneFieldManager: unidesk-hwlab-jd01-v03-control-plane
git:
url: git@github.com:pikasTech/HWLAB.git
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
argo:
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
gitopsBranch: v0.3-gitops
catalogPath: deploy/artifact-catalog.jd01-v03.json
runtime:
path: deploy/gitops/node/jd01/runtime-v03
namespace: hwlab-v03
renderDir: runtime-v03
runtimeStore:
postgres:
mode: local-k3s
secretName: hwlab-v03-postgres
statefulSet: hwlab-v03-postgres
serviceName: hwlab-v03-postgres
adminUser: hwlab_v03
adminPasswordSourceRef: hwlab/jd01-v03-postgres.env
adminPasswordSourceKey: HWLAB_V03_POSTGRES_PASSWORD
cloudApi:
secretName: hwlab-cloud-api-v03-db
secretKey: database-url
database: hwlab_v03
role: hwlab_v03
openfga:
secretName: hwlab-v03-openfga
secretKey: datastore-uri
authnKey: authn-preshared-key
postgresPasswordKey: postgres-password
database: hwlab_openfga
role: hwlab_openfga
poolMax: 16
connectionTimeoutMs: 5000
queryRetryMaxAttempts: 5
queryRetryInitialDelayMs: 250
queryRetryMaxDelayMs: 5000
webProbe:
browserProxyMode: direct
defaultOrigin:
mode: public
baseUrl: https://hwlab.pikapython.com
authLogin:
maxAttempts: 6
requestTimeoutMs: 30000
initialDelayMs: 500
maxDelayMs: 10000
alertThresholds:
sameOriginApiSlowMs: 10000
partialApiSlowMs: 10000
longLivedStreamOpenSlowMs: 10000
visibleLoadingSlowMs: 10000
turnTimingSampleSlackSeconds: 3
turnElapsedSevereTimeoutSeconds: 120
uncommandedStateChangeCommandWindowMs: 10000
scrollJumpCommandWindowMs: 8000
scrollJumpFromY: 250
scrollJumpToY: 40
sessionRailFallbackRatio: 0.5
tektonDir: tekton-v03
argoApplicationFile: application-v03.yaml
registryPrefix: 127.0.0.1:5000/hwlab
baseImage: 127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim
baseImageSource: node:20-bookworm-slim
serviceIds:
- hwlab-cloud-api
- hwlab-workbench-runtime
- hwlab-user-billing
- hwlab-project-management
- hwlab-cloud-web
- hwlab-gateway
- hwlab-edge-proxy
- hwlab-agent-skills
buildkit:
sidecarImage: 127.0.0.1:5000/hwlab/buildkit:rootless
sourceImage: docker.io/moby/buildkit:rootless
stepEnv:
HOME: /tekton/home
XDG_CONFIG_HOME: /tekton/home/.config
observability:
prometheusOperator: false
webProbe:
sentinels:
- id: jd01-web-probe-sentinel
enabled: true
configRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.sentinel
runtimeImageRewrites:
- source: postgres:16-alpine
target: 127.0.0.1:5000/hwlab/postgres:16-alpine
- source: fatedier/frpc:v0.68.1
target: 127.0.0.1:5000/hwlab/frpc:v0.68.1
- source: openfga/openfga:v1.17.0
target: 127.0.0.1:5000/hwlab/openfga:v1.17.0
runtimeImageBuilds:
- id: moonbridge
kind: moonbridge
target: 127.0.0.1:5000/hwlab/moonbridge:1b99888d3dae
sourceRepo: https://github.com/ZhiYi-R/moon-bridge.git
sourceRef: 1b99888d3dae889b79ee602cb875c7907f7e76f2
builderImage: golang:1.25-bookworm
goProxy: https://goproxy.cn,direct
dockerNetworkMode: host
public:
webUrl: https://hwlab.pikapython.com
apiUrl: https://hwlab.pikapython.com
bootstrapAdmin:
username: admin
displayName: HWLAB v0.3 Admin
usernameSourceRef: .env/HWLAB_admin.txt
usernameSourceLine: 1
passwordSourceRef: .env/HWLAB_admin.txt
passwordSourceKey: HWLAB_ADMIN_PASSWORD
passwordSourceLine: 2
passwordHashTransform: hwlab-sha256
secretName: hwlab-v03-bootstrap-admin
secretKey: password-hash
rollout:
deployment: hwlab-cloud-api
codeAgentProvider:
secretName: hwlab-v03-code-agent-provider
sourceRef: hwlab/jd01-v03-code-agent-provider.env
openaiSourceKey: OPENAI_API_KEY
opencodeSourceKey: OPENCODE_API_KEY
codeAgentRuntime:
enabled: true
adapter: agentrun-v02
managerUrl: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080
apiKeySecretName: hwlab-v03-master-server-admin-api-key
apiKeySecretKey: api-key
runnerNamespace: agentrun-v02
secretNamespace: agentrun-v02
repoUrlFrom: runtimeGitReadUrl
providerIdFrom: runtimeNodeId
defaultProviderProfile: codex
codexStdioSupervisor: repo-owned
publicExposure:
mode: pk01-caddy-frp
publicBaseUrl: https://hwlab.pikapython.com
hostname: hwlab.pikapython.com
expectedA: 82.156.23.220
frpc:
serverAddr: 82.156.23.220
serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
secretName: hwlab-v03-frpc-secrets
secretKey: frpc.toml
tokenKey: token
webProxy:
name: hwlab-jd01-v03-cloud-web
remotePort: 22090
localIP: hwlab-cloud-web.hwlab-v03.svc.cluster.local
localPort: 8080
apiProxy:
name: hwlab-jd01-v03-edge-proxy
remotePort: 22089
localIP: hwlab-edge-proxy.hwlab-v03.svc.cluster.local
localPort: 6667
caddy:
route: PK01
configPath: /etc/caddy/Caddyfile
serviceName: caddy
email: ops@pikapython.com
tls: auto
responseHeaderTimeoutSeconds: 600
networkProfiles:
node-ci-egress:
@@ -916,6 +1115,43 @@ networkProfiles:
- .svc
- .svc.cluster.local
- .cluster.local
jd01-node-ci-egress:
proxy:
http: http://127.0.0.1:10808
https: http://127.0.0.1:10808
all: http://127.0.0.1:10808
noProxy:
- localhost
- 127.0.0.1
- ::1
- 127.0.0.1:5000
- localhost:5000
- .svc
- .svc.cluster.local
- .cluster.local
- kubernetes
- kubernetes.default
- kubernetes.default.svc
- 10.0.0.0/8
- 10.42.0.0/16
- 10.43.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- 82.156.23.220
- 74.48.78.17
dockerBuildProxy:
http: http://127.0.0.1:10808
https: http://127.0.0.1:10808
all: http://127.0.0.1:10808
noProxy:
- localhost
- 127.0.0.1
- ::1
- 127.0.0.1:5000
- localhost:5000
- .svc
- .svc.cluster.local
- .cluster.local
downloadProfiles:
node-default:
@@ -978,6 +1214,26 @@ downloadProfiles:
retries: 3
connectTimeoutSeconds: 10
maxTimeSeconds: 120
jd01-node-default:
git:
proxyMode: inherit
retries: 3
timeoutSeconds: 60
npm:
registry: https://registry.npmmirror.com/
retries: 3
fetchTimeoutSeconds: 120
pip:
indexUrl: https://pypi.org/simple
retries: 3
timeoutSeconds: 120
docker:
registryMirrors: []
pullRetries: 3
curl:
retries: 3
connectTimeoutSeconds: 10
maxTimeSeconds: 120
d601-node-no-mirror-benchmark:
git:
proxyMode: inherit
+76
View File
@@ -87,3 +87,79 @@ targets:
targetKey: api-key
scopes:
- api
- id: jd01-v03
node: JD01
lane: v03
namespace: hwlab-v03
publicUrl: https://hwlab.pikapython.com
userBilling:
serviceId: hwlab-user-billing
databaseUrlSource:
sourceRef: hwlab/jd01-v03-postgres.env
sourceKey: DATABASE_URL
accounts:
- logicalId: jd01-v03-admin
kind: bootstrap-admin-api-key
userId: usr_v03_admin
username: admin
displayName: HWLAB v0.3 Admin
role: admin
status: active
permissions:
- admin
- system:hwlab
- "tool:*"
sourceRef: hwlab/jd01-v03-admin.env
sourceKey: HWLAB_API_KEY
createIfMissing:
enabled: true
randomBase64Url:
bytes: 32
prefix: hwl_live_
hostEnvTargets:
- path: /root/.config/hwlab-v03/master-server-admin-api-key.env
envKey: HWLAB_API_KEY
mode: "0600"
target:
kind: kubernetes-secret
namespace: hwlab-v03
secretName: hwlab-v03-master-server-admin-api-key
targetKey: api-key
rolloutDeployment: hwlab-cloud-api
- logicalId: jd01-v03-inner-test
kind: user-billing-api-key
userId: usr_jd01_v03_inner_test
username: inner-test
email: inner-test+jd01@hwlab.local
displayName: HWLAB v0.3 JD01 Test User
role: user
status: active
planId: default
initialCredits: 100
permissions:
- code_agent
- hwpod
- aipod
workbench:
projectId: prj_hwpod_workbench
lane: v03
sourceRef: hwlab/jd01-v03-inner-test.env
sourceKey: HWLAB_API_KEY
createIfMissing:
enabled: true
randomBase64Url:
bytes: 32
prefix: hwl_live_
hostEnvTargets:
- path: /root/.config/hwlab-v03/inner-test-api-key.env
envKey: HWLAB_API_KEY
mode: "0600"
target:
kind: user-billing-api-key
serviceId: hwlab-user-billing
keyId: key_jd01_v03_inner_test
keyName: JD01 v0.3 inner test fixed key
targetKey: api-key
scopes:
- api
@@ -0,0 +1,240 @@
version: 1
kind: HwlabWebProbeSentinelProfiles
metadata:
id: hwlab-web-probe-sentinel-profiles
owner: UniDesk
specRef: PJ2026-01060508
composition:
mode: yaml-anchors-and-merge
intent: node overlays inherit common web-probe sentinel baselines and render node/lane identity from variables.
baselines:
sentinel: &sentinel-base
enabled: true
mode: web-probe-observe-wrapper
runtime:
common: &runtime-common
namespace: hwlab-${lane}
listenHost: 0.0.0.0
servicePort: 8080
pvcStorage: 10Gi
replicas: 1
healthPath: /api/health
metricsPath: /metrics
scheduler: &scheduler-10m
intervalMs: 600000
heartbeatStaleSeconds: 900
maxConcurrentRuns: 1
scheduler15m: &scheduler-15m
intervalMs: 900000
heartbeatStaleSeconds: 900
maxConcurrentRuns: 1
sqlite: &sqlite-common
busyTimeoutMs: 2000
cicd:
source: &cicd-source
repository: pikasTech/unidesk
branch: master
gitSshUrl: ssh://git@ssh.github.com:443/pikasTech/unidesk.git
gitMirrorReadUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
buildContext: .
entrypoint: scripts/web-probe-sentinel-service.ts
checkoutPaths:
- scripts
- config
- config.json
- src
- package.json
- bun.lock
- bun.lockb
builder: &cicd-builder
namespace: devops-infra
sourceMode: sparse-git-checkout
gitSshSecretName: git-mirror-github-ssh
dockerSocketPath: /var/run/docker.sock
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 3600
monitorWeb: &monitor-web
frontendStack: vue3-vendored-browser-build
runtimeMode: runner-served-bridge
assetRoot: scripts/assets/web-probe-sentinel-monitor-web
envReuse:
mode: docker-layer-and-ci-node-deps
nodeDepsPath: /opt/hwlab-ci-node-deps/node_modules
gitMirror:
source: source.gitMirrorReadUrl
preSync: required
postFlush: required
ciBudget:
maxSeconds: 120
maintenance: &maintenance
startCommand: sentinel maintenance start
stopCommand: sentinel maintenance stop
confirmWait: &confirm-wait
maxSeconds: 120
publicExposure:
common: &public-exposure-common
enabled: true
mode: pk01-caddy-frp-path
hostname: monitor.pikapython.com
expectedA: 82.156.23.220
frpc: &frpc-common
image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
serverAddr: 82.156.23.220
serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
secretKey: frpc.toml
tokenKey: token
caddy: &caddy-common
route: PK01
configPath: /etc/caddy/Caddyfile
serviceName: caddy
email: ops@pikapython.com
tls: auto
responseHeaderTimeoutSeconds: 600
secrets:
jd01BootstrapSource: &jd01-bootstrap-source
purpose: bootstrap-admin
sourceRef: .env/HWLAB_admin.txt
sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
sourceLine: 2
dsflashPromptSource: &dsflash-prompt-source
purpose: prompt-set
sourceRef: hwlab/web-probe-sentinel-dsflash-go.env
sourceKey: DSFLASH_GO_TOOL_CALL_10X_PROMPTS_JSON
frpTokenSource: &frp-token-source
purpose: frp-token
sourceRef: platform-infra/pk01-frp.env
sourceKey: FRP_TOKEN
nodes:
JD01:
target: &jd01-target
node: ${NODE}
lane: ${LANE}
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.${LANE}.targets.${NODE}.public.webUrl
cicdCommon: &jd01-cicd-common
controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets[1]
source:
<<: *cicd-source
argo: &jd01-argo
namespace: argocd
projectName: hwlab-jd01
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
targetRevision: v0.3-gitops
maintenance:
<<: *maintenance
monitorWeb:
<<: *monitor-web
confirmWait:
<<: *confirm-wait
sentinels:
jd01-web-probe-sentinel:
sentinel:
<<: *sentinel-base
id: jd01-web-probe-sentinel
configRefs:
runtime: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.runtime
scenarios: config/hwlab-web-probe-sentinel/scenarios.multi-sentinel.yaml#sentinel.scenarios
promptSet: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet
reportViews: config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml#sentinel.reportViews
publicExposure: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.publicExposure
cicd: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.cicd
secrets: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.secrets
runtime:
<<: *runtime-common
target:
<<: *jd01-target
observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.${LANE}.targets.${NODE}.observability.webProbe.sentinels[0]
serviceAccountName: hwlab-web-probe-sentinel-${nodeLower}
deploymentName: hwlab-web-probe-sentinel-${nodeLower}
serviceName: hwlab-web-probe-sentinel-${nodeLower}
pvcName: hwlab-web-probe-sentinel-${nodeLower}-state
stateRoot: /var/lib/web-probe-sentinel-${nodeLower}
imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel-${nodeLower}:source-commit
scheduler:
<<: *scheduler-10m
sqlite:
<<: *sqlite-common
path: /var/lib/web-probe-sentinel-${nodeLower}/index.sqlite
publicExposure:
<<: *public-exposure-common
publicBaseUrl: https://monitor.pikapython.com/sentinels/${nodeLower}-web-probe-sentinel
routePrefix: /sentinels/${nodeLower}-web-probe-sentinel
frpc:
<<: *frpc-common
deploymentName: hwlab-web-probe-sentinel-${nodeLower}-frpc
secretName: hwlab-web-probe-sentinel-${nodeLower}-frpc
httpProxy:
name: hwlab-${nodeLower}-${lane}-web-probe-sentinel
remotePort: 22094
localIP: hwlab-web-probe-sentinel-${nodeLower}.hwlab-${lane}.svc.cluster.local
localPort: 8080
caddy:
<<: *caddy-common
managedBlockOwner: hwlab-web-probe-sentinel-${nodeLower}-${lane}
cicd:
<<: *jd01-cicd-common
builder:
<<: *cicd-builder
jobPrefix: web-probe-sentinel-${nodeLower}-publish
gitopsPath: deploy/gitops/node/${nodeLower}/web-probe-sentinel
argo:
<<: *jd01-argo
applicationName: hwlab-web-probe-sentinel-${nodeLower}
image:
repository: 127.0.0.1:5000/hwlab/web-probe-sentinel-${nodeLower}
tagSource: source-commit
baseImageRef: config/hwlab-node-control-plane.yaml#targets[1].tekton.toolsImage.output
envRecipeRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.runtime
targetValidation:
scenarioId: workbench-dsflash-go-tool-call-10x
maxSeconds: 300
serviceUnavailablePolicy: structured-failure
secrets:
sources:
- <<: *jd01-bootstrap-source
- <<: *dsflash-prompt-source
- purpose: account-a
sourceRef: .env/HWLAB_admin.txt
sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
sourceLine: 2
format: web-account-json
usernameSourceRef: .env/HWLAB_admin.txt
usernameSourceLine: 1
- purpose: account-b
sourceRef: hwlab/${nodeLower}-${lane}-preset-users.env
sourceKey: ${NODE}_SECOND_USER_PASSWORD
format: web-account-json
username: ${nodeLower}-sentinel@hwlab.local
- <<: *frp-token-source
runtimeSecrets:
- name: hwlab-web-probe-sentinel-${nodeLower}-bootstrap
namespace: hwlab-${lane}
data:
- sourcePurpose: bootstrap-admin
targetKey: bootstrap-admin-password
- name: hwlab-web-probe-sentinel-${nodeLower}-prompt-set
namespace: hwlab-${lane}
data:
- sourcePurpose: prompt-set
targetKey: prompts.json
- name: hwlab-web-probe-sentinel-${nodeLower}-accounts
namespace: hwlab-${lane}
data:
- sourcePurpose: account-a
targetKey: account-a.json
- sourcePurpose: account-b
targetKey: account-b.json
- name: hwlab-web-probe-sentinel-${nodeLower}-frpc
namespace: hwlab-${lane}
data:
- sourcePurpose: frp-token
targetKey: token
@@ -0,0 +1,24 @@
version: 1
kind: HwlabWebProbeSentinelReportViews
metadata:
id: web-probe-sentinel-multi-scenario-report-views
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
reportViews:
defaultView: summary
views:
- summary
- turn-summary
- auth-session-switch-summary
- findings
- trace-frame
pageSize: 20
maxPageSize: 100
rawAccess: explicit-only
checkCatalogRef: config/hwlab-web-probe-sentinel/check-catalog.yaml#sentinel.checkCatalog
redaction:
prompt: hash-and-byte-count
assistantFinal: summary-and-hash
providerPayload: denied
secrets: denied
@@ -0,0 +1,126 @@
version: 1
kind: HwlabWebProbeSentinelScenarios
metadata:
id: web-probe-sentinel-multi-scenario-suite
owner: UniDesk
specRef: PJ2026-01060508
sentinel:
scenarios:
- id: workbench-dsflash-go-tool-call-10x
enabled: true
cadence: 10m
observeTargetPath: /workbench
sampleIntervalMs: 1000
screenshotIntervalMs: 60000
maxRunSeconds: 1200
providerProfile: dsflash-go
providerProfileMode: exact
promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet
reportViewRef: config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml#sentinel.reportViews
commandSequence:
- type: newSession
- type: selectProvider
provider: dsflash-go
- type: sendPrompt
promptSource: promptSet
repeat: 10
sessionInvarianceChecks:
- id: after-round-1-navigation-invariance
afterRound: 1
refreshCurrent: true
switchAwayAndBack: true
alternateSessionStrategy: existing-or-create
assertSessionInvariant: true
expectedSentinelRange: sentinel-01..sentinel-01
findingId: workbench-message-order-user-clustered-after-navigation
severity: amber
blocking: false
- id: after-round-5-navigation-invariance
afterRound: 5
refreshCurrent: true
switchAwayAndBack: true
alternateSessionStrategy: existing-or-create
assertSessionInvariant: true
requireComposerReady: true
expectedSentinelRange: sentinel-01..sentinel-05
findingId: workbench-message-order-user-clustered-after-navigation
severity: amber
blocking: false
- id: after-round-10-refresh-invariance
afterRound: 10
refreshCurrent: true
switchAwayAndBack: false
assertSessionInvariant: true
expectedSentinelRange: sentinel-01..sentinel-10
findingId: workbench-message-order-user-clustered-after-navigation
severity: amber
blocking: false
- id: workbench-auth-session-switch-2users
enabled: true
cadence: 10m
observeTargetPath: /workbench
sampleIntervalMs: 1000
screenshotIntervalMs: 60000
maxRunSeconds: 900
providerProfile: session-switch-sentinel
providerProfileMode: exact
promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml#sentinel.promptSet
reportViewRef: config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml#sentinel.reportViews
accounts:
- id: account-a
sourcePurpose: account-a
usernameKey: username
passwordKey: password
- id: account-b
sourcePurpose: account-b
usernameKey: username
passwordKey: password
commandSequence:
- type: loginAccount
accountId: account-a
- type: listSessions
- type: logout
- type: loginAccount
accountId: account-b
- type: listSessions
- type: switchSessions
fromAccountId: account-b
toAccountId: account-a
- type: listSessions
- type: logout
- id: mdtodo-visual-regression
enabled: true
cadence: 15m
observeTargetPath: /projects/mdtodo
viewport: 1440x900
sampleIntervalMs: 1000
screenshotIntervalMs: 60000
maxRunSeconds: 360
providerProfile: dsflash-go
providerProfileMode: exact
promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet
reportViewRef: config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml#sentinel.reportViews
commandSequence:
- type: goto
path: /projects/mdtodo/sources/constart-71freq-mdtodo/files/file_0db4dc4e46adf188/tasks/R1
- type: screenshot
label: mdtodo-desktop-few-task-gap
waitProjectManagementReady: true
- type: goto
path: /projects/mdtodo/sources/constart-71freq-mdtodo/files/file_5f9645ffe8774b92/tasks/R14
- type: screenshot
label: mdtodo-r14-selected
waitProjectManagementReady: true
- type: openMdtodoReportPreview
task: R14
link: R14
- type: screenshot
label: mdtodo-r14-report-preview
waitProjectManagementReady: true
- type: toggleMdtodoReportFullscreen
text: toggle
- type: screenshot
label: mdtodo-r14-report-fullscreen
waitProjectManagementReady: true
+112
View File
@@ -0,0 +1,112 @@
version: 1
kind: platform-infra-host-proxy
metadata:
owner: unidesk
relatedIssues:
- 1148
- 1110
- 1117
defaults:
targetId: JD01
server:
id: benchmark-validated-master-shadowsocks-server
enabled: true
benchmarkRef: pikasTech/unidesk#1110
implementationRef: pikasTech/unidesk#1117
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks
composeFile: config/platform-infra/sub2api-master-egress-proxy.compose.yaml
serviceName: sub2api-master-egress-proxy
containerName: unidesk-sub2api-master-egress-proxy
image: ghcr.io/shadowsocks/ssserver-rust:latest
configPath: /root/unidesk/.state/secrets/platform-infra/sub2api-master-egress-proxy.config.json
listenHost: 0.0.0.0
listenPort: 18792
health:
mode: tcp
host: 127.0.0.1
port: 18792
sources:
jd01-real-deps-master-shadowsocks:
sourceType: benchmark-validated-master-shadowsocks
serverRef: server.benchmark-validated-master-shadowsocks-server
benchmarkRef: pikasTech/unidesk#1110
implementationRef: pikasTech/unidesk#1117
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks
client:
mode: trans-static-binary
upstreamUrl: https://github.com/SagerNet/sing-box/releases/download/v1.13.14/sing-box-1.13.14-linux-amd64.tar.gz
version: v1.13.14
archiveCachePath: .state/artifacts/platform-infra/sing-box-1.13.14-linux-amd64.tar.gz
archiveSha256: f48703461a15476951ac4967cdad339d986f4b8096b4eb3ff0829a500502d697
archiveInstallPath: /var/cache/unidesk/host-egress-proxy/sing-box-1.13.14-linux-amd64.tar.gz
binaryMember: sing-box-1.13.14-linux-amd64/sing-box
binaryCachePath: .state/artifacts/platform-infra/sing-box-1.13.14-linux-amd64
binarySha256: 68aeab83cc4ab2659a5b92232261a20746ccdafc3b3d1e19b2d63247eec3bbf7
installPath: /usr/local/bin/sing-box
configPath: /etc/unidesk/host-egress-proxy/sing-box.json
unitPath: /etc/systemd/system/unidesk-host-egress-proxy.service
serviceName: unidesk-host-egress-proxy
listenHost: 127.0.0.1
listenPort: 10808
podAccess:
enabled: true
listenHost: 10.42.0.1
listenPort: 10808
proxyUrl: http://10.42.0.1:10808
clashApiListen: 127.0.0.1:19090
healthUrl: http://127.0.0.1:19090/connections
proxyUrl: http://127.0.0.1:10808
externalProbeUrl: https://www.gstatic.com/generate_204
targets:
JD01:
route: JD01
enabled: true
sourceRef: sources.jd01-real-deps-master-shadowsocks
env:
httpProxy: http://127.0.0.1:10808
httpsProxy: http://127.0.0.1:10808
allProxy: http://127.0.0.1:10808
noProxy:
- localhost
- 127.0.0.1
- ::1
- host.docker.internal
- 74.48.78.17
- 82.156.23.220
- 10.0.0.0/8
- 10.42.0.0/16
- 10.43.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- .svc
- .svc.cluster.local
- .cluster.local
- kubernetes
- kubernetes.default
- kubernetes.default.svc
- argocd-repo-server
- argocd-repo-server.argocd
- argocd-redis
- argocd-redis.argocd
- git-mirror-http
- git-mirror-http.devops-infra
- git-mirror-write
- git-mirror-write.devops-infra
- 127.0.0.1:5000
- localhost:5000
- hyueapi.com
- .hyueapi.com
files:
envFile: /etc/unidesk/proxy.env
profile: /etc/profile.d/unidesk-proxy.sh
apt: /etc/apt/apt.conf.d/90unidesk-proxy
dockerSystemdDropIn: /etc/systemd/system/docker.service.d/10-unidesk-proxy.conf
k3sSystemdDropIn: /etc/systemd/system/k3s.service.d/10-unidesk-proxy.conf
apply:
reloadSystemd: true
restartDocker: true
restartK3s: false
+39
View File
@@ -35,6 +35,12 @@ targets:
role: active
enabled: true
createNamespace: true
- id: JD01
route: JD01:k3s
namespace: platform-infra
role: active
enabled: true
createNamespace: true
collector:
deploymentName: otel-collector
@@ -97,6 +103,20 @@ instrumentation:
- projection_write
- trace_events_read
- turn_status_read
- serviceName: hwlab-cloud-api
owningRepo: pikasTech/HWLAB
configRefs:
targetNode: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.node
lane: config/hwlab-node-lanes.yaml#lanes.v03.version
namespace: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.runtime.namespace
requiredSpans:
- POST /v1/agent/chat
- durable_admission
- billing_preflight
- agentrun_dispatch
- projection_write
- trace_events_read
- turn_status_read
- serviceName: user-billing
owningRepo: pikasTech/HWLAB
configRefs:
@@ -113,6 +133,14 @@ instrumentation:
namespace: config/hwlab-node-lanes.yaml#lanes.v03.targets.D518.runtime.namespace
requiredSpans:
- billing_preflight
- serviceName: user-billing
owningRepo: pikasTech/HWLAB
configRefs:
targetNode: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.node
lane: config/hwlab-node-lanes.yaml#lanes.v03.version
namespace: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.runtime.namespace
requiredSpans:
- billing_preflight
- serviceName: agentrun-manager
owningRepo: pikasTech/agentrun
configRefs:
@@ -124,6 +152,17 @@ instrumentation:
- run_created
- command_result
- projection_sync
- serviceName: agentrun-manager
owningRepo: pikasTech/agentrun
configRefs:
targetNode: config/agentrun.yaml#controlPlane.lanes.jd01-v02.node
lane: config/agentrun.yaml#controlPlane.lanes.jd01-v02.version
namespace: config/agentrun.yaml#controlPlane.lanes.jd01-v02.runtime.namespace
requiredSpans:
- agentrun_dispatch
- run_created
- command_result
- projection_sync
resourceAttributes:
required:
+66
View File
@@ -233,6 +233,72 @@ targets:
- 74.48.78.17
- hyueapi.com
- .hyueapi.com
- id: JD01
route: JD01:k3s
namespace: platform-infra
role: active
enabled: true
databaseMode: bundled
redisMode: bundled-persistent
appReplicas: 1
redisReplicas: 1
image:
repository: weishaw/sub2api
tag: 0.1.138
pullPolicy: IfNotPresent
dependencyImages:
postgres: docker.m.daocloud.io/library/postgres:18-alpine
redis: docker.m.daocloud.io/library/redis:8-alpine
codexPool:
sentinelImageBuild:
baseImageCachePolicy: pull
noProxy:
- localhost
- 127.0.0.1
- ::1
- host.docker.internal
- 74.48.78.17
- 192.168.0.0/16
- 10.0.0.0/8
- 172.16.0.0/12
- 10.42.0.0/16
- 10.43.0.0/16
- .svc
- .svc.cluster.local
- .cluster.local
- kubernetes
- kubernetes.default
- kubernetes.default.svc
- 127.0.0.1:5000
- localhost:5000
egressProxy:
enabled: false
deploymentName: sub2api-egress-proxy
serviceName: sub2api-egress-proxy
secretName: sub2api-egress-proxy-config
secretKey: config.json
image: ghcr.io/sagernet/sing-box:latest
imagePullPolicy: IfNotPresent
listenPort: 10808
hostNetwork: true
hostProxyConfigRef: config/platform-infra/host-proxy.yaml#targets.JD01
proxyEnvPath: /etc/unidesk/proxy.env
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks
applyToSub2Api: false
applyToSentinel: false
noProxy:
- localhost
- 127.0.0.1
- ::1
- .svc
- .cluster.local
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 82.156.23.220
- 74.48.78.17
- hyueapi.com
- .hyueapi.com
runtime:
database:
mode: external
+23
View File
@@ -54,6 +54,7 @@ export interface AgentRunLaneSpec {
readonly nodeKubeRoute: string;
readonly version: string;
readonly source: {
readonly statusMode: "host-worktree" | "k3s-git-mirror";
readonly repository: string;
readonly branch: string;
readonly bootstrapFromBranch: string | null;
@@ -76,6 +77,7 @@ export interface AgentRunLaneSpec {
readonly serviceAccountName: string;
readonly registryPrefix: string;
readonly toolsImage: string;
readonly buildkitImage: string | null;
};
readonly gitops: {
readonly branch: string;
@@ -123,6 +125,10 @@ export interface AgentRunLaneSpec {
readonly image: string | null;
readonly storage: string | null;
readonly port: number | null;
readonly database: string | null;
readonly user: string | null;
readonly passwordSourceRef: string | null;
readonly passwordSourceKey: string | null;
};
};
readonly gitMirror: {
@@ -271,6 +277,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
source: {
repository: spec.source.repository,
branch: spec.source.branch,
statusMode: spec.source.statusMode,
bootstrapFromBranch: spec.source.bootstrapFromBranch,
bootstrapTimeoutSeconds: spec.source.bootstrapTimeoutSeconds,
bootstrapPollSeconds: spec.source.bootstrapPollSeconds,
@@ -289,6 +296,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
pipelineRunPrefix: spec.ci.pipelineRunPrefix,
serviceAccountName: spec.ci.serviceAccountName,
registryPrefix: spec.ci.registryPrefix,
buildkitImage: spec.ci.buildkitImage,
},
gitops: {
branch: spec.gitops.branch,
@@ -491,6 +499,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
nodeKubeRoute: node.kubeRoute,
version: stringField(input, "version", path),
source: {
statusMode: sourceStatusModeField(optionalStringField(source, "statusMode", `${path}.source`) ?? "host-worktree", `${path}.source.statusMode`),
repository: stringField(source, "repository", `${path}.source`),
branch: stringField(source, "branch", `${path}.source`),
bootstrapFromBranch: optionalStringField(source, "bootstrapFromBranch", `${path}.source`) ?? null,
@@ -513,6 +522,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
serviceAccountName: stringField(ci, "serviceAccountName", `${path}.ci`),
registryPrefix: stringField(ci, "registryPrefix", `${path}.ci`),
toolsImage: stringField(ci, "toolsImage", `${path}.ci`),
buildkitImage: optionalStringField(ci, "buildkitImage", `${path}.ci`) ?? null,
},
gitops: {
branch: stringField(gitops, "branch", `${path}.gitops`),
@@ -544,6 +554,11 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
};
}
function sourceStatusModeField(value: string, path: string): "host-worktree" | "k3s-git-mirror" {
if (value !== "host-worktree" && value !== "k3s-git-mirror") throw new Error(`${path} must be host-worktree or k3s-git-mirror`);
return value;
}
function parseDeployment(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"] {
const argocd = recordField(input, "argocd", path);
const manager = recordField(input, "manager", path);
@@ -660,6 +675,10 @@ function parseLocalPostgres(input: Record<string, unknown>, path: string): Agent
image: optionalStringField(input, "image", path) ?? null,
storage: optionalStringField(input, "storage", path) ?? null,
port: optionalIntegerField(input, "port", path) ?? null,
database: optionalStringField(input, "database", path) ?? null,
user: optionalStringField(input, "user", path) ?? null,
passwordSourceRef: optionalStringField(input, "passwordSourceRef", path) ?? null,
passwordSourceKey: optionalStringField(input, "passwordSourceKey", path) ?? null,
};
}
return {
@@ -668,6 +687,10 @@ function parseLocalPostgres(input: Record<string, unknown>, path: string): Agent
image: stringField(input, "image", path),
storage: stringField(input, "storage", path),
port: integerField(input, "port", path),
database: stringField(input, "database", path),
user: stringField(input, "user", path),
passwordSourceRef: secretSourceRefField(input, "passwordSourceRef", path),
passwordSourceKey: stringField(input, "passwordSourceKey", path),
};
}
+9 -1
View File
@@ -300,10 +300,11 @@ function agentRunRuntimeNamespaceManifest(spec: AgentRunLaneSpec): Record<string
function agentRunPostgresManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
const localPostgres = spec.deployment.localPostgres;
if (!localPostgres.enabled || localPostgres.serviceName === null || localPostgres.image === null || localPostgres.storage === null || localPostgres.port === null) {
if (!localPostgres.enabled || localPostgres.serviceName === null || localPostgres.image === null || localPostgres.storage === null || localPostgres.port === null || localPostgres.database === null || localPostgres.user === null) {
throw new Error(`localPostgres is enabled for ${spec.version} without renderable YAML fields`);
}
const name = localPostgres.serviceName;
const secretName = spec.database.secretRef.name;
return {
apiVersion: "v1",
kind: "List",
@@ -330,6 +331,13 @@ function agentRunPostgresManifest(spec: AgentRunLaneSpec): Record<string, unknow
name: "postgres",
image: localPostgres.image,
ports: [{ name: "postgres", containerPort: localPostgres.port }],
env: [
{ name: "POSTGRES_DB", valueFrom: { secretKeyRef: { name: secretName, key: "POSTGRES_DB" } } },
{ name: "POSTGRES_USER", valueFrom: { secretKeyRef: { name: secretName, key: "POSTGRES_USER" } } },
{ name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name: secretName, key: "POSTGRES_PASSWORD" } } },
{ name: "PGDATA", value: "/var/lib/postgresql/data/pgdata" },
],
volumeMounts: [{ name: "data", mountPath: "/var/lib/postgresql/data" }],
},
],
},
+140 -48
View File
@@ -35,10 +35,17 @@ import { sha256Fingerprint } from "../platform-infra-ops-library";
import { runYamlLaneGitMirrorFlushJob, runYamlLaneGitMirrorSyncJob, yamlLanePipelineRunCreateScript } from "./secrets";
import { capture, captureJsonPayload, compactCapture, isGitSha, progressEvent, stringOrNull } from "./utils";
import { runYamlLaneGitopsPublishJob, waitForYamlLaneBuildImage, waitForYamlLaneSourceBootstrap, yamlLaneBuildImageSubmitScript, yamlLaneSourceBootstrapSubmitScript, yamlLaneSourceRestoreScript } from "./yaml-lane";
import { runYamlLaneGitopsPublishJob, runYamlLaneK3sBuildImageJob, waitForYamlLaneBuildImage, waitForYamlLaneSourceBootstrap, yamlLaneBuildImageSubmitScript, yamlLaneK3sSourceStatusScript, yamlLaneSourceBootstrapSubmitScript, yamlLaneSourceRestoreScript } from "./yaml-lane";
export async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise<Record<string, unknown>> {
const result = await triggerCurrentYamlLaneConfirmedSteps(config, spec, configPath, waited);
if (spec.source.statusMode === "k3s-git-mirror") {
return {
...result,
sourceWorkspaceRestore: { ok: true, status: "skipped", statusMode: spec.source.statusMode, reason: "k3s-git-mirror-does-not-use-host-worktree", valuesPrinted: false },
valuesPrinted: false,
};
}
const restore = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceRestoreScript(spec)]);
const restorePayload = captureJsonPayload(restore);
const restoreOk = restore.exitCode === 0 && restorePayload.ok === true;
@@ -53,63 +60,27 @@ export async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spe
}
export async function triggerCurrentYamlLaneConfirmedSteps(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise<Record<string, unknown>> {
progressEvent("agentrun.yaml-lane.source-bootstrap.progress", {
node: spec.nodeId,
lane: spec.lane,
status: "submitting",
});
const bootstrapSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapSubmitScript(spec)]);
const bootstrapSubmitPayload = captureJsonPayload(bootstrapSubmit);
if (bootstrapSubmit.exitCode !== 0 || bootstrapSubmitPayload.ok === false) {
const source = await resolveTriggerCurrentSource(config, spec, configPath, waited);
if (source.ok !== true) return source;
const sourceCommit = stringOrNull(source.sourceCommit);
const bootstrapPayload = source.sourcePayload;
if (sourceCommit === null || !isGitSha(sourceCommit)) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "source-bootstrap-submit",
degradedReason: "yaml-lane-source-bootstrap-submit-failed",
result: bootstrapSubmitPayload,
capture: compactCapture(bootstrapSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
const bootstrap = await waitForYamlLaneSourceBootstrap(config, spec, stringOrNull(bootstrapSubmitPayload.jobId));
const bootstrapPayload = bootstrap.payload;
const sourceCommit = stringOrNull(bootstrapPayload.sourceCommit);
if (bootstrap.ok !== true || sourceCommit === null || !isGitSha(sourceCommit)) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "source-bootstrap",
degradedReason: "yaml-lane-source-bootstrap-failed",
phase: "source-resolve",
degradedReason: "yaml-lane-source-commit-invalid",
result: bootstrapPayload,
bootstrapStatus: bootstrap,
valuesPrinted: false,
};
}
const buildSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]);
const buildSubmitPayload = captureJsonPayload(buildSubmit);
if (buildSubmit.exitCode !== 0 || buildSubmitPayload.ok === false) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "image-build-submit",
sourceCommit,
degradedReason: "yaml-lane-image-build-submit-failed",
sourceBootstrap: bootstrapPayload,
result: buildSubmitPayload,
capture: compactCapture(buildSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
const build = await waitForYamlLaneBuildImage(config, spec, sourceCommit, stringOrNull(buildSubmitPayload.jobId));
const buildResult = await buildTriggerCurrentImage(config, spec, sourceCommit, bootstrapPayload, configPath, waited);
if (buildResult.ok !== true) return buildResult;
const build = buildResult.buildStatus;
const buildSubmitPayload = buildResult.buildSubmitPayload;
const buildPayload = build.payload;
const digest = stringOrNull(buildPayload.digest);
const envIdentity = stringOrNull(buildPayload.envIdentity);
@@ -227,3 +198,124 @@ export async function triggerCurrentYamlLaneConfirmedSteps(config: UniDeskConfig
valuesPrinted: false,
};
}
async function resolveTriggerCurrentSource(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise<Record<string, unknown> & { ok: boolean; sourceCommit?: string | null; sourcePayload?: Record<string, unknown> }> {
if (spec.source.statusMode === "k3s-git-mirror") {
progressEvent("agentrun.yaml-lane.source-status.progress", {
node: spec.nodeId,
lane: spec.lane,
statusMode: spec.source.statusMode,
status: "probing",
});
const sourceStatus = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)]);
const sourcePayload = captureJsonPayload(sourceStatus);
const sourceCommit = stringOrNull(sourcePayload.sourceCommit) ?? stringOrNull(sourcePayload.remoteBranchCommit);
if (sourceStatus.exitCode !== 0 || sourcePayload.ok !== true || sourceCommit === null || !isGitSha(sourceCommit)) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "source-status",
degradedReason: "yaml-lane-k3s-source-status-failed",
result: sourcePayload,
capture: compactCapture(sourceStatus, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
return { ok: true, sourceCommit, sourcePayload, valuesPrinted: false };
}
progressEvent("agentrun.yaml-lane.source-bootstrap.progress", {
node: spec.nodeId,
lane: spec.lane,
status: "submitting",
});
const bootstrapSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapSubmitScript(spec)]);
const bootstrapSubmitPayload = captureJsonPayload(bootstrapSubmit);
if (bootstrapSubmit.exitCode !== 0 || bootstrapSubmitPayload.ok === false) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "source-bootstrap-submit",
degradedReason: "yaml-lane-source-bootstrap-submit-failed",
result: bootstrapSubmitPayload,
capture: compactCapture(bootstrapSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
const bootstrap = await waitForYamlLaneSourceBootstrap(config, spec, stringOrNull(bootstrapSubmitPayload.jobId));
const sourcePayload = bootstrap.payload;
const sourceCommit = stringOrNull(sourcePayload.sourceCommit);
if (bootstrap.ok !== true || sourceCommit === null || !isGitSha(sourceCommit)) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "source-bootstrap",
degradedReason: "yaml-lane-source-bootstrap-failed",
result: sourcePayload,
bootstrapStatus: bootstrap,
valuesPrinted: false,
};
}
return { ok: true, sourceCommit, sourcePayload, sourceBootstrapSubmit: bootstrapSubmitPayload, valuesPrinted: false };
}
async function buildTriggerCurrentImage(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string, sourcePayload: Record<string, unknown>, configPath: string, waited: boolean): Promise<Record<string, unknown> & { ok: boolean; buildStatus?: Record<string, unknown> & { ok: boolean; payload: Record<string, unknown> }; buildSubmitPayload?: Record<string, unknown> }> {
if (spec.source.statusMode === "k3s-git-mirror") {
const build = await runYamlLaneK3sBuildImageJob(config, spec, sourceCommit);
const buildSubmitPayload = {
ok: build.ok !== false,
status: "submitted",
mode: "k3s-buildkit-job",
jobName: stringOrNull(build.jobName) ?? null,
valuesPrinted: false,
};
if (build.ok !== true) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "image-build",
sourceCommit,
degradedReason: "yaml-lane-k3s-image-build-failed",
sourceBootstrap: sourcePayload,
buildSubmit: buildSubmitPayload,
result: build.payload,
buildStatus: build,
valuesPrinted: false,
};
}
return { ok: true, buildStatus: build, buildSubmitPayload, valuesPrinted: false };
}
const buildSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]);
const buildSubmitPayload = captureJsonPayload(buildSubmit);
if (buildSubmit.exitCode !== 0 || buildSubmitPayload.ok === false) {
return {
ok: false,
command: "agentrun control-plane trigger-current",
mode: waited ? "confirmed-waited" : "confirmed-trigger",
configPath,
target: agentRunLaneSummary(spec),
phase: "image-build-submit",
sourceCommit,
degradedReason: "yaml-lane-image-build-submit-failed",
sourceBootstrap: sourcePayload,
result: buildSubmitPayload,
capture: compactCapture(buildSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
const build = await waitForYamlLaneBuildImage(config, spec, sourceCommit, stringOrNull(buildSubmitPayload.jobId));
return { ok: true, buildStatus: build, buildSubmitPayload, valuesPrinted: false };
}
+8 -4
View File
@@ -38,7 +38,7 @@ import { agentRunControlPlaneStatusCommand } from "./public-exposure";
import { applyYamlScript, manifestObjectRef, yamlLaneGitMirrorStatusScript } from "./secrets";
import { compactAgentRunLaneStatusTarget, compactLaneSecretsStatus } from "./trigger";
import { capture, captureJsonPayload, compactCapture, record, stringOrNull, timedStatusStage } from "./utils";
import { yamlLaneRuntimeStatusScript, yamlLaneSourceStatusScript } from "./yaml-lane";
import { yamlLaneK3sSourceStatusScript, yamlLaneRuntimeStatusScript, yamlLaneSourceStatusScript } from "./yaml-lane";
export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
const base = parseConfirmOptions(args);
@@ -315,7 +315,9 @@ export async function status(config: UniDeskConfig, options: StatusOptions): Pro
export async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
const sourceProbe = await timedStatusStage("source", () => capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror"
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
const sourcePayload = captureJsonPayload(sourceProbe.value);
const branchTipCommit = stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead);
const initialSourceCommit = options.sourceCommit
@@ -362,14 +364,15 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
: null,
valuesPrinted: false,
};
const sourceWorkspaceRequired = spec.source.statusMode === "host-worktree";
const warnings = [
...(sourceWorktreeDetached ? ["source-worktree-detached"] : []),
...(sourceBranchAdvanced ? ["source-branch-advanced-after-target"] : []),
];
const blockers = [
...(sourcePayload.workspaceExists === true ? [] : ["source-worktree-missing"]),
...(sourceWorkspaceRequired && sourcePayload.workspaceExists !== true ? ["source-worktree-missing"] : []),
...(sourcePayload.remoteBranchExists === true ? [] : ["source-branch-missing"]),
...(sourcePayload.workspaceClean === true || sourcePayload.workspaceExists !== true ? [] : ["source-worktree-dirty"]),
...(sourceWorkspaceRequired && sourcePayload.workspaceClean !== true && sourcePayload.workspaceExists === true ? ["source-worktree-dirty"] : []),
...(mirrorPayload.readReady === true ? [] : ["git-mirror-read-not-ready"]),
...(mirrorPayload.writeReady === true ? [] : ["git-mirror-write-not-ready"]),
...(mirrorPayload.cacheReady === true ? [] : ["git-mirror-cache-not-ready"]),
@@ -422,6 +425,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
? { code: "runtime-blocked", summary: `runtime alignment is blocked: ${runtimeBlockers.join(", ")}`, command: statusFullCommand }
: { code: "inspect-full-status", summary: "alignment is inconclusive; inspect full status details", command: statusFullCommand };
const compactSourceStatus = {
statusMode: spec.source.statusMode,
workspaceExists: sourcePayload.workspaceExists ?? false,
workspaceClean: sourcePayload.workspaceClean ?? null,
branch: sourcePayload.branch ?? null,
+21 -15
View File
@@ -446,21 +446,27 @@ export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string {
`application=${shQuote(spec.gitops.argoApplication)}`,
"kubectl -n \"$namespace\" annotate application \"$application\" argocd.argoproj.io/refresh=hard --overwrite >/tmp/agentrun-refresh-output.txt",
"kubectl -n \"$namespace\" get application \"$application\" -o json >/tmp/agentrun-refresh-app.json",
"NAMESPACE=\"$namespace\" APPLICATION=\"$application\" node <<'NODE'",
"const fs = require('node:fs');",
"let app = {};",
"try { app = JSON.parse(fs.readFileSync('/tmp/agentrun-refresh-app.json', 'utf8')); } catch {}",
"console.log(JSON.stringify({",
" ok: true,",
" namespace: process.env.NAMESPACE,",
" application: process.env.APPLICATION,",
" revision: app.status?.sync?.revision ?? null,",
" syncStatus: app.status?.sync?.status ?? null,",
" healthStatus: app.status?.health?.status ?? null,",
" observedAt: new Date().toISOString(),",
" valuesPrinted: false",
"}));",
"NODE",
"NAMESPACE=\"$namespace\" APPLICATION=\"$application\" python3 - <<'PY'",
"import datetime, json, os",
"try:",
" with open('/tmp/agentrun-refresh-app.json', 'r', encoding='utf-8') as fh:",
" app = json.load(fh)",
"except Exception:",
" app = {}",
"status = app.get('status') or {}",
"sync = status.get('sync') or {}",
"health = status.get('health') or {}",
"print(json.dumps({",
" 'ok': True,",
" 'namespace': os.environ.get('NAMESPACE'),",
" 'application': os.environ.get('APPLICATION'),",
" 'revision': sync.get('revision'),",
" 'syncStatus': sync.get('status'),",
" 'healthStatus': health.get('status'),",
" 'observedAt': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),",
" 'valuesPrinted': False,",
"}, ensure_ascii=False))",
"PY",
].join("\n");
}
+182 -95
View File
@@ -343,9 +343,10 @@ export function yamlLanePipelineRunCreateScript(spec: AgentRunLaneSpec, sourceCo
"if [ \"$existing_status\" = False ]; then kubectl -n \"$namespace\" delete pipelinerun \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true; fi",
"if kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" >/dev/null 2>&1; then created=false; else kubectl create -f \"$tmp\"; created=true; fi",
"status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)",
"CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" node <<'NODE'",
"console.log(JSON.stringify({ ok: true, pipelineRun: process.env.PIPELINE_RUN, created: process.env.CREATED === 'true', status: process.env.STATUS || null, valuesPrinted: false }));",
"NODE",
"CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" python3 - <<'PY'",
"import json, os",
"print(json.dumps({'ok': True, 'pipelineRun': os.environ.get('PIPELINE_RUN'), 'created': os.environ.get('CREATED') == 'true', 'status': os.environ.get('STATUS') or None, 'valuesPrinted': False}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -361,9 +362,10 @@ export function createYamlLaneJobScript(namespace: string, jobName: string, mani
"printf '%s' \"$manifest_b64\" | base64 -d > \"$tmp\"",
"kubectl -n \"$namespace\" delete job \"$job\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
"kubectl create -f \"$tmp\"",
"JOB=\"$job\" node <<'NODE'",
"console.log(JSON.stringify({ ok: true, jobName: process.env.JOB, valuesPrinted: false }));",
"NODE",
"JOB=\"$job\" python3 - <<'PY'",
"import json, os",
"print(json.dumps({'ok': True, 'jobName': os.environ.get('JOB'), 'valuesPrinted': False}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -375,14 +377,27 @@ export function yamlLaneJobProbeScript(namespace: string, jobName: string): stri
"kubectl -n \"$namespace\" get job \"$job\" -o json > /tmp/agentrun-job.json 2>/dev/null",
"job_exit=$?",
"kubectl -n \"$namespace\" logs \"job/$job\" --tail=120 > /tmp/agentrun-job.log 2>/dev/null",
"JOB_EXIT=\"$job_exit\" JOB=\"$job\" node <<'NODE'",
"const fs = require('node:fs');",
"let job = null; try { job = JSON.parse(fs.readFileSync('/tmp/agentrun-job.json', 'utf8')); } catch {}",
"let log = ''; try { log = fs.readFileSync('/tmp/agentrun-job.log', 'utf8'); } catch {}",
"const succeeded = Number(job?.status?.succeeded || 0) > 0;",
"const failed = Number(job?.status?.failed || 0) > 0;",
"console.log(JSON.stringify({ ok: process.env.JOB_EXIT === '0', jobName: process.env.JOB, succeeded, failed, active: job?.status?.active || 0, logsTail: log.slice(-4000), valuesPrinted: false }));",
"NODE",
"JOB_EXIT=\"$job_exit\" JOB=\"$job\" python3 - <<'PY'",
"import json, os",
"def read_json(path):",
" try:",
" with open(path, 'r', encoding='utf-8') as fh:",
" return json.load(fh)",
" except Exception:",
" return None",
"def read_text(path):",
" try:",
" with open(path, 'r', encoding='utf-8') as fh:",
" return fh.read()",
" except Exception:",
" return ''",
"job = read_json('/tmp/agentrun-job.json') or {}",
"status = job.get('status') or {}",
"log = read_text('/tmp/agentrun-job.log')",
"succeeded = int(status.get('succeeded') or 0) > 0",
"failed = int(status.get('failed') or 0) > 0",
"print(json.dumps({'ok': os.environ.get('JOB_EXIT') == '0', 'jobName': os.environ.get('JOB'), 'succeeded': succeeded, 'failed': failed, 'active': status.get('active') or 0, 'logsTail': log[-4000:], 'valuesPrinted': False}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -619,38 +634,53 @@ export function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string {
"repo_path=\"/cache/${repository}.git\"",
"rm -f /tmp/agentrun-gitmirror-refs.txt",
"if [ \"$read_exit\" -eq 0 ]; then",
" kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; gitops_branch=\"$3\"; printf \"sourceCommit=\"; git --git-dir=\"$repo_path\" rev-parse \"refs/heads/$source_branch\" 2>/dev/null || true; printf \"gitopsCommit=\"; git --git-dir=\"$repo_path\" rev-parse \"refs/heads/$gitops_branch\" 2>/dev/null || true' sh \"$repo_path\" \"$source_branch\" \"$gitops_branch\" > /tmp/agentrun-gitmirror-refs.txt 2>/dev/null",
" kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; gitops_branch=\"$3\"; source_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$source_branch^{commit}\" 2>/dev/null || true); gitops_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$gitops_branch^{commit}\" 2>/dev/null || true); printf \"sourceCommit=%s\\n\" \"$source_commit\"; printf \"gitopsCommit=%s\\n\" \"$gitops_commit\"' sh \"$repo_path\" \"$source_branch\" \"$gitops_branch\" > /tmp/agentrun-gitmirror-refs.txt 2>/dev/null",
"fi",
"NAMESPACE=\"$namespace\" READ_EXIT=\"$read_exit\" WRITE_EXIT=\"$write_exit\" CACHE_EXIT=\"$cache_exit\" CACHE_MODE=\"$cache_mode\" CACHE_HOST_PATH=\"$cache_host_path\" REPOSITORY=\"$repository\" SOURCE_BRANCH=\"$source_branch\" GITOPS_BRANCH=\"$gitops_branch\" REPOSITORIES_JSON=\"$repositories_json\" node <<'NODE'",
"const fs = require('node:fs');",
"const repositories = JSON.parse(process.env.REPOSITORIES_JSON || '[]');",
"let names = ''; try { names = fs.readFileSync('/tmp/agentrun-gitmirror-names.txt', 'utf8'); } catch {}",
"let refs = ''; try { refs = fs.readFileSync('/tmp/agentrun-gitmirror-refs.txt', 'utf8'); } catch {}",
"const refValue = (key) => refs.split(/\\r?\\n/).find((line) => line.startsWith(`${key}=`))?.slice(key.length + 1).trim() || null;",
"const readReady = process.env.READ_EXIT === '0';",
"const writeReady = process.env.WRITE_EXIT === '0';",
"const cachePvcExists = process.env.CACHE_EXIT === '0';",
"const cacheMode = process.env.CACHE_MODE || 'pvc';",
"const cacheReady = cacheMode === 'hostPath' ? true : cachePvcExists;",
"console.log(JSON.stringify({",
" ok: readReady && writeReady && cacheReady,",
" namespace: process.env.NAMESPACE,",
" readReady,",
" writeReady,",
" cacheMode,",
" cacheReady,",
" cachePvcExists,",
" cacheHostPath: process.env.CACHE_HOST_PATH || null,",
" resources: names.split(/\\r?\\n/).filter(Boolean).slice(0, 40),",
" repository: process.env.REPOSITORY,",
" sourceBranch: process.env.SOURCE_BRANCH,",
" gitopsBranch: process.env.GITOPS_BRANCH,",
" sourceCommit: refValue('sourceCommit'),",
" gitopsCommit: refValue('gitopsCommit'),",
" repositories,",
" valuesPrinted: false",
"}));",
"NODE",
"NAMESPACE=\"$namespace\" READ_EXIT=\"$read_exit\" WRITE_EXIT=\"$write_exit\" CACHE_EXIT=\"$cache_exit\" CACHE_MODE=\"$cache_mode\" CACHE_HOST_PATH=\"$cache_host_path\" REPOSITORY=\"$repository\" SOURCE_BRANCH=\"$source_branch\" GITOPS_BRANCH=\"$gitops_branch\" REPOSITORIES_JSON=\"$repositories_json\" python3 - <<'PY'",
"import json, os, re",
"def read_text(path):",
" try:",
" with open(path, 'r', encoding='utf-8') as fh:",
" return fh.read()",
" except Exception:",
" return ''",
"def ref_value(text, key):",
" prefix = key + '='",
" for line in re.split(r'\\r?\\n', text):",
" if line.startswith(prefix):",
" value = line[len(prefix):].strip()",
" return value or None",
" return None",
"try:",
" repositories = json.loads(os.environ.get('REPOSITORIES_JSON') or '[]')",
"except Exception:",
" repositories = []",
"names = read_text('/tmp/agentrun-gitmirror-names.txt')",
"refs = read_text('/tmp/agentrun-gitmirror-refs.txt')",
"read_ready = os.environ.get('READ_EXIT') == '0'",
"write_ready = os.environ.get('WRITE_EXIT') == '0'",
"cache_pvc_exists = os.environ.get('CACHE_EXIT') == '0'",
"cache_mode = os.environ.get('CACHE_MODE') or 'pvc'",
"cache_ready = True if cache_mode == 'hostPath' else cache_pvc_exists",
"print(json.dumps({",
" 'ok': read_ready and write_ready and cache_ready,",
" 'namespace': os.environ.get('NAMESPACE'),",
" 'readReady': read_ready,",
" 'writeReady': write_ready,",
" 'cacheMode': cache_mode,",
" 'cacheReady': cache_ready,",
" 'cachePvcExists': cache_pvc_exists,",
" 'cacheHostPath': os.environ.get('CACHE_HOST_PATH') or None,",
" 'resources': [line for line in re.split(r'\\r?\\n', names) if line][:40],",
" 'repository': os.environ.get('REPOSITORY'),",
" 'sourceBranch': os.environ.get('SOURCE_BRANCH'),",
" 'gitopsBranch': os.environ.get('GITOPS_BRANCH'),",
" 'sourceCommit': ref_value(refs, 'sourceCommit'),",
" 'gitopsCommit': ref_value(refs, 'gitopsCommit'),",
" 'repositories': repositories,",
" 'valuesPrinted': False",
"}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -660,6 +690,7 @@ export type LaneSecretSource = {
sourceMode: "env" | "file";
sourceKey: string | null;
targetRef: { namespace: string; name: string; key: string };
transform?: "local-postgres-database" | "local-postgres-user" | "local-postgres-database-url";
};
export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecretSource): { redactedPath: string; value: string; valueBytes: number; fingerprint: string } {
@@ -680,6 +711,7 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret
value = envValue;
}
if (value.length === 0) throw new Error(`secret source ${sourceRef} is empty`);
value = transformSecretSourceValue(spec, source, value);
return {
redactedPath: sourceRef.startsWith("/") ? redactAbsoluteSecretPath(sourceRef) : `.state/secrets/${sourceRef}`,
value,
@@ -688,6 +720,20 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret
};
}
function transformSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecretSource, rawValue: string): string {
if (source.transform === undefined) return rawValue;
const localPostgres = spec.deployment.localPostgres;
if (!localPostgres.enabled || localPostgres.serviceName === null || localPostgres.database === null || localPostgres.user === null || localPostgres.port === null) {
throw new Error(`secret source ${source.id} requires enabled localPostgres with database/user/port`);
}
if (source.transform === "local-postgres-database") return localPostgres.database;
if (source.transform === "local-postgres-user") return localPostgres.user;
const user = encodeURIComponent(localPostgres.user);
const password = encodeURIComponent(rawValue);
const database = encodeURIComponent(localPostgres.database);
return `postgresql://${user}:${password}@${localPostgres.serviceName}:${localPostgres.port}/${database}`;
}
export function redactAbsoluteSecretPath(sourceRef: string): string {
const parts = sourceRef.split("/").filter(Boolean);
return parts.length === 0 ? "/" : `/${parts.slice(0, -1).join("/")}/<redacted>`;
@@ -732,6 +778,45 @@ export function collectLaneSecretSources(spec: AgentRunLaneSpec): LaneSecretSour
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: spec.database.secretRef.key },
});
}
const localPostgres = spec.deployment.localPostgres;
if (spec.database.mode === "local-postgres" && localPostgres.enabled) {
if (localPostgres.passwordSourceRef === null || localPostgres.passwordSourceKey === null) {
throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.deployment.localPostgres must declare passwordSourceRef/passwordSourceKey`);
}
result.push(
{
id: "local-postgres-password",
sourceRef: localPostgres.passwordSourceRef,
sourceMode: "env",
sourceKey: localPostgres.passwordSourceKey,
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: "POSTGRES_PASSWORD" },
},
{
id: "local-postgres-user",
sourceRef: localPostgres.passwordSourceRef,
sourceMode: "env",
sourceKey: localPostgres.passwordSourceKey,
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: "POSTGRES_USER" },
transform: "local-postgres-user",
},
{
id: "local-postgres-database",
sourceRef: localPostgres.passwordSourceRef,
sourceMode: "env",
sourceKey: localPostgres.passwordSourceKey,
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: "POSTGRES_DB" },
transform: "local-postgres-database",
},
{
id: "database",
sourceRef: localPostgres.passwordSourceRef,
sourceMode: "env",
sourceKey: localPostgres.passwordSourceKey,
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: spec.database.secretRef.key },
transform: "local-postgres-database-url",
},
);
}
for (const secret of spec.secrets) {
result.push({
id: secret.id,
@@ -762,47 +847,44 @@ export function secretSyncScript(_spec: AgentRunLaneSpec, values: Array<{ target
"trap 'rm -rf \"$tmp_dir\"' EXIT",
"payload_json=\"$tmp_dir/payload.json\"",
"printf '%s' \"$payload_b64\" | base64 -d > \"$payload_json\"",
"PAYLOAD_JSON=\"$payload_json\" TMP_DIR=\"$tmp_dir\" node <<'NODE'",
"const fs = require('node:fs');",
"const cp = require('node:child_process');",
"const crypto = require('node:crypto');",
"const items = JSON.parse(fs.readFileSync(process.env.PAYLOAD_JSON, 'utf8'));",
"const results = [];",
"const groups = new Map();",
"function run(argv, input) {",
" const out = cp.spawnSync(argv[0], argv.slice(1), { input, encoding: 'utf8' });",
" if (out.status !== 0) throw new Error(`${argv.join(' ')} failed: ${out.stderr || out.stdout}`);",
" return out;",
"}",
"function tryRun(argv) {",
" return cp.spawnSync(argv[0], argv.slice(1), { encoding: 'utf8' });",
"}",
"for (let index = 0; index < items.length; index += 1) {",
" const item = items[index];",
" const ref = item.targetRef;",
" const groupKey = `${ref.namespace}\\u0000${ref.name}`;",
" if (!groups.has(groupKey)) groups.set(groupKey, { namespace: ref.namespace, name: ref.name, items: [] });",
" groups.get(groupKey).items.push({ key: ref.key, valueBase64: item.valueBase64 });",
"}",
"for (const group of groups.values()) {",
" const ns = run(['kubectl', 'create', 'namespace', group.namespace, '--dry-run=client', '-o', 'yaml']).stdout;",
" run(['kubectl', 'apply', '--server-side', '--field-manager=unidesk-agentrun-secret-sync', '-f', '-'], ns);",
" const existing = tryRun(['kubectl', '-n', group.namespace, 'get', 'secret', group.name]);",
" if (existing.status !== 0) run(['kubectl', '-n', group.namespace, 'create', 'secret', 'generic', group.name]);",
" const patch = { data: {} };",
" for (const entry of group.items) patch.data[entry.key] = entry.valueBase64;",
" const patchFile = `${process.env.TMP_DIR}/${group.namespace}-${group.name}-patch.json`;",
" fs.writeFileSync(patchFile, JSON.stringify(patch));",
" run(['kubectl', '-n', group.namespace, 'patch', 'secret', group.name, '--type=merge', `--patch-file=${patchFile}`]);",
" const fetched = JSON.parse(run(['kubectl', '-n', group.namespace, 'get', 'secret', group.name, '-o', 'json']).stdout);",
" for (const entry of group.items) {",
" const raw = fetched.data?.[entry.key] || '';",
" const decoded = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);",
" results.push({ namespace: group.namespace, secret: group.name, key: entry.key, ok: raw.length > 0, valueBytes: decoded.length, fingerprint: raw ? 'sha256:' + crypto.createHash('sha256').update(decoded).digest('hex') : null, valuesPrinted: false });",
" }",
"}",
"console.log(JSON.stringify({ ok: results.every((item) => item.ok), secretCount: results.length, items: results, valuesPrinted: false }));",
"NODE",
"PAYLOAD_JSON=\"$payload_json\" TMP_DIR=\"$tmp_dir\" python3 - <<'PY'",
"import base64, hashlib, json, os, subprocess",
"def run(argv, input_text=None):",
" out = subprocess.run(argv, input=input_text, text=True, capture_output=True)",
" if out.returncode != 0:",
" raise RuntimeError('%s failed: %s' % (' '.join(argv), (out.stderr or out.stdout).strip()))",
" return out",
"def try_run(argv):",
" return subprocess.run(argv, text=True, capture_output=True)",
"with open(os.environ['PAYLOAD_JSON'], 'r', encoding='utf-8') as fh:",
" items = json.load(fh)",
"groups = {}",
"for item in items:",
" ref = item.get('targetRef') or {}",
" key = (ref.get('namespace'), ref.get('name'))",
" groups.setdefault(key, {'namespace': ref.get('namespace'), 'name': ref.get('name'), 'items': []})['items'].append({'key': ref.get('key'), 'valueBase64': item.get('valueBase64') or ''})",
"results = []",
"for group in groups.values():",
" namespace = group['namespace']",
" name = group['name']",
" ns_yaml = run(['kubectl', 'create', 'namespace', namespace, '--dry-run=client', '-o', 'yaml']).stdout",
" run(['kubectl', 'apply', '--server-side', '--field-manager=unidesk-agentrun-secret-sync', '-f', '-'], ns_yaml)",
" existing = try_run(['kubectl', '-n', namespace, 'get', 'secret', name])",
" if existing.returncode != 0:",
" run(['kubectl', '-n', namespace, 'create', 'secret', 'generic', name])",
" patch = {'data': {entry['key']: entry['valueBase64'] for entry in group['items']}}",
" patch_file = os.path.join(os.environ['TMP_DIR'], '%s-%s-patch.json' % (namespace, name))",
" with open(patch_file, 'w', encoding='utf-8') as fh:",
" json.dump(patch, fh)",
" run(['kubectl', '-n', namespace, 'patch', 'secret', name, '--type=merge', '--patch-file=%s' % patch_file])",
" fetched = json.loads(run(['kubectl', '-n', namespace, 'get', 'secret', name, '-o', 'json']).stdout)",
" data = fetched.get('data') or {}",
" for entry in group['items']:",
" raw = data.get(entry['key']) or ''",
" decoded = base64.b64decode(raw.encode('utf-8')) if raw else b''",
" results.append({'namespace': namespace, 'secret': name, 'key': entry['key'], 'ok': bool(raw), 'valueBytes': len(decoded), 'fingerprint': ('sha256:' + hashlib.sha256(decoded).hexdigest()) if raw else None, 'valuesPrinted': False})",
"print(json.dumps({'ok': all(item['ok'] for item in results), 'secretCount': len(results), 'items': results, 'valuesPrinted': False}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -869,15 +951,20 @@ export function applyYamlScript(yaml: string, fieldManager: string, dryRun: bool
"kubectl apply $args -f \"$manifest\" > \"$tmp_dir/apply.out\" 2> \"$tmp_dir/apply.err\"",
"apply_exit=$?",
"set -e",
"APPLY_EXIT=\"$apply_exit\" APPLY_OUT=\"$tmp_dir/apply.out\" APPLY_ERR=\"$tmp_dir/apply.err\" MANIFEST=\"$manifest\" node <<'NODE'",
"const fs = require('node:fs');",
"const crypto = require('node:crypto');",
"const out = fs.readFileSync(process.env.APPLY_OUT, 'utf8');",
"const err = fs.readFileSync(process.env.APPLY_ERR, 'utf8');",
"const manifest = fs.readFileSync(process.env.MANIFEST, 'utf8');",
"const resources = out.split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean).slice(0, 80);",
"console.log(JSON.stringify({ ok: process.env.APPLY_EXIT === '0', exitCode: Number(process.env.APPLY_EXIT), resourceCount: resources.length, resources, manifestBytes: Buffer.byteLength(manifest, 'utf8'), manifestDigest: 'sha256:' + crypto.createHash('sha256').update(manifest).digest('hex'), stderrTail: err.slice(-3000), valuesPrinted: false }));",
"NODE",
"APPLY_EXIT=\"$apply_exit\" APPLY_OUT=\"$tmp_dir/apply.out\" APPLY_ERR=\"$tmp_dir/apply.err\" MANIFEST=\"$manifest\" python3 - <<'PY'",
"import hashlib, json, os, re",
"def read_bytes(path):",
" try:",
" with open(path, 'rb') as fh:",
" return fh.read()",
" except Exception:",
" return b''",
"out = read_bytes(os.environ['APPLY_OUT']).decode('utf-8', errors='replace')",
"err = read_bytes(os.environ['APPLY_ERR']).decode('utf-8', errors='replace')",
"manifest = read_bytes(os.environ['MANIFEST'])",
"resources = [line.strip() for line in re.split(r'\\r?\\n', out) if line.strip()][:80]",
"print(json.dumps({'ok': os.environ.get('APPLY_EXIT') == '0', 'exitCode': int(os.environ.get('APPLY_EXIT') or '1'), 'resourceCount': len(resources), 'resources': resources, 'manifestBytes': len(manifest), 'manifestDigest': 'sha256:' + hashlib.sha256(manifest).hexdigest(), 'stderrTail': err[-3000:], 'valuesPrinted': False}, ensure_ascii=False))",
"PY",
"exit \"$apply_exit\"",
].join("\n");
}
+363 -67
View File
@@ -354,26 +354,78 @@ export function yamlLaneSourceStatusScript(spec: AgentRunLaneSpec): string {
" actual_workspace=$(pwd)",
"fi",
"export expected_workspace source_branch workspace_exists workspace_clean local_head branch remote_url remote_branch_exists remote_branch_commit status_short actual_workspace",
"node <<'NODE'",
"function nullable(value) { return value && value !== 'null' ? value : null; }",
"function booleanValue(value) { if (value === 'true') return true; if (value === 'false') return false; return null; }",
"const env = process.env;",
"console.log(JSON.stringify({",
" ok: env.workspace_exists === 'true',",
" expectedWorkspace: env.expected_workspace,",
" actualWorkspace: env.actual_workspace || null,",
" workspaceExists: env.workspace_exists === 'true',",
" workspaceClean: booleanValue(env.workspace_clean),",
" branch: nullable(env.branch),",
" remoteUrl: nullable(env.remote_url),",
" localHead: nullable(env.local_head),",
" remoteBranch: env.source_branch,",
" remoteBranchExists: env.remote_branch_exists === 'true',",
" remoteBranchCommit: nullable(env.remote_branch_commit),",
" statusShort: nullable(env.status_short),",
" valuesPrinted: false",
"}));",
"NODE",
"python3 - <<'PY'",
"import json, os",
"def nullable(value):",
" return value if value and value != 'null' else None",
"def boolean_value(value):",
" if value == 'true': return True",
" if value == 'false': return False",
" return None",
"env = os.environ",
"print(json.dumps({",
" 'ok': env.get('workspace_exists') == 'true',",
" 'statusMode': 'host-worktree',",
" 'expectedWorkspace': env.get('expected_workspace'),",
" 'actualWorkspace': env.get('actual_workspace') or None,",
" 'workspaceExists': env.get('workspace_exists') == 'true',",
" 'workspaceClean': boolean_value(env.get('workspace_clean')),",
" 'branch': nullable(env.get('branch')),",
" 'remoteUrl': nullable(env.get('remote_url')),",
" 'localHead': nullable(env.get('local_head')),",
" 'remoteBranch': env.get('source_branch'),",
" 'remoteBranchExists': env.get('remote_branch_exists') == 'true',",
" 'remoteBranchCommit': nullable(env.get('remote_branch_commit')),",
" 'statusShort': nullable(env.get('status_short')),",
" 'valuesPrinted': False,",
"}, ensure_ascii=False))",
"PY",
].join("\n");
}
export function yamlLaneK3sSourceStatusScript(spec: AgentRunLaneSpec): string {
return [
"set +e",
`namespace=${shQuote(spec.gitMirror.namespace)}`,
`read_deployment=${shQuote(spec.gitMirror.readDeployment)}`,
`repository=${shQuote(spec.source.repository)}`,
`source_branch=${shQuote(spec.source.branch)}`,
"repo_path=\"/cache/${repository}.git\"",
"kubectl -n \"$namespace\" get deploy \"$read_deployment\" >/tmp/agentrun-source-read-deploy.txt 2>/dev/null",
"read_exit=$?",
"source_commit=''",
"if [ \"$read_exit\" -eq 0 ]; then",
" source_commit=$(kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$source_branch^{commit}\" 2>/dev/null || true' sh \"$repo_path\" \"$source_branch\" 2>/dev/null | tail -n 1 | tr -d '\\r')",
"fi",
"export namespace read_deployment repository source_branch read_exit source_commit",
"python3 - <<'PY'",
"import json, os",
"source_commit = os.environ.get('source_commit') or None",
"read_ready = os.environ.get('read_exit') == '0'",
"print(json.dumps({",
" 'ok': read_ready and source_commit is not None,",
" 'statusMode': 'k3s-git-mirror',",
" 'expectedWorkspace': None,",
" 'actualWorkspace': None,",
" 'workspaceExists': None,",
" 'workspaceClean': None,",
" 'branch': os.environ.get('source_branch'),",
" 'remoteUrl': None,",
" 'localHead': source_commit,",
" 'remoteBranch': os.environ.get('source_branch'),",
" 'remoteBranchExists': source_commit is not None,",
" 'remoteBranchCommit': source_commit,",
" 'sourceCommit': source_commit,",
" 'gitMirror': {",
" 'namespace': os.environ.get('namespace'),",
" 'readDeployment': os.environ.get('read_deployment'),",
" 'readReady': read_ready,",
" 'repository': os.environ.get('repository'),",
" },",
" 'statusShort': None,",
" 'valuesPrinted': False,",
"}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -412,54 +464,70 @@ export function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun:
"manager_svc_exit=$?",
"kubectl -n \"$runtime_namespace\" get secret \"$database_secret\" -o json > \"$tmp_dir/db-secret.json\" 2>/dev/null",
"db_secret_exit=$?",
"SECRET_REFS_JSON=\"$secrets_json\" NODE_TMP=\"$tmp_dir\" node <<'NODE' > \"$tmp_dir/secrets.json\"",
"const fs = require('node:fs');",
"const cp = require('node:child_process');",
"const refs = JSON.parse(process.env.SECRET_REFS_JSON || '[]');",
"const result = [];",
"for (const ref of refs) {",
" const out = cp.spawnSync('kubectl', ['-n', ref.namespace, 'get', 'secret', ref.name, '-o', 'json'], { encoding: 'utf8' });",
" let keyPresent = false;",
" if (out.status === 0) {",
" try { keyPresent = Object.prototype.hasOwnProperty.call((JSON.parse(out.stdout).data || {}), ref.key); } catch {}",
" }",
" result.push({ namespace: ref.namespace, name: ref.name, key: ref.key, present: out.status === 0, keyPresent, valuesPrinted: false });",
"}",
"console.log(JSON.stringify({ ready: result.every((item) => item.present && item.keyPresent), count: result.length, items: result, valuesPrinted: false }));",
"NODE",
"SECRET_REFS_JSON=\"$secrets_json\" python3 - <<'PY' > \"$tmp_dir/secrets.json\"",
"import json, os, subprocess",
"refs = json.loads(os.environ.get('SECRET_REFS_JSON') or '[]')",
"result = []",
"for ref in refs:",
" out = subprocess.run(['kubectl', '-n', ref.get('namespace', ''), 'get', 'secret', ref.get('name', ''), '-o', 'json'], text=True, capture_output=True)",
" key_present = False",
" if out.returncode == 0:",
" try:",
" key_present = ref.get('key') in (json.loads(out.stdout).get('data') or {})",
" except Exception:",
" key_present = False",
" result.append({'namespace': ref.get('namespace'), 'name': ref.get('name'), 'key': ref.get('key'), 'present': out.returncode == 0, 'keyPresent': key_present, 'valuesPrinted': False})",
"print(json.dumps({'ready': all(item['present'] and item['keyPresent'] for item in result), 'count': len(result), 'items': result, 'valuesPrinted': False}, ensure_ascii=False))",
"PY",
"kubectl -n \"$runtime_namespace\" get deploy,sts,svc,secret -o name > \"$tmp_dir/runtime-names.txt\" 2>/dev/null",
"NODE_TMP=\"$tmp_dir\" RUNTIME_NS_EXIT=\"$runtime_ns_exit\" CI_NS_EXIT=\"$ci_ns_exit\" PIPELINE_EXIT=\"$pipeline_exit\" SERVICE_ACCOUNT_EXIT=\"$sa_exit\" PIPELINERUN_EXIT=\"$pr_exit\" ARGO_EXIT=\"$argo_exit\" MANAGER_DEPLOY_EXIT=\"$manager_deploy_exit\" MANAGER_SVC_EXIT=\"$manager_svc_exit\" DB_SECRET_EXIT=\"$db_secret_exit\" node <<'NODE'",
"const fs = require('node:fs');",
"const path = require('node:path');",
"const dir = process.env.NODE_TMP;",
"function readJson(name) { try { return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { return null; } }",
"function exists(exitName) { return process.env[exitName] === '0'; }",
"function condition(obj) { return obj?.status?.conditions?.[0] || null; }",
"function envValue(deploy, name) { return deploy?.spec?.template?.spec?.containers?.[0]?.env?.find((entry) => entry.name === name)?.value || null; }",
"function pipelineRunParam(obj, name) { return (obj?.spec?.params || []).find((entry) => entry.name === name)?.value || null; }",
"const pipelineRun = readJson('pipelinerun.json');",
"const argo = readJson('argo.json');",
"const managerDeploy = readJson('manager-deploy.json');",
"const managerSvc = readJson('manager-svc.json');",
"const dbSecret = readJson('db-secret.json');",
"const secrets = readJson('secrets.json') || { ready: true, count: 0, items: [], valuesPrinted: false };",
"let names = ''; try { names = fs.readFileSync(path.join(dir, 'runtime-names.txt'), 'utf8'); } catch {}",
"const c = condition(pipelineRun);",
"console.log(JSON.stringify({",
" ok: exists('RUNTIME_NS_EXIT') && exists('CI_NS_EXIT'),",
" runtimeNamespaceExists: exists('RUNTIME_NS_EXIT'),",
" ciNamespaceExists: exists('CI_NS_EXIT'),",
" serviceAccountExists: exists('SERVICE_ACCOUNT_EXIT'),",
" pipeline: { exists: exists('PIPELINE_EXIT'), name: process.env.pipeline_name },",
" pipelineRun: { exists: exists('PIPELINERUN_EXIT'), name: process.env.pipeline_run || null, status: c?.status || null, reason: c?.reason || null, sourceCommit: pipelineRunParam(pipelineRun, 'revision'), startTime: pipelineRun?.status?.startTime || null, completionTime: pipelineRun?.status?.completionTime || null },",
" argo: { exists: exists('ARGO_EXIT'), namespace: process.env.argo_namespace, application: process.env.argo_application, revision: argo?.status?.sync?.revision || null, syncStatus: argo?.status?.sync?.status || null, healthStatus: argo?.status?.health?.status || null },",
" manager: { deploymentExists: exists('MANAGER_DEPLOY_EXIT'), serviceExists: exists('MANAGER_SVC_EXIT'), deployment: process.env.manager_deployment, service: process.env.manager_service, image: managerDeploy?.spec?.template?.spec?.containers?.[0]?.image || null, sourceCommit: envValue(managerDeploy, 'AGENTRUN_SOURCE_COMMIT'), servicePorts: Array.isArray(managerSvc?.spec?.ports) ? managerSvc.spec.ports.map((port) => ({ name: port.name || null, port: port.port || null, targetPort: port.targetPort || null })) : [] },",
" database: { secretPresent: exists('DB_SECRET_EXIT'), secretName: process.env.database_secret, key: process.env.database_key, keyPresent: Boolean(dbSecret?.data && Object.prototype.hasOwnProperty.call(dbSecret.data, process.env.database_key || '')) , valuesPrinted: false },",
" secrets,",
" localPostgres: { absent: !/postgres/i.test(names), matchingObjects: names.split(/\\r?\\n/).filter((line) => /postgres/i.test(line)).slice(0, 20) },",
" valuesPrinted: false",
"}));",
"NODE",
"NODE_TMP=\"$tmp_dir\" RUNTIME_NS_EXIT=\"$runtime_ns_exit\" CI_NS_EXIT=\"$ci_ns_exit\" PIPELINE_EXIT=\"$pipeline_exit\" SERVICE_ACCOUNT_EXIT=\"$sa_exit\" PIPELINERUN_EXIT=\"$pr_exit\" ARGO_EXIT=\"$argo_exit\" MANAGER_DEPLOY_EXIT=\"$manager_deploy_exit\" MANAGER_SVC_EXIT=\"$manager_svc_exit\" DB_SECRET_EXIT=\"$db_secret_exit\" python3 - <<'PY'",
"import json, os, pathlib, re",
"base = pathlib.Path(os.environ['NODE_TMP'])",
"def read_json(name):",
" try: return json.loads((base / name).read_text())",
" except Exception: return None",
"def exists(name): return os.environ.get(name) == '0'",
"def condition(obj):",
" conds = (((obj or {}).get('status') or {}).get('conditions') or [])",
" return conds[0] if conds else {}",
"def env_value(deploy, name):",
" containers = (((((deploy or {}).get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or [])",
" envs = (containers[0].get('env') or []) if containers else []",
" for entry in envs:",
" if entry.get('name') == name: return entry.get('value')",
" return None",
"def pipeline_run_param(obj, name):",
" for entry in (((obj or {}).get('spec') or {}).get('params') or []):",
" if entry.get('name') == name: return entry.get('value')",
" return None",
"pipeline_run = read_json('pipelinerun.json')",
"argo = read_json('argo.json')",
"manager_deploy = read_json('manager-deploy.json')",
"manager_svc = read_json('manager-svc.json')",
"db_secret = read_json('db-secret.json')",
"secrets = read_json('secrets.json') or {'ready': True, 'count': 0, 'items': [], 'valuesPrinted': False}",
"try: names = (base / 'runtime-names.txt').read_text()",
"except Exception: names = ''",
"c = condition(pipeline_run)",
"ports = []",
"for port in ((((manager_svc or {}).get('spec') or {}).get('ports')) or []):",
" ports.append({'name': port.get('name'), 'port': port.get('port'), 'targetPort': port.get('targetPort')})",
"matching_postgres = [line for line in re.split(r'\\r?\\n', names) if re.search('postgres', line, re.I)][:20]",
"print(json.dumps({",
" 'ok': exists('RUNTIME_NS_EXIT') and exists('CI_NS_EXIT'),",
" 'runtimeNamespaceExists': exists('RUNTIME_NS_EXIT'),",
" 'ciNamespaceExists': exists('CI_NS_EXIT'),",
" 'serviceAccountExists': exists('SERVICE_ACCOUNT_EXIT'),",
" 'pipeline': {'exists': exists('PIPELINE_EXIT'), 'name': os.environ.get('pipeline_name')},",
" 'pipelineRun': {'exists': exists('PIPELINERUN_EXIT'), 'name': os.environ.get('pipeline_run') or None, 'status': c.get('status'), 'reason': c.get('reason'), 'sourceCommit': pipeline_run_param(pipeline_run, 'revision'), 'startTime': ((pipeline_run or {}).get('status') or {}).get('startTime'), 'completionTime': ((pipeline_run or {}).get('status') or {}).get('completionTime')},",
" 'argo': {'exists': exists('ARGO_EXIT'), 'namespace': os.environ.get('argo_namespace'), 'application': os.environ.get('argo_application'), 'revision': (((argo or {}).get('status') or {}).get('sync') or {}).get('revision'), 'syncStatus': (((argo or {}).get('status') or {}).get('sync') or {}).get('status'), 'healthStatus': (((argo or {}).get('status') or {}).get('health') or {}).get('status')},",
" 'manager': {'deploymentExists': exists('MANAGER_DEPLOY_EXIT'), 'serviceExists': exists('MANAGER_SVC_EXIT'), 'deployment': os.environ.get('manager_deployment'), 'service': os.environ.get('manager_service'), 'image': ((((((manager_deploy or {}).get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or [{}])[0]).get('image'), 'sourceCommit': env_value(manager_deploy, 'AGENTRUN_SOURCE_COMMIT'), 'servicePorts': ports},",
" 'database': {'secretPresent': exists('DB_SECRET_EXIT'), 'secretName': os.environ.get('database_secret'), 'key': os.environ.get('database_key'), 'keyPresent': os.environ.get('database_key') in (((db_secret or {}).get('data') or {})), 'valuesPrinted': False},",
" 'secrets': secrets,",
" 'localPostgres': {'absent': len(matching_postgres) == 0, 'matchingObjects': matching_postgres},",
" 'valuesPrinted': False,",
"}, ensure_ascii=False))",
"PY",
].join("\n");
}
@@ -930,6 +998,234 @@ export function yamlLaneBuildImageStatusScript(spec: AgentRunLaneSpec, jobId: st
].join("\n");
}
export function yamlLaneK3sBuildImageJobManifest(spec: AgentRunLaneSpec, sourceCommit: string, jobName: string): Record<string, unknown> {
const build = spec.deployment.manager.imageBuild;
if (spec.ci.buildkitImage === null) throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.ci.buildkitImage is required when source.statusMode=k3s-git-mirror`);
const imageRepository = `${spec.ci.registryPrefix}/${build.repository}`;
const envIdentity = `source-${sourceCommit.slice(0, 12)}`;
const image = `${imageRepository}:${envIdentity}`;
const proxyEnv = yamlLaneK3sBuildProxyEnv(spec);
return {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: jobName,
namespace: spec.ci.namespace,
labels: {
"app.kubernetes.io/name": "agentrun-manager-image-build",
"app.kubernetes.io/part-of": "agentrun",
"agentrun.pikastech.local/lane": spec.version,
"agentrun.pikastech.local/node": spec.nodeId,
"agentrun.pikastech.local/source-commit": sourceCommit,
},
},
spec: {
backoffLimit: 0,
activeDeadlineSeconds: Math.max(60, build.timeoutSeconds),
ttlSecondsAfterFinished: 3600,
template: {
metadata: {
labels: {
"app.kubernetes.io/name": "agentrun-manager-image-build",
"app.kubernetes.io/part-of": "agentrun",
"agentrun.pikastech.local/lane": spec.version,
"agentrun.pikastech.local/node": spec.nodeId,
"agentrun.pikastech.local/source-commit": sourceCommit,
},
},
spec: {
restartPolicy: "Never",
serviceAccountName: spec.ci.serviceAccountName,
hostNetwork: true,
dnsPolicy: "ClusterFirstWithHostNet",
securityContext: { fsGroup: 1000 },
volumes: [
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
{ name: "buildkit-state", emptyDir: { sizeLimit: "8Gi" } },
{ name: "tmp", emptyDir: {} },
],
initContainers: [{
name: "source",
image: spec.ci.toolsImage,
imagePullPolicy: "IfNotPresent",
env: proxyEnv,
command: ["/bin/sh", "-ec", yamlLaneK3sBuildSourceShell(spec, sourceCommit)],
volumeMounts: [{ name: "workspace", mountPath: "/workspace" }],
}],
containers: [{
name: "buildkit",
image: spec.ci.buildkitImage,
imagePullPolicy: "IfNotPresent",
env: [
...proxyEnv,
{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" },
],
command: ["/bin/sh", "-ec", yamlLaneK3sBuildImageShell(spec, sourceCommit, image, envIdentity)],
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
volumeMounts: [
{ name: "workspace", mountPath: "/workspace" },
{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" },
{ name: "tmp", mountPath: "/tmp" },
],
}],
},
},
},
};
}
export async function runYamlLaneK3sBuildImageJob(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string): Promise<Record<string, unknown> & { ok: boolean; payload: Record<string, unknown> }> {
const jobName = `agentrun-build-${spec.nodeId.toLowerCase()}-${spec.lane}-${sourceCommit.slice(0, 12)}`.slice(0, 63);
const manifest = yamlLaneK3sBuildImageJobManifest(spec, sourceCommit, jobName);
const created = await capture(config, spec.nodeKubeRoute, ["sh", "--", createYamlLaneJobScript(spec.ci.namespace, jobName, manifest)]);
const createPayload = captureJsonPayload(created);
if (created.exitCode !== 0 || createPayload.ok === false) {
return {
ok: false,
payload: { ok: false, status: "create-failed", jobName, degradedReason: "k3s-buildkit-job-create-failed", valuesPrinted: false },
create: createPayload,
capture: compactCapture(created, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
const startedAt = Date.now();
const timeoutMs = Math.max(60, spec.deployment.manager.imageBuild.timeoutSeconds) * 1000;
const pollMs = Math.max(1, spec.deployment.manager.imageBuild.pollSeconds) * 1000;
let polls = 0;
let lastPayload: Record<string, unknown> = {};
let lastProbe: SshCaptureResult | null = null;
while (Date.now() - startedAt < timeoutMs) {
polls += 1;
lastProbe = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneJobProbeScript(spec.ci.namespace, jobName)]);
const probePayload = captureJsonPayload(lastProbe);
const buildPayload = yamlLaneGitopsPublishPayloadFromProbe(probePayload);
if (Object.keys(buildPayload).length > 0) lastPayload = buildPayload;
progressEvent("agentrun.yaml-lane.k3s-image-build.progress", {
node: spec.nodeId,
lane: spec.lane,
sourceCommit,
jobName,
polls,
status: stringOrNull(buildPayload.status) ?? (probePayload.succeeded === true ? "succeeded" : probePayload.failed === true ? "failed" : "running"),
digest: stringOrNull(buildPayload.digest),
elapsedMs: Date.now() - startedAt,
});
if (probePayload.succeeded === true) {
if (buildPayload.ok === true && stringOrNull(buildPayload.digest) !== null && stringOrNull(buildPayload.envIdentity) !== null) {
return { ok: true, payload: buildPayload, jobName, create: createPayload, polls, elapsedMs: Date.now() - startedAt, probe: compactCapture(lastProbe), valuesPrinted: false };
}
return {
ok: false,
payload: { ...buildPayload, ok: false, status: stringOrNull(buildPayload.status) ?? "succeeded-without-result", degradedReason: "k3s-buildkit-result-missing", jobName, valuesPrinted: false },
jobName,
create: createPayload,
polls,
elapsedMs: Date.now() - startedAt,
probe: compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
if (probePayload.failed === true) {
const payload = Object.keys(buildPayload).length > 0 ? buildPayload : { ok: false, status: "failed", degradedReason: "k3s-buildkit-job-failed", jobName, valuesPrinted: false };
return {
ok: false,
payload,
jobName,
create: createPayload,
polls,
elapsedMs: Date.now() - startedAt,
probe: compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
await sleep(pollMs);
}
return {
ok: false,
payload: { ...lastPayload, ok: false, status: "timeout", degradedReason: "k3s-buildkit-job-timeout", jobName, valuesPrinted: false },
jobName,
create: createPayload,
polls,
elapsedMs: Date.now() - startedAt,
probe: lastProbe === null ? null : compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
valuesPrinted: false,
};
}
function yamlLaneK3sBuildProxyEnv(spec: AgentRunLaneSpec): Array<{ name: string; value: string }> {
const build = spec.deployment.manager.imageBuild;
const result: Array<{ name: string; value: string }> = [];
const noProxy = build.noProxy.join(",");
const allProxy = build.httpsProxy ?? build.httpProxy;
if (build.httpProxy !== null) result.push({ name: "HTTP_PROXY", value: build.httpProxy }, { name: "http_proxy", value: build.httpProxy });
if (build.httpsProxy !== null) result.push({ name: "HTTPS_PROXY", value: build.httpsProxy }, { name: "https_proxy", value: build.httpsProxy });
if (allProxy !== null) result.push({ name: "ALL_PROXY", value: allProxy }, { name: "all_proxy", value: allProxy });
result.push({ name: "NO_PROXY", value: noProxy }, { name: "no_proxy", value: noProxy });
return result;
}
function yamlLaneK3sBuildSourceShell(spec: AgentRunLaneSpec, sourceCommit: string): string {
return [
"set -eu",
`read_url=${shQuote(spec.gitMirror.readUrl)}`,
`source_branch=${shQuote(spec.source.branch)}`,
`source_commit=${shQuote(sourceCommit)}`,
"rm -rf /workspace/repo",
"git clone --no-checkout \"$read_url\" /workspace/repo",
"cd /workspace/repo",
"git fetch origin \"refs/heads/$source_branch:refs/remotes/origin/$source_branch\"",
"git checkout --detach \"$source_commit\"",
"actual=$(git rev-parse HEAD)",
"test \"$actual\" = \"$source_commit\"",
"chmod -R a+rwX /workspace",
].join("\n");
}
function yamlLaneK3sBuildImageShell(spec: AgentRunLaneSpec, sourceCommit: string, image: string, envIdentity: string): string {
const build = spec.deployment.manager.imageBuild;
const contextPath = build.context === "." ? "/workspace/repo" : `/workspace/repo/${build.context.replace(/^\.\//u, "")}`;
const buildContainerNoProxy = build.buildContainerProxy.noProxy.join(",");
const buildArgs = [
...Object.entries(build.buildArgs).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `build-arg:${key}=${value}`),
...yamlLaneK3sBuildProxyBuildArgs(build.buildContainerProxy.httpProxy, build.buildContainerProxy.httpsProxy, buildContainerNoProxy),
];
const buildctlArgs = [
"build",
"--allow", "network.host",
"--frontend", "dockerfile.v0",
"--local", `context=${contextPath}`,
"--local", "dockerfile=/workspace/repo",
"--opt", `filename=${build.containerfile}`,
"--opt", `network=${build.network}`,
...buildArgs.flatMap((arg) => ["--opt", arg]),
"--metadata-file", "/workspace/build-metadata.json",
"--output", `type=image,name=${image},push=true,registry.insecure=true`,
];
const repositoryDigestBase = image.slice(0, image.lastIndexOf(":"));
return [
"set -eu",
"cd /workspace/repo",
`buildctl-daemonless.sh ${buildctlArgs.map((arg) => shQuote(arg)).join(" ")}`,
"metadata_compact=$(tr -d '\\n' < /workspace/build-metadata.json)",
"digest=$(printf '%s' \"$metadata_compact\" | sed -n 's/.*\"containerimage.digest\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | head -n 1)",
"if [ -z \"$digest\" ]; then",
` printf '{"ok":false,"status":"failed","failureKind":"image-digest-missing","sourceCommit":"%s","envIdentity":"%s","image":"%s","valuesPrinted":false}\\n' ${shQuote(sourceCommit)} ${shQuote(envIdentity)} ${shQuote(image)}`,
" exit 1",
"fi",
`printf '{"ok":true,"status":"built","sourceCommit":"%s","envIdentity":"%s","image":"%s","digest":"%s","repositoryDigest":"%s@%s","valuesPrinted":false}\\n' ${shQuote(sourceCommit)} ${shQuote(envIdentity)} ${shQuote(image)} "$digest" ${shQuote(repositoryDigestBase)} "$digest"`,
].join("\n");
}
function yamlLaneK3sBuildProxyBuildArgs(httpProxy: string | null, httpsProxy: string | null, noProxy: string): string[] {
const result: string[] = [];
const allProxy = httpsProxy ?? httpProxy;
if (httpProxy !== null) result.push(`build-arg:HTTP_PROXY=${httpProxy}`, `build-arg:http_proxy=${httpProxy}`);
if (httpsProxy !== null) result.push(`build-arg:HTTPS_PROXY=${httpsProxy}`, `build-arg:https_proxy=${httpsProxy}`);
if (allProxy !== null) result.push(`build-arg:ALL_PROXY=${allProxy}`, `build-arg:all_proxy=${allProxy}`);
if (noProxy.length > 0) result.push(`build-arg:NO_PROXY=${noProxy}`, `build-arg:no_proxy=${noProxy}`);
return result;
}
export async function runYamlLaneGitopsPublishJob(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string, files: readonly { path: string; content: string }[]): Promise<Record<string, unknown> & { ok: boolean; payload: Record<string, unknown> }> {
const jobName = `gitops-publish-${spec.nodeId.toLowerCase()}-${spec.lane}-${Date.now().toString(36)}`.slice(0, 63);
const manifest = yamlLaneGitopsPublishJobManifest(spec, files, jobName);
@@ -0,0 +1,371 @@
export const HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH = "config/hwlab-node-control-plane.yaml";
export type InfraAction = "plan" | "status" | "apply";
export type ToolsImageAction = "status" | "build" | "logs";
export type TektonInstallAction = "status" | "apply" | "logs";
export type ArgoAction = "status" | "apply" | "logs";
export type EgressBenchmarkAction = "benchmark" | "status" | "logs";
export type CiBuildBenchmarkAction = "benchmark" | "status" | "logs";
export type K3sInstallAction = "plan" | "install" | "status";
export interface InfraOptions {
action: InfraAction;
node: string;
lane: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
}
export interface ToolsImageOptions {
action: ToolsImageAction;
node: string;
lane: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
tailLines: number;
}
export interface TektonInstallOptions {
action: TektonInstallAction;
node: string;
lane: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
tailLines: number;
}
export interface ArgoOptions {
action: ArgoAction;
node: string;
lane: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
tailLines: number;
}
export interface EgressBenchmarkOptions {
action: EgressBenchmarkAction;
node: string;
lane: string;
profile: "no-mirror";
dryRun: boolean;
confirm: boolean;
samples: number;
sampleTimeoutSeconds: number;
timeoutSeconds: number;
tailLines: number;
}
export interface CiBuildBenchmarkOptions {
action: CiBuildBenchmarkAction;
node: string;
lane: string;
profile: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
tailLines: number;
}
export interface K3sInstallOptions {
action: K3sInstallAction;
node: string;
lane: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
tailLines: number;
}
export interface CiBuildBenchmarkCachePolicy {
noPipelineRunReuse: boolean;
forceFullBuild: boolean;
forbidGitopsCatalogReuse: boolean;
forbidDependencyCache: boolean;
forbidBuildkitCache: boolean;
forbidRegistryMirror: boolean;
forbidLocalPreheatedImages: boolean;
}
export interface CiBuildBenchmarkProfileSpec {
profile: string;
runtimeLaneConfigRef: string;
pipelineRunPrefix: string;
catalogPathTemplate: string;
imageTagMode: "full";
pipelineTimeoutSeconds: number;
cachePolicy: CiBuildBenchmarkCachePolicy;
requiredTimings: readonly string[];
failureFamilies: readonly string[];
}
export type ControlPlaneEgressProxySpec = ControlPlaneK8sServiceEgressProxySpec | ControlPlaneHostRouteEgressProxySpec;
export interface ControlPlaneK8sServiceEgressProxySpec {
mode: "k8s-service-cluster-ip";
clientName: string;
namespace: string;
serviceName: string;
port: number;
sourceConfigRef: string | null;
sourceFingerprint: string | null;
sourceRef: string;
sourceKey: string;
sourceType: "subscription-url" | "master-shadowsocks";
preferredOutbound: "vless-reality" | "hysteria2" | null;
noProxy: readonly string[];
}
export interface ControlPlaneHostRouteEgressProxySpec {
mode: "host-route";
clientName: string;
hostProxyConfigRef: string;
proxyEnvPath: string;
proxyUrl: string;
noProxy: readonly string[];
}
export interface ControlPlaneGitMirrorEgressProxySpec {
mode: "node-global" | "host-route" | "direct";
required: boolean;
podHostNetwork: boolean;
injectPodEnv: boolean;
}
export interface ControlPlaneRuntimeProxySpec {
enabled: boolean;
mode: "host-route";
configRef: string | null;
hostNetwork: boolean;
injectEnv: boolean;
deployments: readonly string[];
statefulSets: readonly string[];
}
export type ControlPlaneGitMirrorGithubTransportSpec =
| {
mode: "ssh";
privateKeySecretKey: string;
privateKeySourceRef: string;
privateKeySourceKey: string;
privateKeySourceEncoding: "plain" | "base64";
knownHostsSecretKey: string | null;
knownHostsSourceRef: string | null;
knownHostsSourceKey: string | null;
knownHostsSourceEncoding: "plain" | "base64" | null;
}
| {
mode: "https";
username: string;
tokenSecretName: string;
tokenSecretKey: string;
tokenSourceRef: string;
tokenSourceKey: string;
};
export interface ControlPlaneTektonGitWorkspaceSecretSpec {
name: string;
namespace: string;
sourceRefFrom: "gitMirror.githubTransport";
privateKeySecretKey: string;
knownHostsSecretKey: string;
}
export interface ControlPlaneTektonRuntimeObserverRbacSpec {
namespace: string;
roleName: string;
roleBindingName: string;
}
export interface ControlPlaneTektonArgoObserverRbacSpec {
namespace: string;
roleName: string;
roleBindingName: string;
}
export interface ControlPlaneTektonInstallManifestSpec {
name: string;
url: string;
}
export interface ControlPlaneTektonInstallSpec {
enabled: boolean;
sourceKind: "url";
version: string;
fieldManager: string;
manifests: readonly ControlPlaneTektonInstallManifestSpec[];
requiredCrds: readonly string[];
expectedDeploymentNamespaces: readonly string[];
readinessTimeoutSeconds: number;
runtimeProxy: ControlPlaneRuntimeProxySpec;
}
export interface ControlPlaneNodeSpec {
id: string;
route: string;
kubeRoute: string;
k3s: ControlPlaneK3sNodeSpec | null;
registry: { endpoint: string };
egressProxy: ControlPlaneEgressProxySpec | null;
}
export interface ControlPlaneK3sNodeSpec {
serviceName: string;
dropInPath: string;
nodeStatusName: string;
execStartPre: readonly (readonly string[])[];
install: ControlPlaneK3sInstallSpec | null;
serverArgs: readonly string[];
kubelet: { maxPods: number };
}
export interface ControlPlaneK3sInstallSpec {
enabled: boolean;
channel: string;
version: string;
installScriptUrl: string;
binaryUrl: string;
sha256Url: string;
expectedSha256: string;
hostProxyConfigRef: string;
proxyEnvPath: string;
registriesYamlPath: string;
localRegistry: {
containerName: string;
image: string;
canonicalImage: string;
bind: string;
};
state: {
dir: string;
logPath: string;
statusPath: string;
};
downloads: {
connectTimeoutSeconds: number;
maxTimeSeconds: number;
retry: number;
retryDelaySeconds: number;
};
}
export interface DockerfileInlineSpec {
filename: string;
lines: readonly string[];
}
export interface ImageRewriteSpec {
source: string;
pullImage: string;
target: string;
}
export interface ControlPlaneTargetSpec {
id: string;
node: string;
lane: string;
enabled: boolean;
ciNamespace: string;
runtimeNamespace: string;
source: { repository: string; branch: string };
gitops: { branch: string; path: string };
gitMirror: {
namespace: string;
serviceReadName: string;
serviceWriteName: string;
cachePvcName: string;
cachePvcStorage: string;
cacheHostPath: string | null;
servicePort: number;
readContainerPort: number;
writeContainerPort: number;
deploymentReplicas: number;
secretName: string;
syncConfigMapName: string;
syncJobPrefix: string;
flushJobPrefix: string;
readUrl: string;
writeUrl: string;
egressProxy: ControlPlaneGitMirrorEgressProxySpec | null;
githubTransport: ControlPlaneGitMirrorGithubTransportSpec;
};
tekton: {
install: ControlPlaneTektonInstallSpec;
pipelineName: string;
serviceAccountName: string;
pipelineRunPrefix: string;
gitWorkspaceSecret: ControlPlaneTektonGitWorkspaceSecretSpec;
runtimeObserverRbac: ControlPlaneTektonRuntimeObserverRbacSpec;
argoObserverRbac: ControlPlaneTektonArgoObserverRbacSpec;
toolsImage: {
output: string;
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
sourceKind: "dockerfile" | "docker-compose";
context: string;
dockerfile?: string;
dockerfileInline?: DockerfileInlineSpec;
composeFile?: string;
buildArgs: Readonly<Record<string, string>>;
buildNetwork: string | null;
publicBaseImages: readonly string[];
buildOwner: string;
buildMode: string;
};
};
ciBuildBenchmarks: readonly CiBuildBenchmarkProfileSpec[];
argo: {
namespace: string;
projectName: string;
applicationName: string;
applicationFile: string;
install: {
enabled: boolean;
sourceKind: "url";
version: string;
manifestUrl: string;
fieldManager: string;
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
preloadImages: readonly string[];
imageRewrites: readonly ImageRewriteSpec[];
requiredCrds: readonly string[];
expectedDeployments: readonly string[];
expectedStatefulSets: readonly string[];
readinessTimeoutSeconds: number;
runtimeProxy: ControlPlaneRuntimeProxySpec;
};
};
}
export interface ControlPlaneImagePolicy {
requireReproducibleBuildSource: boolean;
forbidPrivateOrNodeLocalImagesAsInputs: boolean;
allowNodeLocalRegistryAsBuildOutput: boolean;
requiredSourceKinds: readonly ("dockerfile" | "docker-compose")[];
}
export interface ControlPlaneConfig {
version: number;
kind: string;
metadata: { owner: string; relatedIssues: readonly number[] };
imagePolicy: ControlPlaneImagePolicy;
nodes: Record<string, ControlPlaneNodeSpec>;
targets: readonly ControlPlaneTargetSpec[];
}
export interface HwlabNodeControlPlaneSourceWorkspaceBootstrapSpec {
readonly configPath: string;
readonly targetId: string;
readonly node: string;
readonly lane: string;
readonly ciNamespace: string;
readonly serviceAccountName: string;
readonly toolsImage: string;
readonly imagePullPolicy: "Always" | "IfNotPresent" | "Never";
readonly gitReadUrl: string;
readonly gitWriteUrl: string;
readonly gitMirrorNamespace: string;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -307,8 +307,12 @@ export interface HwlabRuntimePublicExposureSpec {
export interface HwlabRuntimeBootstrapAdminSpec {
readonly username: string;
readonly displayName: string;
readonly usernameSourceRef?: string;
readonly usernameSourceKey?: string;
readonly usernameSourceLine?: number;
readonly passwordSourceRef: string;
readonly passwordSourceKey: string;
readonly passwordSourceLine?: number;
readonly passwordHashTransform: "hwlab-sha256";
readonly secretName: string;
readonly secretKey: string;
@@ -756,11 +760,19 @@ function bootstrapAdminConfig(value: unknown, path: string): HwlabRuntimeBootstr
const transform = stringField(raw, "passwordHashTransform", path);
if (transform !== "hwlab-sha256") throw new Error(`${path}.passwordHashTransform must be hwlab-sha256`);
const rollout = asRecord(raw.rollout, `${path}.rollout`);
const usernameSourceRef = raw.usernameSourceRef === undefined ? undefined : sourceRefField(raw, "usernameSourceRef", path);
const usernameSourceKey = raw.usernameSourceKey === undefined ? undefined : secretKeyField(raw, "usernameSourceKey", path);
const usernameSourceLine = raw.usernameSourceLine === undefined ? undefined : numberField(raw, "usernameSourceLine", path);
const passwordSourceLine = raw.passwordSourceLine === undefined ? undefined : numberField(raw, "passwordSourceLine", path);
return {
username: stringField(raw, "username", path),
displayName: stringField(raw, "displayName", path),
...(usernameSourceRef === undefined ? {} : { usernameSourceRef }),
...(usernameSourceKey === undefined ? {} : { usernameSourceKey }),
...(usernameSourceLine === undefined ? {} : { usernameSourceLine }),
passwordSourceRef: sourceRefField(raw, "passwordSourceRef", path),
passwordSourceKey: secretKeyField(raw, "passwordSourceKey", path),
...(passwordSourceLine === undefined ? {} : { passwordSourceLine }),
passwordHashTransform: transform,
secretName: stringField(raw, "secretName", path),
secretKey: secretKeyField(raw, "secretKey", path),
@@ -1044,7 +1056,7 @@ function validateConfigRef(ref: string, path: string): void {
if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
throw new Error(`${path} must reference a repo-relative config/*.yaml file without ..`);
}
if (!/^[A-Za-z0-9_.\-[\]]+$/u.test(fragment)) {
if (!/^[A-Za-z0-9_.\-[\]${}]+$/u.test(fragment)) {
throw new Error(`${path} has an unsupported YAML path fragment`);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-29-p14-yaml-template-refs.
// Responsibility: Render YAML-first web-probe sentinel configRefs with node/lane variables and selector paths.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
export interface WebProbeSentinelTemplateContext {
readonly nodeId: string;
readonly lane: string;
readonly runtimeNamespace?: string;
}
type TemplateVarValue = string | number | boolean;
export interface WebProbeSentinelRenderedConfigRef {
readonly originalRef: string;
readonly ref: string;
readonly file: string;
readonly path: string;
readonly target: unknown;
readonly sha256: string;
readonly byteCount: number;
readonly variableNames: readonly string[];
}
export function readWebProbeSentinelConfigRefTarget(context: WebProbeSentinelTemplateContext, ref: string): unknown {
return readWebProbeSentinelConfigRef(context, ref).target;
}
export function readWebProbeSentinelConfigRef(context: WebProbeSentinelTemplateContext, ref: string): WebProbeSentinelRenderedConfigRef {
const builtinVars = templateContextVars(context);
const renderedRef = renderTemplateString(ref, builtinVars, "configRef");
const parsed = parseRenderedConfigRef(renderedRef);
const absPath = rootPath(parsed.file);
if (!existsSync(absPath)) throw new Error(`${parsed.file} does not exist`);
const text = readFileSync(absPath, "utf8");
const doc = Bun.YAML.parse(text) as unknown;
const vars = { ...builtinVars, ...documentVars(doc, builtinVars) };
const path = renderTemplateString(parsed.path, vars, `${parsed.file}#path`);
const target = valueAtConfigPath(doc, path);
if (target === undefined) throw new Error(`${parsed.file}#${path} is missing`);
return {
originalRef: ref,
ref: `${parsed.file}#${path}`,
file: parsed.file,
path,
target: renderTemplateValue(target, vars),
sha256: `sha256:${createHash("sha256").update(text).digest("hex")}`,
byteCount: Buffer.byteLength(text),
variableNames: Object.keys(vars).sort(),
};
}
export function renderWebProbeSentinelConfigRefString(context: WebProbeSentinelTemplateContext, ref: string): string {
return renderTemplateString(ref, templateContextVars(context), "configRef");
}
function templateContextVars(context: WebProbeSentinelTemplateContext): Record<string, TemplateVarValue> {
const node = context.nodeId;
const nodeLower = node.toLowerCase();
const lane = context.lane;
const laneMinor = lane.replace(/^v/u, "");
return {
NODE: node,
node,
nodeLower,
NODE_LOWER: nodeLower,
LANE: lane,
lane,
laneMinor,
LANE_MINOR: laneMinor,
nodeLane: `${nodeLower}-${lane}`,
NODE_LANE: `${node}-${lane}`,
runtimeNamespace: context.runtimeNamespace ?? "",
...contextTemplateVars(context),
};
}
function contextTemplateVars(context: WebProbeSentinelTemplateContext): Record<string, TemplateVarValue> {
const observability = isRecord((context as { readonly observability?: unknown }).observability)
? (context as { readonly observability: Record<string, unknown> }).observability
: {};
const webProbe = isRecord(observability.webProbe) ? observability.webProbe : {};
const templateVars = isRecord(webProbe.templateVars) ? webProbe.templateVars : {};
const result: Record<string, TemplateVarValue> = {};
for (const [key, value] of Object.entries(templateVars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`observability.webProbe.templateVars.${key} is not a supported variable name`);
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
throw new Error(`observability.webProbe.templateVars.${key} must be a scalar`);
}
result[key] = value;
}
return result;
}
function documentVars(doc: unknown, vars: Record<string, TemplateVarValue>): Record<string, TemplateVarValue> {
if (!isRecord(doc) || !isRecord(doc.vars)) return {};
const result: Record<string, TemplateVarValue> = {};
for (const [key, value] of Object.entries(doc.vars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`vars.${key} is not a supported variable name`);
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
throw new Error(`vars.${key} must be a scalar`);
}
result[key] = typeof value === "string"
? renderTemplateString(value, { ...vars, ...result }, `vars.${key}`)
: value;
}
return result;
}
function renderTemplateValue(value: unknown, vars: Record<string, TemplateVarValue>): unknown {
if (typeof value === "string") return renderTemplateScalar(value, vars, "YAML string");
if (Array.isArray(value)) return value.map((item) => renderTemplateValue(item, vars));
if (!isRecord(value)) return value;
const localVars = localTemplateVars(value, vars);
const rendered: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (key === "vars") continue;
rendered[key] = renderTemplateValue(item, localVars);
}
return rendered;
}
function localTemplateVars(value: Record<string, unknown>, parentVars: Record<string, TemplateVarValue>): Record<string, TemplateVarValue> {
if (!isRecord(value.vars)) return parentVars;
const local: Record<string, TemplateVarValue> = {};
for (const [key, item] of Object.entries(value.vars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`vars.${key} is not a supported variable name`);
if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") throw new Error(`vars.${key} must be a scalar`);
local[key] = typeof item === "string"
? renderTemplateString(item, { ...parentVars, ...local }, `vars.${key}`)
: item;
}
return { ...parentVars, ...local };
}
function renderTemplateScalar(value: string, vars: Record<string, TemplateVarValue>, label: string): TemplateVarValue {
const whole = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/u.exec(value);
if (whole !== null) {
const rendered = vars[whole[1]];
if (rendered === undefined) throw new Error(`${label} references undefined variable ${whole[1]}`);
return rendered;
}
return renderTemplateString(value, vars, label);
}
function renderTemplateString(value: string, vars: Record<string, TemplateVarValue>, label: string): string {
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, key: string) => {
const rendered = vars[key];
if (rendered === undefined) throw new Error(`${label} references undefined variable ${key}`);
return String(rendered);
});
}
function parseRenderedConfigRef(ref: string): { readonly file: string; readonly path: string } {
const [file, path, extra] = ref.split("#");
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) {
throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`);
}
if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
throw new Error(`${ref} must reference a repo-relative config/*.yaml file without ..`);
}
return { file, path };
}
function valueAtConfigPath(value: unknown, path: string): unknown {
let current = value;
for (const segment of path.split(".")) {
if (segment.length === 0) return undefined;
current = valueAtSegment(current, segment);
if (current === undefined) return undefined;
}
return current;
}
function valueAtSegment(value: unknown, segment: string): unknown {
let rest = segment;
let current = value;
const key = /^[A-Za-z0-9_-]+/u.exec(rest)?.[0] ?? null;
if (key !== null) {
if (!isRecord(current)) return undefined;
current = current[key];
rest = rest.slice(key.length);
}
if (key === null && !rest.startsWith("[")) return undefined;
while (rest.length > 0) {
const match = /^\[([^\]]+)\]/u.exec(rest);
if (match === null) return undefined;
current = valueAtBracket(current, match[1]);
if (current === undefined) return undefined;
rest = rest.slice(match[0].length);
}
return current;
}
function valueAtBracket(value: unknown, selector: string): unknown {
if (/^\d+$/u.test(selector)) {
if (!Array.isArray(value)) return undefined;
return value[Number(selector)];
}
const match = /^([A-Za-z0-9_-]+)=([A-Za-z0-9_.:/@+-]+)$/u.exec(selector);
if (match === null || !Array.isArray(value)) return undefined;
const [, key, expected] = match;
return value.find((item) => isRecord(item) && item[key] === expected);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+21 -33
View File
@@ -1,9 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
import { readWebProbeSentinelConfigRef } from "./hwlab-node-web-sentinel-config-ref";
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes";
import { effectiveWebProbeSentinelPublicExposure, resolveWebProbeSentinel, webProbeSentinelRegistryRows, type WebProbeSentinelRegistryRow } from "./hwlab-node-web-sentinel-resolver";
import type { RenderedCliResult } from "./output";
@@ -212,7 +210,7 @@ export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: W
const selected = resolveWebProbeSentinel(spec, sentinelId);
const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS
.map((key) => readSentinelConfigRef(key, selected.configRefs[key]))
.map((key) => readSentinelConfigRef(spec, key, selected.configRefs[key]))
.map((ref) => effectiveConfigRefStatus(spec, selected.id, ref));
const conflicts = selected.enabled ? crossReferenceConflicts(spec, refs) : [];
const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null);
@@ -255,28 +253,22 @@ export function withWebProbeSentinelConfigRendered(value: WebProbeSentinelConfig
};
}
function readSentinelConfigRef(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: string): InternalConfigRefStatus {
const parsed = parseConfigRef(ref);
if (parsed.error !== null) return emptyRefStatus(key, ref, parsed.file, parsed.path, parsed.error);
const absPath = rootPath(parsed.file);
if (!existsSync(absPath)) return emptyRefStatus(key, ref, parsed.file, parsed.path, `${parsed.file} does not exist`);
function readSentinelConfigRef(spec: HwlabRuntimeLaneSpec, key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: string): InternalConfigRefStatus {
try {
const text = readFileSync(absPath, "utf8");
const sha256 = `sha256:${createHash("sha256").update(text).digest("hex")}`;
const doc = Bun.YAML.parse(text) as unknown;
const target = valueAtPath(doc, parsed.path);
const resolved = readWebProbeSentinelConfigRef(spec, ref);
const target = resolved.target;
const targetKind = target === undefined ? "missing" : targetKindOf(target);
const missingFields = target === undefined ? ["target"] : missingFieldsForTarget(key, target);
return {
key,
ref,
file: parsed.file,
path: parsed.path,
file: resolved.file,
path: resolved.path,
present: true,
targetPresent: target !== undefined,
targetKind,
sha256,
byteCount: Buffer.byteLength(text),
sha256: resolved.sha256,
byteCount: resolved.byteCount,
missingFields,
conflicts: [],
summary: summarizeTarget(key, target),
@@ -285,21 +277,10 @@ function readSentinelConfigRef(key: HwlabRuntimeWebProbeSentinelConfigRefKey, re
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return emptyRefStatus(key, ref, parsed.file, parsed.path, message);
return emptyRefStatus(key, ref, "", "", message);
}
}
function parseConfigRef(ref: string): { readonly file: string; readonly path: string; readonly error: string | null } {
const [file, path, extra] = ref.split("#");
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) {
return { file: file ?? "", path: path ?? "", error: "configRef must use path/to/file.yaml#object.path" };
}
if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
return { file, path, error: "configRef file must be repo-relative config/*.yaml without .." };
}
return { file, path, error: null };
}
function emptyRefStatus(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: string, file: string, path: string, error: string): InternalConfigRefStatus {
return {
key,
@@ -348,8 +329,6 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
requireEquals(conflicts, byKey.get("runtime"), "namespace", spec.runtimeNamespace, `selected namespace ${spec.runtimeNamespace}`);
}
const promptSetRef = byKey.get("promptSet")?.ref ?? null;
const reportViewsRef = byKey.get("reportViews")?.ref ?? null;
const scenarioIds = new Set<string>();
const scenarioProviders = new Set<string>();
for (const [index, scenario] of scenarios.entries()) {
@@ -357,8 +336,8 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
if (id !== null) scenarioIds.add(id);
const provider = stringAt(scenario, "providerProfile");
if (provider !== null) scenarioProviders.add(provider);
if (promptSetRef !== null) requireEquals(conflicts, byKey.get("scenarios"), `[${index}].promptSetRef`, promptSetRef, "promptSet configRef");
if (reportViewsRef !== null) requireEquals(conflicts, byKey.get("scenarios"), `[${index}].reportViewRef`, reportViewsRef, "reportViews configRef");
requireReadableConfigRef(spec, conflicts, byKey.get("scenarios"), `[${index}].promptSetRef`, scenario.promptSetRef);
requireReadableConfigRef(spec, conflicts, byKey.get("scenarios"), `[${index}].reportViewRef`, scenario.reportViewRef);
}
const promptProvider = promptSet === null ? null : stringAt(promptSet, "providerProfile");
@@ -384,6 +363,15 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
return conflicts;
}
function requireReadableConfigRef(spec: HwlabRuntimeLaneSpec, conflicts: string[], ref: InternalConfigRefStatus | undefined, path: string, value: unknown): void {
if (typeof value !== "string" || value.length === 0) return;
try {
readWebProbeSentinelConfigRef(spec, value);
} catch (error) {
conflicts.push(`${ref?.file ?? "-"}#${ref?.path ?? "-"}.${path}=${value} is not readable: ${error instanceof Error ? error.message : String(error)}`);
}
}
function requireEquals(conflicts: string[], ref: InternalConfigRefStatus | undefined, path: string, expected: string, expectedLabel: string): void {
if (ref === undefined) return;
const actual = stringAt(ref.target, path);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
// Responsibility: Resolve YAML-first web-probe sentinel registry entries into one selected sentinel config graph.
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
import { readWebProbeSentinelConfigRefTarget, type WebProbeSentinelTemplateContext } from "./hwlab-node-web-sentinel-config-ref";
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey, type HwlabRuntimeWebProbeSentinelRegistryItemSpec } from "./hwlab-node-lanes";
export interface ResolvedWebProbeSentinel {
@@ -110,7 +111,7 @@ function resolveRegistrySentinel(spec: HwlabRuntimeLaneSpec, registry: readonly
const ids = registry.map((item) => item.id).join(", ");
throw new Error(`unknown web-probe sentinel ${sentinelId ?? "-"} for ${spec.nodeId}/${spec.lane}; available: ${ids}`);
}
const target = readConfigRefRecord(selected.configRef);
const target = readConfigRefRecord(selected.configRef, spec);
const targetId = optionalStringAt(target, "id") ?? selected.id;
if (targetId !== selected.id) {
throw new Error(`${selected.configRef}.id=${targetId} does not match registry id ${selected.id}`);
@@ -151,13 +152,14 @@ function normalizeSentinelConfigRefs(target: Record<string, unknown>, ref: strin
return Object.fromEntries(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => [key, normalized[key]])) as Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
}
function readConfigRefRecord(ref: string): Record<string, unknown> {
const target = readConfigRefTarget(ref);
function readConfigRefRecord(ref: string, context: WebProbeSentinelTemplateContext): Record<string, unknown> {
const target = readConfigRefTarget(ref, context);
if (!isRecord(target)) throw new Error(`${ref} must point to a YAML object`);
return target;
}
export function readConfigRefTarget(ref: string): unknown {
export function readConfigRefTarget(ref: string, context?: WebProbeSentinelTemplateContext): unknown {
if (context !== undefined) return readWebProbeSentinelConfigRefTarget(context, ref);
const [file, path, extra] = ref.split("#");
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) {
throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`);
+8 -16
View File
@@ -7,10 +7,9 @@
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Database } from "bun:sqlite";
import { rootPath } from "./config";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
@@ -91,12 +90,12 @@ export interface WebProbeSentinelService {
export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit<WebProbeSentinelServiceOptions, "spec"> = {}): WebProbeSentinelServiceConfig {
const sentinel = resolveWebProbeSentinel(spec, options.sentinelId ?? null);
const plan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime));
const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios));
const reportViews = resolveReportViewsWithCheckCatalog(recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews)));
const rawPublicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure));
const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime, spec));
const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios, spec));
const reportViews = resolveReportViewsWithCheckCatalog(recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews, spec)), spec);
const rawPublicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure, spec));
const publicExposure = effectiveWebProbeSentinelPublicExposure(spec, sentinel.id, rawPublicExposure);
const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd));
const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd, spec));
const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot");
const yamlSqlitePath = stringAt(runtime, "sqlite.path");
return {
@@ -269,12 +268,12 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
return service;
}
function resolveReportViewsWithCheckCatalog(reportViews: Record<string, unknown>): Record<string, unknown> {
function resolveReportViewsWithCheckCatalog(reportViews: Record<string, unknown>, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
const ref = stringOrNull(reportViews.checkCatalogRef);
if (ref === null) return reportViews;
return {
...reportViews,
checkCatalog: recordTarget(readSentinelConfigRefTarget(ref)),
checkCatalog: recordTarget(readSentinelConfigRefTarget(ref, spec)),
};
}
@@ -694,13 +693,6 @@ function plannedRunBacklog(config: WebProbeSentinelServiceConfig, db: Database):
};
}
function readConfigRefTarget(ref: string): unknown {
const [file, path] = ref.split("#");
if (file === undefined || path === undefined) throw new Error(`invalid configRef: ${ref}`);
const text = readFileSync(rootPath(file), "utf8");
return valueAtPath(Bun.YAML.parse(text) as unknown, path);
}
function writeMetadata(db: Database, key: string, value: unknown): void {
db.query("INSERT INTO metadata (key, value_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at")
.run(key, JSON.stringify(value), nowIso());
+33 -1
View File
@@ -468,6 +468,15 @@ export function nodeRuntimeSync(scoped: ReturnType<typeof parseNodeScopedDelegat
},
};
const patchB64 = Buffer.from(JSON.stringify(operation), "utf8").toString("base64");
const staleOperationStatusPatch = {
status: {
operationState: {
phase: "Failed",
message: "terminated by unidesk control-plane sync stale-operation recovery",
},
},
};
const staleOperationStatusPatchB64 = Buffer.from(JSON.stringify(staleOperationStatusPatch), "utf8").toString("base64");
const script = [
"set +e",
`app=${shellQuote(spec.app)}`,
@@ -475,23 +484,38 @@ export function nodeRuntimeSync(scoped: ReturnType<typeof parseNodeScopedDelegat
`runtime_namespace=${shellQuote(spec.runtimeNamespace)}`,
`dry_run=${shellQuote(scoped.dryRun ? "true" : "false")}`,
`patch_b64=${shellQuote(patchB64)}`,
`stale_operation_status_patch_b64=${shellQuote(staleOperationStatusPatchB64)}`,
"terminate_patch_file=$(mktemp /tmp/hwlab-node-argocd-terminate.XXXXXX.json)",
"stale_operation_status_patch_file=$(mktemp /tmp/hwlab-node-argocd-stale-operation-status.XXXXXX.json)",
"patch_file=$(mktemp /tmp/hwlab-node-argocd-sync.XXXXXX.json)",
"printf '{\"operation\":null}' > \"$terminate_patch_file\"",
"printf '%s' \"$stale_operation_status_patch_b64\" | base64 -d > \"$stale_operation_status_patch_file\"",
"printf '%s' \"$patch_b64\" | base64 -d > \"$patch_file\"",
"before=$(kubectl -n \"$argo_namespace\" get application \"$app\" -o 'jsonpath={.status.sync.status}{\"\\t\"}{.status.health.status}{\"\\t\"}{.status.sync.revision}{\"\\t\"}{.status.operationState.phase}{\"\\t\"}{.status.operationState.message}' 2>/dev/null || true)",
"operation_phase=$(kubectl -n \"$argo_namespace\" get application \"$app\" -o 'jsonpath={.status.operationState.phase}' 2>/dev/null || true)",
"terminate_code=0",
"terminate_output=",
"terminated_operation=false",
"post_terminate_phase=",
"stale_status_patch_code=0",
"stale_status_patch_output=",
"patched_stale_operation_status=false",
"if [ \"$operation_phase\" = Running ]; then",
" if [ \"$dry_run\" = true ]; then",
" terminated_operation=would-terminate",
" patched_stale_operation_status=would-patch",
" else",
" terminate_output=$(kubectl -n \"$argo_namespace\" patch application \"$app\" --type merge --patch-file \"$terminate_patch_file\" -o name 2>&1)",
" terminate_code=$?",
" if [ \"$terminate_code\" -eq 0 ]; then terminated_operation=true; else terminated_operation=failed; fi",
" sleep 2",
" post_terminate_phase=$(kubectl -n \"$argo_namespace\" get application \"$app\" -o 'jsonpath={.status.operationState.phase}' 2>/dev/null || true)",
" if [ \"$terminate_code\" -eq 0 ] && [ \"$post_terminate_phase\" = Running ]; then",
" stale_status_patch_output=$(kubectl -n \"$argo_namespace\" patch application \"$app\" --type merge --patch-file \"$stale_operation_status_patch_file\" -o name 2>&1)",
" stale_status_patch_code=$?",
" if [ \"$stale_status_patch_code\" -eq 0 ]; then patched_stale_operation_status=true; else patched_stale_operation_status=failed; fi",
" sleep 1",
" fi",
" fi",
"fi",
"failed_hook_count=0",
@@ -570,6 +594,10 @@ export function nodeRuntimeSync(scoped: ReturnType<typeof parseNodeScopedDelegat
"printf 'terminatedOperation\\t%s\\n' \"$terminated_operation\"",
"printf 'terminateExitCode\\t%s\\n' \"$terminate_code\"",
"printf 'terminateOutput\\t%s\\n' \"$(printf '%s' \"$terminate_output\" | tr '\\n\\t' ' ' | cut -c1-500)\"",
"printf 'postTerminatePhase\\t%s\\n' \"$post_terminate_phase\"",
"printf 'patchedStaleOperationStatus\\t%s\\n' \"$patched_stale_operation_status\"",
"printf 'staleStatusPatchExitCode\\t%s\\n' \"$stale_status_patch_code\"",
"printf 'staleStatusPatchOutput\\t%s\\n' \"$(printf '%s' \"$stale_status_patch_output\" | tr '\\n\\t' ' ' | cut -c1-500)\"",
"printf 'failedHookCount\\t%s\\n' \"$failed_hook_count\"",
"printf 'failedHooks\\t%s\\n' \"$failed_hooks\"",
"printf 'deletedHookCount\\t%s\\n' \"$deleted_hook_count\"",
@@ -582,7 +610,7 @@ export function nodeRuntimeSync(scoped: ReturnType<typeof parseNodeScopedDelegat
"printf 'staleStatefulSetPodDeleteErrors\\t%s\\n' \"$stale_statefulset_pod_delete_errors\"",
"printf 'patchExitCode\\t%s\\n' \"$code\"",
"printf 'patchOutput\\t%s\\n' \"$(printf '%s' \"$output\" | tr '\\n\\t' ' ' | cut -c1-500)\"",
"rm -f \"$patch_file\" \"$terminate_patch_file\"",
"rm -f \"$patch_file\" \"$terminate_patch_file\" \"$stale_operation_status_patch_file\"",
"exit \"$code\"",
].join("\n");
const result = runNodeK3sScript(spec, script, scoped.timeoutSeconds);
@@ -608,6 +636,10 @@ export function nodeRuntimeSync(scoped: ReturnType<typeof parseNodeScopedDelegat
terminatedOperation: fields.terminatedOperation || "false",
terminateExitCode: numericField(fields.terminateExitCode),
terminateOutput: fields.terminateOutput || null,
postTerminatePhase: fields.postTerminatePhase || null,
patchedStaleOperationStatus: fields.patchedStaleOperationStatus || "false",
staleStatusPatchExitCode: numericField(fields.staleStatusPatchExitCode),
staleStatusPatchOutput: fields.staleStatusPatchOutput || null,
failedHookCount: numericField(fields.failedHookCount),
failedHooks: commaListField(fields.failedHooks),
deletedHookCount: numericField(fields.deletedHookCount),
+24
View File
@@ -306,8 +306,14 @@ export interface NodeSecretOptions {
export interface BootstrapAdminSecretMaterial {
ok: boolean;
username: string | null;
usernameSourceRef: string | null;
usernameSourceKey: string | null;
usernameSourceLine: number | null;
usernameFingerprint: string | null;
sourceRef: string | null;
sourceKey: string | null;
sourceLine: number | null;
sourcePath: string | null;
sourcePresent: boolean;
sourceFingerprint: string | null;
@@ -317,8 +323,14 @@ export interface BootstrapAdminSecretMaterial {
export interface BootstrapAdminPasswordMaterial {
ok: boolean;
username: string | null;
usernameSourceRef: string | null;
usernameSourceKey: string | null;
usernameSourceLine: number | null;
usernameFingerprint: string | null;
sourceRef: string | null;
sourceKey: string | null;
sourceLine: number | null;
sourcePath: string | null;
sourcePresent: boolean;
sourceFingerprint: string | null;
@@ -372,8 +384,12 @@ export interface RuntimeSecretSpec {
bootstrapAdminPasswordHashKey: string;
bootstrapAdminUsername: string;
bootstrapAdminDisplayName: string;
bootstrapAdminUsernameSourceRef?: string;
bootstrapAdminUsernameSourceKey?: string;
bootstrapAdminUsernameSourceLine?: number;
bootstrapAdminPasswordSourceRef?: string;
bootstrapAdminPasswordSourceKey?: string;
bootstrapAdminPasswordSourceLine?: number;
bootstrapAdminPasswordHashTransform?: "hwlab-sha256";
bootstrapAdminSourceNamespace: string;
bootstrapAdminSourceSecret: string;
@@ -442,6 +458,14 @@ export type NodeRuntimeGitMirrorGithubTransportSpec =
export type NodeRuntimeGitMirrorEgressProxySpec =
| { mode: "direct"; required: false }
| {
mode: "host-route";
clientName: string;
hostProxyConfigRef: string;
proxyEnvPath: string;
proxyUrl: string;
noProxy: string[];
}
| {
mode: "k8s-service-cluster-ip";
clientName: string;
+1 -1
View File
@@ -256,7 +256,7 @@ export function nodeObservabilityPerformanceSummary(options: NodeObservabilityOp
};
}
const timeoutSeconds = Math.max(10, Math.min(options.timeoutSeconds, 55));
const script = nodePerformanceSummaryRemoteScript(options.spec.publicWebUrl, summaryPath, secretSpec.bootstrapAdminUsername, material.password);
const script = nodePerformanceSummaryRemoteScript(options.spec.publicWebUrl, summaryPath, material.username ?? secretSpec.bootstrapAdminUsername, material.password);
const result = runTransWorkspaceStdinScript(options.node, options.spec.workspace, script, timeoutSeconds);
const report = parseJsonRecordFromText(result.stdout);
const performance = record(report.performance);
+2
View File
@@ -43,6 +43,7 @@ export function parseNodeScopedDelegatedOptions(domain: DelegatedNodeDomain, arg
dryRun: boolean;
wait: boolean;
rerun: boolean;
discardStaleGitops: boolean;
allowLiveDbRead: boolean;
timeoutSeconds: number;
originalArgs: string[];
@@ -69,6 +70,7 @@ export function parseNodeScopedDelegatedOptions(domain: DelegatedNodeDomain, arg
dryRun,
wait: args.includes("--wait"),
rerun: args.includes("--rerun"),
discardStaleGitops: args.includes("--discard-stale-gitops"),
allowLiveDbRead: args.includes("--allow-live-db-read"),
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", NODE_RUNTIME_CICD_WAIT_WARNING_SECONDS, 3600),
originalArgs: [...args],
@@ -228,8 +228,12 @@ export function runtimeSecretSpec(input: { node: string; lane: string }): Runtim
bootstrapAdminPasswordHashKey: bootstrapAdmin?.secretKey ?? BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY,
bootstrapAdminUsername: bootstrapAdmin?.username ?? "admin",
bootstrapAdminDisplayName: bootstrapAdmin?.displayName ?? `HWLAB ${input.lane} Admin`,
...(bootstrapAdmin?.usernameSourceRef === undefined ? {} : { bootstrapAdminUsernameSourceRef: bootstrapAdmin.usernameSourceRef }),
...(bootstrapAdmin?.usernameSourceKey === undefined ? {} : { bootstrapAdminUsernameSourceKey: bootstrapAdmin.usernameSourceKey }),
...(bootstrapAdmin?.usernameSourceLine === undefined ? {} : { bootstrapAdminUsernameSourceLine: bootstrapAdmin.usernameSourceLine }),
...(bootstrapAdmin?.passwordSourceRef === undefined ? {} : { bootstrapAdminPasswordSourceRef: bootstrapAdmin.passwordSourceRef }),
...(bootstrapAdmin?.passwordSourceKey === undefined ? {} : { bootstrapAdminPasswordSourceKey: bootstrapAdmin.passwordSourceKey }),
...(bootstrapAdmin?.passwordSourceLine === undefined ? {} : { bootstrapAdminPasswordSourceLine: bootstrapAdmin.passwordSourceLine }),
...(bootstrapAdmin?.passwordHashTransform === undefined ? {} : { bootstrapAdminPasswordHashTransform: bootstrapAdmin.passwordHashTransform }),
bootstrapAdminSourceNamespace: BOOTSTRAP_ADMIN_SOURCE_NAMESPACE,
bootstrapAdminSourceSecret: BOOTSTRAP_ADMIN_SOURCE_SECRET,
+129 -5
View File
@@ -45,7 +45,12 @@ export function nodeRuntimeGitMirrorJobName(mirror: NodeRuntimeGitMirrorTargetSp
return `${prefix}-${Date.now().toString(36)}`.slice(0, 63);
}
export function nodeRuntimeGitMirrorJobManifest(mirror: NodeRuntimeGitMirrorTargetSpec, action: "sync" | "flush", jobName: string): Record<string, unknown> {
export function nodeRuntimeGitMirrorJobManifest(
mirror: NodeRuntimeGitMirrorTargetSpec,
action: "sync" | "flush",
jobName: string,
options: { discardStaleGitops?: boolean } = {},
): Record<string, unknown> {
const volumes: Record<string, unknown>[] = [
{ name: "cache", ...(mirror.cacheHostPath === null ? { persistentVolumeClaim: { claimName: mirror.cachePvcName } } : { hostPath: { path: mirror.cacheHostPath, type: "DirectoryOrCreate" } }) },
{ name: "script", configMap: { name: mirror.syncConfigMapName, defaultMode: 0o755 } },
@@ -88,6 +93,7 @@ export function nodeRuntimeGitMirrorJobManifest(mirror: NodeRuntimeGitMirrorTarg
},
},
spec: {
...(mirror.egressProxy.mode === "host-route" ? { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" } : {}),
restartPolicy: "Never",
volumes,
containers: [{
@@ -95,7 +101,11 @@ export function nodeRuntimeGitMirrorJobManifest(mirror: NodeRuntimeGitMirrorTarg
image: mirror.toolsImage,
imagePullPolicy: mirror.toolsImagePullPolicy,
command: [action === "sync" ? "/script/sync.sh" : "/script/flush.sh"],
env: [...nodeRuntimeGitMirrorProxyEnv(mirror), ...nodeRuntimeGitMirrorGithubTransportEnv(mirror)],
env: [
...nodeRuntimeGitMirrorProxyEnv(mirror),
...nodeRuntimeGitMirrorGithubTransportEnv(mirror),
...(options.discardStaleGitops === true ? [{ name: "UNIDESK_GIT_MIRROR_DISCARD_STALE_GITOPS", value: "true" }] : []),
],
volumeMounts,
}],
},
@@ -107,7 +117,7 @@ export function nodeRuntimeGitMirrorJobManifest(mirror: NodeRuntimeGitMirrorTarg
export function nodeRuntimeGitMirrorProxyEnv(mirror: NodeRuntimeGitMirrorTargetSpec): Record<string, unknown>[] {
const proxy = mirror.egressProxy;
if (proxy.mode === "direct") return [];
const proxyUrl = `http://${proxy.serviceName}.${proxy.namespace}.svc.cluster.local:${proxy.port}`;
const proxyUrl = proxy.mode === "host-route" ? proxy.proxyUrl : `http://${proxy.serviceName}.${proxy.namespace}.svc.cluster.local:${proxy.port}`;
const noProxy = proxy.noProxy.join(",");
return [
{ name: "HTTP_PROXY", value: proxyUrl },
@@ -1101,7 +1111,7 @@ export function renderNodeRuntimeControlPlaneOnNode(spec: HwlabRuntimeLaneSpec,
"};",
"if (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;",
"if (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;",
"if (overlay.gitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.gitMirror;",
"if (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;",
"fs.writeFileSync(path, YAML.stringify(doc));",
"NODE",
"if [ -f scripts/gitops-render.mjs ]; then render_script=scripts/gitops-render.mjs; else echo 'render script missing: scripts/gitops-render.mjs' >&2; exit 43; fi",
@@ -1186,7 +1196,7 @@ export function renderNodeRuntimeControlPlaneLocal(spec: HwlabRuntimeLaneSpec, s
"};",
"if (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;",
"if (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;",
"if (overlay.gitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.gitMirror;",
"if (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;",
"fs.writeFileSync(path, YAML.stringify(doc));",
"NODE",
"if [ -f scripts/gitops-render.mjs ]; then render_script=scripts/gitops-render.mjs; else echo 'render script missing: scripts/gitops-render.mjs' >&2; exit 43; fi",
@@ -1283,6 +1293,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" runtimeStore: overlay.runtimeStore,",
" codeAgentRuntime: overlay.codeAgentRuntime,",
" observability: overlay.observability,",
" deployYamlGitMirror: overlay.deployYamlGitMirror,",
" runtimeImageRewrites: overlay.runtimeImageRewrites,",
" dockerProxyHttp: overlay.dockerProxyHttp,",
" dockerProxyHttps: overlay.dockerProxyHttps,",
@@ -1332,6 +1343,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
"};",
"if (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;",
"if (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;",
"if (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;",
"fs.writeFileSync(file, YAML.stringify(doc));",
"console.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: true, lane: overlay.lane, httpProxy: overlay.dockerProxyHttp, noProxyCount: overlay.dockerNoProxyList.length }));",
"NODE_UNIDESK_DEPLOY_YAML_OVERLAY`;",
@@ -2200,6 +2212,117 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" if (!changed) throw new Error(`generated git-mirror ConfigMap ${configMapName} was not found in ${gitMirrorFile}`);",
" fs.writeFileSync(gitMirrorFile, docs.map((doc) => YAML.stringify(doc).trimEnd()).join('\\n---\\n') + '\\n');",
"}",
"function patchGitMirrorHostRouteYaml() {",
" const mirror = overlay.gitMirror || {};",
" const proxy = mirror.egressProxy || {};",
" if (proxy.mode !== 'host-route') return;",
" if (!YAML) throw new Error('yaml module is required to patch git-mirror host-route proxy');",
" const requireString = (pathName, value) => {",
" if (typeof value !== 'string' || value.length === 0) throw new Error('overlay.' + pathName + ' is required for git-mirror host-route proxy');",
" return value;",
" };",
" const proxyUrl = requireString('gitMirror.egressProxy.proxyUrl', proxy.proxyUrl);",
" const configMapName = requireString('gitMirror.syncConfigMapName', mirror.syncConfigMapName);",
" let proxyEndpoint;",
" try { proxyEndpoint = new URL(proxyUrl); } catch (error) { throw new Error(`overlay.gitMirror.egressProxy.proxyUrl is invalid: ${error.message}`); }",
" if (proxyEndpoint.protocol !== 'http:') throw new Error('overlay.gitMirror.egressProxy.proxyUrl must use http:// for host-route');",
" const proxyHost = proxyEndpoint.hostname;",
" const proxyPort = Number(proxyEndpoint.port || '80');",
" if (!proxyHost || !Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) throw new Error('overlay.gitMirror.egressProxy.proxyUrl must include a valid host and port');",
" const noProxy = Array.isArray(proxy.noProxy) ? proxy.noProxy.join(',') : '';",
" const envEntries = [",
" ['HTTP_PROXY', proxyUrl],",
" ['HTTPS_PROXY', proxyUrl],",
" ['ALL_PROXY', proxyUrl],",
" ['http_proxy', proxyUrl],",
" ['https_proxy', proxyUrl],",
" ['all_proxy', proxyUrl],",
" ['NO_PROXY', noProxy],",
" ['no_proxy', noProxy],",
" ];",
" function readDocs(file) { return YAML.parseAllDocuments(fs.readFileSync(file, 'utf8')).map((document) => document.toJS()).filter((doc) => doc !== null); }",
" function flattenDocs(docs) {",
" const manifests = [];",
" for (const doc of docs) {",
" if (doc && typeof doc === 'object' && doc.kind === 'List' && Array.isArray(doc.items)) manifests.push(...doc.items);",
" else manifests.push(doc);",
" }",
" return manifests;",
" }",
" function setContainerEnv(container, name, value) {",
" if (!container || typeof container !== 'object') return false;",
" container.env = Array.isArray(container.env) ? container.env : [];",
" let item = container.env.find((env) => env && env.name === name);",
" if (!item) { item = { name }; container.env.push(item); }",
" const changed = item.value !== value || item.valueFrom !== undefined;",
" item.value = value;",
" delete item.valueFrom;",
" return changed;",
" }",
" function patchProxyScript(script, key) {",
" let next = String(script || '');",
" if (next.length === 0) throw new Error(`generated git-mirror ConfigMap ${configMapName} missing ${key}`);",
" next = next.replace(/node \\/tmp\\/hwlab-github-proxy-connect\\.(?:mjs|cjs) \\S+ \\d+/g, `node /tmp/hwlab-github-proxy-connect.mjs ${proxyHost} ${proxyPort}`);",
" const missing = [];",
" for (const [name, value] of envEntries) {",
" let replaced = false;",
" const exportPattern = new RegExp(`(^|\\\\n)([ \\\\t]*export ${escapeRegExp(name)}=)[^\\\\n]*`, 'g');",
" next = next.replace(exportPattern, (_match, prefix, left) => { replaced = true; return `${prefix}${left}${shellSingle(value)}`; });",
" if (!replaced) missing.push([name, value]);",
" }",
" if (missing.length > 0) {",
" const block = missing.map(([name, value]) => `export ${name}=${shellSingle(value)}`).join('\\\\n');",
" if (next.includes('\\\\nset -eu\\\\n')) next = next.replace('\\\\nset -eu\\\\n', `\\\\nset -eu\\\\n${block}\\\\n`);",
" else next = `${block}\\\\n${next}`;",
" }",
" next = next.replace(/mode=node-global/g, 'mode=host-route');",
" return next;",
" }",
" const gitMirrorFile = path.join(renderDir, 'devops-infra', 'git-mirror.yaml');",
" if (!fs.existsSync(gitMirrorFile)) throw new Error(`generated git-mirror manifest missing: ${gitMirrorFile}`);",
" const docs = readDocs(gitMirrorFile);",
" const manifests = flattenDocs(docs);",
" let configMapChanged = false;",
" let workloadChanged = false;",
" const workloadNames = new Set([mirror.serviceReadName, mirror.serviceWriteName].filter((value) => typeof value === 'string' && value.length > 0));",
" for (const doc of manifests) {",
" if (!doc || typeof doc !== 'object') continue;",
" if (doc.kind === 'ConfigMap' && doc.metadata && doc.metadata.name === configMapName) {",
" doc.data = doc.data || {};",
" doc.data['sync.sh'] = patchProxyScript(doc.data['sync.sh'], 'sync.sh');",
" doc.data['flush.sh'] = patchProxyScript(doc.data['flush.sh'], 'flush.sh');",
" configMapChanged = true;",
" continue;",
" }",
" if (!['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'ReplicationController'].includes(doc.kind)) continue;",
" const name = doc.metadata && doc.metadata.name ? String(doc.metadata.name) : '';",
" const labels = doc.metadata && doc.metadata.labels && typeof doc.metadata.labels === 'object' ? doc.metadata.labels : {};",
" if (!workloadNames.has(name) && labels['app.kubernetes.io/name'] !== 'git-mirror' && !name.includes('git-mirror')) continue;",
" doc.spec = doc.spec || {};",
" doc.spec.template = doc.spec.template || {};",
" doc.spec.template.metadata = doc.spec.template.metadata || {};",
" doc.spec.template.metadata.annotations = doc.spec.template.metadata.annotations || {};",
" doc.spec.template.metadata.annotations['unidesk.ai/git-mirror-egress-proxy'] = 'host-route';",
" doc.spec.template.metadata.annotations['unidesk.ai/git-mirror-host-proxy-config-ref'] = String(proxy.hostProxyConfigRef || '');",
" doc.spec.template.spec = doc.spec.template.spec || {};",
" if (proxy.podHostNetwork) {",
" doc.spec.template.spec.hostNetwork = true;",
" doc.spec.template.spec.dnsPolicy = 'ClusterFirstWithHostNet';",
" } else {",
" delete doc.spec.template.spec.hostNetwork;",
" delete doc.spec.template.spec.dnsPolicy;",
" }",
" for (const group of ['containers', 'initContainers']) {",
" for (const container of Array.isArray(doc.spec.template.spec[group]) ? doc.spec.template.spec[group] : []) {",
" for (const [envName, envValue] of envEntries) setContainerEnv(container, envName, envValue);",
" }",
" }",
" workloadChanged = true;",
" }",
" if (!configMapChanged) throw new Error(`generated git-mirror ConfigMap ${configMapName} was not found in ${gitMirrorFile}`);",
" if (!workloadChanged) throw new Error(`generated git-mirror workload for host-route proxy was not found in ${gitMirrorFile}`);",
" fs.writeFileSync(gitMirrorFile, docs.map((doc) => YAML.stringify(doc).trimEnd()).join('\\n---\\n') + '\\n');",
"}",
"const structured = patchStructuredPipeline();",
"function replaceParamDefault(name, value) {",
" const namePattern = escapeRegExp(name);",
@@ -2253,6 +2376,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
"if (text.includes('/yaml/-/yaml-') || text.includes('bun add --no-save --ignore-scripts') || text.includes('npm install --package-lock=false --no-save')) { throw new Error(`generated pipeline still downloads yaml during prepare-source in ${pipelinePath}`); }",
"if (text.includes('npm run gitops:ts:check')) { throw new Error(`generated pipeline still uses npm gitops:ts:check gate in ${pipelinePath}`); }",
"fs.writeFileSync(pipelinePath, text);",
"patchGitMirrorHostRouteYaml();",
"patchGitMirrorTransportYaml();",
"function patchArgoYaml(filePath) {",
" if (!YAML || !fs.existsSync(filePath)) return;",
+31 -11
View File
@@ -292,28 +292,48 @@ export function runNodeHostScriptAsync(spec: HwlabRuntimeLaneSpec, script: strin
return commandResultFromAsync(spec, payload, statusPath, false);
}
}
const kill = runNodeHostScript(spec, [
const pending = runNodeHostScript(spec, [
"set +e",
`status_path=${shellQuote(statusPath)}`,
"pid=$(python3 - \"$status_path\" <<'PY' 2>/dev/null",
"import json, sys",
"try: print(json.load(open(sys.argv[1])).get('pid') or '')",
"except Exception: print('')",
`stdout_path=${shellQuote(stdoutPath)}`,
`stderr_path=${shellQuote(stderrPath)}`,
`timeout_seconds=${shellQuote(String(timeoutSeconds))}`,
"python3 - \"$status_path\" \"$stdout_path\" \"$stderr_path\" \"$timeout_seconds\" <<'PY'",
"import datetime, json, pathlib, subprocess, sys",
"status_path, stdout_path, stderr_path, timeout_seconds = sys.argv[1:5]",
"try:",
" payload = json.load(open(status_path))",
"except Exception:",
" payload = {'state': 'missing', 'ok': False, 'exitCode': 127}",
"def tail(path):",
" try:",
" return pathlib.Path(path).read_text(errors='replace')[-12000:]",
" except FileNotFoundError:",
" return ''",
"pid = payload.get('pid')",
"pid_alive = None",
"if isinstance(pid, int) and pid > 0:",
" pid_alive = subprocess.run(['kill', '-0', str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0",
"payload['stdout'] = tail(stdout_path)",
"stderr = tail(stderr_path)",
"pending_note = f\"remote async command still pending after {timeout_seconds}s; statusPath={status_path}; pidAlive={pid_alive}\"",
"payload['stderr'] = '\\n'.join([item for item in [pending_note, stderr] if item])",
"payload['timedOutAt'] = datetime.datetime.utcnow().isoformat() + 'Z'",
"payload['pidAlive'] = pid_alive",
"json.dump(payload, sys.stdout, indent=2)",
"PY",
")",
"if [ -n \"$pid\" ]; then kill \"$pid\" 2>/dev/null || true; fi",
"cat \"$status_path\" 2>/dev/null || true",
].join("\n"), 55);
payload = parseJsonObject(pending.stdout.trim());
return {
command: [transPath(), spec.nodeRoute, "sh", "--", "<remote async script>"],
cwd: repoRoot,
exitCode: 124,
stdout: typeof payload.stdout === "string" ? payload.stdout : "",
stdout: typeof payload.stdout === "string" ? payload.stdout : pending.stdout,
stderr: [
`remote async command timed out after ${timeoutSeconds}s; statusPath=${statusPath}`,
`remote async command pending after ${timeoutSeconds}s; statusPath=${statusPath}`,
typeof payload.stderr === "string" ? payload.stderr : "",
lastStatus?.stderr.trim() ?? "",
kill.stderr.trim(),
pending.stderr.trim(),
].filter(Boolean).join("\n"),
signal: null,
timedOut: true,
+55 -3
View File
@@ -1051,10 +1051,15 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
`namespace=${shellQuote(spec.namespace)}`,
`name=${shellQuote(spec.bootstrapAdminSecret)}`,
`password_hash_key=${shellQuote(spec.bootstrapAdminPasswordHashKey)}`,
`username=${shellQuote(spec.bootstrapAdminUsername)}`,
`username=${shellQuote(material?.username ?? spec.bootstrapAdminUsername)}`,
`display_name=${shellQuote(spec.bootstrapAdminDisplayName)}`,
`username_source_ref=${shellQuote(material?.usernameSourceRef ?? "")}`,
`username_source_key=${shellQuote(material?.usernameSourceKey ?? "")}`,
`username_source_line=${shellQuote(material?.usernameSourceLine === null || material?.usernameSourceLine === undefined ? "" : String(material.usernameSourceLine))}`,
`username_source_fingerprint=${shellQuote(material?.usernameFingerprint ?? "")}`,
`source_ref=${shellQuote(spec.bootstrapAdminPasswordSourceRef ?? "")}`,
`source_key=${shellQuote(spec.bootstrapAdminPasswordSourceKey ?? "")}`,
`source_line=${shellQuote(material?.sourceLine === null || material?.sourceLine === undefined ? "" : String(material.sourceLine))}`,
`source_path=${shellQuote(material?.sourcePath === null || material?.sourcePath === undefined ? "" : displayRepoPath(material.sourcePath))}`,
`source_present=${shellQuote(material?.sourcePresent === true ? "yes" : "no")}`,
`source_fingerprint=${shellQuote(material?.sourceFingerprint ?? "")}`,
@@ -1075,8 +1080,13 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
"before_hash_b64=$(secret_b64_key \"$password_hash_key\")",
"before_source_ref=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-ref)",
"before_source_key=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-key)",
"before_source_line=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-line)",
"before_source_fingerprint=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-fingerprint)",
"before_username=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username)",
"before_username_source_ref=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-ref)",
"before_username_source_key=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-key)",
"before_username_source_line=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-line)",
"before_username_source_fingerprint=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-fingerprint)",
"before_hash_present=$([ -n \"$before_hash_b64\" ] && printf yes || printf no)",
"before_hash_bytes=$(decoded_length \"$before_hash_b64\")",
"action=observed",
@@ -1087,7 +1097,8 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
"if [ \"$action_request\" = ensure ]; then",
" needs_sync=false",
" [ \"$before_exists\" = yes ] && [ \"$before_hash_bytes\" -gt 0 ] || needs_sync=true",
" [ \"$before_source_ref\" = \"$source_ref\" ] && [ \"$before_source_key\" = \"$source_key\" ] && [ \"$before_source_fingerprint\" = \"$source_fingerprint\" ] && [ \"$before_username\" = \"$username\" ] || needs_sync=true",
" [ \"$before_source_ref\" = \"$source_ref\" ] && [ \"$before_source_key\" = \"$source_key\" ] && [ \"$before_source_line\" = \"$source_line\" ] && [ \"$before_source_fingerprint\" = \"$source_fingerprint\" ] && [ \"$before_username\" = \"$username\" ] || needs_sync=true",
" [ \"$before_username_source_ref\" = \"$username_source_ref\" ] && [ \"$before_username_source_key\" = \"$username_source_key\" ] && [ \"$before_username_source_line\" = \"$username_source_line\" ] && [ \"$before_username_source_fingerprint\" = \"$username_source_fingerprint\" ] || needs_sync=true",
" [ \"$force_sync\" = true ] && needs_sync=true",
" if [ \"$material_ok\" != true ]; then",
" action=${source_error:-secret-source-invalid}",
@@ -1109,8 +1120,13 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
" annotations:",
" hwlab.pikastech.local/bootstrap-admin-username: \"$username\"",
" hwlab.pikastech.local/bootstrap-admin-display-name: \"$display_name\"",
" hwlab.pikastech.local/bootstrap-admin-username-source-ref: \"$username_source_ref\"",
" hwlab.pikastech.local/bootstrap-admin-username-source-key: \"$username_source_key\"",
" hwlab.pikastech.local/bootstrap-admin-username-source-line: \"$username_source_line\"",
" hwlab.pikastech.local/bootstrap-admin-username-source-fingerprint: \"$username_source_fingerprint\"",
" hwlab.pikastech.local/bootstrap-admin-source-ref: \"$source_ref\"",
" hwlab.pikastech.local/bootstrap-admin-source-key: \"$source_key\"",
" hwlab.pikastech.local/bootstrap-admin-source-line: \"$source_line\"",
" hwlab.pikastech.local/bootstrap-admin-source-fingerprint: \"$source_fingerprint\"",
" hwlab.pikastech.local/bootstrap-admin-password-transform: \"$transform\"",
" labels:",
@@ -1140,8 +1156,13 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
"after_hash_b64=$(secret_b64_key \"$password_hash_key\")",
"after_source_ref=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-ref)",
"after_source_key=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-key)",
"after_source_line=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-line)",
"after_source_fingerprint=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-fingerprint)",
"after_username=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username)",
"after_username_source_ref=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-ref)",
"after_username_source_key=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-key)",
"after_username_source_line=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-line)",
"after_username_source_fingerprint=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username-source-fingerprint)",
"after_hash_present=$([ -n \"$after_hash_b64\" ] && printf yes || printf no)",
"after_hash_bytes=$(decoded_length \"$after_hash_b64\")",
"printf 'namespace\\t%s\\n' \"$namespace\"",
@@ -1150,8 +1171,13 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
"printf 'preset\\t%s\\n' \"$preset\"",
"printf 'username\\t%s\\n' \"$username\"",
"printf 'displayName\\t%s\\n' \"$display_name\"",
"printf 'usernameSourceRef\\t%s\\n' \"$username_source_ref\"",
"printf 'usernameSourceKey\\t%s\\n' \"$username_source_key\"",
"printf 'usernameSourceLine\\t%s\\n' \"$username_source_line\"",
"printf 'usernameSourceFingerprint\\t%s\\n' \"$username_source_fingerprint\"",
"printf 'sourceRef\\t%s\\n' \"$source_ref\"",
"printf 'sourceKey\\t%s\\n' \"$source_key\"",
"printf 'sourceLine\\t%s\\n' \"$source_line\"",
"printf 'sourcePath\\t%s\\n' \"$source_path\"",
"printf 'sourceExists\\t%s\\n' \"$source_present\"",
"printf 'sourceFingerprint\\t%s\\n' \"$source_fingerprint\"",
@@ -1165,15 +1191,25 @@ export function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: Run
"printf 'beforePasswordHashBytes\\t%s\\n' \"$before_hash_bytes\"",
"printf 'beforeSourceRef\\t%s\\n' \"$before_source_ref\"",
"printf 'beforeSourceKey\\t%s\\n' \"$before_source_key\"",
"printf 'beforeSourceLine\\t%s\\n' \"$before_source_line\"",
"printf 'beforeSourceFingerprint\\t%s\\n' \"$before_source_fingerprint\"",
"printf 'beforeUsername\\t%s\\n' \"$before_username\"",
"printf 'beforeUsernameSourceRef\\t%s\\n' \"$before_username_source_ref\"",
"printf 'beforeUsernameSourceKey\\t%s\\n' \"$before_username_source_key\"",
"printf 'beforeUsernameSourceLine\\t%s\\n' \"$before_username_source_line\"",
"printf 'beforeUsernameSourceFingerprint\\t%s\\n' \"$before_username_source_fingerprint\"",
"printf 'afterExists\\t%s\\n' \"$after_exists\"",
"printf 'afterPasswordHashPresent\\t%s\\n' \"$after_hash_present\"",
"printf 'afterPasswordHashBytes\\t%s\\n' \"$after_hash_bytes\"",
"printf 'afterSourceRef\\t%s\\n' \"$after_source_ref\"",
"printf 'afterSourceKey\\t%s\\n' \"$after_source_key\"",
"printf 'afterSourceLine\\t%s\\n' \"$after_source_line\"",
"printf 'afterSourceFingerprint\\t%s\\n' \"$after_source_fingerprint\"",
"printf 'afterUsername\\t%s\\n' \"$after_username\"",
"printf 'afterUsernameSourceRef\\t%s\\n' \"$after_username_source_ref\"",
"printf 'afterUsernameSourceKey\\t%s\\n' \"$after_username_source_key\"",
"printf 'afterUsernameSourceLine\\t%s\\n' \"$after_username_source_line\"",
"printf 'afterUsernameSourceFingerprint\\t%s\\n' \"$after_username_source_fingerprint\"",
"printf 'cloudApiDeployment\\t%s\\n' \"$cloud_api_deployment\"",
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
"printf 'rolloutRestartExitCode\\t%s\\n' \"$rollout_restart_exit\"",
@@ -1759,8 +1795,13 @@ export function secretStatusFromText(text: string, commandOk: boolean, exitCode:
fields.sourceFingerprint.length > 0 &&
fields.afterSourceRef === fields.sourceRef &&
fields.afterSourceKey === fields.sourceKey &&
(fields.afterSourceLine || "") === (fields.sourceLine || "") &&
fields.afterSourceFingerprint === fields.sourceFingerprint &&
fields.afterUsername === fields.username
fields.afterUsername === fields.username &&
(fields.afterUsernameSourceRef || "") === (fields.usernameSourceRef || "") &&
(fields.afterUsernameSourceKey || "") === (fields.usernameSourceKey || "") &&
(fields.afterUsernameSourceLine || "") === (fields.usernameSourceLine || "") &&
(fields.afterUsernameSourceFingerprint || "") === (fields.usernameSourceFingerprint || "")
);
const healthy = targetHashReady && yamlSourceReady;
return {
@@ -1777,6 +1818,7 @@ export function secretStatusFromText(text: string, commandOk: boolean, exitCode:
? {
sourceRef: fields.sourceRef,
sourceKey: fields.sourceKey || null,
sourceLine: numericField(fields.sourceLine),
sourcePath: fields.sourcePath || null,
exists: fields.sourceExists === "yes",
fingerprint: fields.sourceFingerprint || null,
@@ -1799,8 +1841,13 @@ export function secretStatusFromText(text: string, commandOk: boolean, exitCode:
...(yamlSourceMode ? {
sourceRef: fields.beforeSourceRef || null,
sourceKey: fields.beforeSourceKey || null,
sourceLine: numericField(fields.beforeSourceLine),
sourceFingerprint: fields.beforeSourceFingerprint || null,
username: fields.beforeUsername || null,
usernameSourceRef: fields.beforeUsernameSourceRef || null,
usernameSourceKey: fields.beforeUsernameSourceKey || null,
usernameSourceLine: numericField(fields.beforeUsernameSourceLine),
usernameSourceFingerprint: fields.beforeUsernameSourceFingerprint || null,
} : {}),
},
after: {
@@ -1809,8 +1856,13 @@ export function secretStatusFromText(text: string, commandOk: boolean, exitCode:
...(yamlSourceMode ? {
sourceRef: fields.afterSourceRef || null,
sourceKey: fields.afterSourceKey || null,
sourceLine: numericField(fields.afterSourceLine),
sourceFingerprint: fields.afterSourceFingerprint || null,
username: fields.afterUsername || null,
usernameSourceRef: fields.afterUsernameSourceRef || null,
usernameSourceKey: fields.afterUsernameSourceKey || null,
usernameSourceLine: numericField(fields.afterUsernameSourceLine),
usernameSourceFingerprint: fields.afterUsernameSourceFingerprint || null,
} : {}),
},
cloudApiDeployment: fields.cloudApiDeployment || spec.cloudApiDeployment,
+52 -28
View File
@@ -117,7 +117,14 @@ export function nodeRuntimeGitMirrorStatus(scoped: ReturnType<typeof parseNodeSc
"PY",
")",
"summary_json=$(kubectl -n \"$namespace\" exec deploy/\"$read_deploy\" -- sh -lc '/etc/git-mirror/status.sh' 2>/tmp/hwlab-node-gitmirror-status.err || true)",
"if ! printf '%s' \"$summary_json\" | node -e 'let s=\"\"; process.stdin.on(\"data\", c => s += c); process.stdin.on(\"end\", () => { try { const o = JSON.parse(s || \"{}\"); process.exit(o && o.localSource ? 0 : 1); } catch { process.exit(1); } });'; then",
"if ! SUMMARY_JSON=\"$summary_json\" python3 - <<'PY'; then",
"import json, os, sys",
"try:",
" value = json.loads(os.environ.get('SUMMARY_JSON') or '{}')",
"except Exception:",
" value = {}",
"sys.exit(0 if isinstance(value, dict) and value.get('localSource') else 1)",
"PY",
" summary_json=$(kubectl -n \"$namespace\" exec deploy/\"$read_deploy\" -- sh -lc \"source_repository=\\$1 source_branch=\\$2 gitops_branch=\\$3 node <<'NODE'",
"const { execFileSync } = require('node:child_process');",
"const { readFileSync, existsSync } = require('node:fs');",
@@ -161,29 +168,37 @@ export function nodeRuntimeGitMirrorStatus(scoped: ReturnType<typeof parseNodeSc
"cache_pvc_exists=$(exists_res \"$namespace\" pvc \"$cache_pvc\")",
"cache_host_path_exists=false",
"if [ -n \"$cache_host_path\" ] && kubectl -n \"$namespace\" exec deploy/\"$read_deploy\" -- sh -lc 'test -d /cache' >/dev/null 2>&1; then cache_host_path_exists=true; fi",
"SUMMARY_JSON=\"$summary_json\" GITHUB_TRANSPORT_JSON=\"$github_transport_json\" read_deployment_ready=\"$read_deployment_ready\" write_deployment_ready=\"$write_deployment_ready\" read_service_exists=\"$read_service_exists\" write_service_exists=\"$write_service_exists\" read_endpoints_ready=\"$read_endpoints_ready\" write_endpoints_ready=\"$write_endpoints_ready\" cache_pvc_exists=\"$cache_pvc_exists\" cache_host_path=\"$cache_host_path\" cache_host_path_exists=\"$cache_host_path_exists\" node <<'NODE'",
"const summary = (() => { try { return JSON.parse(process.env.SUMMARY_JSON || '{}'); } catch { return {}; } })();",
"const githubTransport = (() => { try { return JSON.parse(process.env.GITHUB_TRANSPORT_JSON || '{}'); } catch { return {}; } })();",
"const env = process.env;",
"const ok = env.read_deployment_ready === 'true' && env.write_deployment_ready === 'true' && env.read_service_exists === 'true' && env.write_service_exists === 'true' && env.read_endpoints_ready === 'true' && env.write_endpoints_ready === 'true' && (env.cache_pvc_exists === 'true' || env.cache_host_path_exists === 'true') && githubTransport.ready !== false && summary.localSource;",
"console.log(JSON.stringify({",
" ok: Boolean(ok),",
" resources: {",
" readDeploymentReady: env.read_deployment_ready === 'true',",
" writeDeploymentReady: env.write_deployment_ready === 'true',",
" readServiceExists: env.read_service_exists === 'true',",
" writeServiceExists: env.write_service_exists === 'true',",
" readEndpointsReady: env.read_endpoints_ready === 'true',",
" writeEndpointsReady: env.write_endpoints_ready === 'true',",
" cachePvcExists: env.cache_pvc_exists === 'true',",
" cacheHostPathConfigured: Boolean(env.cache_host_path),",
" cacheHostPathExists: env.cache_host_path_exists === 'true'",
" },",
" githubTransport,",
" summary,",
" valuesPrinted: false",
"}));",
"NODE",
"SUMMARY_JSON=\"$summary_json\" GITHUB_TRANSPORT_JSON=\"$github_transport_json\" read_deployment_ready=\"$read_deployment_ready\" write_deployment_ready=\"$write_deployment_ready\" read_service_exists=\"$read_service_exists\" write_service_exists=\"$write_service_exists\" read_endpoints_ready=\"$read_endpoints_ready\" write_endpoints_ready=\"$write_endpoints_ready\" cache_pvc_exists=\"$cache_pvc_exists\" cache_host_path=\"$cache_host_path\" cache_host_path_exists=\"$cache_host_path_exists\" python3 - <<'PY'",
"import json, os",
"def load_env_json(name):",
" try:",
" value = json.loads(os.environ.get(name) or '{}')",
" return value if isinstance(value, dict) else {}",
" except Exception:",
" return {}",
"def truth(name):",
" return os.environ.get(name) == 'true'",
"summary = load_env_json('SUMMARY_JSON')",
"github_transport = load_env_json('GITHUB_TRANSPORT_JSON')",
"ok = truth('read_deployment_ready') and truth('write_deployment_ready') and truth('read_service_exists') and truth('write_service_exists') and truth('read_endpoints_ready') and truth('write_endpoints_ready') and (truth('cache_pvc_exists') or truth('cache_host_path_exists')) and github_transport.get('ready') is not False and bool(summary.get('localSource'))",
"print(json.dumps({",
" 'ok': bool(ok),",
" 'resources': {",
" 'readDeploymentReady': truth('read_deployment_ready'),",
" 'writeDeploymentReady': truth('write_deployment_ready'),",
" 'readServiceExists': truth('read_service_exists'),",
" 'writeServiceExists': truth('write_service_exists'),",
" 'readEndpointsReady': truth('read_endpoints_ready'),",
" 'writeEndpointsReady': truth('write_endpoints_ready'),",
" 'cachePvcExists': truth('cache_pvc_exists'),",
" 'cacheHostPathConfigured': bool(os.environ.get('cache_host_path')),",
" 'cacheHostPathExists': truth('cache_host_path_exists'),",
" },",
" 'githubTransport': github_transport,",
" 'summary': summary,",
" 'valuesPrinted': False,",
"}))",
"PY",
].join("\n");
const result = runNodeK3sScript(spec, script, scoped.timeoutSeconds);
const parsed = record(parseJsonObject(statusText(result)));
@@ -240,7 +255,9 @@ export function nodeRuntimeGitMirrorRun(scoped: ReturnType<typeof parseNodeScope
for (let attempt = 1; attempt <= retryMaxAttempts; attempt += 1) {
const retryLabel = `${attempt}/${retryMaxAttempts}`;
const jobName = nodeRuntimeGitMirrorJobName(mirror, scoped.action);
const manifest = nodeRuntimeGitMirrorJobManifest(mirror, scoped.action, jobName);
const manifest = nodeRuntimeGitMirrorJobManifest(mirror, scoped.action, jobName, {
discardStaleGitops: scoped.discardStaleGitops === true,
});
const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64");
const waitTimeoutSeconds = Math.max(5, Math.min(45, Math.max(5, scoped.timeoutSeconds - 10)));
const script = [
@@ -429,7 +446,7 @@ export function nodeRuntimeGitMirrorRetryableFailure(
: waitTimeoutFailure
? "Git mirror job wait exceeded the controlled short-connection budget. Standard git-mirror retries with exponential backoff and stops when retry budget is exhausted."
: proxyConnectFailure || sshProxyBannerFailure
? "Git mirror job hit a retryable YAML-first SSH-over-proxy failure. Standard git-mirror keeps using the configured node-global proxy and stops when retry budget is exhausted."
? "Git mirror job hit a retryable YAML-first SSH-over-proxy failure. Standard git-mirror keeps using the configured proxy and stops when retry budget is exhausted."
: `Git mirror job hit a retryable upstream GitHub ${mirror.githubTransport.mode} transport/fetch failure. Standard git-mirror stops without host workspace fallback.`,
refSources: nodeRuntimeGitMirrorRefSources(scoped, mirror),
githubTransport: nodeRuntimeGitMirrorGithubTransportSummary(mirror),
@@ -489,7 +506,15 @@ export function nodeRuntimeEnsureGitMirrorSourceCurrent(scoped: ReturnType<typeo
degradedReason: flush.ok === true ? undefined : "node-runtime-git-mirror-pre-flush-failed",
};
}
const sync = nodeRuntimeGitMirrorRun({ ...scoped, domain: "git-mirror", action: "sync", confirm: true, dryRun: false, wait: true });
const sync = nodeRuntimeGitMirrorRun({
...scoped,
domain: "git-mirror",
action: "sync",
confirm: true,
dryRun: false,
wait: true,
discardStaleGitops: scoped.discardStaleGitops === true || scoped.rerun === true,
});
const after = record(sync.status);
const afterSummary = Object.keys(after).length > 0 ? compactNodeRuntimeGitMirrorStatus(after) : {};
const sourceOk = sync.ok === true && afterSummary.localSource === sourceCommit && afterSummary.githubSource === sourceCommit;
@@ -633,7 +658,6 @@ export function nodeRuntimeGitMirrorNeedsFlush(status: Record<string, unknown>):
const githubGitops = typeof summary.githubGitops === "string" ? summary.githubGitops : null;
return summary.pendingFlush === true
|| summary.flushNeeded === true
|| summary.githubInSync === false
|| (localGitops !== null && githubGitops !== null && localGitops !== githubGitops);
}
@@ -470,7 +470,7 @@ export function runNodeWebProbeScript(
credential: Record<string, unknown>,
): Record<string, unknown> {
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.password ?? "", webProbeProxy);
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy);
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const commandTimedOut = result.timedOut || result.exitCode === 124;
const stdoutReport = parseJsonObject(result.stdout);
@@ -606,7 +606,7 @@ function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Re
};
}
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, password: string, webProbeProxy: NodeWebProbeHostProxyEnv): string {
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv): string {
const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64");
const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64");
return [
@@ -625,7 +625,7 @@ export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions
[
...webProbeProxy.envAssignments,
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_USER=${shellQuote(username)}`,
`HWLAB_WEB_PASS=${shellQuote(password)}`,
"UNIDESK_WEB_PROBE_RUN_DIR=\"$run_dir\"",
"UNIDESK_WEB_PROBE_USER_SCRIPT=\"$user_script\"",
+8 -4
View File
@@ -582,7 +582,7 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
const webProbeProxy = nodeWebProbeHostProxyEnv(spec);
const script = [
"set -eu",
`${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`].join(" ")} ${probeArgs.map(shellQuote).join(" ")}`,
`${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`].join(" ")} ${probeArgs.map(shellQuote).join(" ")}`,
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const probe = compactWebProbeResult(parseJsonObject(result.stdout));
@@ -922,7 +922,7 @@ export function runNodeWebProbeAsync(
const webProbeProxy = nodeWebProbeHostProxyEnv(spec);
const startScript = [
"set -eu",
`${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password ?? "")}`].join(" ")} ${startArgs.map(shellQuote).join(" ")}`,
`${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password ?? "")}`].join(" ")} ${startArgs.map(shellQuote).join(" ")}`,
].join("\n");
const startResult = runTransWorkspaceStdinScript(options.node, spec.workspace, startScript, 55);
const start = parseJsonObject(startResult.stdout);
@@ -1114,7 +1114,11 @@ export function webProbeCommandTimeoutSummary(options: NodeWebProbeRunOptions, t
export function webProbeCredential(secretSpec: RuntimeSecretSpec, material: BootstrapAdminPasswordMaterial): Record<string, unknown> {
return {
username: secretSpec.bootstrapAdminUsername,
username: material.username === null ? secretSpec.bootstrapAdminUsername : "<source-ref>",
usernameSourceRef: material.usernameSourceRef,
usernameSourceKey: material.usernameSourceKey,
usernameSourceLine: material.usernameSourceLine,
usernameFingerprint: material.usernameFingerprint,
sourceRef: material.sourceRef,
sourceKey: material.sourceKey,
sourcePath: material.sourcePath === null ? null : displayRepoPath(material.sourcePath),
@@ -1310,7 +1314,7 @@ export function runNodeWebProbeObserveStart(
...webProbeProxy.envAssignments,
...webProbeAccountEnvAssignments(),
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_PASS=${shellQuote(material.password)}`,
`UNIDESK_WEB_OBSERVE_STATE_DIR=${shellQuote(stateDir)}`,
`UNIDESK_WEB_OBSERVE_JOB_ID=${shellQuote(jobId)}`,
+153 -34
View File
@@ -49,12 +49,28 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<str
egressProxy: gitMirror.egressProxy.mode === "direct" ? {
mode: "direct",
required: false,
} : gitMirror.egressProxy.mode === "host-route" ? {
...gitMirror.egressProxy,
mode: "host-route",
required: true,
} : {
...gitMirror.egressProxy,
mode: "node-global",
required: true,
},
};
const deployYamlGitMirror = {
...renderGitMirror,
egressProxy: renderGitMirror.egressProxy.mode !== "host-route" ? renderGitMirror.egressProxy : {
mode: "node-global",
required: true,
clientName: renderGitMirror.egressProxy.clientName,
namespace: "platform-infra",
serviceName: renderGitMirror.egressProxy.clientName,
port: httpProxyEndpoint(renderGitMirror.egressProxy.proxyUrl)?.port ?? 10808,
noProxy: renderGitMirror.egressProxy.noProxy,
},
};
return {
nodeId: spec.nodeId,
lane: spec.lane,
@@ -75,6 +91,7 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<str
toolsImage: gitMirror.toolsImage,
toolsImagePullPolicy: gitMirror.toolsImagePullPolicy,
gitMirror: renderGitMirror,
deployYamlGitMirror,
networkProfileId: spec.networkProfileId,
downloadProfileId: spec.downloadProfileId,
gitSshProxyHost: gitSshProxy?.host,
@@ -704,6 +721,7 @@ export function readLocalPostgresPasswordMaterial(input: { sourceRef: string; so
}
export function localSecretSourcePaths(sourceRef: string): string[] {
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
const paths = index >= 0
@@ -1034,6 +1052,16 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean,
" if [ \"$mode\" = build-moonbridge ]; then",
" action=build",
" source_present_before=build-source",
" effective_build_container_http_proxy=\"$build_container_http_proxy\"",
" effective_build_container_https_proxy=\"$build_container_https_proxy\"",
" effective_build_container_all_proxy=\"$build_container_all_proxy\"",
" effective_docker_build_add_host_args=\"$docker_build_add_host_args\"",
" if [ \"$docker_network_mode\" = host ]; then",
" effective_build_container_http_proxy=\"$build_http_proxy\"",
" effective_build_container_https_proxy=\"$build_https_proxy\"",
" effective_build_container_all_proxy=\"$build_all_proxy\"",
" effective_docker_build_add_host_args=\"\"",
" fi",
" tmpdir=$(mktemp -d /tmp/hwlab-node-runtime-image-$id.XXXXXX)",
" dockerfile=\"$tmpdir/Dockerfile\"",
" cat > \"$dockerfile\" <<'DOCKERFILE'",
@@ -1069,7 +1097,7 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean,
"USER 65532:65532",
"ENTRYPOINT [\"/app/moonbridge\"]",
"DOCKERFILE",
" if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker build $docker_build_add_host_args --network \"$docker_network_mode\" --build-arg BUILDER_IMAGE=\"$builder_image\" --build-arg MOONBRIDGE_REPO=\"$source_repo\" --build-arg MOONBRIDGE_REF=\"$source_ref\" --build-arg GOPROXY_VALUE=\"$go_proxy\" --build-arg HTTP_PROXY=\"$build_container_http_proxy\" --build-arg HTTPS_PROXY=\"$build_container_https_proxy\" --build-arg ALL_PROXY=\"$build_container_all_proxy\" --build-arg NO_PROXY=\"$build_no_proxy\" --build-arg http_proxy=\"$build_container_http_proxy\" --build-arg https_proxy=\"$build_container_https_proxy\" --build-arg all_proxy=\"$build_container_all_proxy\" --build-arg no_proxy=\"$build_no_proxy\" -t \"$target\" -f \"$dockerfile\" \"$tmpdir\" >/tmp/hwlab-node-runtime-image-$id-build.out 2>&1; then",
" if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker build $effective_docker_build_add_host_args --network \"$docker_network_mode\" --build-arg BUILDER_IMAGE=\"$builder_image\" --build-arg MOONBRIDGE_REPO=\"$source_repo\" --build-arg MOONBRIDGE_REF=\"$source_ref\" --build-arg GOPROXY_VALUE=\"$go_proxy\" --build-arg HTTP_PROXY=\"$effective_build_container_http_proxy\" --build-arg HTTPS_PROXY=\"$effective_build_container_https_proxy\" --build-arg ALL_PROXY=\"$effective_build_container_all_proxy\" --build-arg NO_PROXY=\"$build_no_proxy\" --build-arg http_proxy=\"$effective_build_container_http_proxy\" --build-arg https_proxy=\"$effective_build_container_https_proxy\" --build-arg all_proxy=\"$effective_build_container_all_proxy\" --build-arg no_proxy=\"$build_no_proxy\" -t \"$target\" -f \"$dockerfile\" \"$tmpdir\" >/tmp/hwlab-node-runtime-image-$id-build.out 2>&1; then",
" docker push \"$target\" >/tmp/hwlab-node-runtime-image-$id-push.out 2>&1 || { cat /tmp/hwlab-node-runtime-image-$id-push.out >&2 2>/dev/null || true; failed=true; }",
" else",
" cat /tmp/hwlab-node-runtime-image-$id-build.out >&2 2>/dev/null || true",
@@ -1122,9 +1150,12 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean,
? runNodeHostScriptAsync(spec, script, Math.min(timeoutSeconds, 600), "runtime-image-build")
: runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300));
const imageRows = parseNodeRuntimeImageRows(statusText(result));
const dependencyCount = nodeRuntimeImageDependencies(spec).length;
const imageRowsComplete = imageRows.length === dependencyCount
&& imageRows.every((image) => image.presentAfter === true || image.registryTagPresent === true);
const base = imageRows.find((image) => image.id === "base") ?? {};
return {
ok: isCommandSuccess(result),
ok: isCommandSuccess(result) || imageRowsComplete,
dryRun,
target: base.target ?? spec.baseImage,
source: base.source ?? spec.baseImageSource,
@@ -1196,6 +1227,7 @@ export function readSecretSourceValue(secretRoot: string, sourceRef: string, key
}
export function secretSourcePaths(sourceRef: string): string[] {
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
@@ -1203,6 +1235,14 @@ export function secretSourcePaths(sourceRef: string): string[] {
return [...new Set(paths)];
}
function ownerFileSourcePaths(sourceRef: string): string[] {
if (sourceRef.includes("..") || sourceRef.includes("\0")) return [];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot];
return [...new Set(roots.map((root) => join(root, sourceRef)))];
}
export function displayRepoPath(path: string): string {
const normalizedRoot = repoRoot.replace(/\/+$/u, "");
if (path === normalizedRoot) return ".";
@@ -1225,27 +1265,28 @@ export function hwlabPasswordHash(password: string): string {
export function readBootstrapAdminSecretMaterial(spec: RuntimeSecretSpec): BootstrapAdminSecretMaterial {
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
const sourceLine = spec.bootstrapAdminPasswordSourceLine ?? null;
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
return { ok: false, sourceRef: sourceRef ?? null, sourceKey: sourceKey ?? null, sourcePath: null, sourcePresent: false, sourceFingerprint: null, passwordHash: null, error: "bootstrap-admin-yaml-source-missing" };
}
const paths = secretSourcePaths(sourceRef);
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef);
if (!existsSync(sourcePath)) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: false, sourceFingerprint: null, passwordHash: null, error: "secret-source-missing" };
}
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const password = values[sourceKey];
if (password === undefined || password.length === 0) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: true, sourceFingerprint: null, passwordHash: null, error: "secret-key-missing" };
return bootstrapAdminSecretMaterialError(spec, sourceRef ?? null, sourceKey ?? null, sourceLine, null, null, "bootstrap-admin-yaml-source-missing");
}
const password = readSecretSourceScalar(sourceRef, sourceKey, sourceLine);
if (!password.ok) return bootstrapAdminSecretMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, password.error);
const username = readBootstrapAdminUsername(spec);
if (!username.ok) return bootstrapAdminSecretMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, username.error);
return {
ok: true,
username: username.value,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: shortSecretFingerprint(username.value),
sourceRef,
sourceKey,
sourcePath,
sourceLine,
sourcePath: password.sourcePath,
sourcePresent: true,
sourceFingerprint: `sha256:${createHash("sha256").update(password).digest("hex").slice(0, 16)}`,
passwordHash: hwlabPasswordHash(password),
sourceFingerprint: shortSecretFingerprint(password.value),
passwordHash: hwlabPasswordHash(password.value),
error: null,
};
}
@@ -1253,32 +1294,96 @@ export function readBootstrapAdminSecretMaterial(spec: RuntimeSecretSpec): Boots
export function readBootstrapAdminPasswordMaterial(spec: RuntimeSecretSpec): BootstrapAdminPasswordMaterial {
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
const sourceLine = spec.bootstrapAdminPasswordSourceLine ?? null;
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
return { ok: false, sourceRef: sourceRef ?? null, sourceKey: sourceKey ?? null, sourcePath: null, sourcePresent: false, sourceFingerprint: null, password: null, error: "bootstrap-admin-yaml-source-missing" };
}
const paths = secretSourcePaths(sourceRef);
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef);
const runtimePassword = process.env[sourceKey];
if (!existsSync(sourcePath) && (runtimePassword === undefined || runtimePassword.length === 0)) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: false, sourceFingerprint: null, password: null, error: "secret-source-missing" };
}
const values = existsSync(sourcePath) ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {};
const password = values[sourceKey] ?? runtimePassword;
if (password === undefined || password.length === 0) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: true, sourceFingerprint: null, password: null, error: "secret-key-missing" };
return bootstrapAdminPasswordMaterialError(spec, sourceRef ?? null, sourceKey ?? null, sourceLine, null, null, "bootstrap-admin-yaml-source-missing");
}
const password = readSecretSourceScalar(sourceRef, sourceKey, sourceLine);
if (!password.ok) return bootstrapAdminPasswordMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, password.error);
const username = readBootstrapAdminUsername(spec);
if (!username.ok) return bootstrapAdminPasswordMaterialError(spec, sourceRef, sourceKey, sourceLine, password.sourcePath, password.sourcePresent, username.error);
return {
ok: true,
username: username.value,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: shortSecretFingerprint(username.value),
sourceRef,
sourceKey,
sourcePath,
sourceLine,
sourcePath: password.sourcePath,
sourcePresent: true,
sourceFingerprint: `sha256:${createHash("sha256").update(password).digest("hex").slice(0, 16)}`,
password,
sourceFingerprint: shortSecretFingerprint(password.value),
password: password.value,
error: null,
};
}
function readBootstrapAdminUsername(spec: RuntimeSecretSpec): { ok: true; value: string } | { ok: false; error: string } {
const ref = spec.bootstrapAdminUsernameSourceRef;
if (ref === undefined) return { ok: true, value: spec.bootstrapAdminUsername };
const key = spec.bootstrapAdminUsernameSourceKey ?? "username";
const material = readSecretSourceScalar(ref, key, spec.bootstrapAdminUsernameSourceLine ?? null);
if (!material.ok) return { ok: false, error: `username-${material.error}` };
return { ok: true, value: material.value };
}
function readSecretSourceScalar(sourceRef: string, sourceKey: string, sourceLine: number | null): { ok: true; value: string; sourcePath: string; sourcePresent: true } | { ok: false; sourcePath: string; sourcePresent: boolean; error: string } {
const paths = secretSourcePaths(sourceRef);
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef);
if (!existsSync(sourcePath)) return { ok: false, sourcePath, sourcePresent: false, error: "secret-source-missing" };
const text = readFileSync(sourcePath, "utf8");
if (sourceLine !== null) {
const line = text.split(/\r?\n/u)[sourceLine - 1]?.replace(/\r$/u, "") ?? "";
if (line.length === 0) return { ok: false, sourcePath, sourcePresent: true, error: "secret-line-missing" };
return { ok: true, value: line, sourcePath, sourcePresent: true };
}
const values = parseEnvFile(text);
const runtimeValue = process.env[sourceKey];
const value = values[sourceKey] ?? runtimeValue;
if (value === undefined || value.length === 0) return { ok: false, sourcePath, sourcePresent: true, error: "secret-key-missing" };
return { ok: true, value, sourcePath, sourcePresent: true };
}
function bootstrapAdminSecretMaterialError(spec: RuntimeSecretSpec, sourceRef: string | null, sourceKey: string | null, sourceLine: number | null, sourcePath: string | null, sourcePresent: boolean | null, error: string): BootstrapAdminSecretMaterial {
return {
ok: false,
username: null,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: null,
sourceRef,
sourceKey,
sourceLine,
sourcePath,
sourcePresent: sourcePresent === true,
sourceFingerprint: null,
passwordHash: null,
error,
};
}
function bootstrapAdminPasswordMaterialError(spec: RuntimeSecretSpec, sourceRef: string | null, sourceKey: string | null, sourceLine: number | null, sourcePath: string | null, sourcePresent: boolean | null, error: string): BootstrapAdminPasswordMaterial {
return {
ok: false,
username: null,
usernameSourceRef: spec.bootstrapAdminUsernameSourceRef ?? null,
usernameSourceKey: spec.bootstrapAdminUsernameSourceKey ?? null,
usernameSourceLine: spec.bootstrapAdminUsernameSourceLine ?? null,
usernameFingerprint: null,
sourceRef,
sourceKey,
sourceLine,
sourcePath,
sourcePresent: sourcePresent === true,
sourceFingerprint: null,
password: null,
error,
};
}
export function parseEnvFile(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/u)) {
@@ -1547,11 +1652,13 @@ export function nodeRuntimeGitMirrorTarget(spec: HwlabRuntimeLaneSpec): NodeRunt
const gitMirror = record(target.gitMirror);
const gitMirrorEgressProxy = record(gitMirror.egressProxy);
const gitMirrorEgressMode = stringValue(gitMirrorEgressProxy.mode, "gitMirror.egressProxy.mode");
if (gitMirrorEgressMode !== "node-global" && gitMirrorEgressMode !== "direct") throw new Error(`gitMirror.egressProxy.mode must be node-global or direct for node=${spec.nodeId} lane=${spec.lane}`);
if (gitMirrorEgressMode !== "node-global" && gitMirrorEgressMode !== "host-route" && gitMirrorEgressMode !== "direct") throw new Error(`gitMirror.egressProxy.mode must be node-global, host-route, or direct for node=${spec.nodeId} lane=${spec.lane}`);
const nodeEgressProxy = gitMirrorEgressMode === "direct"
? { mode: "direct" as const, required: false as const }
: nodeRuntimeGitMirrorEgressProxySpec(record(node.egressProxy), `nodes.${spec.nodeId}.egressProxy`);
if (gitMirrorEgressMode === "node-global" && gitMirrorEgressProxy.required !== true) throw new Error(`gitMirror.egressProxy.required must be true for node=${spec.nodeId} lane=${spec.lane}`);
if ((gitMirrorEgressMode === "node-global" || gitMirrorEgressMode === "host-route") && gitMirrorEgressProxy.required !== true) throw new Error(`gitMirror.egressProxy.required must be true for node=${spec.nodeId} lane=${spec.lane}`);
if (gitMirrorEgressMode === "node-global" && nodeEgressProxy.mode !== "k8s-service-cluster-ip") throw new Error(`gitMirror.egressProxy.mode=node-global requires nodes.${spec.nodeId}.egressProxy.mode=k8s-service-cluster-ip`);
if (gitMirrorEgressMode === "host-route" && nodeEgressProxy.mode !== "host-route") throw new Error(`gitMirror.egressProxy.mode=host-route requires nodes.${spec.nodeId}.egressProxy.mode=host-route`);
const githubTransport = nodeRuntimeGitMirrorGithubTransportSpec(record(gitMirror.githubTransport), "gitMirror.githubTransport");
const source = record(target.source);
const gitops = record(target.gitops);
@@ -1626,7 +1733,19 @@ function gitMirrorSecretSourceEncoding(raw: unknown, path: string): "plain" | "b
export function nodeRuntimeGitMirrorEgressProxySpec(raw: Record<string, unknown>, path: string): NodeRuntimeGitMirrorEgressProxySpec {
const mode = stringValue(raw.mode, `${path}.mode`);
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`);
if (mode === "host-route") {
const noProxyRaw = raw.noProxy;
if (!Array.isArray(noProxyRaw)) throw new Error(`${path}.noProxy must be an array`);
return {
mode,
clientName: stringValue(raw.clientName, `${path}.clientName`),
hostProxyConfigRef: stringValue(raw.hostProxyConfigRef, `${path}.hostProxyConfigRef`),
proxyEnvPath: stringValue(raw.proxyEnvPath, `${path}.proxyEnvPath`),
proxyUrl: stringValue(raw.proxyUrl, `${path}.proxyUrl`),
noProxy: noProxyRaw.map((item, index) => stringValue(item, `${path}.noProxy[${index}]`)),
};
}
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip or host-route`);
const port = Number(raw.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${path}.port must be a TCP port`);
const sourceConfigRef = optionalStringValue(raw.sourceConfigRef, `${path}.sourceConfigRef`) ?? null;
@@ -34,6 +34,10 @@ interface TrafficOptions {
type EgressProxyOptions = BenchmarkOptions | TrafficOptions;
export async function runPlatformInfraEgressProxyCommand(_config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
if (args[0] === "host") {
const { runPlatformInfraHostProxyCommand } = await import("./platform-infra-host-proxy");
return await runPlatformInfraHostProxyCommand(_config, args.slice(1));
}
if (args[0] === "k3s-build-benchmark") return runK3sBuildBenchmarkCommand(args.slice(1));
const options = parseEgressProxyOptions(args);
if (options.action === "traffic") {
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -117,7 +117,9 @@ export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): s
const configHash = configHashFor(sub2api, target);
const image = targetImage(sub2api, target);
const dependencyImages = targetDependencyImages(sub2api, target);
const proxyEnv = sub2ApiProxyEnv(target);
return template
.replaceAll("unidesk.ai/runtime-node: G14", `unidesk.ai/runtime-node: ${target.id}`)
.replaceAll("__SUB2API_IMAGE__", imageRef(sub2api, target))
.replaceAll("__SUB2API_IMAGE_PULL_POLICY__", image.pullPolicy)
.replaceAll("postgres:18-alpine", dependencyImages.postgres)
@@ -126,7 +128,22 @@ export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): s
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ENABLED__", String(urlAllowlist.enabled))
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP__", String(urlAllowlist.allowInsecureHttp))
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS__", String(urlAllowlist.allowPrivateHosts))
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS__", urlAllowlist.upstreamHosts.join(","));
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS__", urlAllowlist.upstreamHosts.join(","))
.replaceAll(
'UPDATE_PROXY_URL: ""',
[
`UPDATE_PROXY_URL: "${proxyEnv.httpProxy}"`,
` HTTP_PROXY: "${proxyEnv.httpProxy}"`,
` HTTPS_PROXY: "${proxyEnv.httpProxy}"`,
` ALL_PROXY: "${proxyEnv.httpProxy}"`,
` http_proxy: "${proxyEnv.httpProxy}"`,
` https_proxy: "${proxyEnv.httpProxy}"`,
` all_proxy: "${proxyEnv.httpProxy}"`,
` NO_PROXY: "${proxyEnv.noProxy}"`,
` no_proxy: "${proxyEnv.noProxy}"`,
].join("\n"),
)
.concat("\n", renderPublicExposureManifest(target), renderEgressProxyManifest(target));
}
export function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
+29 -1
View File
@@ -45,6 +45,16 @@ function safeProviderSlug(providerId: string): string {
return providerId.replace(/[^a-zA-Z0-9_.-]/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "provider";
}
function defaultHostSshUser(hostProjectRoot: string): string {
const homeMatch = hostProjectRoot.match(/^\/home\/([^/]+)/u);
if (homeMatch?.[1]) return homeMatch[1];
return "root";
}
function defaultHostRemoteCwd(user: string): string {
return user === "root" ? "/root" : `/home/${user}`;
}
function defaultMasterServer(config: UniDeskConfig): string {
return `http://${config.network.publicHost}/`;
}
@@ -88,11 +98,27 @@ function parseAttachOptions(config: UniDeskConfig, args: string[]): ProviderAtta
}
function envContent(options: ProviderAttachOptions): string {
const slug = safeProviderSlug(options.providerId);
const hostSshUser = defaultHostSshUser(repoRoot);
const lines = [
"# Generated by: bun scripts/cli.ts provider attach",
"# Required attach inputs are intentionally limited to master server and provider id.",
"# Required attach inputs are intentionally limited to master server, provider id, and optional provider token.",
`UNIDESK_MASTER_SERVER=${options.masterServer}`,
`PROVIDER_ID=${options.providerId}`,
`PROVIDER_NAME=${options.providerId}`,
`PROVIDER_UPGRADE_HOST_PROJECT_ROOT=${repoRoot}`,
"PROVIDER_UPGRADE_WORKSPACE_PATH=/workspace",
`PROVIDER_UPGRADE_COMPOSE_FILE=provider-${options.providerId}.yml`,
`PROVIDER_UPGRADE_ENV_FILE=.state/provider-${options.providerId}.env`,
`PROVIDER_UPGRADE_COMPOSE_PROJECT=unidesk-${slug}`,
"PROVIDER_UPGRADE_SERVICE=provider-gateway",
`PROVIDER_UPGRADE_RUNNER_IMAGE=unidesk_provider-gateway:${slug}`,
"HOST_SSH_HOST=host.docker.internal",
"HOST_SSH_PORT=22",
`HOST_SSH_USER=${hostSshUser}`,
"HOST_SSH_KEY=/run/host-ssh/id_ed25519",
`HOST_REMOTE_CWD=${defaultHostRemoteCwd(hostSshUser)}`,
"HOST_LOGIN_SHELL=/bin/bash",
];
if (options.token !== null) lines.push(`PROVIDER_TOKEN=${options.token}`);
return `${lines.join("\n")}\n`;
@@ -109,6 +135,8 @@ function composeContent(options: ProviderAttachOptions): string {
" build:",
" context: .",
" dockerfile: src/components/provider-gateway/Dockerfile",
" args:",
' ALPINE_REPOSITORY: "${PROVIDER_GATEWAY_ALPINE_REPOSITORY:-}"',
` container_name: unidesk-provider-gateway-${slug}`,
" restart: always",
' pid: "host"',
+38 -3
View File
@@ -91,7 +91,7 @@ class SshFileTransferError extends Error {
}
const fileTransferWriteB64ArgvLimit = 48_000;
const fileTransferWriteRawChunkBytes = 1_048_576;
const fileTransferWriteRawChunkBytes = 131_072;
const fileTransferWriteB64ChunkChars = 1_398_104;
const fileTransferProgressEveryChunks = 16;
@@ -247,7 +247,7 @@ async function writeRemoteFileVerified(
const encoded = content.length <= fileTransferWriteB64ArgvLimit
? content.toString("base64")
: "";
if (invocation.route.plane !== "win" && encoded.length <= fileTransferWriteB64ArgvLimit) {
if (invocation.route.plane !== "win" && encoded.length > 0 && encoded.length <= fileTransferWriteB64ArgvLimit) {
await checkedFileTransfer(invocation, executor, builders, "write-b64-argv", [remotePath, expectedBytes, expectedSha256, ...chunkString(encoded, fileTransferWriteB64ChunkChars)]);
return { strategy: "argv", chunks: encoded.length === 0 ? 0 : Math.ceil(encoded.length / fileTransferWriteB64ChunkChars) };
}
@@ -260,14 +260,18 @@ async function writeRemoteFileVerified(
}
}
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
const startedAtMs = Date.now();
let chunks = 0;
await checkedFileTransfer(invocation, executor, builders, "write-b64-begin", [remotePath, token]);
for (let offset = 0; offset < content.length; offset += fileTransferWriteRawChunkBytes) {
const encodedChunk = content.subarray(offset, Math.min(content.length, offset + fileTransferWriteRawChunkBytes)).toString("base64");
const nextOffset = Math.min(content.length, offset + fileTransferWriteRawChunkBytes);
const encodedChunk = content.subarray(offset, nextOffset).toString("base64");
await checkedFileTransfer(invocation, executor, builders, "write-b64-append-stdin", [remotePath, token], encodedChunk);
chunks += 1;
emitUploadProgress(invocation, remotePath, chunks, nextOffset, content.length, nextOffset - offset, startedAtMs);
}
await checkedFileTransfer(invocation, executor, builders, "write-b64-commit", [remotePath, token, expectedBytes, expectedSha256]);
emitUploadProgress(invocation, remotePath, Math.max(1, chunks), content.length, content.length, 0, startedAtMs, true);
return { strategy: "chunked-stdin", chunks };
}
@@ -383,6 +387,37 @@ function emitDownloadProgress(
})}\n`);
}
function emitUploadProgress(
invocation: ParsedSshInvocation,
remotePath: string,
chunkCount: number,
actualBytes: number,
expectedBytes: number,
lastChunkBytes: number,
startedAtMs: number,
force = false,
): void {
if (!force && chunkCount !== 1 && actualBytes < expectedBytes && chunkCount % fileTransferProgressEveryChunks !== 0) return;
const elapsedMs = Math.max(1, Date.now() - startedAtMs);
process.stderr.write(`${JSON.stringify({
event: "unidesk.ssh.upload.progress",
at: new Date().toISOString(),
route: invocation.route.raw,
providerId: invocation.providerId,
remotePath,
strategy: "chunked-stdin",
chunks: chunkCount,
bytes: actualBytes,
totalBytes: expectedBytes,
actualBytes,
expectedBytes,
lastChunkBytes,
remainingBytes: Math.max(0, expectedBytes - actualBytes),
elapsedMs,
throughputBytesPerSecond: Math.round((actualBytes * 1000) / elapsedMs),
})}\n`);
}
async function statRemoteFile(
invocation: ParsedSshInvocation,
executor: SshRemoteCommandExecutor,
+4 -4
View File
@@ -157,10 +157,10 @@ function sentinelSchedules(spec: ReturnType<typeof hwlabRuntimeLaneSpecForNode>,
}
return selectedRows.map((row) => {
const sentinel = resolveWebProbeSentinel(spec, row.id);
const publicExposure = record(readConfigRefTarget(sentinel.configRefs.publicExposure), sentinel.configRefs.publicExposure);
const runtime = record(readConfigRefTarget(sentinel.configRefs.runtime), sentinel.configRefs.runtime);
const cicd = record(readConfigRefTarget(sentinel.configRefs.cicd), sentinel.configRefs.cicd);
const scenarios = scenarioRows(readConfigRefTarget(sentinel.configRefs.scenarios));
const publicExposure = record(readConfigRefTarget(sentinel.configRefs.publicExposure, spec), sentinel.configRefs.publicExposure);
const runtime = record(readConfigRefTarget(sentinel.configRefs.runtime, spec), sentinel.configRefs.runtime);
const cicd = record(readConfigRefTarget(sentinel.configRefs.cicd, spec), sentinel.configRefs.cicd);
const scenarios = scenarioRows(readConfigRefTarget(sentinel.configRefs.scenarios, spec));
const enabledScenarios = scenarios.filter((scenario) => scenario.enabled !== false);
const scenarioCadences = enabledScenarios
.map((scenario) => typeof scenario.cadence === "string" ? parseDurationSeconds(scenario.cadence) : null)
@@ -1,4 +1,6 @@
FROM oven/bun:1-alpine
ARG ALPINE_REPOSITORY=
RUN if [ -n "$ALPINE_REPOSITORY" ]; then printf '%s\n' "$ALPINE_REPOSITORY/main" "$ALPINE_REPOSITORY/community" > /etc/apk/repositories; fi
RUN apk add --no-cache bash docker-cli docker-cli-compose openssh-client
WORKDIR /app/src/components/provider-gateway
COPY src/components/provider-gateway/package.json ./package.json