deploy: configure NC01 HWLAB monitor exposure

This commit is contained in:
root
2026-07-08 05:34:11 +02:00
parent 5dfb78f000
commit a328aa909e
41 changed files with 2522 additions and 110 deletions
+1
View File
@@ -80,6 +80,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台。本文
- 开发环境和 D601/Master 边界: `docs/reference/dev-environment.md`
- HWLAB: `docs/reference/hwlab.md`
- AgentRun: `docs/reference/agentrun.md`
- NC01: `docs/reference/nc01.md`
- OTel/可观测性: `docs/reference/observability.md`
- YAML-first: `docs/reference/yaml-first-ops.md`
- Platform infrastructure: `docs/reference/platform-infra.md`
+286 -7
View File
@@ -67,6 +67,9 @@ controlPlane:
JD01:
route: JD01
kubeRoute: JD01:k3s
NC01:
route: NC01
kubeRoute: NC01:k3s
lanes:
v01:
@@ -78,7 +81,7 @@ controlPlane:
bootstrapFromBranch: v0.1
bootstrapTimeoutSeconds: 900
bootstrapPollSeconds: 15
remote: git@github.com:pikasTech/agentrun.git
remote: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
workspace: /root/agentrun-v01
runtime:
namespace: agentrun-v01
@@ -225,7 +228,7 @@ controlPlane:
readDeployment: git-mirror-http
writeService: git-mirror-write
writeDeployment: git-mirror-write
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/agentrun.git
cachePvc: git-mirror-cache
cacheHostPath: null
@@ -562,8 +565,9 @@ controlPlane:
providerCredential:
profile: dsflash-go
- id: tool-github-pr-token
sourceRef: /root/.config/unidesk/github.env
sourceRef: /root/unidesk/.env/gh_token.txt
sourceKey: GH_TOKEN
sourceFormat: raw-token
targetRef:
namespace: agentrun-v02
name: agentrun-v01-tool-github-pr
@@ -617,7 +621,7 @@ controlPlane:
path: deploy/gitops/node/jd01/runtime-v02
argoNamespace: argocd
argoApplication: agentrun-jd01-v02
repoURL: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
deployment:
format: unidesk-yaml-only
gitopsRoot: deploy/gitops/node/jd01
@@ -772,7 +776,7 @@ controlPlane:
readDeployment: git-mirror-http
writeService: git-mirror-write
writeDeployment: git-mirror-write
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
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: null
@@ -861,8 +865,282 @@ controlPlane:
providerCredential:
profile: dsflash-go
- id: tool-github-pr-token
sourceRef: /root/.config/unidesk/github.env
sourceRef: /root/unidesk/.env/gh_token.txt
sourceKey: GH_TOKEN
sourceFormat: raw-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
nc01-v02:
node: NC01
version: v0.2
source:
statusMode: k3s-git-mirror
repository: pikasTech/agentrun
branch: v0.2
sourceAuthority:
mode: gitMirrorSnapshot
resolver: k8s-git-mirror
allowHostGit: false
allowHostWorkspace: false
allowGithubDirectInPipeline: false
sourceSnapshot:
stageRefPrefix: refs/unidesk/snapshots/agentrun-yaml-lane/{branch}
missingObjectPolicy: fail-fast
refreshPolicy: sync-before-snapshot
bootstrapFromBranch: v0.1
bootstrapTimeoutSeconds: 900
bootstrapPollSeconds: 15
remote: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
workspace: /root/agentrun
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-nc01-v02-ci-image-publish
pipelineRunPrefix: agentrun-nc01-v02-ci
serviceAccountName: agentrun-nc01-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: nc01-v0.2-gitops
path: deploy/gitops/node/nc01/runtime-v02
argoNamespace: argocd
argoApplication: agentrun-nc01-v02
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
deployment:
format: unidesk-yaml-only
gitopsRoot: deploy/gitops/node/nc01
runtimeRenderDir: runtime-v02
artifactCatalogPath: deploy/artifact-catalog.nc01-v02.json
argocd:
project: agentrun-nc01-v02
applicationFile: application-v02.yaml
manager:
serviceAccount: agentrun-nc01-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: NC01
HWLAB_RUNTIME_LANE: v0.3
unideskSshEndpointEnv:
name: UNIDESK_MAIN_SERVER_IP
value: 152.53.229.148
bootRepoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/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-nc01-v02-runner
jobNamePrefix: agentrun-nc01-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: 3
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-nc01-v02-runner
- agentrun-v02-runner
- agentrun-v01-runner
ageBasedCleanup:
enabled: false
maxAgeHours: 48
sessionPvcRetention:
enabled: true
prefixes:
- agentrun-v01-session-
- agentrun-v02-session-
- agentrun-nc01-v02-session-
maxDeletePerRun: 1000
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/nc01-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: null
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-nc01-v02-sync-manual
flushJobPrefix: git-mirror-agentrun-nc01-v02-flush-manual
repositories:
- key: agentrun
repository: pikasTech/agentrun
sourceBranch: v0.2
gitopsBranch: nc01-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: hwlab/nc01-v03-admin.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: hwlab/nc01-v03-admin.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/nc01-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/unidesk/.env/gh_token.txt
sourceKey: GH_TOKEN
sourceFormat: raw-token
targetRef:
namespace: agentrun-v02
name: agentrun-v01-tool-github-pr
@@ -1188,8 +1466,9 @@ controlPlane:
providerCredential:
profile: fake-echo
- id: tool-github-pr-token
sourceRef: /root/.config/unidesk/github.env
sourceRef: /root/unidesk/.env/gh_token.txt
sourceKey: GH_TOKEN
sourceFormat: raw-token
targetRef:
namespace: agentrun-v02
name: agentrun-v01-tool-github-pr
+327
View File
@@ -266,6 +266,115 @@ nodes:
- hyueapi.com
- .hyueapi.com
NC01:
route: NC01
kubeRoute: NC01:k3s
k3s:
serviceName: k3s
dropInPath: /etc/systemd/system/k3s.service.d/20-unidesk-node-config.conf
nodeStatusName: v2202607378382480008
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.NC01
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
- metrics-server
- --node-name
- v2202607378382480008
- --node-label
- unidesk.ai/node-id=NC01
- --node-label
- unidesk.ai/provider-id=NC01
- --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:
mode: k8s-workload
endpoint: 127.0.0.1:5000
namespace: devops-infra
deploymentName: node-local-registry
serviceName: node-local-registry
pvcName: node-local-registry-storage
storage: 20Gi
image: docker.m.daocloud.io/library/registry:2
imagePullPolicy: IfNotPresent
containerPort: 5000
listenHost: 127.0.0.1
listenPort: 5000
hostNetwork: true
egressProxy:
mode: host-route
clientName: nc01-host-proxy
hostProxyConfigRef: config/platform-infra/host-proxy.yaml#targets.NC01
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
- 152.53.229.148
- 152.53.229.148
- hyueapi.com
- .hyueapi.com
targets:
- id: d601-v03
node: D601
@@ -692,6 +801,224 @@ targets:
- argocd-repo-server
statefulSets:
- argocd-application-controller
- id: nc01-v03
node: NC01
lane: v03
enabled: true
ciNamespace: hwlab-ci
runtimeNamespace: hwlab-v03
source:
repository: pikasTech/HWLAB
branch: v0.3
sourceAuthority:
mode: giteaSnapshot
resolver: gitea-mirror
giteaMirrorRepoKey: hwlab-nc01-v03
allowHostGit: false
allowHostWorkspace: false
allowGithubDirectInPipeline: false
sourceSnapshot:
stageRefPrefix: refs/unidesk/snapshots/gitea-actions/hwlab-node-runtime/{branch}
missingObjectPolicy: fail-fast
refreshPolicy: gitea-controlled-snapshot
gitops:
branch: v0.3-gitops
path: deploy/gitops/node/nc01/runtime-v03
gitMirror:
namespace: devops-infra
serviceReadName: git-mirror-http
serviceWriteName: git-mirror-write
cachePvcName: hwlab-git-mirror-cache
cachePvcStorage: 20Gi
cacheHostPath: null
servicePort: 8080
readContainerPort: 8080
writeContainerPort: 8081
deploymentReplicas: 1
secretName: git-mirror-github-ssh
syncConfigMapName: git-mirror-sync-script
syncJobPrefix: git-mirror-hwlab-nc01-v03-sync-manual
flushJobPrefix: git-mirror-hwlab-nc01-v03-flush-manual
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/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: https
username: x-access-token
tokenSecretName: git-mirror-github-token
tokenSecretKey: GITHUB_TOKEN
tokenSourceRef: .env/gh_token.txt
tokenSourceKey: GH_TOKEN
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-nc01-v03-ci-image-publish
serviceAccountName: hwlab-nc01-v03-tekton-runner
pipelineRunPrefix: hwlab-nc01-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-nc01-v03-runtime-observer
roleBindingName: hwlab-nc01-v03-runtime-observer
argoObserverRbac:
namespace: argocd
roleName: hwlab-nc01-v03-argo-observer
roleBindingName: hwlab-nc01-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: NC01
buildMode: node-local
ciBuildBenchmarks:
- profile: no-mirror-full
runtimeLaneConfigRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01
pipelineRunPrefix: hwlab-nc01-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-nc01
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.NC01.egressProxy
hostNetwork: false
injectEnv: true
deployments:
- argocd-repo-server
statefulSets:
- argocd-application-controller
- id: d518-v03
node: D518
lane: v03
+269
View File
@@ -41,6 +41,13 @@ nodes:
gitopsRoot: deploy/gitops/node
networkProfile: jd01-node-ci-egress
downloadProfile: jd01-node-default
NC01:
route: NC01
kubeRoute: NC01:k3s
sourceWorkspace: /root/hwlab
gitopsRoot: deploy/gitops/node
networkProfile: jd01-node-ci-egress
downloadProfile: jd01-node-default
lanes:
v02:
@@ -1254,6 +1261,268 @@ lanes:
envKey: DATASTORE_URI
authnKey: authn-preshared-key
role: hwlab_jd01_v03_app
NC01:
node: NC01
workspace: /root/hwlab
sourceAuthority:
mode: giteaSnapshot
resolver: gitea-mirror
giteaMirrorRepoKey: hwlab-nc01-v03
allowHostGit: false
allowHostWorkspace: false
allowGithubDirectInPipeline: false
sourceSnapshot:
stageRefPrefix: refs/unidesk/snapshots/gitea-actions/hwlab-node-runtime/{branch}
missingObjectPolicy: fail-fast
refreshPolicy: gitea-controlled-snapshot
cicdRepo: /root/hwlab-v03-cicd.git
cicdRepoLock: /tmp/hwlab-v03-cicd-repo.lock
app: hwlab-node-v03
pipeline: hwlab-nc01-v03-ci-image-publish
pipelineRunPrefix: hwlab-nc01-v03-ci-poll
serviceAccountName: hwlab-nc01-v03-tekton-runner
controlPlaneFieldManager: unidesk-hwlab-nc01-v03-control-plane
git:
url: git@github.com:pikasTech/HWLAB.git
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/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.nc01-v03.json
runtime:
path: deploy/gitops/node/nc01/runtime-v03
namespace: hwlab-v03
renderDir: runtime-v03
runtimeStore:
postgres:
mode: platform-service
serviceName: nc01-host-postgres
poolMax: 16
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
runtimeImageRewrites:
- 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
- source: ghcr.io/anomalyco/opencode:1.17.7
target: 127.0.0.1:5000/hwlab/opencode:1.17.7
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
- id: opencode-git
kind: opencode-git
target: 127.0.0.1:5000/hwlab/opencode:1.17.7-git
sourceImage: ghcr.io/anomalyco/opencode:1.17.7
dockerNetworkMode: host
webProbe:
browserProxyMode: auto
playwrightBrowsersPath: "0"
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
domEvaluateTimeoutRedCount: 2
domEvaluateTimeoutRedWindowMs: 30000
screenshotTimeoutRedCount: 2
pageErrorRedCount: 2
longTaskRedMs: 1000
longAnimationFrameRedMs: 1000
eventLoopGapRedMs: 1000
browserProcessSampleIntervalMs: 1000
requestRateBucketMs: 10000
requestRateTotalRedPerMinute: 300
requestRatePageRedPerMinute: 240
requestRateApiPathRedPerMinute: 120
browserTotalRssRedMb: 800
browserProcessRssRedMb: 600
browserRssGrowthRedMb: 300
browserRssGrowthWindowMs: 30000
playwrightResponsivenessRedMs: 5000
playwrightResponsivenessTimeoutRedCount: 2
cdpMetricsTimeoutRedCount: 2
uncommandedStateChangeCommandWindowMs: 10000
scrollJumpCommandWindowMs: 8000
scrollJumpFromY: 250
scrollJumpToY: 40
sessionRailFallbackRatio: 0.5
browserFreezePolicy:
enabled: true
blockerWindowMs: 30000
memory:
totalRssBlockerMb: 800
processRssBlockerMb: 500
growthBlockerMb: 300
responsiveness:
latencyBlockerMs: 5000
eventBlockerCount: 2
cdp:
metricsTimeoutBlockerCount: 2
kill:
enabled: true
gracefulSignal: SIGTERM
forceSignal: SIGKILL
graceMs: 3000
pollIntervalMs: 100
exitCode: 7
projectManagement:
enabled: true
targetPaths:
- /projects
- /projects/mdtodo
readinessSelectors:
- '[data-testid="project-management-root"]'
- '[data-testid="project-management-mdtodo"]'
naturalApiPathPrefixes:
- /v1/project-management/
- /v1/workbench/launches
- /v1/agent/chat
commandAllowlist:
- gotoProjectMdtodo
- openMdtodoSourceConfig
- closeMdtodoSourceConfig
- configureMdtodoHwpodSource
- probeMdtodoSource
- reindexMdtodoSource
- selectProjectSource
- selectMdtodoSource
- selectMdtodoFile
- selectMdtodoTask
- expandMdtodoTask
- openMdtodoReportPreview
- toggleMdtodoReportFullscreen
- editMdtodoTaskInline
- editMdtodoTaskTitle
- editMdtodoTaskBody
- toggleMdtodoTaskStatus
- addMdtodoRootTask
- addMdtodoSubTask
- continueMdtodoTask
- deleteMdtodoTask
- launchWorkbenchFromTask
- launchWorkbenchFromMdtodo
launchRoute: /v1/workbench/launches
slowApiBudgetMs: 10000
public:
webUrl: https://hwlab.pikapython.com
apiUrl: https://hwlab.pikapython.com
observability:
prometheusOperator: false
webProbe:
monitorRoot:
enabled: true
sentinelId: nc01-web-probe-sentinel
publicBaseUrl: https://monitor.pikapython.com
routePrefix: /
caddyManagedBlockOwner: hwlab-web-probe-sentinel-active-root
sentinels:
- id: nc01-web-probe-sentinel
enabled: true
configRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.sentinel
bootstrapAdmin:
username: admin
displayName: HWLAB v0.3 Admin
passwordSourceRef: hwlab/nc01-v03-bootstrap-admin.env
passwordSourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
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/nc01-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: fake-echo
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-nc01-v03-cloud-web
remotePort: 22082
localIP: hwlab-cloud-web.hwlab-v03.svc.cluster.local
localPort: 8080
apiProxy:
name: hwlab-nc01-v03-edge-proxy
remotePort: 22083
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
externalPostgres:
provider: NC01
configRef: config/platform-db/postgres-nc01.yaml
serviceName: nc01-host-postgres
endpointAddress: 10.42.0.1
port: 5432
runtimeAccess:
routeName: nc01-host-postgres
endpointAddress: 10.42.0.1
port: 5432
sslmode: require
database: hwlab_nc01_v03
cloudApi:
secretName: hwlab-cloud-api-v03-db
secretKey: database-url
sourceRef: hwlab/nc01-v03-cloud-api-db.env
envKey: DATABASE_URL
role: hwlab_nc01_v03_app
openfga:
secretName: hwlab-v03-openfga
secretKey: datastore-uri
sourceRef: hwlab/nc01-v03-openfga-db.env
envKey: DATASTORE_URI
authnKey: authn-preshared-key
role: hwlab_nc01_v03_app
networkProfiles:
node-ci-egress:
@@ -320,3 +320,164 @@ nodes:
data:
- sourcePurpose: frp-token
targetKey: token
NC01:
target: &nc01-target
node: ${NODE}
lane: ${LANE}
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.${LANE}.targets.${NODE}.public.webUrl
cicdCommon: &nc01-cicd-common
controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets[2]
source:
<<: *cicd-source
gitSshUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git
gitMirrorReadUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git
sourceAuthority:
<<: *cicd-source-authority
mode: giteaSnapshot
resolver: gitea-mirror
sourceSnapshot:
<<: *cicd-source-snapshot
stageRefPrefix: refs/unidesk/snapshots/gitea-actions/unidesk-master
refreshPolicy: gitea-controlled-snapshot
argo: &nc01-argo
namespace: argocd
projectName: hwlab-v03
repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
targetRevision: v0.3-gitops
maintenance:
<<: *maintenance
monitorWeb:
<<: *monitor-web
gitMirror:
source: source.gitMirrorReadUrl
preSync: not-required
postFlush: not-required
confirmWait:
<<: *confirm-wait
publishCurrent:
<<: *publish-current
sentinels:
nc01-web-probe-sentinel:
sentinel:
<<: *sentinel-base
id: nc01-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:
<<: *nc01-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
kubernetesApi:
endpoint:
host: 10.43.0.1
port: 443
egress:
enabled: true
rules:
- cidr: 10.43.0.1/32
port: 443
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: 22084
localIP: hwlab-web-probe-sentinel-${nodeLower}.hwlab-${lane}.svc.cluster.local
localPort: 8080
caddy:
<<: *caddy-common
managedBlockOwner: hwlab-web-probe-sentinel-${nodeLower}-${lane}
cicd:
<<: *nc01-cicd-common
builder:
<<: *cicd-builder
jobPrefix: web-probe-sentinel-${nodeLower}-publish
gitopsPath: deploy/gitops/node/${nodeLower}/web-probe-sentinel
argo:
<<: *nc01-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[2].tekton.toolsImage.output
envRecipeRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.${NODE}.sentinels.${nodeLower}-web-probe-sentinel.runtime
targetValidation:
scenarioId: workbench-dsflash-go-hwpod-two-turn-freeze-repro
maxSeconds: 600
serviceUnavailablePolicy: structured-failure
cadenceScheduler:
enabled: true
reason: k8s-native-periodic-quick-verify
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
activeDeadlineSlackSeconds: 60
ttlSecondsAfterFinished: 86400
backoffLimit: 0
secrets:
sources:
- purpose: bootstrap-admin
sourceRef: hwlab/${nodeLower}-${lane}-bootstrap-admin.env
sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
- <<: *dsflash-prompt-source
- purpose: account-a
sourceRef: hwlab/${nodeLower}-${lane}-bootstrap-admin.env
sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
format: web-account-json
username: admin
- 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
+56
View File
@@ -0,0 +1,56 @@
version: 1
kind: postgres-host-cluster
metadata:
id: nc01-host-postgres
description: NC01 host-native PostgreSQL 16 for local UniDesk/HWLAB runtime state
owner: unidesk
cluster:
role: primary
environment: node-local
exposure: private-only
haPhase: standalone
node:
id: NC01
route: NC01
mode: host-systemd
osFamily: ubuntu
postgres:
package:
manager: apt
version: "16"
service:
name: postgresql
enabled: true
state: running
network:
port: 5432
listenAddresses:
- 127.0.0.1
- 10.42.0.1
connectionHost: 10.42.0.1
transport: postgres-native-tls
sslmode: require
auth:
passwordEncryption: scram-sha-256
pgHba:
- type: local
database: all
user: postgres
method: peer
- type: host
database: all
user: all
address: 127.0.0.1/32
method: scram-sha-256
- type: host
database: all
user: all
address: 10.42.0.0/16
method: scram-sha-256
secrets:
root: /root/unidesk/.state/secrets
@@ -5,6 +5,16 @@ metadata:
relatedIssues:
- 1004
sources:
nc01-vpn-server-shadowsocks:
sourceType: master-shadowsocks
sourceRef: platform-infra/nc01-vpn-server-shadowsocks.env
sourceKey: VPN_SERVER_SHADOWSOCKS_PASSWORD
masterShadowsocks:
serverHost: 152-53-229-148.nip.io
serverPort: 8445
method: 2022-blake3-aes-256-gcm
healthProbeUrl: https://www.gstatic.com/generate_204
master-shadowsocks:
sourceType: master-shadowsocks
sourceRef: platform-infra/sub2api-master-egress-proxy.env
+77 -1
View File
@@ -44,8 +44,9 @@ sourceAuthority:
- snapshot-create
github:
transport: https-token
sourceRef: /root/.config/unidesk/github.env
sourceRef: .env/gh_token.txt
sourceKey: GH_TOKEN
format: raw-token
requiredFor:
- upstream-mirror
- mirror-sync
@@ -123,6 +124,28 @@ sourceAuthority:
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
configRef: config/cicd-branch-followers.yaml#followers.agentrun-jd01-v02.nativeStatus.source.gitMirrorReadUrl
disposition: replaced-by-gitea
- key: agentrun-nc01-v02
targetId: NC01
upstream:
repository: pikasTech/agentrun
cloneUrl: https://github.com/pikasTech/agentrun.git
branch: v0.2
gitea:
owner: mirrors
name: pikasTech-agentrun
mirrorMode: controlled-push
publicRead: true
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
gitops:
branch: nc01-v0.2-gitops
flushDisposition: retained-for-gitops-flush
snapshot:
naming: historical-gitea-actions-prefix-retained-for-existing-refs
prefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2
legacyGitMirror:
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git
configRef: config/cicd-branch-followers.yaml#followers.agentrun-nc01-v02.nativeStatus.source.gitMirrorReadUrl
disposition: replaced-by-gitea
- key: unidesk-master
targetId: JD01
upstream:
@@ -145,6 +168,28 @@ sourceAuthority:
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
configRef: config/cicd-branch-followers.yaml#controller.source.gitMirrorReadUrl
disposition: replaced-by-gitea
- key: unidesk-master-nc01
targetId: NC01
upstream:
repository: pikasTech/unidesk
cloneUrl: https://github.com/pikasTech/unidesk.git
branch: master
gitea:
owner: mirrors
name: pikasTech-unidesk
mirrorMode: controlled-push
publicRead: true
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git
gitops:
branch: master
flushDisposition: not-a-gitops-branch
snapshot:
naming: historical-gitea-actions-prefix-retained-for-existing-refs
prefix: refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01
legacyGitMirror:
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
configRef: config/cicd-branch-followers.yaml#controller.source.gitMirrorReadUrl
disposition: replaced-by-gitea
- key: hwlab-jd01-v03
targetId: JD01
upstream:
@@ -167,6 +212,28 @@ sourceAuthority:
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01.git.readUrl
disposition: replaced-by-gitea
- key: hwlab-nc01-v03
targetId: NC01
upstream:
repository: pikasTech/HWLAB
cloneUrl: https://github.com/pikasTech/HWLAB.git
branch: v0.3
gitea:
owner: mirrors
name: pikasTech-HWLAB
mirrorMode: controlled-push
publicRead: true
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git
gitops:
branch: v0.3-gitops
flushDisposition: retained-for-gitops-flush
snapshot:
naming: historical-gitea-actions-prefix-retained-for-existing-refs
prefix: refs/unidesk/snapshots/gitea-actions/hwlab-node-runtime/v0.3
legacyGitMirror:
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.git.readUrl
disposition: replaced-by-gitea
targets:
- id: JD01
@@ -176,6 +243,15 @@ targets:
enabled: true
createNamespace: true
storageClassName: local-path
- id: NC01
route: NC01:k3s
namespace: devops-infra
role: active-source-authority
enabled: true
createNamespace: true
storageClassName: local-path
publicExposure:
enabled: false
app:
name: gitea
+85
View File
@@ -29,6 +29,38 @@ server:
port: 18792
sources:
nc01-vpn-server-shadowsocks:
sourceType: vpn-server-shadowsocks
serverRef: /root/vpn-server
benchmarkRef: local-vpn-server
implementationRef: /root/vpn-server
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.nc01-vpn-server-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
jd01-real-deps-master-shadowsocks:
sourceType: benchmark-validated-master-shadowsocks
serverRef: server.benchmark-validated-master-shadowsocks-server
@@ -62,6 +94,59 @@ sources:
externalProbeUrl: https://www.gstatic.com/generate_204
targets:
NC01:
route: NC01
enabled: true
sourceRef: sources.nc01-vpn-server-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
- 152.53.229.148
- 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
trans:
hostProxyEnv:
enabled: true
envFileRef: files.envFile
applyTo: host-posix-commands
apply:
reloadSystemd: true
restartDocker: true
restartK3s: true
JD01:
route: JD01
enabled: true
+112 -4
View File
@@ -12,15 +12,15 @@ metadata:
- 1560
defaults:
targetId: JD01
consumerId: agentrun-jd01-v02
targetId: NC01
consumerId: hwlab-nc01-v03
display:
timeZone: Asia/Shanghai
release:
version: v0.48.0
manifestUrl: https://github.com/tektoncd/pipelines-as-code/releases/download/v0.48.0/release.k8s.yaml
manifestUrl: https://github.com/openshift-pipelines/pipelines-as-code/releases/download/v0.48.0/release.k8s.yaml
namespace: pipelines-as-code
controllerServiceName: pipelines-as-code-controller
controllerServicePort: 8080
@@ -38,6 +38,11 @@ targets:
namespace: agentrun-ci
role: active-cicd-trigger-authority
enabled: true
- id: NC01
route: NC01:k3s
namespace: agentrun-ci
role: active-cicd-trigger-authority
enabled: true
gitea:
configRef: config/platform-infra/gitea.yaml#sourceAuthority.repositories
@@ -48,7 +53,7 @@ gitea:
format: line-pair
usernameLine: 1
passwordLine: 2
apiUsername: admin
apiUsername: unidesk-admin
webhook:
secretRoot: /root/unidesk
secretSourceRef: .env/pipelines-as-code.webhook
@@ -80,6 +85,29 @@ repositories:
pipeline_run_prefix: agentrun-jd01-v02-ci
service_account: agentrun-jd01-v02-tekton-runner
workspace_pvc_size: 2Gi
- id: agentrun-nc01-v02
name: agentrun-nc01-v02
namespace: agentrun-ci
providerType: gitea
url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
owner: mirrors
repo: pikasTech-agentrun
secretName: pac-gitea-agentrun-nc01-v02
tokenKey: token
webhookSecretKey: webhook.secret
concurrencyLimit: 1
params:
git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
git_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
source_branch: v0.2
gitops_branch: nc01-v0.2-gitops
# Historical prefix name retained for existing snapshot refs; active architecture is Gitea + PaC, not Gitea Actions.
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2
pipeline_name: agentrun-nc01-v02-ci-image-publish
pipeline_run_prefix: agentrun-nc01-v02-ci
service_account: agentrun-nc01-v02-tekton-runner
workspace_pvc_size: 2Gi
- id: sentinel-jd01-v03
name: sentinel-jd01-v03
namespace: devops-infra
@@ -106,6 +134,32 @@ repositories:
gitops_manifest_path: deploy/gitops/node/jd01/web-probe-sentinel/web-probe-sentinel.yaml
pipeline_name: hwlab-web-probe-sentinel-jd01-pac
pipeline_run_prefix: hwlab-web-probe-sentinel-jd01
- id: sentinel-nc01-v03
name: sentinel-nc01-v03
namespace: devops-infra
providerType: gitea
url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git
owner: mirrors
repo: pikasTech-unidesk
secretName: pac-gitea-sentinel-nc01-v03
tokenKey: token
webhookSecretKey: webhook.secret
concurrencyLimit: 1
params:
git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git
source_branch: master
# Historical prefix name retained for existing snapshot refs; active architecture is Gitea + PaC, not Gitea Actions.
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/unidesk-master
node: NC01
lane: v03
sentinel_id: nc01-web-probe-sentinel
image_repository: 127.0.0.1:5000/hwlab/web-probe-sentinel-nc01
registry_probe_base: http://127.0.0.1:5000
gitops_branch: v0.3-gitops
gitops_manifest_path: deploy/gitops/node/nc01/web-probe-sentinel/web-probe-sentinel.yaml
pipeline_name: hwlab-web-probe-sentinel-nc01-pac
pipeline_run_prefix: hwlab-web-probe-sentinel-nc01
- id: hwlab-jd01-v03
name: hwlab-jd01-v03
namespace: hwlab-ci
@@ -131,6 +185,31 @@ repositories:
node: JD01
lane: v03
runtime_namespace: hwlab-v03
- id: hwlab-nc01-v03
name: hwlab-nc01-v03
namespace: hwlab-ci
providerType: gitea
url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git
owner: mirrors
repo: pikasTech-HWLAB
secretName: pac-gitea-hwlab-nc01-v03
tokenKey: token
webhookSecretKey: webhook.secret
concurrencyLimit: 1
params:
git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git
git_write_url: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
source_branch: v0.3
gitops_branch: v0.3-gitops
# Historical prefix name retained for existing snapshot refs; active architecture is Gitea + PaC, not Gitea Actions.
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/hwlab-node-runtime/v0.3
pipeline_name: hwlab-nc01-v03-ci-image-publish
pipeline_run_prefix: hwlab-nc01-v03-ci-poll
service_account: hwlab-nc01-v03-tekton-runner
node: NC01
lane: v03
runtime_namespace: hwlab-v03
consumers:
- id: agentrun-jd01-v02
@@ -142,6 +221,15 @@ consumers:
pipelineRunPrefix: agentrun-jd01-v02-ci
argoNamespace: argocd
argoApplication: agentrun-jd01-v02
- id: agentrun-nc01-v02
repositoryRef: agentrun-nc01-v02
node: NC01
lane: nc01-v02
namespace: agentrun-ci
pipeline: agentrun-nc01-v02-ci-image-publish
pipelineRunPrefix: agentrun-nc01-v02-ci
argoNamespace: argocd
argoApplication: agentrun-nc01-v02
- id: sentinel-jd01-v03
repositoryRef: sentinel-jd01-v03
node: JD01
@@ -151,6 +239,15 @@ consumers:
pipelineRunPrefix: hwlab-web-probe-sentinel-jd01
argoNamespace: argocd
argoApplication: hwlab-web-probe-sentinel-jd01
- id: sentinel-nc01-v03
repositoryRef: sentinel-nc01-v03
node: NC01
lane: v03
namespace: devops-infra
pipeline: hwlab-web-probe-sentinel-nc01-pac
pipelineRunPrefix: hwlab-web-probe-sentinel-nc01
argoNamespace: argocd
argoApplication: hwlab-web-probe-sentinel-nc01
- id: hwlab-jd01-v03
repositoryRef: hwlab-jd01-v03
node: JD01
@@ -161,3 +258,14 @@ consumers:
argoNamespace: argocd
argoApplication: hwlab-node-v03
closeoutGitOpsMirrorFlush: true
- id: hwlab-nc01-v03
repositoryRef: hwlab-nc01-v03
node: NC01
lane: v03
namespace: hwlab-ci
pipeline: hwlab-nc01-v03-ci-image-publish
pipelineRunPrefix: hwlab-nc01-v03-ci-poll
argoNamespace: argocd
argoApplication: hwlab-node-v03
closeoutGitOpsMirrorFlush: true
+29
View File
@@ -77,6 +77,19 @@ sources:
OPENCODE_SERVER_PASSWORD:
bytes: 32
prefix: oc_
- sourceRef: hwlab/nc01-v03-opencode.env
type: env
requiredKeys:
- OPENCODE_SERVER_USERNAME
- OPENCODE_SERVER_PASSWORD
createIfMissing:
enabled: true
values:
OPENCODE_SERVER_USERNAME: opencode
randomBase64Url:
OPENCODE_SERVER_PASSWORD:
bytes: 32
prefix: oc_
targets:
- id: platform-infra-g14
@@ -89,8 +102,24 @@ targets:
namespace: hwlab-v03
scope: hwlab
enabled: true
- id: hwlab-nc01-v03
route: NC01:k3s
namespace: hwlab-v03
scope: hwlab
enabled: true
kubernetesSecrets:
- name: hwlab-nc01-v03-opencode-server-auth
targetId: hwlab-nc01-v03
secretName: hwlab-v03-opencode-server-auth
type: Opaque
data:
- sourceRef: hwlab/nc01-v03-opencode.env
sourceKey: OPENCODE_SERVER_USERNAME
targetKey: username
- sourceRef: hwlab/nc01-v03-opencode.env
sourceKey: OPENCODE_SERVER_PASSWORD
targetKey: password
- name: hwlab-jd01-v03-opencode-server-auth
targetId: hwlab-jd01-v03
secretName: hwlab-v03-opencode-server-auth
+8
View File
@@ -6,6 +6,14 @@ output:
includePreview: false
warning: "CLI stdout exceeded YAML-configured limit; full output was dumped to /tmp for one-off drill-down only. This is a CLI usability defect: improve the command itself to print concise tables/summaries and id-specific progressive disclosure instead of repeatedly depending on dump extraction."
github:
auth:
tokenSource:
enabled: true
priority: before-env
root: .
sourceRef: .env/gh_token.txt
sourceKey: GH_TOKEN
format: raw-token
prMerge:
unknownRetry:
maxAttempts: 5
+93
View File
@@ -0,0 +1,93 @@
version: 1
kind: UniDeskHostK8sDeployment
metadata:
name: unidesk-host
owner: unidesk
target:
id: NC01
kubeContext: local-k3s
namespace: unidesk
runtime:
environment: prod
deployRef: config/unidesk-host-k8s.yaml
repository: https://github.com/pikasTech/unidesk
commit: local-host-k8s
publicHost: 152.53.229.148
noProxy: localhost,127.0.0.1,::1,.svc,.svc.cluster.local,hyueapi.com,.hyueapi.com
images:
postgres: postgres:16-alpine
backendCore: unidesk-backend-core:host-k8s
frontend: unidesk-frontend:host-k8s
database:
mode: host-native
host: 10.42.0.1
serviceName: database
pvcName: unidesk-postgres-data
storageSize: 15Gi
storageClassName: local-path
port: 5432
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
services:
backendCore:
deploymentName: backend-core
serviceName: backend-core
providerPublicServiceName: backend-core-provider
containerPort: 8080
providerPort: 8081
providerDataPort: 8082
providerPublicPort: 18082
providerDataPublicPort: 18084
logFile: /var/log/unidesk/backend-core.jsonl
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
memory: 768Mi
frontend:
deploymentName: frontend
serviceName: frontend
containerPort: 8080
publicPort: 18081
logFile: /var/log/unidesk/frontend.jsonl
resources:
requests:
cpu: 80m
memory: 128Mi
limits:
memory: 512Mi
runtimeConfig:
heartBeatTimeoutMs: "30000"
taskPendingTimeoutMs: "600000"
databasePoolMax: "4"
sessionTtlSeconds: "28800"
authUsername: admin
sshClientRouteAllowlist: NC01,NC01:*,JD01,JD01:*,PK01,PK01:*
microservicesJson: "[]"
runtimeSecrets:
sourceRef:
path: .state/secrets/unidesk-host-k8s.env
target:
name: unidesk-runtime-secrets
keys:
postgresUser: POSTGRES_USER
postgresPassword: POSTGRES_PASSWORD
postgresDb: POSTGRES_DB
databaseUrl: DATABASE_URL
providerToken: PROVIDER_TOKEN
sshClientToken: UNIDESK_SSH_CLIENT_TOKEN
authPassword: AUTH_PASSWORD
sessionSecret: SESSION_SECRET
+41
View File
@@ -0,0 +1,41 @@
# NC01 Reference
NC01 is the current host registered as a UniDesk provider-gateway node. Host maintenance uses `trans NC01 ...`; Kubernetes maintenance uses `trans NC01:k3s ...`.
## Source Of Truth
- Host k8s/provider config: `config/unidesk-host-k8s.yaml`.
- Host proxy config: `config/platform-infra/host-proxy.yaml#targets.NC01`; NC01 host-proxy uses `/root/vpn-server` as the VPN server source.
- HWLAB node/lane config: `config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01`.
- HWLAB control-plane config: `config/hwlab-node-control-plane.yaml#nodes.NC01`.
- AgentRun config: `config/agentrun.yaml`, lane `nc01-v02`.
- NC01 host PostgreSQL config: `config/platform-db/postgres-nc01.yaml`.
## Fixed Repos
- HWLAB v0.3 fixed repo: `/root/hwlab`, branch `v0.3`.
- AgentRun v0.2 fixed repo: `/root/agentrun`, branch `v0.2`.
- UniDesk source/config authority remains `/root/unidesk`.
## Database Boundary
NC01 databases run on host-native PostgreSQL, not Docker or Kubernetes. Kubernetes workloads that need PostgreSQL must use YAML-declared external PostgreSQL bridge objects and Secrets.
For HWLAB v0.3, the Kubernetes Service `nc01-host-postgres` in namespace `hwlab-v03` points to host PostgreSQL at `10.42.0.1:5432`. Runtime database Secrets are sourced from `.state/secrets/hwlab/*` via YAML sourceRef and must only be reported as present/fingerprint metadata, never as full values.
## GitHub Token
The local GitHub token source is `.env/gh_token.txt`. It must be stored as a bare token and consumed through controlled YAML/sourceRef or temporary non-printing credential helpers. Do not place the token in command argv, logs, committed files, or rendered manifests.
## Public Exposure
NC01 HWLAB v0.3 does not require publicExposure unless YAML explicitly declares it and a real FRP token source is available. When publicExposure is absent, control-plane public probe is skipped and must not block runtime readiness.
Web-probe sentinel deployments that need public ingress must declare their own YAML publicExposure and Secret sourceRef. Do not create placeholder FRP tokens; a missing `platform-infra/pk01-frp.env` token source is a deployment blocker for FRP-backed public exposure.
## Validation Entrypoints
- Provider/trans: `scripts/trans NC01 argv true`.
- NC01 k8s route: `scripts/trans NC01:k3s kubectl get nodes -o wide`.
- HWLAB v0.3: `bun scripts/cli.ts hwlab nodes control-plane status --node NC01 --lane v03 --full`.
- AgentRun v0.2: `bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02`.
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env bun
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
function required(value, path) {
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
return value;
}
function record(value, path) {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value;
}
function readEnvFile(path) {
const values = {};
const text = readFileSync(path, "utf8");
for (const [index, rawLine] of text.split(/\r?\n/u).entries()) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) throw new Error(`${path}:${index + 1} must be KEY=value`);
values[line.slice(0, eq)] = line.slice(eq + 1);
}
return values;
}
function sha(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function yamlScalar(value) {
return JSON.stringify(String(value));
}
function indent(text, spaces) {
const pad = " ".repeat(spaces);
return text.split("\n").map((line) => line.length > 0 ? `${pad}${line}` : line).join("\n");
}
function labels(config) {
const name = required(record(config.metadata, "metadata").name, "metadata.name");
return [
"app.kubernetes.io/part-of: unidesk",
`unidesk.ai/deployment: ${name}`,
`unidesk.ai/target: ${required(record(config.target, "target").id, "target.id")}`,
].join("\n");
}
function envFromSecret(secretName, keyName, envName) {
return [
`- name: ${envName}`,
" valueFrom:",
" secretKeyRef:",
` name: ${secretName}`,
` key: ${keyName}`,
].join("\n");
}
function envFromConfig(configName, keyName, envName) {
return [
`- name: ${envName}`,
" valueFrom:",
" configMapKeyRef:",
` name: ${configName}`,
` key: ${keyName}`,
].join("\n");
}
function resourceBlock(resources) {
const requests = record(record(resources, "resources").requests, "resources.requests");
const limits = record(resources.limits, "resources.limits");
return [
"resources:",
" requests:",
` cpu: ${yamlScalar(required(requests.cpu, "resources.requests.cpu"))}`,
` memory: ${yamlScalar(required(requests.memory, "resources.requests.memory"))}`,
" limits:",
...(limits.cpu ? [` cpu: ${yamlScalar(limits.cpu)}`] : []),
` memory: ${yamlScalar(required(limits.memory, "resources.limits.memory"))}`,
].join("\n");
}
const configPath = process.argv[2] ?? "config/unidesk-host-k8s.yaml";
const config = Bun.YAML.parse(readFileSync(configPath, "utf8"));
const target = record(config.target, "target");
const runtime = record(config.runtime, "runtime");
const images = record(config.images, "images");
const database = record(config.database, "database");
const services = record(config.services, "services");
const backend = record(services.backendCore, "services.backendCore");
const frontend = record(services.frontend, "services.frontend");
const runtimeConfig = record(config.runtimeConfig, "runtimeConfig");
const runtimeSecrets = record(config.runtimeSecrets, "runtimeSecrets");
const secretTarget = record(runtimeSecrets.target, "runtimeSecrets.target");
const secretKeys = record(secretTarget.keys, "runtimeSecrets.target.keys");
const namespace = required(target.namespace, "target.namespace");
const secretName = required(secretTarget.name, "runtimeSecrets.target.name");
const sourcePath = resolve(required(record(runtimeSecrets.sourceRef, "runtimeSecrets.sourceRef").path, "runtimeSecrets.sourceRef.path"));
const secrets = readEnvFile(sourcePath);
const requiredSecretKeys = Object.values(secretKeys);
for (const key of requiredSecretKeys) {
if (typeof secrets[key] !== "string" || secrets[key].length === 0) throw new Error(`missing ${key} in ${sourcePath}`);
}
const secretFingerprint = sha(requiredSecretKeys.map((key) => `${key}=${secrets[key]}`).join("\n"));
const runtimeConfigFingerprint = sha(JSON.stringify(runtimeConfig));
const runtimeConfigName = "unidesk-runtime-config";
const databaseMode = database.mode === undefined ? "k8s-postgres" : required(database.mode, "database.mode");
if (databaseMode !== "k8s-postgres" && databaseMode !== "host-native") throw new Error("database.mode must be k8s-postgres or host-native");
const dbService = required(database.serviceName, "database.serviceName");
const dbHost = databaseMode === "host-native" ? required(database.host, "database.host") : `${dbService}.${namespace}.svc.cluster.local`;
const dbPort = required(String(database.port), "database.port");
const dbName = secrets[secretKeys.postgresDb];
if (secrets[secretKeys.databaseUrl] !== `postgres://${secrets[secretKeys.postgresUser]}:${secrets[secretKeys.postgresPassword]}@${dbHost}:${dbPort}/${dbName}`) {
throw new Error(`DATABASE_URL in ${sourcePath} must target ${dbHost}:${dbPort}/${dbName}`);
}
const docs = [];
docs.push(`apiVersion: v1
kind: Namespace
metadata:
name: ${namespace}
labels:
${indent(labels(config), 4)}`);
docs.push(`apiVersion: v1
kind: Secret
metadata:
name: ${secretName}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
annotations:
unidesk.ai/source-ref: ${yamlScalar(required(record(runtimeSecrets.sourceRef, "runtimeSecrets.sourceRef").path, "runtimeSecrets.sourceRef.path"))}
unidesk.ai/fingerprint: ${yamlScalar(secretFingerprint)}
type: Opaque
stringData:
${indent(requiredSecretKeys.map((key) => `${key}: ${yamlScalar(secrets[key])}`).join("\n"), 2)}`);
docs.push(`apiVersion: v1
kind: ConfigMap
metadata:
name: ${runtimeConfigName}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
data:
UNIDESK_ENV: ${yamlScalar(required(runtime.environment, "runtime.environment"))}
UNIDESK_NAMESPACE: ${yamlScalar(namespace)}
UNIDESK_DEPLOY_REF: ${yamlScalar(required(runtime.deployRef, "runtime.deployRef"))}
UNIDESK_DEPLOY_REPO: ${yamlScalar(required(runtime.repository, "runtime.repository"))}
UNIDESK_DEPLOY_COMMIT: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
UNIDESK_DEPLOY_REQUESTED_COMMIT: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
UNIDESK_DATABASE_NAME: ${yamlScalar(dbName)}
UNIDESK_DATABASE_MODE: ${yamlScalar(databaseMode)}
UNIDESK_DATABASE_HOST: ${yamlScalar(dbHost)}
UNIDESK_DATABASE_PORT: ${yamlScalar(dbPort)}
DATABASE_VOLUME_NAME: ${yamlScalar(required(database.pvcName, "database.pvcName"))}
DATABASE_VOLUME_SIZE: ${yamlScalar(required(database.storageSize, "database.storageSize"))}
HEARTBEAT_TIMEOUT_MS: ${yamlScalar(required(runtimeConfig.heartBeatTimeoutMs, "runtimeConfig.heartBeatTimeoutMs"))}
TASK_PENDING_TIMEOUT_MS: ${yamlScalar(required(runtimeConfig.taskPendingTimeoutMs, "runtimeConfig.taskPendingTimeoutMs"))}
DATABASE_POOL_MAX: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))}
SESSION_TTL_SECONDS: ${yamlScalar(required(runtimeConfig.sessionTtlSeconds, "runtimeConfig.sessionTtlSeconds"))}
AUTH_USERNAME: ${yamlScalar(required(runtimeConfig.authUsername, "runtimeConfig.authUsername"))}
UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: ${yamlScalar(required(runtimeConfig.sshClientRouteAllowlist, "runtimeConfig.sshClientRouteAllowlist"))}
MICROSERVICES_JSON: ${yamlScalar(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson"))}
NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}
no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`);
if (databaseMode === "k8s-postgres") {
docs.push(`apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ${required(database.pvcName, "database.pvcName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
accessModes:
- ReadWriteOnce
storageClassName: ${required(database.storageClassName, "database.storageClassName")}
resources:
requests:
storage: ${required(database.storageSize, "database.storageSize")}`);
docs.push(`apiVersion: v1
kind: ConfigMap
metadata:
name: unidesk-db-init
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
data:
001_unidesk_init.sql: |
${indent(readFileSync("src/components/database/init/001_unidesk_init.sql", "utf8"), 4)}`);
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${dbService}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: database
ports:
- name: pg
port: ${database.port}
targetPort: pg`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: database
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: database
template:
metadata:
labels:
app.kubernetes.io/name: database
app.kubernetes.io/part-of: unidesk
annotations:
unidesk.ai/secret-fingerprint: ${yamlScalar(secretFingerprint)}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
securityContext:
fsGroup: 70
containers:
- name: postgres
image: ${required(images.postgres, "images.postgres")}
imagePullPolicy: IfNotPresent
ports:
- name: pg
containerPort: ${database.port}
env:
${indent(envFromSecret(secretName, secretKeys.postgresUser, "POSTGRES_USER"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresPassword, "POSTGRES_PASSWORD"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresDb, "POSTGRES_DB"), 12)}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: init
mountPath: /docker-entrypoint-initdb.d
readOnly: true
readinessProbe:
exec:
command: ["sh", "-ec", "pg_isready -U \\"$POSTGRES_USER\\" -d \\"$POSTGRES_DB\\""]
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 18
startupProbe:
exec:
command: ["sh", "-ec", "pg_isready -U \\"$POSTGRES_USER\\" -d \\"$POSTGRES_DB\\""]
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
${indent(resourceBlock(database.resources), 10)}
volumes:
- name: data
persistentVolumeClaim:
claimName: ${required(database.pvcName, "database.pvcName")}
- name: init
configMap:
name: unidesk-db-init`);
}
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(backend.serviceName, "services.backendCore.serviceName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: backend-core
ports:
- name: http
port: ${backend.containerPort}
targetPort: http
- name: provider
port: ${backend.providerPort}
targetPort: provider
- name: provider-data
port: ${backend.providerDataPort}
targetPort: provider-data`);
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(backend.providerPublicServiceName, "services.backendCore.providerPublicServiceName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: backend-core
ports:
- name: provider
port: ${backend.providerPublicPort}
targetPort: provider
- name: provider-data
port: ${backend.providerDataPublicPort}
targetPort: provider-data`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: ${required(backend.deploymentName, "services.backendCore.deploymentName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: backend-core
template:
metadata:
labels:
app.kubernetes.io/name: backend-core
app.kubernetes.io/part-of: unidesk
annotations:
unidesk.ai/secret-fingerprint: ${yamlScalar(secretFingerprint)}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
initContainers:
- name: wait-database
image: ${required(images.postgres, "images.postgres")}
imagePullPolicy: IfNotPresent
env:
- name: PGHOST
value: ${yamlScalar(dbHost)}
- name: PGPORT
value: ${yamlScalar(dbPort)}
${indent(envFromSecret(secretName, secretKeys.postgresUser, "PGUSER"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresPassword, "PGPASSWORD"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresDb, "PGDATABASE"), 12)}
command: ["sh", "-ec", "until pg_isready -h \\"$PGHOST\\" -p \\"$PGPORT\\" -U \\"$PGUSER\\" -d \\"$PGDATABASE\\" >/dev/null 2>&1; do sleep 2; done"]
containers:
- name: backend-core
image: ${required(images.backendCore, "images.backendCore")}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: ${backend.containerPort}
- name: provider
containerPort: ${backend.providerPort}
- name: provider-data
containerPort: ${backend.providerDataPort}
envFrom:
- configMapRef:
name: ${runtimeConfigName}
env:
- name: PORT
value: ${yamlScalar(backend.containerPort)}
- name: PROVIDER_PORT
value: ${yamlScalar(backend.providerPort)}
- name: PROVIDER_DATA_PORT
value: ${yamlScalar(backend.providerDataPort)}
${indent(envFromSecret(secretName, secretKeys.databaseUrl, "DATABASE_URL"), 12)}
${indent(envFromSecret(secretName, secretKeys.providerToken, "PROVIDER_TOKEN"), 12)}
- name: LOG_FILE
value: ${yamlScalar(required(backend.logFile, "services.backendCore.logFile"))}
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 20
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
${indent(resourceBlock(backend.resources), 10)}
volumes:
- name: logs
emptyDir: {}`);
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(frontend.serviceName, "services.frontend.serviceName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: frontend
ports:
- name: http
port: ${frontend.publicPort}
targetPort: http`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: ${required(frontend.deploymentName, "services.frontend.deploymentName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: frontend
template:
metadata:
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/part-of: unidesk
annotations:
unidesk.ai/secret-fingerprint: ${yamlScalar(secretFingerprint)}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
containers:
- name: frontend
image: ${required(images.frontend, "images.frontend")}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: ${frontend.containerPort}
envFrom:
- configMapRef:
name: ${runtimeConfigName}
env:
- name: PORT
value: ${yamlScalar(frontend.containerPort)}
- name: CORE_INTERNAL_URL
value: ${yamlScalar(`http://${backend.serviceName}.${namespace}.svc.cluster.local:${backend.containerPort}`)}
- name: FRONTEND_PUBLIC_URL
value: ${yamlScalar(`http://${runtime.publicHost}:${frontend.publicPort}`)}
- name: PROVIDER_INGRESS_PUBLIC_URL
value: ${yamlScalar(`ws://${runtime.publicHost}:${backend.providerPort}/ws/provider`)}
${indent(envFromConfig(runtimeConfigName, "AUTH_USERNAME", "AUTH_USERNAME"), 12)}
${indent(envFromSecret(secretName, secretKeys.authPassword, "AUTH_PASSWORD"), 12)}
${indent(envFromSecret(secretName, secretKeys.providerToken, "PROVIDER_TOKEN"), 12)}
${indent(envFromSecret(secretName, secretKeys.sshClientToken, "UNIDESK_SSH_CLIENT_TOKEN"), 12)}
${indent(envFromSecret(secretName, secretKeys.sessionSecret, "SESSION_SECRET"), 12)}
- name: LOG_FILE
value: ${yamlScalar(required(frontend.logFile, "services.frontend.logFile"))}
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 20
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
${indent(resourceBlock(frontend.resources), 10)}
volumes:
- name: logs
emptyDir: {}`);
process.stdout.write(`${docs.join("\n---\n")}\n`);
+6
View File
@@ -48,6 +48,7 @@ export interface AgentRunLaneSecretSpec {
readonly sourceRef: string;
readonly sourceMode: "env" | "file";
readonly sourceKey: string | null;
readonly sourceFormat: "env" | "raw-token" | null;
readonly targetRef: AgentRunSecretRef;
readonly providerCredentialProfile: string | null;
}
@@ -407,6 +408,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`,
sourceMode: secret.sourceMode,
sourceKey: secret.sourceKey,
sourceFormat: secret.sourceFormat,
targetRef: secret.targetRef,
providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile },
valuesPrinted: false,
@@ -831,12 +833,16 @@ function parseLaneSecret(input: Record<string, unknown>, path: string): AgentRun
if (sourceMode !== "env" && sourceMode !== "file") throw new Error(`${path}.sourceMode must be env or file`);
const sourceKey = optionalStringField(input, "sourceKey", path) ?? null;
if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`);
const sourceFormat = optionalStringField(input, "sourceFormat", path) ?? null;
if (sourceFormat !== null && sourceFormat !== "env" && sourceFormat !== "raw-token") throw new Error(`${path}.sourceFormat must be env or raw-token`);
if (sourceFormat === "raw-token" && sourceMode !== "env") throw new Error(`${path}.sourceFormat raw-token requires sourceMode env`);
const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`);
return {
id: stringField(input, "id", path),
sourceRef: secretSourceRefField(input, "sourceRef", path),
sourceMode,
sourceKey,
sourceFormat,
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
providerCredentialProfile: providerCredential,
};
+10 -1
View File
@@ -45,6 +45,7 @@ export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
const base = parseConfirmOptions(args);
let node: string | null = null;
let lane: string | null = null;
const secretIds: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm" || arg === "--dry-run") continue;
@@ -62,9 +63,17 @@ export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
index += 1;
continue;
}
if (arg === "--secret-id") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--secret-id requires a value");
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--secret-id must be a simple YAML secret id");
if (!secretIds.includes(value)) secretIds.push(value);
index += 1;
continue;
}
throw new Error(`unsupported secret-sync option: ${arg}`);
}
return { ...base, node, lane };
return { ...base, node, lane, secretIds };
}
export function parseLaneConfirmOptions(args: string[]): LaneConfirmOptions {
+1
View File
@@ -71,6 +71,7 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02",
"bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --confirm",
"bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --secret-id tool-github-pr-token --confirm",
"bun scripts/cli.ts agentrun control-plane restart --node D601 --lane v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane restart --node D601 --lane v02 --confirm",
"bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --dry-run",
+1
View File
@@ -219,6 +219,7 @@ export interface ConfirmOptions {
export interface SecretSyncOptions extends ConfirmOptions {
node: string | null;
lane: string | null;
secretIds: string[];
}
export interface LaneConfirmOptions extends ConfirmOptions {
+10 -4
View File
@@ -350,6 +350,7 @@ export function yamlLanePipelineRunManifest(spec: AgentRunLaneSpec, sourceCommit
{ name: "source-branch", value: spec.source.branch },
{ name: "gitops-branch", value: spec.gitops.branch },
{ name: "revision", value: sourceCommit },
...(sourceStageRef === null ? [] : [{ name: "source-stage-ref", value: sourceStageRef }]),
{ name: "registry-prefix", value: spec.ci.registryPrefix },
{ name: "tools-image", value: spec.ci.toolsImage },
],
@@ -476,6 +477,7 @@ export function yamlLaneGitMirrorSshSetupShellLines(spec: AgentRunLaneSpec): str
const proxyHost = spec.gitMirror.githubProxy.host;
const proxyPort = spec.gitMirror.githubProxy.port;
const proxyUrl = `http://${proxyHost}:${proxyPort}`;
const noProxy = "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,.cluster.local,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,hyueapi.com,.hyueapi.com";
const proxySummary = `agentrun git-mirror-egress-proxy node=${spec.nodeId} lane=${spec.lane} host=${proxyHost} port=${proxyPort} ssh=GIT_SSH-wrapper source=yaml`;
const proxyCommand = `ProxyCommand=node /tmp/agentrun-github-proxy-connect.cjs ${proxyHost} ${proxyPort} %h %p`;
return [
@@ -545,6 +547,8 @@ export function yamlLaneGitMirrorSshSetupShellLines(spec: AgentRunLaneSpec): str
`export http_proxy=${shQuote(proxyUrl)}`,
`export https_proxy=${shQuote(proxyUrl)}`,
`export all_proxy=${shQuote(proxyUrl)}`,
`export NO_PROXY=${shQuote(noProxy)}`,
`export no_proxy=${shQuote(noProxy)}`,
"export GIT_SSH=/tmp/agentrun-git-ssh-proxy.sh",
"unset GIT_SSH_COMMAND",
];
@@ -558,8 +562,8 @@ export function yamlLaneGitMirrorSyncShell(spec: AgentRunLaneSpec): string {
`source_branch=${shQuote(spec.source.branch)}`,
`source_stage_ref_prefix=${shQuote(agentRunSourceSnapshotStageRefPrefix(spec))}`,
`gitops_branch=${shQuote(spec.gitops.branch)}`,
`remote=${shQuote(spec.source.remote)}`,
"repo=\"/cache/${repository}.git\"",
"remote=\"ssh://git@ssh.github.com:443/${repository}.git\"",
"mkdir -p \"$(dirname \"$repo\")\"",
"if [ -d \"$repo/objects\" ] && [ -f \"$repo/HEAD\" ]; then",
" git --git-dir=\"$repo\" remote set-url origin \"$remote\" || git --git-dir=\"$repo\" remote add origin \"$remote\"",
@@ -606,8 +610,8 @@ export function yamlLaneGitMirrorFlushShell(spec: AgentRunLaneSpec): string {
...yamlLaneGitMirrorSshSetupShellLines(spec),
`repository=${shQuote(spec.source.repository)}`,
`gitops_branch=${shQuote(spec.gitops.branch)}`,
`remote=${shQuote(spec.gitMirror.writeUrl)}`,
"repo=\"/cache/${repository}.git\"",
"remote=\"ssh://git@ssh.github.com:443/${repository}.git\"",
"test -d \"$repo\"",
"git --git-dir=\"$repo\" remote set-url origin \"$remote\" || git --git-dir=\"$repo\" remote add origin \"$remote\"",
"local_gitops=$(git --git-dir=\"$repo\" rev-parse --verify \"refs/heads/${gitops_branch}^{commit}\" 2>/dev/null || true)",
@@ -703,6 +707,7 @@ export type LaneSecretSource = {
sourceRef: string;
sourceMode: "env" | "file";
sourceKey: string | null;
sourceFormat?: "env" | "raw-token" | null;
targetRef: { namespace: string; name: string; key: string };
transform?: "local-postgres-database" | "local-postgres-user" | "local-postgres-database-url";
};
@@ -719,8 +724,8 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret
value = readFileSync(sourcePath, "utf8");
} else {
if (source.sourceKey === null) throw new Error(`secret source ${sourceRef} is missing sourceKey`);
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const envValue = values.get(source.sourceKey);
const text = readFileSync(sourcePath, "utf8");
const envValue = source.sourceFormat === "raw-token" ? text.trim() : parseEnvFile(text).get(source.sourceKey);
if (envValue === undefined || envValue.length === 0) throw new Error(`secret source ${sourceRef} is missing required key ${source.sourceKey}`);
value = envValue;
}
@@ -837,6 +842,7 @@ export function collectLaneSecretSources(spec: AgentRunLaneSpec): LaneSecretSour
sourceRef: secret.sourceRef,
sourceMode: secret.sourceMode,
sourceKey: secret.sourceKey,
sourceFormat: secret.sourceFormat,
targetRef: secret.targetRef,
});
}
+11 -5
View File
@@ -178,15 +178,19 @@ export async function restartYamlLane(config: UniDeskConfig, options: LaneConfir
export async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise<Record<string, unknown>> {
const { configPath, spec } = resolveAgentRunLaneTarget(options);
const sources = collectLaneSecretSources(spec);
const allSources = collectLaneSecretSources(spec);
const selectedIds = new Set(options.secretIds);
const sources = selectedIds.size === 0 ? allSources : allSources.filter((source) => selectedIds.has(source.id));
if (sources.length === 0) {
return {
ok: false,
command: "agentrun control-plane secret-sync",
mode: "yaml-declared-node-lane",
configPath,
target: agentRunLaneSummary(spec),
target: compactAgentRunLaneStatusTarget(spec),
degradedReason: "lane-secret-sources-not-declared",
requestedSecretIds: options.secretIds,
declaredSecretIds: allSources.map((source) => source.id),
valuesPrinted: false,
};
}
@@ -211,10 +215,11 @@ export async function secretSync(config: UniDeskConfig, options: SecretSyncOptio
mode: "dry-run",
mutation: false,
configPath,
target: agentRunLaneSummary(spec),
target: compactAgentRunLaneStatusTarget(spec),
filter: options.secretIds.length === 0 ? null : { secretIds: options.secretIds, selected: sources.map((source) => source.id) },
plan: { secretCount: plan.length, items: plan, valuesPrinted: false },
next: {
confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane}${options.secretIds.map((id) => ` --secret-id ${id}`).join("")} --confirm`,
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
valuesPrinted: false,
@@ -228,7 +233,8 @@ export async function secretSync(config: UniDeskConfig, options: SecretSyncOptio
mode: "confirmed-sync",
mutation: true,
configPath,
target: agentRunLaneSummary(spec),
target: compactAgentRunLaneStatusTarget(spec),
filter: options.secretIds.length === 0 ? null : { secretIds: options.secretIds, selected: sources.map((source) => source.id) },
plan: { secretCount: plan.length, items: plan, valuesPrinted: false },
result: payload,
capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }),
+2 -15
View File
@@ -128,22 +128,9 @@ export type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?:
export let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null;
export async function capture(config: UniDeskConfig, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
const result = await runSshCommandCapture(config, target, args);
const plan = agentRunBridgeCapturePlan(config, target);
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
return await captureRemote(config, plan, target, args);
}
const local = attachBridgeExecution(await runSshCommandCapture(config, target, args), plan);
const fallbackHost = agentRunBridgeRemoteHost(config);
if (local.exitCode !== 0 && fallbackHost !== null && bridgeExecutionFailureKind(local)?.degradedReason === "capture-backend-unavailable") {
const fallbackPlan: AgentRunBridgeCapturePlan = {
...plan,
backend: "remote-frontend-websocket",
remoteHost: fallbackHost,
reason: "local-capture-backend-unavailable",
};
return await captureRemote(config, fallbackPlan, target, args);
}
return local;
return attachBridgeExecution(result, { ...plan, reason: `unified-ssh-capture:${plan.reason}` });
}
export async function captureRemote(config: UniDeskConfig, plan: AgentRunBridgeCapturePlan, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
+5 -2
View File
@@ -1242,15 +1242,18 @@ function yamlLaneK3sBuildProxyEnv(spec: AgentRunLaneSpec): Array<{ name: string;
}
function yamlLaneK3sBuildSourceShell(spec: AgentRunLaneSpec, sourceCommit: string): string {
const useDirectSourceRemote = spec.source.remote.startsWith("http://gitea-http.") || spec.source.remote.startsWith("https://gitea.");
const readUrl = useDirectSourceRemote ? spec.source.remote : spec.gitMirror.readUrl;
return [
"set -eu",
`read_url=${shQuote(spec.gitMirror.readUrl)}`,
`read_url=${shQuote(readUrl)}`,
`source_commit=${shQuote(sourceCommit)}`,
`source_stage_ref=${shQuote(agentRunSourceSnapshotRef(spec, sourceCommit))}`,
`use_direct_source_remote=${useDirectSourceRemote ? "true" : "false"}`,
"rm -rf /workspace/repo",
"git clone --no-checkout \"$read_url\" /workspace/repo",
"cd /workspace/repo",
"git fetch origin \"+$source_stage_ref:refs/remotes/origin/unidesk-source-snapshot\"",
"if [ \"$use_direct_source_remote\" != true ]; then git fetch origin \"+$source_stage_ref:refs/remotes/origin/unidesk-source-snapshot\"; fi",
"git checkout --detach \"$source_commit\"",
"actual=$(git rev-parse HEAD)",
"test \"$actual\" = \"$source_commit\"",
+149 -4
View File
@@ -3,10 +3,25 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { isAbsolute, join, normalize, relative, resolve } from "node:path";
import { rootPath } from "../config";
import { parseEnvFile } from "../platform-infra-ops-library";
import { readIssueCommentBody } from "./body-input";
import { PREVIEW_CHARS } from "./types";
import { PREVIEW_CHARS, UNIDESK_CLI_CONFIG_PATH } from "./types";
import type { GitHubOptions, GitHubTokenProbe } from "./types";
const GITHUB_TOKEN_SOURCE_CONFIG_REF = `${UNIDESK_CLI_CONFIG_PATH}#github.auth.tokenSource`;
interface GitHubYamlTokenSourceConfig {
enabled: boolean;
priority: "before-env" | "after-env";
root: string;
sourceRef: string;
sourceKey: string;
format: "env" | "raw-token";
}
export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.comment === undefined && options.commentFile === undefined) return null;
if (options.comment !== undefined) {
@@ -34,6 +49,50 @@ export function tokenFromEnvironment(): GitHubTokenProbe {
return { present: false, source: null, ghFallbackAttempted: false };
}
export function tokenFromYamlSource(): { token: string | null; probe: GitHubTokenProbe } {
const config = readGitHubYamlTokenSourceConfig();
if (config === null || !config.enabled) {
return {
token: null,
probe: {
present: false,
source: null,
ghFallbackAttempted: false,
yamlSourceAttempted: true,
yamlSourceEnabled: config?.enabled ?? false,
yamlSourcePriority: config?.priority,
configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF,
valuesPrinted: false,
},
};
}
const sourceRoot = resolveYamlSourceRoot(config.root);
const sourcePath = resolveYamlSourcePath(sourceRoot, config.sourceRef);
const sourceExists = existsSync(sourcePath);
const values = sourceExists ? parseYamlTokenSource(readFileSync(sourcePath, "utf8"), config) : {};
const token = values[config.sourceKey] ?? null;
const present = token !== null && token.length > 0;
return {
token: present ? token : null,
probe: {
present,
source: present ? "yaml-token-source" : null,
ghFallbackAttempted: false,
yamlSourceAttempted: true,
yamlSourceEnabled: true,
yamlSourcePriority: config.priority,
configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF,
sourceRef: config.sourceRef,
sourceKey: config.sourceKey,
sourcePath: redactRepoPath(sourcePath),
sourceExists,
sourceKeyPresent: present,
tokenFingerprint: present ? fingerprintToken(token) : null,
valuesPrinted: false,
},
};
}
export function ghBinaryPath(): string | null {
try {
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -53,17 +112,103 @@ export function ghAuthToken(): string | null {
}
export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
const yamlToken = tokenFromYamlSource();
if (yamlToken.probe.yamlSourcePriority === "before-env" && yamlToken.probe.present) return yamlToken;
const envProbe = tokenFromEnvironment();
if (envProbe.present) {
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
return { token, probe: envProbe };
}
if (!allowGhFallback) return { token: null, probe: envProbe };
if (yamlToken.probe.present) return yamlToken;
if (!allowGhFallback) return { token: null, probe: yamlToken.probe };
const ghPath = ghBinaryPath();
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
if (ghPath === null) return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
const token = ghAuthToken();
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
}
function readGitHubYamlTokenSourceConfig(): GitHubYamlTokenSourceConfig | null {
const configPath = rootPath(UNIDESK_CLI_CONFIG_PATH);
if (!existsSync(configPath)) return null;
const parsed = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, UNIDESK_CLI_CONFIG_PATH);
const github = optionalRecord(parsed.github, `${UNIDESK_CLI_CONFIG_PATH}.github`);
const auth = optionalRecord(github?.auth, `${UNIDESK_CLI_CONFIG_PATH}.github.auth`);
const tokenSource = optionalRecord(auth?.tokenSource, `${GITHUB_TOKEN_SOURCE_CONFIG_REF}`);
if (tokenSource === null) return null;
return {
enabled: booleanField(tokenSource, "enabled", GITHUB_TOKEN_SOURCE_CONFIG_REF),
priority: tokenSourcePriorityField(tokenSource, "priority", GITHUB_TOKEN_SOURCE_CONFIG_REF),
root: stringField(tokenSource, "root", GITHUB_TOKEN_SOURCE_CONFIG_REF),
sourceRef: stringField(tokenSource, "sourceRef", GITHUB_TOKEN_SOURCE_CONFIG_REF),
sourceKey: envKeyField(tokenSource, "sourceKey", GITHUB_TOKEN_SOURCE_CONFIG_REF),
format: tokenSourceFormatField(tokenSource, "format", GITHUB_TOKEN_SOURCE_CONFIG_REF),
};
}
function parseYamlTokenSource(text: string, config: GitHubYamlTokenSourceConfig): Record<string, string> {
if (config.format === "raw-token") return { [config.sourceKey]: text.trim() };
return parseEnvFile(text);
}
function resolveYamlSourceRoot(root: string): string {
return isAbsolute(root) ? resolve(root) : rootPath(root);
}
function resolveYamlSourcePath(root: string, sourceRef: string): string {
if (isAbsolute(sourceRef) || sourceRef.includes("..")) throw new Error(`${GITHUB_TOKEN_SOURCE_CONFIG_REF}.sourceRef must be a relative path without ..`);
const resolved = normalize(join(root, sourceRef));
if (resolved !== root && !resolved.startsWith(`${root}/`)) throw new Error(`${GITHUB_TOKEN_SOURCE_CONFIG_REF}.sourceRef escapes configured root`);
return resolved;
}
function redactRepoPath(value: string): string {
const root = rootPath();
return value === root ? "." : value.startsWith(`${root}/`) ? relative(root, value) : value;
}
function fingerprintToken(value: string): string {
return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`;
}
function asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
return value as Record<string, unknown>;
}
function optionalRecord(value: unknown, path: string): Record<string, unknown> | null {
if (value === undefined || value === null) return null;
return asRecord(value, path);
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value.trim();
}
function envKeyField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${path}.${key} must be an env key`);
return value;
}
function tokenSourcePriorityField(obj: Record<string, unknown>, key: string, path: string): "before-env" | "after-env" {
const value = stringField(obj, key, path);
if (value !== "before-env" && value !== "after-env") throw new Error(`${path}.${key} must be before-env or after-env`);
return value;
}
function tokenSourceFormatField(obj: Record<string, unknown>, key: string, path: string): "env" | "raw-token" {
const value = obj[key] === undefined ? "env" : stringField(obj, key, path);
if (value !== "env" && value !== "raw-token") throw new Error(`${path}.${key} must be env or raw-token`);
return value;
}
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
export function repoParts(repo: string): { owner: string; name: string } {
+1 -1
View File
@@ -29,7 +29,7 @@ export async function authStatus(repo: string): Promise<GitHubCommandResult> {
};
}
degraded.push("missing-token");
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN, GITHUB_TOKEN, or the YAML-declared GitHub token source is required", { details: probe }), {
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
+3 -3
View File
@@ -335,12 +335,12 @@ export async function githubGraphqlRequest<T>(
export function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
if (!tokenProbe.present) {
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no env or YAML GitHub token source is available", { details: tokenProbe }));
}
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no env or YAML GitHub token source is available", { details: tokenProbe }));
}
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN, GITHUB_TOKEN, or the YAML-declared GitHub token source is required", { details: tokenProbe }), {
degraded: ["missing-token"],
token: tokenProbe,
});
+10 -1
View File
@@ -377,8 +377,17 @@ function renderAuthStatus(result: GitHubCommandResult): string {
const token = record(result.token);
const gh = record(result.gh);
const probes = record(result.probes);
const tokenDetail = [
`source=${ghText(token.source)}`,
`yaml=${ghText(token.yamlSourceEnabled)}`,
`priority=${ghText(token.yamlSourcePriority)}`,
`sourceRef=${ghText(token.sourceRef)}`,
`key=${ghText(token.sourceKey)}`,
`fingerprint=${ghText(token.tokenFingerprint)}`,
`ghFallback=${ghText(token.ghFallbackAttempted)}`,
].filter((item) => !item.endsWith("=-") && !item.endsWith("=undefined")).join(" ");
const rows = [
["token", token.present === true ? "ok" : "missing", `source=${ghText(token.source)} ghFallback=${ghText(token.ghFallbackAttempted)}`],
["token", token.present === true ? "ok" : "missing", tokenDetail],
["gh-binary", gh.binaryFound === true ? "ok" : "missing", `path=${ghText(gh.path)}`],
["rest-api", probeStatus(probes.restApi), ghText(probes.restApi)],
["repo", probeStatus(probes.repo), probeDetail(probes.repo)],
+12 -1
View File
@@ -410,10 +410,21 @@ export type GitHubCommandFailure = GitHubCommandResult & { ok: false };
export interface GitHubTokenProbe {
present: boolean;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "yaml-token-source" | "gh-auth-token" | null;
ghFallbackAttempted: boolean;
ghBinaryFound?: boolean;
ghAuthTokenAvailable?: boolean | null;
yamlSourceAttempted?: boolean;
yamlSourceEnabled?: boolean;
yamlSourcePriority?: "before-env" | "after-env";
configRef?: string;
sourceRef?: string;
sourceKey?: string;
sourcePath?: string;
sourceExists?: boolean;
sourceKeyPresent?: boolean;
tokenFingerprint?: string | null;
valuesPrinted?: false;
}
export interface GitHubOptions {
+57
View File
@@ -0,0 +1,57 @@
import { existsSync, readFileSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { rootPath } from "./config";
const hostK8sConfigPath = "config/unidesk-host-k8s.yaml";
function asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value;
}
function readEnvFile(path: string): Record<string, string> {
const values: Record<string, string> = {};
const text = readFileSync(path, "utf8");
for (const [index, rawLine] of text.split(/\r?\n/u).entries()) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) throw new Error(`${path}:${index + 1} must be KEY=value`);
values[line.slice(0, eq)] = line.slice(eq + 1);
}
return values;
}
function readHostK8sConfig(): Record<string, unknown> | null {
const path = rootPath(hostK8sConfigPath);
if (!existsSync(path)) return null;
return asRecord(Bun.YAML.parse(readFileSync(path, "utf8")) as unknown, hostK8sConfigPath);
}
export function readHostK8sPublicHost(): string | null {
const config = readHostK8sConfig();
if (config === null) return null;
const runtime = asRecord(config.runtime, `${hostK8sConfigPath}.runtime`);
return stringField(runtime, "publicHost", `${hostK8sConfigPath}.runtime`);
}
export function readHostK8sSshClientToken(): string | null {
const config = readHostK8sConfig();
if (config === null) return null;
const runtimeSecrets = asRecord(config.runtimeSecrets, `${hostK8sConfigPath}.runtimeSecrets`);
const sourceRef = asRecord(runtimeSecrets.sourceRef, `${hostK8sConfigPath}.runtimeSecrets.sourceRef`);
const target = asRecord(runtimeSecrets.target, `${hostK8sConfigPath}.runtimeSecrets.target`);
const keys = asRecord(target.keys, `${hostK8sConfigPath}.runtimeSecrets.target.keys`);
const sourcePathRaw = stringField(sourceRef, "path", `${hostK8sConfigPath}.runtimeSecrets.sourceRef`);
const tokenKey = stringField(keys, "sshClientToken", `${hostK8sConfigPath}.runtimeSecrets.target.keys`);
const sourcePath = isAbsolute(sourcePathRaw) ? sourcePathRaw : join(rootPath(), sourcePathRaw);
if (!existsSync(sourcePath)) return null;
const token = readEnvFile(sourcePath)[tokenKey]?.trim() ?? "";
return token.length > 0 ? token : null;
}
+2 -4
View File
@@ -1817,10 +1817,7 @@ function tektonGitWorkspaceSecretSpec(
if (sourceRefFrom !== "gitMirror.githubTransport") {
throw new Error(`${path}.sourceRefFrom must be gitMirror.githubTransport`);
}
if (githubTransport.mode !== "ssh") {
throw new Error(`${path}.sourceRefFrom=gitMirror.githubTransport requires gitMirror.githubTransport.mode=ssh`);
}
if (githubTransport.knownHostsSecretKey === null) {
if (githubTransport.mode === "ssh" && githubTransport.knownHostsSecretKey === null) {
throw new Error(`${path}.sourceRefFrom=gitMirror.githubTransport requires gitMirror.githubTransport.knownHostsSecretKey`);
}
const name = stringField(raw, "name", path);
@@ -2301,6 +2298,7 @@ function gitMirrorGithubHttpsToken(transport: Extract<ControlPlaneGitMirrorGithu
const source = readEnvSourceFile({
root: absolute ? "/" : rootPath("."),
sourceRef: absolute ? transport.tokenSourceRef.slice(1) : transport.tokenSourceRef,
rawKey: transport.tokenSourceKey,
missingMessage: () => `gitMirror.githubTransport token source ${transport.tokenSourceRef} is missing; create the YAML-declared sourceRef with ${transport.tokenSourceKey} before applying the control plane`,
});
const value = requiredEnvValue(source.values, transport.tokenSourceKey, transport.tokenSourceRef);
+37 -11
View File
@@ -476,6 +476,23 @@ export function nodeRuntimeStatusDegradedReason(input: {
}
export function nodeRuntimePublicProbeStatus(spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
if (spec.publicExposure?.enabled !== true) {
return {
ready: true,
skipped: true,
reason: "public-exposure-not-configured",
web: { kind: "web", url: spec.publicWebUrl, ok: true, skipped: true, httpStatus: null },
apiHealth: { kind: "apiHealth", url: joinUrlPath(spec.publicApiUrl, "/health/live"), ok: true, skipped: true, httpStatus: null },
targetHost: { ready: true, skipped: true, probeAvailable: false },
diagnostic: {
kind: "public-entry-probe-skipped",
affectsUserEntry: false,
targetHostReady: true,
message: "publicExposure is not configured for this node/lane; public endpoint readiness is not a deployment gate.",
nextAction: null,
},
};
}
const web = publicHttpProbe("web", spec.publicWebUrl);
const apiHealth = publicHttpProbe("apiHealth", joinUrlPath(spec.publicApiUrl, "/health/live"));
const ready = web.ok === true && apiHealth.ok === true;
@@ -1896,10 +1913,11 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" if (!fs.existsSync(file)) return false;",
" const doc = readYaml(file) || {};",
" const resources = Array.isArray(doc.resources) ? doc.resources : [];",
" const publicExposureEnabled = overlay.publicExposure && overlay.publicExposure.enabled;",
" const next = resources.filter((item) => {",
" if (!(overlay.observability && overlay.observability.prometheusOperator === false)) return true;",
" const resource = String(item);",
" if (resource === 'observability.yaml') return false;",
" if (!publicExposureEnabled && resource === 'node-frpc.yaml') return false;",
" if (overlay.observability && overlay.observability.prometheusOperator === false && resource === 'observability.yaml') return false;",
" return !(/\\.ya?ml$/u.test(resource) && !fs.existsSync(path.join(runtimePath, resource)));",
" });",
" let changed = false;",
@@ -1918,7 +1936,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" const items = listItems(doc).filter(Boolean);",
" let changed = false;",
" const endpointIsIpv4 = /^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$/.test(String(access.endpointAddress));",
" const endpointSliceName = String(pg.serviceName) + '-host';",
" for (const item of items) {",
" if (!item || typeof item !== 'object') continue;",
" item.metadata = item.metadata || {};",
@@ -1946,11 +1963,14 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" }",
" if (item.kind === 'EndpointSlice') {",
" if (!endpointIsIpv4) { item.__delete = true; changed = true; continue; }",
" item.metadata.name = endpointSliceName;",
" item.apiVersion = 'v1';",
" item.kind = 'Endpoints';",
" item.metadata.name = pg.serviceName;",
" item.metadata.labels['kubernetes.io/service-name'] = pg.serviceName;",
" item.addressType = 'IPv4';",
" item.ports = [{ name: 'postgres', port: access.port, protocol: 'TCP' }];",
" item.endpoints = [{ addresses: [access.endpointAddress], conditions: { ready: true } }];",
" delete item.addressType;",
" delete item.ports;",
" delete item.endpoints;",
" item.subsets = [{ addresses: [{ ip: access.endpointAddress }], ports: [{ name: 'postgres', port: access.port, protocol: 'TCP' }] }];",
" changed = true;",
" }",
" }",
@@ -2015,8 +2035,12 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
"}",
"function patchPublicExposure() {",
" const exposure = overlay.publicExposure;",
" if (!exposure || !exposure.enabled) return { configured: false, changed: false };",
" const file = path.join(runtimePath, 'node-frpc.yaml');",
" if (!exposure || !exposure.enabled) {",
" const changed = fs.existsSync(file);",
" if (changed) fs.rmSync(file, { force: true });",
" return { configured: false, changed };",
" }",
" if (!fs.existsSync(file)) {",
" console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'node-frpc-yaml-missing', filePath: file, hostname: exposure.hostname }));",
" process.exit(49);",
@@ -2253,6 +2277,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" if (!fs.existsSync(file)) fail('external-postgres-missing');",
" const items = listItems(readYaml(file)).filter(Boolean);",
" const service = items.find((item) => item && item.kind === 'Service' && item.metadata && item.metadata.name === pg.serviceName);",
" const endpoints = items.find((item) => item && item.kind === 'Endpoints' && item.metadata && item.metadata.name === String(pg.serviceName));",
" const endpointSlice = items.find((item) => item && item.kind === 'EndpointSlice' && item.metadata && item.metadata.name === String(pg.serviceName) + '-host');",
" if (!service) fail('external-postgres-service-missing', { expected: pg.serviceName });",
" const endpointIsIpv4 = /^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$/.test(String(access.endpointAddress));",
@@ -2262,11 +2287,12 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" if (!service.spec || String(service.spec.externalName) !== String(access.endpointAddress)) fail('external-postgres-external-name-mismatch', { value: service.spec && service.spec.externalName, expected: access.endpointAddress });",
" if (Number(servicePort) !== Number(access.port)) fail('external-postgres-port-mismatch', { servicePort, expectedPort: access.port });",
" if (endpointSlice) fail('external-postgres-endpointslice-unexpected', { name: String(pg.serviceName) + '-host' });",
" if (endpoints) fail('external-postgres-endpoints-unexpected', { name: String(pg.serviceName) });",
" checks.push('external-postgres-bridge');",
" } else {",
" if (!endpointSlice) fail('external-postgres-endpointslice-missing', { expected: String(pg.serviceName) + '-host' });",
" const endpointPort = Array.isArray(endpointSlice.ports) && endpointSlice.ports[0] ? endpointSlice.ports[0].port : null;",
" const endpointAddress = Array.isArray(endpointSlice.endpoints) && endpointSlice.endpoints[0] && Array.isArray(endpointSlice.endpoints[0].addresses) ? endpointSlice.endpoints[0].addresses[0] : null;",
" if (!endpoints && !endpointSlice) fail('external-postgres-endpoints-missing', { expected: String(pg.serviceName) });",
" const endpointPort = endpoints && Array.isArray(endpoints.subsets) && endpoints.subsets[0] && Array.isArray(endpoints.subsets[0].ports) && endpoints.subsets[0].ports[0] ? endpoints.subsets[0].ports[0].port : endpointSlice && Array.isArray(endpointSlice.ports) && endpointSlice.ports[0] ? endpointSlice.ports[0].port : null;",
" const endpointAddress = endpoints && Array.isArray(endpoints.subsets) && endpoints.subsets[0] && Array.isArray(endpoints.subsets[0].addresses) && endpoints.subsets[0].addresses[0] ? endpoints.subsets[0].addresses[0].ip : endpointSlice && Array.isArray(endpointSlice.endpoints) && endpointSlice.endpoints[0] && Array.isArray(endpointSlice.endpoints[0].addresses) ? endpointSlice.endpoints[0].addresses[0] : null;",
" if (Number(servicePort) !== Number(access.port) || Number(endpointPort) !== Number(access.port)) fail('external-postgres-port-mismatch', { servicePort, endpointPort, expectedPort: access.port });",
" if (String(endpointAddress) !== String(access.endpointAddress)) fail('external-postgres-address-mismatch', { endpointAddress, expectedEndpointAddress: access.endpointAddress });",
" checks.push('external-postgres-bridge');",
+19 -13
View File
@@ -62,7 +62,13 @@ function shellUrlEncodeFunction(): string[] {
export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
if (options.dryRun && options.confirm) throw new Error("control-plane allow-endpoint-bridge accepts only one of --dry-run or --confirm");
const dryRun = options.dryRun || !options.confirm;
const result = runTransScript(options.node, endpointBridgeScript({ lane: options.lane, dryRun }), "", options.timeoutSeconds);
const secretSpec = runtimeSecretSpec({ node: options.node, lane: options.lane });
const result = runTransScript(options.node, endpointBridgeScript({
lane: options.lane,
dryRun,
platformService: secretSpec.platformPostgresService,
hostEndpointSlice: secretSpec.platformPostgresEndpointSlice,
}), "", options.timeoutSeconds);
const fields = keyValueLinesFromText(statusText(result));
const beforeExcluded = fields.beforeEndpointResourcesExcluded === "yes";
const beforeIgnored = fields.beforeEndpointsIgnoreUpdates === "yes" || fields.beforeEndpointSliceIgnoreUpdates === "yes";
@@ -74,8 +80,8 @@ export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScoped
const afterLegacyEndpoints = fields.afterLegacyEndpointsExists === "yes";
const beforeHostEndpointSlice = fields.beforeHostEndpointSliceExists === "yes";
const afterHostEndpointSlice = fields.afterHostEndpointSliceExists === "yes";
const bridgeReady = !afterLegacyEndpoints && afterHostEndpointSlice && afterExtraEndpointSlices.length === 0;
const ok = result.exitCode === 0 && !afterExcluded && !afterIgnored && bridgeReady;
const bridgeReady = (afterLegacyEndpoints || afterHostEndpointSlice) && afterExtraEndpointSlices.length === 0;
const ok = result.exitCode === 0 && !afterExcluded && !afterIgnored;
return {
ok: dryRun ? result.exitCode === 0 : ok,
command: "hwlab nodes control-plane allow-endpoint-bridge",
@@ -105,8 +111,8 @@ export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScoped
extraEndpointSlices: afterExtraEndpointSlices,
},
runtimeNamespace: fields.runtimeNamespace || `hwlab-${options.lane}`,
platformService: fields.platformService || "g14-platform-postgres",
hostEndpointSlice: fields.hostEndpointSlice || "g14-platform-postgres-host",
platformService: fields.platformService || secretSpec.platformPostgresService,
hostEndpointSlice: fields.hostEndpointSlice || secretSpec.platformPostgresEndpointSlice,
patchExitCode: numericField(fields.patchExitCode),
rolloutRestartExitCode: numericField(fields.rolloutRestartExitCode),
rolloutStatusExitCode: numericField(fields.rolloutStatusExitCode),
@@ -115,15 +121,16 @@ export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScoped
refreshExitCode: numericField(fields.refreshExitCode),
exitCode: result.exitCode,
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000),
summary: !afterExcluded && !afterIgnored && bridgeReady
? "Argo tracks HWLAB external Postgres EndpointSlice and no legacy Endpoints remain"
: "Argo endpoint bridge is not in final Service plus EndpointSlice shape",
bridgeReady,
summary: !afterExcluded && !afterIgnored
? "Argo tracks HWLAB external Postgres bridge resource"
: "Argo endpoint bridge resources are still excluded or ignored",
},
result: compactCommandResult(result),
};
}
export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun: boolean }): string {
export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun: boolean; platformService: string; hostEndpointSlice: string }): string {
const application = `hwlab-node-${options.lane}`;
const runtimeNamespace = `hwlab-${options.lane}`;
return [
@@ -133,8 +140,8 @@ export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun:
"configmap=argocd-cm",
`application=${shellQuote(application)}`,
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
"platform_service=g14-platform-postgres",
"host_endpointslice=g14-platform-postgres-host",
`platform_service=${shellQuote(options.platformService)}`,
`host_endpointslice=${shellQuote(options.hostEndpointSlice)}`,
"preset=endpoint-bridge-resource-tracking",
"cm_data() { kubectl -n \"$namespace\" get configmap \"$configmap\" -o \"go-template={{ index .data \\\"$1\\\" }}\" 2>/dev/null || true; }",
"cm_has_key() { value=$(cm_data \"$1\"); [ -n \"$value\" ] && [ \"$value\" != \"<no value>\" ] && printf yes || printf no; }",
@@ -146,7 +153,7 @@ export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun:
" current_legacy=$(resource_exists endpoints \"$platform_service\")",
" current_extra=$(extra_endpoint_slices)",
" current_host=$(resource_exists endpointslice \"$host_endpointslice\")",
" if [ \"$current_legacy\" != yes ] && [ -z \"$current_extra\" ] && [ \"$current_host\" = yes ]; then return 0; fi",
" if { [ \"$current_legacy\" = yes ] || [ \"$current_host\" = yes ]; } && [ -z \"$current_extra\" ]; then return 0; fi",
" sleep 2",
" done",
" return 1",
@@ -310,7 +317,6 @@ export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun:
"printf 'deleteExtraEndpointSlicesExitCode\\t%s\\n' \"$delete_extra_endpointslices_exit\"",
"printf 'refreshExitCode\\t%s\\n' \"$refresh_exit\"",
"if [ \"$dry_run\" != true ] && { [ \"$after_endpoint_resources_excluded\" = yes ] || [ \"$after_endpoints_ignore_updates\" = yes ] || [ \"$after_endpoint_slice_ignore_updates\" = yes ]; }; then exit 46; fi",
"if [ \"$dry_run\" != true ] && { [ \"$after_legacy_endpoints_exists\" = yes ] || [ -n \"$after_extra_endpoint_slice_names\" ] || [ \"$after_host_endpointslice_exists\" != yes ]; }; then exit 47; fi",
"if [ -n \"$patch_exit\" ] && [ \"$patch_exit\" != 0 ]; then exit \"$patch_exit\"; fi",
"if [ -n \"$rollout_restart_exit\" ] && [ \"$rollout_restart_exit\" != 0 ]; then exit \"$rollout_restart_exit\"; fi",
"if [ -n \"$rollout_status_exit\" ] && [ \"$rollout_status_exit\" != 0 ]; then exit \"$rollout_status_exit\"; fi",
+11 -4
View File
@@ -1706,14 +1706,20 @@ export function externalPostgresBridgeStatus(spec: HwlabRuntimeLaneSpec, namespa
service: compactRuntimeCommand(service),
};
}
const endpointSlice = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpointslice", `${pg.serviceName}-host`, "-o", "jsonpath={.addressType}{\"\\n\"}{.endpoints[0].addresses[0]}{\"\\n\"}{.ports[0].port}{\"\\n\"}"], 60);
const [addressType = "", address = "", port = ""] = endpointSlice.stdout.split(/\r?\n/u);
const endpoints = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpoints", pg.serviceName, "-o", "jsonpath={.subsets[0].addresses[0].ip}{\"\\n\"}{.subsets[0].ports[0].port}{\"\\n\"}"], 60);
const [endpointsAddress = "", endpointsPort = ""] = endpoints.stdout.split(/\r?\n/u);
const endpointSlice = endpoints.exitCode === 0 && endpointsAddress.length > 0
? { exitCode: 1, stdout: "", stderr: "", timedOut: false }
: runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpointslice", `${pg.serviceName}-host`, "-o", "jsonpath={.addressType}{\"\\n\"}{.endpoints[0].addresses[0]}{\"\\n\"}{.ports[0].port}{\"\\n\"}"], 60);
const [addressType = "", endpointSliceAddress = "", endpointSlicePort = ""] = endpointSlice.stdout.split(/\r?\n/u);
const address = endpointsAddress || endpointSliceAddress;
const port = endpointsPort || endpointSlicePort;
return {
required: true,
ready: service.exitCode === 0 && endpointSlice.exitCode === 0 && serviceType !== "ExternalName" && address === runtimeAccess.endpointAddress && Number(port) === runtimeAccess.port,
ready: service.exitCode === 0 && (endpoints.exitCode === 0 || endpointSlice.exitCode === 0) && serviceType !== "ExternalName" && address === runtimeAccess.endpointAddress && Number(port) === runtimeAccess.port,
serviceName: pg.serviceName,
serviceType: serviceType || null,
addressType: addressType || null,
addressType: endpoints.exitCode === 0 ? "IPv4" : addressType || null,
endpointAddress: address || null,
expectedEndpointAddress: runtimeAccess.endpointAddress,
port: numericField(port),
@@ -1722,6 +1728,7 @@ export function externalPostgresBridgeStatus(spec: HwlabRuntimeLaneSpec, namespa
providerPort: pg.port,
runtimeAccess: pg.runtimeAccess ?? null,
service: compactRuntimeCommand(service),
endpoints: compactRuntimeCommand(endpoints),
endpointSlice: compactRuntimeCommand(endpointSlice),
};
}
+12 -4
View File
@@ -49,8 +49,9 @@ process.stderr.on("error", (error) => {
});
export function emitJson<T>(command: string, data: T, ok = true): void {
const envelope: JsonEnvelope<T> = { ok, command, data };
safeStdoutWrite(renderEnvelope(command, envelope));
const safeCommand = redactSensitiveCommandArgs(command);
const envelope: JsonEnvelope<T> = { ok, command: safeCommand, data };
safeStdoutWrite(renderEnvelope(safeCommand, envelope));
}
export function isRenderedCliResult(value: unknown): value is RenderedCliResult {
@@ -67,8 +68,15 @@ export function emitText(text: string, command = "text"): void {
export function emitError(command: string, error: unknown): void {
const payload = normalizeErrorPayload(command, error);
const envelope: JsonEnvelope<never> = { ok: false, command, error: payload };
safeStdoutWrite(renderEnvelope(command, envelope));
const safeCommand = redactSensitiveCommandArgs(command);
const envelope: JsonEnvelope<never> = { ok: false, command: safeCommand, error: payload };
safeStdoutWrite(renderEnvelope(safeCommand, envelope));
}
function redactSensitiveCommandArgs(command: string): string {
return command
.replace(/(--(?:provider-token|token|password|auth-password|session-secret|ssh-client-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/giu, "$1<redacted>")
.replace(/\b((?:PROVIDER_TOKEN|UNIDESK_PROVIDER_TOKEN|UNIDESK_SSH_CLIENT_TOKEN|AUTH_PASSWORD|SESSION_SECRET)=)("[^"]*"|'[^']*'|\S+)/gu, "$1<redacted>");
}
export function readCliOutputPolicy(): CliOutputPolicy {
+7 -2
View File
@@ -30,6 +30,9 @@ capture_json() {
run_apply() {
manifest="$tmp/gitea.k8s.yaml"
printf '%s' "$UNIDESK_GITEA_MANIFEST_B64" | base64 -d >"$manifest"
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl create namespace "$UNIDESK_GITEA_NAMESPACE" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/namespace.out" 2>"$tmp/namespace.err"
fi
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
printf '%s' "$UNIDESK_GITEA_FRPC_TOML_B64" | base64 -d >"$tmp/frpc.toml"
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_FRPC_SECRET_NAME" \
@@ -63,11 +66,13 @@ run_apply() {
fi
fi
dry_arg=""
apply_args="--server-side --force-conflicts"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
dry_arg="--dry-run=server"
dry_arg="--dry-run=client"
apply_args=""
fi
if [ "$frpc_rc" -eq 0 ] && [ "$webhook_secret_rc" -eq 0 ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side $dry_arg --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply $apply_args $dry_arg --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
apply_rc=$?
else
apply_rc=1
+61 -12
View File
@@ -33,6 +33,7 @@ interface GiteaTarget {
enabled: boolean;
createNamespace: boolean;
storageClassName: string;
publicExposureEnabled: boolean;
}
interface GiteaConfig {
@@ -150,6 +151,7 @@ interface GiteaGithubCredential {
transport: "https-token";
sourceRef: string;
sourceKey: string;
format: "env" | "raw-token";
requiredFor: string[];
}
@@ -446,6 +448,7 @@ function parseGithubCredential(record: Record<string, unknown>, path: string): G
transport: y.enumField(record, "transport", path, ["https-token"] as const),
sourceRef: secretSourceRefField(record, "sourceRef", path),
sourceKey: y.envKeyField(record, "sourceKey", path),
format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env",
requiredFor: y.stringArrayField(record, "requiredFor", path),
};
}
@@ -569,6 +572,11 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
function parseTarget(record: Record<string, unknown>, index: number): GiteaTarget {
const path = `targets[${index}]`;
const publicExposureRaw = record.publicExposure;
if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) {
throw new Error(`${configLabel}.${path}.publicExposure must be an object`);
}
const publicExposure = publicExposureRaw as Record<string, unknown> | undefined;
return {
id: y.stringField(record, "id", path),
route: y.stringField(record, "route", path),
@@ -577,6 +585,7 @@ function parseTarget(record: Record<string, unknown>, index: number): GiteaTarge
enabled: y.booleanField(record, "enabled", path),
createNamespace: y.booleanField(record, "createNamespace", path),
storageClassName: y.stringField(record, "storageClassName", path),
publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`),
};
}
@@ -644,7 +653,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
const parsed = parseJsonOutput(result.stdout);
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && gitea.app.publicExposure.enabled
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && publicExposureEnabled(gitea, target)
? await applyGiteaCaddyBlock(config, gitea)
: { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" };
return {
@@ -1265,7 +1274,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
UNIDESK_GITEA_FRPC_ENABLED: gitea.app.publicExposure.enabled ? "1" : "0",
UNIDESK_GITEA_FRPC_ENABLED: publicExposureEnabled(gitea, target) ? "1" : "0",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: gitea.app.publicExposure.frpc.deploymentName,
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea),
@@ -1337,6 +1346,7 @@ function targetSummary(target: GiteaTarget): Record<string, unknown> {
role: target.role,
createNamespace: target.createNamespace,
storageClassName: target.storageClassName,
publicExposureEnabled: target.publicExposureEnabled,
};
}
@@ -1363,11 +1373,20 @@ function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicSer
route: target.route,
namespace: target.namespace,
replicas: 1,
publicExposure: gitea.app.publicExposure,
publicExposure: targetPublicExposure(gitea, target),
};
}
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial {
function publicExposureEnabled(gitea: GiteaConfig, target: GiteaTarget): boolean {
return gitea.app.publicExposure.enabled && target.publicExposureEnabled;
}
function targetPublicExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] {
return publicExposureEnabled(gitea, target) ? gitea.app.publicExposure : { ...gitea.app.publicExposure, enabled: false };
}
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial | undefined {
if (!publicExposureEnabled(gitea, target)) return undefined;
const base = prepareFrpcSecret({
secretRoot: gitea.app.publicExposure.secretRoot,
exposure: gitea.app.publicExposure,
@@ -1438,7 +1457,8 @@ function publicExposureSummary(gitea: GiteaConfig): Record<string, unknown> {
};
}
function frpcSecretSummary(secret: FrpcSecretMaterial): Record<string, unknown> {
function frpcSecretSummary(secret: FrpcSecretMaterial | undefined): Record<string, unknown> {
if (secret === undefined) return { enabled: false, skipped: true, reason: "target-public-exposure-disabled", valuesPrinted: false };
return {
sourceRef: secret.sourceRef,
sourcePath: secret.sourcePath,
@@ -1568,9 +1588,10 @@ function webhookSyncSummary(gitea: GiteaConfig): Record<string, unknown> {
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
const githubPath = credentialPath(gitea, gitea.sourceAuthority.credentials.github.sourceRef);
const github = gitea.sourceAuthority.credentials.github;
const githubPath = credentialPath(gitea, github.sourceRef);
const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null };
const githubEnv = existsSync(githubPath) ? parseEnvFileSafe(githubPath) : {};
const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {};
return [
{
id: "gitea-admin",
@@ -1584,12 +1605,12 @@ function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>>
},
{
id: "github-upstream",
transport: gitea.sourceAuthority.credentials.github.transport,
sourceRef: gitea.sourceAuthority.credentials.github.sourceRef,
transport: github.transport,
sourceRef: github.sourceRef,
sourcePath: githubPath,
present: existsSync(githubPath),
requiredKeysPresent: Boolean(githubEnv[gitea.sourceAuthority.credentials.github.sourceKey]),
fingerprint: fingerprintKeys(githubEnv, [gitea.sourceAuthority.credentials.github.sourceKey]),
requiredKeysPresent: Boolean(githubEnv[github.sourceKey]),
fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]),
valuesPrinted: false,
},
];
@@ -1614,7 +1635,7 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWeb
const github = gitea.sourceAuthority.credentials.github;
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
const githubEnv = parseEnvFileSafe(githubPath);
const githubEnv = parseGithubCredentialFile(githubPath, github);
const webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret);
const adminUsername = adminLinePair.username;
const adminPassword = adminLinePair.password;
@@ -1667,6 +1688,27 @@ function parseEnvFileSafe(path: string): Record<string, string> {
return values;
}
function parseGithubCredentialFile(path: string, github: GiteaGithubCredential): Record<string, string> {
const text = readFileSync(path, "utf8");
if (github.format === "raw-token") return { [github.sourceKey]: text.trim() };
return parseEnvTextSafe(text);
}
function parseEnvTextSafe(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/u)) {
let line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
if (line.startsWith("export ")) line = line.slice("export ".length).trim();
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
values[key] = unquote(line.slice(eq + 1).trim());
}
return values;
}
function unquote(value: string): string {
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
return value;
@@ -2055,6 +2097,13 @@ function literalField<T extends string>(obj: Record<string, unknown>, key: strin
return value as T;
}
function optionalLiteralField<T extends string>(obj: Record<string, unknown>, key: string, path: string, allowed: readonly T[]): T | null {
if (obj[key] === undefined || obj[key] === null) return null;
const value = y.stringField(obj, key, path);
if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`);
return value as T;
}
function quantity(obj: Record<string, unknown>, key: string, path: string): string {
const value = y.stringField(obj, key, path);
if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`);
+41 -7
View File
@@ -42,7 +42,7 @@ interface HostProxyServer {
interface HostProxySource {
id: string;
sourceType: "benchmark-validated-master-shadowsocks";
sourceType: "benchmark-validated-master-shadowsocks" | "vpn-server-shadowsocks";
serverRef: string;
benchmarkRef: string;
implementationRef: string;
@@ -167,7 +167,7 @@ function readHostProxyConfig(): HostProxyConfig {
const server = parseServer(record(root.server, `${configPath}.server`), `${configPath}.server`);
const sources = Object.fromEntries(Object.entries(record(root.sources, `${configPath}.sources`)).map(([id, value]) => {
const source = parseSource(id, record(value, `${configPath}.sources.${id}`));
if (source.serverRef !== `server.${server.id}`) throw new Error(`${configPath}.sources.${id}.serverRef must be server.${server.id}`);
if (sourceUsesManagedServer(source) && source.serverRef !== `server.${server.id}`) throw new Error(`${configPath}.sources.${id}.serverRef must be server.${server.id}`);
return [id, source];
}));
const targets: Record<string, HostProxyTarget> = {};
@@ -232,7 +232,7 @@ function parseServer(raw: Record<string, unknown>, label: string): HostProxyServ
}
function parseSource(id: string, raw: Record<string, unknown>): HostProxySource {
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks"] as const);
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks", "vpn-server-shadowsocks"] as const);
const sourceConfigRef = stringField(raw, "sourceConfigRef", `${configPath}.sources.${id}`);
const resolved = resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.sources.${id}.sourceConfigRef`);
if (resolved.sourceType !== "master-shadowsocks" || resolved.masterShadowsocks === null) {
@@ -389,7 +389,7 @@ function plan(server: HostProxyServer, target: HostProxyTarget): Record<string,
function apply(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
const material = proxySecretMaterial(server, target.source);
const serverApply = applyMasterProxyServer(server, material);
const serverApply = sourceUsesManagedServer(target.source) ? applyMasterProxyServer(server, material) : skippedMasterProxyServer(target.source, "apply");
const artifact = prepareClientArtifact(target.source.client);
const remotePrepare = runTrans(target.route, remotePrepareScript(target.source.client), 30);
const remoteArchive = remotePrepare.exitCode === 0
@@ -429,7 +429,7 @@ function apply(server: HostProxyServer, target: HostProxyTarget): Record<string,
}
function status(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
const serverStatus = masterProxyServerStatus(server);
const serverStatus = sourceUsesManagedServer(target.source) ? masterProxyServerStatus(server) : skippedMasterProxyServer(target.source, "status");
const result = runTrans(target.route, remoteStatusScript(target), 45);
const parsed = parseJson(result.stdout);
return {
@@ -446,6 +446,34 @@ function status(server: HostProxyServer, target: HostProxyTarget): Record<string
};
}
function sourceUsesManagedServer(source: HostProxySource): boolean {
return source.sourceType === "benchmark-validated-master-shadowsocks";
}
function skippedMasterProxyServer(source: HostProxySource, mode: "apply" | "status"): Record<string, unknown> {
return {
ok: true,
id: source.id,
sourceType: source.sourceType,
enabled: false,
mode,
skipped: true,
reason: `source ${source.id} uses externally managed ${source.sourceType} server`,
benchmarkRef: source.benchmarkRef,
implementationRef: source.implementationRef,
sourceConfigRef: source.sourceConfigRef,
sourceFingerprint: source.sourceFingerprint,
materialFingerprint: null,
compose: null,
existingHealthy: null,
inspect: null,
container: "external",
listen: `${source.masterShadowsocks.serverHost}:${source.masterShadowsocks.serverPort}`,
health: { ok: true, mode: "external", host: "", port: 0 },
valuesPrinted: false,
};
}
function proxySecretMaterial(server: HostProxyServer, source: HostProxySource): HostProxySecretMaterial {
const envSource = readEnvSourceFile({
root: rootPath(".state", "secrets"),
@@ -951,9 +979,12 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
const client = record(remote.client);
const podProbe = record(remote.podAccessProbe);
const probe = record(remote.externalProbe);
const serverRows = serverApply.enabled === false
? table(["SOURCE", "OK", "UPSTREAM", "TYPE"], [[text(serverApply.id), text(serverApply.ok), text(serverApply.listen), text(serverApply.sourceType)]])
: table(["SERVER", "OK", "LISTEN", "BENCHMARK"], [[text(server.id), text(serverApply.ok), text(server.listen), text(server.benchmarkRef)]]);
const lines = [
"PLATFORM-INFRA HOST PROXY APPLY",
...table(["SERVER", "OK", "LISTEN", "BENCHMARK"], [[text(server.id), text(serverApply.ok), text(server.listen), text(server.benchmarkRef)]]),
...serverRows,
"",
...table(["SERVER_HEALTH", "CLIENT_BINARY", "DISTRIBUTION"], [[text(serverHealth.ok), text(artifact.binaryAction), text(distribution.mode)]]),
"",
@@ -979,9 +1010,12 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
const health = record(remote.health);
const probe = record(remote.externalProbe);
const podProbe = record(remote.podAccessProbe);
const serverRows = serverStatus.enabled === false
? table(["SOURCE", "OK", "UPSTREAM", "TYPE"], [[text(serverStatus.id), text(serverStatus.ok), text(serverStatus.listen), text(serverStatus.sourceType)]])
: table(["SERVER", "OK", "LISTEN", "CONTAINER"], [[text(server.id), text(serverStatus.ok), text(server.listen), text(serverStatus.container)]]);
const lines = [
"PLATFORM-INFRA HOST PROXY STATUS",
...table(["SERVER", "OK", "LISTEN", "CONTAINER"], [[text(server.id), text(serverStatus.ok), text(server.listen), text(serverStatus.container)]]),
...serverRows,
"",
...table(["TARGET", "ROUTE", "OK", "CLIENT"], [[text(target.id), text(target.route), result.ok === false ? "false" : "true", text(client.service)]]),
"",
@@ -70,6 +70,23 @@ prepare_gitea_api_base() {
export UNIDESK_PAC_GITEA_API_BASE_URL
}
resolve_service_url_to_cluster_ip() {
value="$1"
hostport=$(printf '%s' "$value" | sed -n 's#^http://\([^/]*\).*$#\1#p')
if ! printf '%s' "$hostport" | grep -q '\.svc\.cluster\.local'; then
printf '%s' "$value"
return
fi
host=${hostport%%:*}
port=${hostport##*:}
if [ "$port" = "$hostport" ]; then port=80; fi
service=${host%%.*}
rest=${host#*.}
namespace=${rest%%.*}
cluster_ip=$(kubectl -n "$namespace" get svc "$service" -o jsonpath='{.spec.clusterIP}')
printf '%s' "$value" | sed "s#^http://$hostport#http://$cluster_ip:$port#"
}
gitea_api() {
method="$1"
path="$2"
@@ -118,6 +135,7 @@ NODE
}
repository_manifest() {
repository_url=$(resolve_service_url_to_cluster_ip "$UNIDESK_PAC_REPOSITORY_URL")
cat <<EOF
apiVersion: pipelinesascode.tekton.dev/v1alpha1
kind: Repository
@@ -129,7 +147,7 @@ metadata:
app.kubernetes.io/part-of: $UNIDESK_PAC_PART_OF
unidesk.ai/spec: $UNIDESK_PAC_SPEC
spec:
url: $UNIDESK_PAC_REPOSITORY_URL
url: $repository_url
git_provider:
type: gitea
url: $UNIDESK_PAC_GITEA_BASE_URL
+3 -1
View File
@@ -32,6 +32,7 @@ import {
type SshRemoteCommandExecutor,
type SshRemoteCommandStreamHandlers,
} from "./ssh-file-transfer";
import { readHostK8sSshClientToken } from "./host-k8s-config";
interface FrontendSession {
baseUrl: string;
@@ -282,7 +283,8 @@ async function loginFrontend(host: string, config: UniDeskConfig): Promise<Front
function sshClientTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
const token = env.UNIDESK_SSH_CLIENT_TOKEN?.trim() ?? "";
return token.length > 0 ? token : null;
if (token.length > 0) return token;
return readHostK8sSshClientToken();
}
function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession {
+3 -2
View File
@@ -716,16 +716,17 @@ export function readTextFile(path: string): string {
return readFileSync(path, "utf8");
}
export function readEnvSourceFile(params: { root: string; sourceRef: string; missingMessage?: (sourcePath: string) => string }): EnvSourceFileMaterial {
export function readEnvSourceFile(params: { root: string; sourceRef: string; rawKey?: string; missingMessage?: (sourcePath: string) => string }): EnvSourceFileMaterial {
const sourcePath = join(params.root, params.sourceRef);
if (!existsSync(sourcePath)) {
throw new Error(params.missingMessage?.(sourcePath) ?? `required secret source ${redactRepoPath(sourcePath)} is missing`);
}
const text = readFileSync(sourcePath, "utf8");
return {
sourceRef: params.sourceRef,
sourcePath,
sourcePathRedacted: redactRepoPath(sourcePath),
values: parseEnvFile(readFileSync(sourcePath, "utf8")),
values: params.rawKey === undefined ? parseEnvFile(text) : { [params.rawKey]: text.trim() },
valuesPrinted: false,
};
}
+2
View File
@@ -24,6 +24,7 @@ import {
import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy";
import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config";
import { readCliOutputPolicy } from "./output";
import { readHostK8sPublicHost } from "./host-k8s-config";
export interface ParsedSshArgs {
remoteCommand: string | null;
@@ -3743,6 +3744,7 @@ function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): st
return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP)
?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST)
?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST)
?? normalizeRemoteHost(readHostK8sPublicHost() ?? undefined)
?? normalizeRemoteHost(config.network.publicHost);
}