From a328aa909e9277132226bf23f79bc16f529c826d Mon Sep 17 00:00:00 2001 From: root Date: Wed, 8 Jul 2026 05:34:11 +0200 Subject: [PATCH] deploy: configure NC01 HWLAB monitor exposure --- AGENTS.md | 1 + config/agentrun.yaml | 293 ++++++++++- config/hwlab-node-control-plane.yaml | 327 ++++++++++++ config/hwlab-node-lanes.yaml | 269 ++++++++++ config/hwlab-web-probe-sentinel/profiles.yaml | 161 ++++++ config/platform-db/postgres-nc01.yaml | 56 +++ .../platform-infra/egress-proxy-sources.yaml | 10 + config/platform-infra/gitea.yaml | 78 ++- config/platform-infra/host-proxy.yaml | 85 ++++ config/platform-infra/pipelines-as-code.yaml | 116 ++++- config/secrets-distribution.yaml | 29 ++ config/unidesk-cli.yaml | 8 + config/unidesk-host-k8s.yaml | 93 ++++ docs/reference/nc01.md | 41 ++ .../native/deploy/render-unidesk-host-k8s.mjs | 472 ++++++++++++++++++ scripts/src/agentrun-lanes.ts | 6 + scripts/src/agentrun/control-plane.ts | 11 +- scripts/src/agentrun/entry.ts | 1 + scripts/src/agentrun/options.ts | 1 + scripts/src/agentrun/secrets.ts | 14 +- scripts/src/agentrun/trigger.ts | 16 +- scripts/src/agentrun/utils.ts | 17 +- scripts/src/agentrun/yaml-lane.ts | 7 +- scripts/src/gh/auth-and-safety.ts | 153 +++++- scripts/src/gh/auth-pr-read.ts | 2 +- scripts/src/gh/client.ts | 6 +- scripts/src/gh/default-render.ts | 11 +- scripts/src/gh/types.ts | 13 +- scripts/src/host-k8s-config.ts | 57 +++ scripts/src/hwlab-node-control-plane.ts | 6 +- scripts/src/hwlab-node/render.ts | 48 +- scripts/src/hwlab-node/secret-scripts.ts | 32 +- scripts/src/hwlab-node/web-probe.ts | 15 +- scripts/src/output.ts | 16 +- scripts/src/platform-infra-gitea-remote.sh | 9 +- scripts/src/platform-infra-gitea.ts | 73 ++- scripts/src/platform-infra-host-proxy.ts | 48 +- ...platform-infra-pipelines-as-code-remote.sh | 20 +- scripts/src/remote-ssh.ts | 4 +- scripts/src/secrets.ts | 5 +- scripts/src/ssh.ts | 2 + 41 files changed, 2522 insertions(+), 110 deletions(-) create mode 100644 config/platform-db/postgres-nc01.yaml create mode 100644 config/unidesk-host-k8s.yaml create mode 100644 docs/reference/nc01.md create mode 100755 scripts/native/deploy/render-unidesk-host-k8s.mjs create mode 100644 scripts/src/host-k8s-config.ts diff --git a/AGENTS.md b/AGENTS.md index a9e344cd..9cdc9db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 55936d16..dc35e799 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -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 diff --git a/config/hwlab-node-control-plane.yaml b/config/hwlab-node-control-plane.yaml index 9e9a1ef6..2aea6f8f 100644 --- a/config/hwlab-node-control-plane.yaml +++ b/config/hwlab-node-control-plane.yaml @@ -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 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index a60910db..96564eb2 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -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: diff --git a/config/hwlab-web-probe-sentinel/profiles.yaml b/config/hwlab-web-probe-sentinel/profiles.yaml index ea4b5117..9b4ecf37 100644 --- a/config/hwlab-web-probe-sentinel/profiles.yaml +++ b/config/hwlab-web-probe-sentinel/profiles.yaml @@ -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 diff --git a/config/platform-db/postgres-nc01.yaml b/config/platform-db/postgres-nc01.yaml new file mode 100644 index 00000000..a00ea7c4 --- /dev/null +++ b/config/platform-db/postgres-nc01.yaml @@ -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 diff --git a/config/platform-infra/egress-proxy-sources.yaml b/config/platform-infra/egress-proxy-sources.yaml index 3c973df7..6ef4093a 100644 --- a/config/platform-infra/egress-proxy-sources.yaml +++ b/config/platform-infra/egress-proxy-sources.yaml @@ -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 diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index efa32086..0bac14ba 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -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 diff --git a/config/platform-infra/host-proxy.yaml b/config/platform-infra/host-proxy.yaml index 7bf0fdf2..5bfb57a4 100644 --- a/config/platform-infra/host-proxy.yaml +++ b/config/platform-infra/host-proxy.yaml @@ -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 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index d4d04848..1718d0be 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -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 diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index a22ad805..6ae4b032 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -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 diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index 171a05b6..db16c3ac 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -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 diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml new file mode 100644 index 00000000..b3f73d7d --- /dev/null +++ b/config/unidesk-host-k8s.yaml @@ -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 diff --git a/docs/reference/nc01.md b/docs/reference/nc01.md new file mode 100644 index 00000000..b5456806 --- /dev/null +++ b/docs/reference/nc01.md @@ -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`. diff --git a/scripts/native/deploy/render-unidesk-host-k8s.mjs b/scripts/native/deploy/render-unidesk-host-k8s.mjs new file mode 100755 index 00000000..70c5dd38 --- /dev/null +++ b/scripts/native/deploy/render-unidesk-host-k8s.mjs @@ -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`); diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 6ac68b7b..4bf37b73 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -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, 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, }; diff --git a/scripts/src/agentrun/control-plane.ts b/scripts/src/agentrun/control-plane.ts index 4d948392..c30193a4 100644 --- a/scripts/src/agentrun/control-plane.ts +++ b/scripts/src/agentrun/control-plane.ts @@ -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 { diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index 4dac3190..af0a5f6b 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -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", diff --git a/scripts/src/agentrun/options.ts b/scripts/src/agentrun/options.ts index 195155de..c67400a9 100644 --- a/scripts/src/agentrun/options.ts +++ b/scripts/src/agentrun/options.ts @@ -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 { diff --git a/scripts/src/agentrun/secrets.ts b/scripts/src/agentrun/secrets.ts index de76ee51..e2b1f470 100644 --- a/scripts/src/agentrun/secrets.ts +++ b/scripts/src/agentrun/secrets.ts @@ -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, }); } diff --git a/scripts/src/agentrun/trigger.ts b/scripts/src/agentrun/trigger.ts index 69e9fac7..0504122d 100644 --- a/scripts/src/agentrun/trigger.ts +++ b/scripts/src/agentrun/trigger.ts @@ -178,15 +178,19 @@ export async function restartYamlLane(config: UniDeskConfig, options: LaneConfir export async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise> { 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 }), diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index 7d84f762..63d3206d 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -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 { + 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 { diff --git a/scripts/src/agentrun/yaml-lane.ts b/scripts/src/agentrun/yaml-lane.ts index c4931184..69a5850a 100644 --- a/scripts/src/agentrun/yaml-lane.ts +++ b/scripts/src/agentrun/yaml-lane.ts @@ -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\"", diff --git a/scripts/src/gh/auth-and-safety.ts b/scripts/src/gh/auth-and-safety.ts index d178484f..32c4ee21 100644 --- a/scripts/src/gh/auth-and-safety.ts +++ b/scripts/src/gh/auth-and-safety.ts @@ -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 } | 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 { + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); + return value as Record; +} + +function optionalRecord(value: unknown, path: string): Record | null { + if (value === undefined || value === null) return null; + return asRecord(value, path); +} + +function stringField(obj: Record, 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, 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, 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, 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, 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 } { diff --git a/scripts/src/gh/auth-pr-read.ts b/scripts/src/gh/auth-pr-read.ts index 7400b258..70ce4d0d 100644 --- a/scripts/src/gh/auth-pr-read.ts +++ b/scripts/src/gh/auth-pr-read.ts @@ -29,7 +29,7 @@ export async function authStatus(repo: string): Promise { }; } 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, diff --git a/scripts/src/gh/client.ts b/scripts/src/gh/client.ts index d06bd2b7..b35b95f0 100644 --- a/scripts/src/gh/client.ts +++ b/scripts/src/gh/client.ts @@ -335,12 +335,12 @@ export async function githubGraphqlRequest( 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, }); diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 005a6d27..76995206 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -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)], diff --git a/scripts/src/gh/types.ts b/scripts/src/gh/types.ts index d262c506..624514df 100644 --- a/scripts/src/gh/types.ts +++ b/scripts/src/gh/types.ts @@ -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 { diff --git a/scripts/src/host-k8s-config.ts b/scripts/src/host-k8s-config.ts new file mode 100644 index 00000000..84eeef13 --- /dev/null +++ b/scripts/src/host-k8s-config.ts @@ -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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function stringField(obj: Record, 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 { + const values: Record = {}; + 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 | 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; +} diff --git a/scripts/src/hwlab-node-control-plane.ts b/scripts/src/hwlab-node-control-plane.ts index ed68026a..398190bd 100644 --- a/scripts/src/hwlab-node-control-plane.ts +++ b/scripts/src/hwlab-node-control-plane.ts @@ -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 `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); diff --git a/scripts/src/hwlab-node/render.ts b/scripts/src/hwlab-node/render.ts index 12c05aa9..8cf8dabb 100644 --- a/scripts/src/hwlab-node/render.ts +++ b/scripts/src/hwlab-node/render.ts @@ -476,6 +476,23 @@ export function nodeRuntimeStatusDegradedReason(input: { } export function nodeRuntimePublicProbeStatus(spec: HwlabRuntimeLaneSpec): Record { + 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');", diff --git a/scripts/src/hwlab-node/secret-scripts.ts b/scripts/src/hwlab-node/secret-scripts.ts index 38d82909..009f6141 100644 --- a/scripts/src/hwlab-node/secret-scripts.ts +++ b/scripts/src/hwlab-node/secret-scripts.ts @@ -62,7 +62,13 @@ function shellUrlEncodeFunction(): string[] { export function runNodeEndpointBridge(options: ReturnType): Record { 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/dev/null || true; }", "cm_has_key() { value=$(cm_data \"$1\"); [ -n \"$value\" ] && [ \"$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", diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 3ab8a65e..107171fe 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -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), }; } diff --git a/scripts/src/output.ts b/scripts/src/output.ts index ed32294d..9baf2157 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -49,8 +49,9 @@ process.stderr.on("error", (error) => { }); export function emitJson(command: string, data: T, ok = true): void { - const envelope: JsonEnvelope = { ok, command, data }; - safeStdoutWrite(renderEnvelope(command, envelope)); + const safeCommand = redactSensitiveCommandArgs(command); + const envelope: JsonEnvelope = { 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 = { ok: false, command, error: payload }; - safeStdoutWrite(renderEnvelope(command, envelope)); + const safeCommand = redactSensitiveCommandArgs(command); + const envelope: JsonEnvelope = { 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") + .replace(/\b((?:PROVIDER_TOKEN|UNIDESK_PROVIDER_TOKEN|UNIDESK_SSH_CLIENT_TOKEN|AUTH_PASSWORD|SESSION_SECRET)=)("[^"]*"|'[^']*'|\S+)/gu, "$1"); } export function readCliOutputPolicy(): CliOutputPolicy { diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index ff9e9eee..67b655ca 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -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 diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 1d1babdd..07f830b3 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -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, 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, index: number): function parseTarget(record: Record, 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 | undefined; return { id: y.stringField(record, "id", path), route: y.stringField(record, "route", path), @@ -577,6 +585,7 @@ function parseTarget(record: Record, 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 { 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 { }; } -function frpcSecretSummary(secret: FrpcSecretMaterial): Record { +function frpcSecretSummary(secret: FrpcSecretMaterial | undefined): Record { + 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 { function credentialSummaries(gitea: GiteaConfig): Array> { 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> }, { 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 { return values; } +function parseGithubCredentialFile(path: string, github: GiteaGithubCredential): Record { + const text = readFileSync(path, "utf8"); + if (github.format === "raw-token") return { [github.sourceKey]: text.trim() }; + return parseEnvTextSafe(text); +} + +function parseEnvTextSafe(text: string): Record { + const values: Record = {}; + 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(obj: Record, key: strin return value as T; } +function optionalLiteralField(obj: Record, 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, 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`); diff --git a/scripts/src/platform-infra-host-proxy.ts b/scripts/src/platform-infra-host-proxy.ts index 5d07ae53..87d987c8 100644 --- a/scripts/src/platform-infra-host-proxy.ts +++ b/scripts/src/platform-infra-host-proxy.ts @@ -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 = {}; @@ -232,7 +232,7 @@ function parseServer(raw: Record, label: string): HostProxyServ } function parseSource(id: string, raw: Record): 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 { 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 { - 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 { + 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): 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): 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)]]), "", diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index e07f0724..b47bd82a 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -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 < 0 ? token : null; + if (token.length > 0) return token; + return readHostK8sSshClientToken(); } function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession { diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 9c8c0303..6c689084 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -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, }; } diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 5250153b..79d75e0f 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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); }