diff --git a/.tekton/hwlab-nc01-production-pac.yaml b/.tekton/hwlab-nc01-production-pac.yaml new file mode 100644 index 00000000..e306e0ba --- /dev/null +++ b/.tekton/hwlab-nc01-production-pac.yaml @@ -0,0 +1,86 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + generateName: hwlab-nc01-production-ci-poll- + namespace: hwlab-ci + annotations: + pipelinesascode.tekton.dev/on-event: "[push]" + pipelinesascode.tekton.dev/on-target-branch: "[release]" + pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'release' && node == 'NC01'" + pipelinesascode.tekton.dev/max-keep-runs: "8" + unidesk.ai/owning-config-ref: "config/hwlab-node-lanes.yaml#lanes.production.targets.NC01" + unidesk.ai/effective-config-sha256: sha256:f0b827a59094630ba9a78a63c39a624613f93f87e685779732ebc38bd694af39 + unidesk.ai/source-artifact-renderer: hwlab-runtime-lane + unidesk.ai/source-artifact-mode: remote-pipeline-annotation + pipelinesascode.tekton.dev/pipeline: ci/pipelines/hwlab-nc01-production-ci-image-publish.yaml + hwlab.pikastech.local/ci-contract: tekton-native-primitive-tasks + hwlab.pikastech.local/download-profile: jd01-node-default + hwlab.pikastech.local/gitops-branch: release-gitops + hwlab.pikastech.local/network-profile: jd01-node-ci-egress + hwlab.pikastech.local/node: NC01 + hwlab.pikastech.local/policy: native-per-service-taskrun-image-publish + hwlab.pikastech.local/runtime-path: deploy/gitops/node/nc01/runtime-production + hwlab.pikastech.local/source-branch: release + hwlab.pikastech.local/source-config: .tekton/hwlab-nc01-production-pac.yaml + labels: + app.kubernetes.io/name: hwlab-nc01-production-pac + app.kubernetes.io/part-of: hwlab + unidesk.ai/source-commit: "{{ revision }}" + hwlab.pikastech.local/gitops-target: production + hwlab.pikastech.local/source-commit: "{{ revision }}" + hwlab.pikastech.local/trigger: pipelines-as-code +spec: + timeouts: + pipeline: 1h0m0s + taskRunTemplate: + serviceAccountName: hwlab-nc01-production-tekton-runner + podTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + securityContext: + fsGroup: 1000 + pipelineRef: + name: hwlab-nc01-production-ci-image-publish + workspaces: + - name: source + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 8Gi + - name: git-ssh + secret: + secretName: hwlab-git-ssh + params: + - name: git-url + value: "{{ repo_url }}" + - name: git-read-url + value: "{{ git_read_url }}" + - name: git-write-url + value: "{{ git_write_url }}" + - name: source-branch + value: release + - name: gitops-branch + value: release-gitops + - name: lane + value: production + - name: catalog-path + value: deploy/artifact-catalog.nc01-production.json + - name: image-tag-mode + value: full + - name: runtime-path + value: deploy/gitops/node/nc01/runtime-production + - name: revision + value: "{{ revision }}" + - name: registry-prefix + value: 127.0.0.1:5000/hwlab + - name: services + value: "hwlab-cloud-api,hwlab-workbench-runtime,hwlab-user-billing,hwlab-project-management,hwlab-tasktree-api,hwlab-tasktree-worker,hwlab-harnessrl-api,hwlab-harnessrl-worker,hwlab-workbench-api,hwlab-workbench-worker,hwlab-cloud-web,hwlab-gateway,hwlab-edge-proxy,hwlab-agent-skills" + - name: base-image + value: 127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim + - name: build-cache-mode + value: registry + - name: runtime-ready-timeout-ms + value: "60000" diff --git a/ci/pipelines/hwlab-nc01-production-ci-image-publish.yaml b/ci/pipelines/hwlab-nc01-production-ci-image-publish.yaml new file mode 100644 index 00000000..b74bf972 --- /dev/null +++ b/ci/pipelines/hwlab-nc01-production-ci-image-publish.yaml @@ -0,0 +1,832 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: hwlab-nc01-production-ci-image-publish + namespace: hwlab-ci + labels: + app.kubernetes.io/part-of: hwlab + hwlab.pikastech.local/gitops-target: production + annotations: + hwlab.pikastech.local/source-config: "scripts/gitops-render.mjs#tekton-native-primitive-ci" + hwlab.pikastech.local/ci-contract: tekton-native-primitive-tasks + hwlab.pikastech.local/policy: native-per-service-taskrun-image-publish + hwlab.pikastech.local/download-profile: jd01-node-default + hwlab.pikastech.local/network-profile: jd01-node-ci-egress + unidesk.ai/owning-config-ref: "config/hwlab-node-lanes.yaml#lanes.production.targets.NC01" + unidesk.ai/effective-config-sha256: sha256:f0b827a59094630ba9a78a63c39a624613f93f87e685779732ebc38bd694af39 + unidesk.ai/source-artifact-renderer: hwlab-runtime-lane + unidesk.ai/source-artifact-mode: remote-pipeline-annotation +spec: + params: + - name: git-url + type: string + default: git@github.com:pikasTech/HWLAB.git + - name: git-read-url + type: string + default: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git + - name: git-write-url + type: string + default: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git + - name: source-branch + type: string + default: release + - name: gitops-branch + type: string + default: release-gitops + - name: lane + type: string + default: production + - name: catalog-path + type: string + default: deploy/artifact-catalog.nc01-production.json + - name: image-tag-mode + type: string + default: full + - name: runtime-path + type: string + default: deploy/gitops/node/nc01/runtime-production + - name: revision + type: string + description: Full git commit SHA; the only source truth for image tags. + - name: registry-prefix + type: string + default: 127.0.0.1:5000/hwlab + - name: services + type: string + default: "hwlab-cloud-api,hwlab-workbench-runtime,hwlab-user-billing,hwlab-project-management,hwlab-tasktree-api,hwlab-tasktree-worker,hwlab-harnessrl-api,hwlab-harnessrl-worker,hwlab-workbench-api,hwlab-workbench-worker,hwlab-cloud-web,hwlab-gateway,hwlab-edge-proxy,hwlab-agent-skills" + - name: base-image + type: string + default: 127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim + - name: build-cache-mode + type: string + default: registry + - name: runtime-ready-timeout-ms + type: string + default: "60000" + workspaces: + - name: source + - name: git-ssh + tasks: + - name: prepare-source + workspaces: + - name: source + workspace: source + - name: git-ssh + workspace: git-ssh + taskSpec: + params: + - name: git-url + - name: git-read-url + - name: source-branch + - name: gitops-branch + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: registry-prefix + - name: services + - name: revision + workspaces: + - name: source + - name: git-ssh + steps: + - name: prepare-source + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\nci_now_ms() {\n node -e 'console.log(Date.now())'\n}\n\nci_timing_emit() {\n stage=\"$1\"\n status=\"$2\"\n started_ms=\"$3\"\n finished_ms=\"$(ci_now_ms)\"\n duration_ms=\"$((finished_ms - started_ms))\"\n service_id=\"${HWLAB_TIMING_SERVICE_ID:-}\"\n node -e 'const [stage,status,durationMs,serviceId]=process.argv.slice(1); const payload={event:\"node-cicd-timing\",schemaVersion:\"v1\",stage,status,durationMs:Number(durationMs),pipelineRun:process.env.HWLAB_TEKTON_PIPELINERUN||null,taskRun:process.env.HWLAB_TEKTON_TASKRUN||null,task:process.env.HWLAB_TEKTON_TASK||null,revision:process.env.HWLAB_SOURCE_REVISION||null,serviceId:serviceId||null,source:\"scripts/gitops-render.mjs\",at:new Date().toISOString()}; console.log(JSON.stringify(payload));' \"$stage\" \"$status\" \"$duration_ms\" \"$service_id\"\n}\n\nexport HWLAB_TEKTON_PIPELINERUN=\"$(context.pipelineRun.name)\"\nexport HWLAB_TEKTON_TASKRUN=\"$(context.taskRun.name)\"\nexport HWLAB_TEKTON_TASK=\"prepare-source\"\nexport HWLAB_SOURCE_REVISION=\"$(params.revision)\"\necho '{\"event\":\"ci-base-image\",\"phase\":\"prepare-source\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git timeout; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\nnode -e 'console.log(JSON.stringify({event:\"ci-base-image\",phase:\"prepare-source\",ok:true,node:process.version}))'\ngit_ssh_setup() {\n mkdir -p /root/.ssh\n cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa\n chmod 600 /root/.ssh/id_rsa\n timeout 10 ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true\n timeout 10 ssh-keyscan -p 443 ssh.github.com >> /root/.ssh/known_hosts 2>/dev/null || true\n write_github_proxy_command\n export GIT_SSH_COMMAND=\"ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 -o ProxyCommand='node /tmp/hwlab-github-proxy-connect.mjs 127.0.0.1 10808 %h %p'\"\n git config --global url.\"ssh://git@ssh.github.com:443/\".insteadOf \"git@github.com:\"\n git config --global url.\"ssh://git@ssh.github.com:443/\".insteadOf \"ssh://git@github.com/\"\n}\n\nwrite_github_proxy_command() {\n cat > /tmp/hwlab-github-proxy-connect.mjs <<'NODE_PROXY'\n#!/usr/bin/env node\nimport net from \"node:net\";\n\nconst [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2);\nconst proxyPort = Number.parseInt(proxyPortRaw || \"\", 10);\nconst targetPort = Number.parseInt(targetPortRaw || \"\", 10);\nif (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) {\n console.error(\"usage: hwlab-github-proxy-connect \");\n process.exit(64);\n}\n\nconst socket = net.createConnection({ host: proxyHost, port: proxyPort });\nlet buffer = Buffer.alloc(0);\n\nsocket.setTimeout(10000, () => {\n console.error(\"proxy connect timeout \" + proxyHost + \":\" + proxyPort + \" -> \" + targetHost + \":\" + targetPort);\n socket.destroy();\n process.exit(65);\n});\n\nsocket.on(\"connect\", () => {\n socket.write(\"CONNECT \" + targetHost + \":\" + targetPort + \" HTTP/1.1\\r\\nHost: \" + targetHost + \":\" + targetPort + \"\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n\");\n});\n\nsocket.on(\"error\", (error) => {\n console.error(\"proxy connect failed: \" + error.message);\n process.exit(66);\n});\n\nfunction onData(chunk) {\n buffer = Buffer.concat([buffer, chunk]);\n const headerEnd = buffer.indexOf(\"\\r\\n\\r\\n\");\n if (headerEnd === -1 && buffer.length < 8192) return;\n const head = buffer.slice(0, headerEnd + 4).toString(\"latin1\");\n const statusLine = head.split(\"\\r\\n\", 1)[0] || \"\";\n const statusCode = Number.parseInt(statusLine.split(\" \")[1] || \"\", 10);\n if (!statusLine.startsWith(\"HTTP/1.\") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) {\n console.error(\"proxy CONNECT rejected: \" + (statusLine || \"missing-status\"));\n socket.destroy();\n process.exit(67);\n }\n socket.off(\"data\", onData);\n socket.setTimeout(0);\n const rest = buffer.slice(headerEnd + 4);\n if (rest.length) process.stdout.write(rest);\n process.stdin.pipe(socket);\n socket.pipe(process.stdout);\n}\n\nsocket.on(\"data\", onData);\nsocket.on(\"close\", () => process.exit(0));\nNODE_PROXY\n chmod 0700 /tmp/hwlab-github-proxy-connect.mjs\n}\n\ngit_now_ms() {\n node -e 'console.log(Date.now())' 2>/dev/null || date +%s000\n}\n\ngit_timed() {\n phase=\"$1\"\n timeout_seconds=\"$2\"\n shift 2\n started_ms=\"$(git_now_ms)\"\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"started\",\"timeoutSeconds\":'\"$timeout_seconds\"'}' >&2\n set +e\n timeout \"$timeout_seconds\" \"$@\"\n status=\"$?\"\n set -e\n finished_ms=\"$(git_now_ms)\"\n duration_ms=\"$((finished_ms - started_ms))\"\n if [ \"$status\" -eq 0 ]; then\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"succeeded\",\"durationMs\":'\"$duration_ms\"'}' >&2\n return 0\n fi\n if [ \"$status\" -eq 124 ] || [ \"$status\" -eq 137 ]; then\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"failed\",\"reason\":\"timeout\",\"durationMs\":'\"$duration_ms\"',\"timeoutSeconds\":'\"$timeout_seconds\"'}' >&2\n else\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"failed\",\"exitCode\":'\"$status\"',\"durationMs\":'\"$duration_ms\"'}' >&2\n fi\n return \"$status\"\n}\n\ngit_url_requires_ssh() { case \"$1\" in git@*|ssh://*) return 0 ;; *) return 1 ;; esac; }\ngit_read_url=\"$(params.git-read-url)\"\nif git_url_requires_ssh \"$git_read_url\"; then\n for tool in ssh ssh-keyscan; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\n git_ssh_setup\nelse\n echo '{\"event\":\"git-ssh-setup\",\"phase\":\"prepare-source\",\"status\":\"skipped\",\"reason\":\"non-ssh-git-read-url\"}'\nfi\nrm -rf /workspace/source/repo\nsource_clone_started_ms=\"$(ci_now_ms)\"\ngit_timed source-clone 180 git clone --branch \"$(params.source-branch)\" \"$git_read_url\" /workspace/source/repo\ncd /workspace/source/repo\ngit config --global --add safe.directory /workspace/source/repo\ngit remote set-url origin \"$git_read_url\"\ngit checkout \"$(params.revision)\"\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\ngit merge-base --is-ancestor \"$(params.revision)\" \"origin/$(params.source-branch)\" || { echo '{\"event\":\"source-ancestry\",\"status\":\"failed\",\"revision\":\"'\"$(params.revision)\"'\",\"sourceBranch\":\"'\"$(params.source-branch)\"'\"}'; exit 32; }\nci_timing_emit source-clone succeeded \"$source_clone_started_ms\"\nprepare_source_dependencies_started_ms=\"$(ci_now_ms)\"\necho '{\"event\":\"prepare-source-dependencies\",\"status\":\"skipped\",\"reason\":\"renderer-dependency-install-disabled\",\"dependency\":\"yaml\"}'\nci_timing_emit prepare-source-dependencies succeeded \"$prepare_source_dependencies_started_ms\"\ncatalog_path=\"$(params.catalog-path)\"\nmkdir -p \"$(dirname \"$catalog_path\")\"\ncatalog_fetch_started_ms=\"$(ci_now_ms)\"\nif git_timed catalog-ls-remote 45 git ls-remote --exit-code --heads \"$git_read_url\" \"$(params.gitops-branch)\" >/dev/null; then\n git remote set-url gitops-catalog \"$git_read_url\" 2>/dev/null || git remote add gitops-catalog \"$git_read_url\"\n git_timed catalog-fetch 90 git fetch --depth 1 gitops-catalog \"$(params.gitops-branch)\" >/dev/null || true\n if git cat-file -e FETCH_HEAD:\"$catalog_path\" 2>/dev/null; then\n git show FETCH_HEAD:\"$catalog_path\" > /tmp/hwlab-gitops-artifact-catalog.json\n if node --input-type=module - /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.yaml \"$(params.services)\" <<'NODE'\nimport fs from \"node:fs\";\nconst [catalogPath, deployPath, selectedServices] = process.argv.slice(2);\nconst ids = (doc) => (doc.services || []).map((service) => service.serviceId).filter(Boolean);\nconst topLevelServiceIds = (text) => {\n const values = [];\n let inServices = false;\n for (const line of text.split(/\\r?\\n/u)) {\n if (/^services:\\s*$/u.test(line)) { inServices = true; continue; }\n if (!inServices) continue;\n if (/^\\S/u.test(line)) break;\n const match = line.match(/^\\s*-\\s+serviceId:\\s*['\"]?([^'\"#\\s]+)['\"]?/u);\n if (match) values.push(match[1]);\n }\n return values;\n};\nconst selected = (selectedServices || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);\nconst uniqueSorted = (items) => [...new Set(items)].sort();\nconst gitops = JSON.parse(fs.readFileSync(catalogPath, \"utf8\"));\nconst expected = selected.length ? selected : topLevelServiceIds(fs.readFileSync(deployPath, \"utf8\"));\nconst actual = ids(gitops);\nconst expectedSet = uniqueSorted(expected);\nconst actualSet = uniqueSorted(actual);\nconst sameServices = expectedSet.length === actualSet.length && expectedSet.every((item, index) => item === actualSet[index]);\nif (!sameServices) {\n const missing = expectedSet.filter((item) => !actualSet.includes(item));\n const extra = actualSet.filter((item) => !expectedSet.includes(item));\n console.error(JSON.stringify({ event: \"gitops-artifact-catalog\", phase: \"prepare-source\", status: \"ignored-stale\", reason: \"service-ids-mismatch\", expected, actual, missing, extra }));\n process.exit(42);\n}\nNODE\n then\n cp /tmp/hwlab-gitops-artifact-catalog.json \"$catalog_path\"\n echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"loaded\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'\n else\n echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"ignored-stale\",\"reason\":\"service-ids-mismatch\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'\n fi\n else\n echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"missing-on-gitops-branch\"}'\n fi\nelse\n echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"gitops-branch-missing\"}'\nfi\nif [ ! -s \"$catalog_path\" ] && [ \"$(params.lane)\" = \"v02\" ]; then\n node --input-type=module - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" \"$(params.services)\" <<'NODE'\nimport fs from 'node:fs';\nimport path from 'node:path';\nconst [catalogPath, revision, registryPrefix, imageTagMode, selectedServices] = process.argv.slice(2);\nconst topLevelServiceIds = (text) => {\n const values = [];\n let inServices = false;\n for (const line of text.split(/\\r?\\n/u)) {\n if (/^services:\\s*$/u.test(line)) { inServices = true; continue; }\n if (!inServices) continue;\n if (/^\\S/u.test(line)) break;\n const match = line.match(/^\\s*-\\s+serviceId:\\s*['\"]?([^'\"#\\s]+)['\"]?/u);\n if (match) values.push(match[1]);\n }\n return values;\n};\nconst tag = imageTagMode === 'full' ? revision : revision.slice(0, 7);\nconst namespace = 'hwlab-v02';\nconst selected = (selectedServices || '').split(',').filter(Boolean);\nconst serviceIds = selected.length ? selected : topLevelServiceIds(fs.readFileSync('deploy/deploy.yaml', 'utf8'));\nconst services = serviceIds.map((serviceId) => {\n const service = { serviceId, profile: 'v02', namespace, commitId: tag, sourceCommitId: revision, image: `${registryPrefix}/${serviceId}:${tag}`, imageTag: tag, digest: null, buildBackend: 'contract-skeleton' };\n return service;\n});\nfs.mkdirSync(path.dirname(catalogPath), { recursive: true });\nfs.writeFileSync(catalogPath, JSON.stringify({ catalogVersion: 'v1', kind: 'hwlab-artifact-catalog', environment: 'v02', profile: 'v02', namespace, endpoint: \"https://hwlab.74-48-78-17.nip.io\", commitId: tag, artifactState: 'contract-skeleton', publish: { registryPrefix, sourceCommitId: revision, imageTag: tag, publishedAt: null }, allowedProfiles: ['v02'], forbiddenProfiles: ['dev', 'prod'], services }, null, 2) + '\\n');\nNODE\n echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seeded-v02-skeleton\"}'\nfi\nci_timing_emit catalog-fetch succeeded \"$catalog_fetch_started_ms\"\necho 'node prepare-source complete; validation task count=3'\n" + imagePullPolicy: Always + params: + - name: git-url + value: $(params.git-url) + - name: git-read-url + value: $(params.git-read-url) + - name: source-branch + value: $(params.source-branch) + - name: gitops-branch + value: $(params.gitops-branch) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: registry-prefix + value: $(params.registry-prefix) + - name: services + value: $(params.services) + - name: revision + value: $(params.revision) + - name: repo-reports-guard + runAfter: + - prepare-source + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: revision + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: source-branch + - name: gitops-branch + workspaces: + - name: source + steps: + - name: repo-reports-guard + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\ngit config --global --add safe.directory /workspace/source/repo\ncd /workspace/source/repo\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nnode scripts/repo-reports-guard.mjs\n" + imagePullPolicy: Always + params: + - name: revision + value: $(params.revision) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: source-branch + value: $(params.source-branch) + - name: gitops-branch + value: $(params.gitops-branch) + - name: node-contract-check + runAfter: + - prepare-source + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: revision + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: source-branch + - name: gitops-branch + workspaces: + - name: source + steps: + - name: node-contract-check + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\ngit config --global --add safe.directory /workspace/source/repo\ncd /workspace/source/repo\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nnode --check scripts/gitops-render.mjs\nnode --input-type=module <<'NODE'\nimport { readdirSync, readFileSync, statSync } from \"node:fs\";\nimport path from \"node:path\";\n\nconst root = process.cwd();\nconst ciFileName = \"CI\" + \".json\";\nconst forbiddenCiFragments = [\n \"ci\" + \"-json\",\n \"HWLAB_NODE_TEKTON\" + \"_SINGLE_IMAGE_PUBLISH\",\n \"single\" + \"-dind\",\n \"docker\" + \":29\",\n \"DOCKER\" + \"_HOST\",\n \"Docker\" + \"-in-Docker\",\n \"D\" + \"IND\",\n \"docker\" + \" push\",\n \"--build\" + \"-backend\",\n \"HWLAB_ARTIFACT\" + \"_BUILD_BACKEND\",\n \"--legacy\" + \"-source-images\",\n \"HWLAB_NODE_USE_DEPLOY_IMAGES\" + \"=0\"\n];\nconst scanTargets = [\n \"scripts/gitops-render.mjs\",\n \"scripts/src/runtime-lane.ts\",\n \"scripts/artifact-publish.mjs\",\n \"scripts/src/ci-plan-lib.mjs\",\n \"deploy/gitops/node/tekton\"\n];\nconst skipDirs = new Set([\".git\", \"node_modules\", \".worktree\"]);\n\nfunction walkFiles(relativePath) {\n const absolutePath = path.join(root, relativePath);\n let stat;\n try {\n stat = statSync(absolutePath);\n } catch {\n return [];\n }\n if (stat.isFile()) return [relativePath];\n if (!stat.isDirectory()) return [];\n const files = [];\n for (const entry of readdirSync(absolutePath, { withFileTypes: true })) {\n if (entry.isDirectory() && skipDirs.has(entry.name)) continue;\n files.push(...walkFiles(path.join(relativePath, entry.name)));\n }\n return files;\n}\n\nconst ciFiles = walkFiles(\".\").filter((filePath) => path.basename(filePath) === ciFileName);\nconst violations = [];\nfor (const filePath of scanTargets.flatMap(walkFiles)) {\n const text = readFileSync(path.join(root, filePath), \"utf8\");\n for (const fragment of forbiddenCiFragments) {\n if (text.includes(fragment)) violations.push({ filePath, fragment });\n }\n}\nif (ciFiles.length || violations.length) {\n console.error(JSON.stringify({ status: \"failed\", ciFiles, violations }, null, 2));\n process.exit(1);\n}\nNODE\nnode --check scripts/artifact-publish.mjs\n" + imagePullPolicy: Always + params: + - name: revision + value: $(params.revision) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: source-branch + value: $(params.source-branch) + - name: gitops-branch + value: $(params.gitops-branch) + - name: codex-api-forwarder-check + runAfter: + - prepare-source + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: revision + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: source-branch + - name: gitops-branch + workspaces: + - name: source + steps: + - name: codex-api-forwarder-check + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\ngit config --global --add safe.directory /workspace/source/repo\ncd /workspace/source/repo\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nnode scripts/run-bun.mjs test cmd/hwlab-codex-api-responses-forwarder/main.test.ts\n" + imagePullPolicy: Always + params: + - name: revision + value: $(params.revision) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: source-branch + value: $(params.source-branch) + - name: gitops-branch + value: $(params.gitops-branch) + - name: plan-artifacts + runAfter: + - repo-reports-guard + - node-contract-check + - codex-api-forwarder-check + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: git-url + - name: git-read-url + - name: gitops-branch + - name: lane + - name: revision + - name: catalog-path + - name: registry-prefix + - name: services + results: + - name: affected-hwlab-cloud-api + description: hwlab-cloud-api rollout affected according to ci-plan + - name: build-hwlab-cloud-api + description: hwlab-cloud-api image build required according to ci-plan + - name: affected-hwlab-workbench-runtime + description: hwlab-workbench-runtime rollout affected according to ci-plan + - name: build-hwlab-workbench-runtime + description: hwlab-workbench-runtime image build required according to ci-plan + - name: affected-hwlab-user-billing + description: hwlab-user-billing rollout affected according to ci-plan + - name: build-hwlab-user-billing + description: hwlab-user-billing image build required according to ci-plan + - name: affected-hwlab-project-management + description: hwlab-project-management rollout affected according to ci-plan + - name: build-hwlab-project-management + description: hwlab-project-management image build required according to ci-plan + - name: affected-hwlab-tasktree-api + description: hwlab-tasktree-api rollout affected according to ci-plan + - name: build-hwlab-tasktree-api + description: hwlab-tasktree-api image build required according to ci-plan + - name: affected-hwlab-tasktree-worker + description: hwlab-tasktree-worker rollout affected according to ci-plan + - name: build-hwlab-tasktree-worker + description: hwlab-tasktree-worker image build required according to ci-plan + - name: affected-hwlab-harnessrl-api + description: hwlab-harnessrl-api rollout affected according to ci-plan + - name: build-hwlab-harnessrl-api + description: hwlab-harnessrl-api image build required according to ci-plan + - name: affected-hwlab-harnessrl-worker + description: hwlab-harnessrl-worker rollout affected according to ci-plan + - name: build-hwlab-harnessrl-worker + description: hwlab-harnessrl-worker image build required according to ci-plan + - name: affected-hwlab-workbench-api + description: hwlab-workbench-api rollout affected according to ci-plan + - name: build-hwlab-workbench-api + description: hwlab-workbench-api image build required according to ci-plan + - name: affected-hwlab-workbench-worker + description: hwlab-workbench-worker rollout affected according to ci-plan + - name: build-hwlab-workbench-worker + description: hwlab-workbench-worker image build required according to ci-plan + - name: affected-hwlab-cloud-web + description: hwlab-cloud-web rollout affected according to ci-plan + - name: build-hwlab-cloud-web + description: hwlab-cloud-web image build required according to ci-plan + - name: affected-hwlab-gateway + description: hwlab-gateway rollout affected according to ci-plan + - name: build-hwlab-gateway + description: hwlab-gateway image build required according to ci-plan + - name: affected-hwlab-edge-proxy + description: hwlab-edge-proxy rollout affected according to ci-plan + - name: build-hwlab-edge-proxy + description: hwlab-edge-proxy image build required according to ci-plan + - name: affected-hwlab-agent-skills + description: hwlab-agent-skills rollout affected according to ci-plan + - name: build-hwlab-agent-skills + description: hwlab-agent-skills image build required according to ci-plan + workspaces: + - name: source + steps: + - name: plan + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HWLAB_SELECTED_SERVICES + value: $(params.services) + - name: HWLAB_DEV_REGISTRY_K8S_NATIVE + value: "1" + - name: HWLAB_TEKTON_PIPELINERUN + value: $(context.pipelineRun.name) + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\necho '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ncd /workspace/source/repo\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps/yaml\" ] && [ ! -e node_modules/yaml ]; then\n mkdir -p node_modules\n ln -s \"$ci_node_deps/yaml\" node_modules/yaml\nfi\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nHWLAB_CATALOG_PATH=\"$(params.catalog-path)\" HWLAB_GIT_URL=\"$(params.git-url)\" HWLAB_GIT_READ_URL=\"$(params.git-read-url)\" HWLAB_GITOPS_BRANCH=\"$(params.gitops-branch)\" HWLAB_SERVICES=\"$(params.services)\" node scripts/ci/restore-artifact-catalog.mjs\nnode scripts/ci-plan.mjs --lane \"$(params.lane)\" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog \"$(params.catalog-path)\" --registry-prefix \"$(params.registry-prefix)\" --services \"$(params.services)\" --verify-reuse-registry > /workspace/source/ci-plan.json\nnode - <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(\"/workspace/source/ci-plan.json\", \"utf8\"));\nconst selected = String(process.env.HWLAB_SELECTED_SERVICES || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);\nconst allServices = selected.length > 0 ? selected : [\"hwlab-cloud-api\",\"hwlab-cloud-web\",\"hwlab-gateway\",\"hwlab-edge-proxy\",\"hwlab-agent-skills\"];\nconst affected = new Set(plan.affectedServices || []);\nconst plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null;\nconst selectedSet = new Set(selected);\nconst byService = new Map((plan.services || []).map((service) => [service.serviceId, service]));\nconst entries = allServices.map((serviceId) => {\n const service = byService.get(serviceId) || {};\n const serviceSelected = selectedSet.has(serviceId);\n const rolloutAffected = serviceSelected && affected.has(serviceId);\n const envReuse = service.runtimeMode === \"env-reuse-git-mirror-checkout\" || service.envReuse === true;\n const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected));\n return {\n serviceId,\n selected: serviceSelected,\n affected: rolloutAffected,\n buildRequired,\n rolloutAffected,\n runtimeMode: service.runtimeMode || \"service-image\",\n envChanged: service.envChanged ?? null,\n codeChanged: service.codeChanged ?? null\n };\n});\nfor (const entry of entries) {\n fs.writeFileSync(\"/tekton/results/affected-\" + entry.serviceId, entry.affected ? \"true\" : \"false\");\n fs.writeFileSync(\"/tekton/results/build-\" + entry.serviceId, entry.buildRequired ? \"true\" : \"false\");\n}\nfs.writeFileSync(\"/workspace/source/affected-services.json\", JSON.stringify({\n sourceCommitId: plan.sourceCommitId,\n affectedServices: plan.affectedServices || [],\n rolloutServices: plan.rolloutServices || plan.affectedServices || [],\n buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId),\n rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [],\n reusedServices: plan.reusedServices || [],\n serviceReusedCount: plan.serviceReusedCount || 0,\n imageBuildSkippedServices: plan.imageBuildSkippedServices || [],\n buildSkippedCount: plan.buildSkippedCount || 0,\n artifactCatalog: plan.artifactCatalog || null,\n envArtifactGroups: plan.envArtifactGroups || [],\n changedPathSummary: plan.changedPathSummary || null,\n ciCdPlan: plan.ciCdPlan || null,\n services: plan.services || [],\n entries\n}, null, 2) + String.fromCharCode(10));\nconsole.log(JSON.stringify({ event: \"g14-ci-plan\", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] }));\nNODE\n" + imagePullPolicy: Always + params: + - name: git-url + value: $(params.git-url) + - name: git-read-url + value: $(params.git-read-url) + - name: gitops-branch + value: $(params.gitops-branch) + - name: lane + value: $(params.lane) + - name: revision + value: $(params.revision) + - name: catalog-path + value: $(params.catalog-path) + - name: registry-prefix + value: $(params.registry-prefix) + - name: services + value: $(params.services) + - name: build-services + runAfter: + - plan-artifacts + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: revision + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: registry-prefix + - name: service-id + - name: base-image + - name: build-cache-mode + results: + - name: status + description: hwlab-cloud-api status + - name: service-id + description: hwlab-cloud-api service-id + - name: image + description: hwlab-cloud-api image + - name: image-tag + description: hwlab-cloud-api image-tag + - name: digest + description: hwlab-cloud-api digest + - name: repository-digest + description: hwlab-cloud-api repository-digest + - name: source-commit-id + description: hwlab-cloud-api source-commit-id + - name: component-input-hash + description: hwlab-cloud-api component-input-hash + - name: environment-input-hash + description: hwlab-cloud-api environment-input-hash + - name: code-input-hash + description: hwlab-cloud-api code-input-hash + - name: runtime-mode + description: hwlab-cloud-api runtime-mode + - name: boot-repo + description: hwlab-cloud-api boot-repo + - name: boot-commit + description: hwlab-cloud-api boot-commit + - name: boot-sh + description: hwlab-cloud-api boot-sh + - name: build-created-at + description: hwlab-cloud-api build-created-at + - name: build-backend + description: hwlab-cloud-api build-backend + - name: reused-from + description: hwlab-cloud-api reused-from + - name: go-base-image + description: hwlab-cloud-api go-base-image + - name: go-base-image-status + description: hwlab-cloud-api go-base-image-status + - name: go-base-digest + description: hwlab-cloud-api go-base-digest + - name: go-base-build-duration-ms + description: hwlab-cloud-api go-base-build-duration-ms + - name: go-base-buildkit-cache-ref + description: hwlab-cloud-api go-base-buildkit-cache-ref + workspaces: + - name: source + volumes: + - name: service-work + emptyDir: + {} + - name: buildkit-bin + emptyDir: + {} + - name: buildkit-run + emptyDir: + {} + sidecars: + - name: buildkitd + image: 127.0.0.1:5000/hwlab/buildkit:rootless + args: + - "--addr" + - unix:///workspace/buildkit-run/buildkitd.sock + - "--oci-worker-no-process-sandbox" + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + volumeMounts: + - name: buildkit-run + mountPath: /workspace/buildkit-run + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: true + appArmorProfile: + type: Unconfined + seccompProfile: + type: Unconfined + steps: + - name: prepare-buildkit-client + image: 127.0.0.1:5000/hwlab/buildkit:rootless + securityContext: + runAsUser: 0 + runAsGroup: 0 + volumeMounts: + - name: buildkit-bin + mountPath: /workspace/buildkit-bin + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\nmkdir -p /workspace/buildkit-bin\ncp /usr/bin/buildctl /workspace/buildkit-bin/\nchmod +x /workspace/buildkit-bin/*\n" + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + - name: publish + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + volumeMounts: + - name: service-work + mountPath: /workspace/service-work + - name: buildkit-bin + mountPath: /workspace/buildkit-bin + - name: buildkit-run + mountPath: /workspace/buildkit-run + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\nci_now_ms() {\n node -e 'console.log(Date.now())'\n}\n\nci_timing_emit() {\n stage=\"$1\"\n status=\"$2\"\n started_ms=\"$3\"\n finished_ms=\"$(ci_now_ms)\"\n duration_ms=\"$((finished_ms - started_ms))\"\n service_id=\"${HWLAB_TIMING_SERVICE_ID:-}\"\n node -e 'const [stage,status,durationMs,serviceId]=process.argv.slice(1); const payload={event:\"node-cicd-timing\",schemaVersion:\"v1\",stage,status,durationMs:Number(durationMs),pipelineRun:process.env.HWLAB_TEKTON_PIPELINERUN||null,taskRun:process.env.HWLAB_TEKTON_TASKRUN||null,task:process.env.HWLAB_TEKTON_TASK||null,revision:process.env.HWLAB_SOURCE_REVISION||null,serviceId:serviceId||null,source:\"scripts/gitops-render.mjs\",at:new Date().toISOString()}; console.log(JSON.stringify(payload));' \"$stage\" \"$status\" \"$duration_ms\" \"$service_id\"\n}\n\nexport HWLAB_TEKTON_PIPELINERUN=\"$(context.pipelineRun.name)\"\nexport HWLAB_TEKTON_TASKRUN=\"$(context.taskRun.name)\"\nexport HWLAB_TEKTON_TASK=\"build-$(params.service-id)\"\nexport HWLAB_SOURCE_REVISION=\"$(params.revision)\"\nexport HWLAB_TIMING_SERVICE_ID=\"$(params.service-id)\"\nHWLAB_PROXY_PROBE_PHASE='service-image-publish-proxy-preflight' HWLAB_PROXY_PROBE_URL='http://deb.debian.org/debian/dists/bookworm/InRelease' node <<'NODE' || echo '{\"event\":\"dependency-proxy-probe-nonblocking\",\"phase\":\"service-image-publish-proxy-preflight\",\"ok\":false,\"reason\":\"probe-failed-but-continuing\"}'\nconst net = require(\"node:net\");\n\nconst phase = process.env.HWLAB_PROXY_PROBE_PHASE || \"unknown\";\nconst target = new URL(process.env.HWLAB_PROXY_PROBE_URL || \"http://deb.debian.org/debian/dists/bookworm/InRelease\");\nconst proxyRaw = process.env.HTTP_PROXY || process.env.http_proxy || \"\";\n\nfunction redactProxy(value) {\n if (!value) return \"\";\n try {\n const parsed = new URL(value);\n if (parsed.username || parsed.password) {\n parsed.username = \"***\";\n parsed.password = \"\";\n }\n return parsed.toString();\n } catch {\n return \"\";\n }\n}\n\nfunction emit(payload, exitCode = 0) {\n console.log(JSON.stringify({\n event: \"dependency-proxy-probe\",\n phase,\n target: target.href,\n proxy: redactProxy(proxyRaw),\n ...payload\n }));\n if (exitCode) process.exit(exitCode);\n}\n\nif (!proxyRaw) emit({ ok: false, reason: \"proxy-env-missing\" }, 21);\n\nlet proxy;\ntry {\n proxy = new URL(proxyRaw);\n} catch (error) {\n emit({ ok: false, reason: \"proxy-url-invalid\", error: error.message }, 22);\n}\n\nif (proxy.protocol !== \"http:\" && proxy.protocol !== \"https:\") {\n emit({ ok: false, reason: \"http-proxy-required\", protocol: proxy.protocol }, 23);\n}\n\nconst startedAt = Date.now();\nconst socket = net.createConnection({ host: proxy.hostname, port: Number(proxy.port || (proxy.protocol === \"https:\" ? 443 : 80)) });\nlet bytes = 0;\nlet firstByteMs = null;\nlet responseHead = \"\";\nlet finished = false;\n\nconst timeout = setTimeout(() => {\n if (finished) return;\n finished = true;\n socket.destroy();\n emit({ ok: false, reason: \"timeout\", timeoutMs: 15000 }, 24);\n}, 15000);\n\nsocket.on(\"connect\", () => {\n socket.write(\"GET \" + target.href + \" HTTP/1.1\\r\\nHost: \" + target.host + \"\\r\\nUser-Agent: hwlab-node-proxy-probe\\r\\nConnection: close\\r\\n\\r\\n\");\n});\n\nsocket.on(\"data\", (chunk) => {\n if (firstByteMs === null) firstByteMs = Date.now() - startedAt;\n bytes += chunk.length;\n if (responseHead.length < 240) responseHead += chunk.toString(\"utf8\", 0, Math.min(chunk.length, 240 - responseHead.length));\n});\n\nsocket.on(\"end\", () => {\n if (finished) return;\n finished = true;\n clearTimeout(timeout);\n const totalMs = Date.now() - startedAt;\n const statusLine = responseHead.split(\"\\r\\n\", 1)[0] || \"\";\n const statusCode = Number(statusLine.split(\" \")[1] || 0);\n const ok = statusLine.startsWith(\"HTTP/\") && statusCode >= 200 && statusCode < 400;\n emit({\n ok,\n reason: ok ? null : \"bad-http-status\",\n statusLine,\n statusCode,\n firstByteMs,\n totalMs,\n bytes,\n speedBytesPerSecond: totalMs > 0 ? Math.round(bytes * 1000 / totalMs) : 0\n }, ok ? 0 : 25);\n});\n\nsocket.on(\"error\", (error) => {\n if (finished) return;\n finished = true;\n clearTimeout(timeout);\n emit({ ok: false, reason: \"proxy-connect-failed\", error: error.message, totalMs: Date.now() - startedAt }, 26);\n});\nNODE\necho '{\"event\":\"ci-base-image\",\"phase\":\"service-image-publish\",\"serviceId\":\"'\"$(params.service-id)\"'\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node npm git python3 ssh curl; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"service-image-publish\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\nmkdir -p /workspace/service-work/home\nexport HOME=/workspace/service-work/home\ngit config --global --add safe.directory /workspace/source/repo\ncd /workspace/source/repo\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nexport HWLAB_ARTIFACT_LANE=\"$(params.lane)\"\nexport HWLAB_ARTIFACT_CATALOG_PATH=\"$(params.catalog-path)\"\nexport HWLAB_DEPLOY_MANIFEST_PATH=\"deploy/deploy.yaml\"\nexport HWLAB_ARTIFACT_IMAGE_TAG_MODE=\"$(params.image-tag-mode)\"\nmkdir -p /workspace/source/service-results\nif [ -s /workspace/source/affected-services.json ] && ! node -e 'const fs=require(\"node:fs\"); const p=JSON.parse(fs.readFileSync(\"/workspace/source/affected-services.json\",\"utf8\")); const service=process.argv[1]; const item=(p.entries||[]).find((entry)=>entry.serviceId===service); process.exit(item && item.buildRequired ? 0 : 1);' \"$(params.service-id)\"; then\n node - \"$(params.service-id)\" <<'NODE'\nconst fs = require(\"node:fs\");\nconst serviceId = process.argv[2];\nconst catalog = JSON.parse(fs.readFileSync(process.env.HWLAB_ARTIFACT_CATALOG_PATH, \"utf8\"));\nconst plan = fs.existsSync(\"/workspace/source/affected-services.json\") ? JSON.parse(fs.readFileSync(\"/workspace/source/affected-services.json\", \"utf8\")) : {};\nconst service = (catalog.services || []).find((item) => item.serviceId === serviceId) || {};\nconst planned = (plan.services || []).find((item) => item.serviceId === serviceId) || {};\nconst envReuse = planned.runtimeMode === \"env-reuse-git-mirror-checkout\" || service.runtimeMode === \"env-reuse-git-mirror-checkout\" || planned.envReuse === true || service.envReuse === true;\nconst image = envReuse ? (service.environmentImage || service.image || \"\") : (service.image || \"\");\nconst digest = envReuse ? (service.environmentDigest || service.digest || \"not_published\") : (service.digest || \"not_published\");\nconst imageTag = service.imageTag || (image.includes(\":\") ? image.slice(image.lastIndexOf(\":\") + 1) : \"\");\nconst repository = image.includes(\":\") ? image.slice(0, image.lastIndexOf(\":\")) : image;\nconst revision = process.env.HWLAB_SOURCE_REVISION || planned.bootCommit || service.bootCommit || service.sourceCommitId || service.commitId || imageTag;\nconst componentInputHash = envReuse ? (planned.componentInputHash || service.componentInputHash || \"\") : (service.componentInputHash || \"\");\nconst environmentInputHash = envReuse ? (service.environmentInputHash || planned.environmentInputHash || \"\") : (service.environmentInputHash || \"\");\nconst codeInputHash = envReuse ? (planned.codeInputHash || service.codeInputHash || \"\") : (service.codeInputHash || \"\");\nconst values = {\n \"service-id\": serviceId,\n status: /^sha256:[a-f0-9]{64}$/.test(digest) ? \"reused\" : \"blocked_reuse_unavailable\",\n image,\n \"image-tag\": imageTag,\n digest,\n \"repository-digest\": /^sha256:[a-f0-9]{64}$/.test(digest) && repository ? repository + \"@\" + digest : \"\",\n \"source-commit-id\": envReuse ? revision : (service.sourceCommitId || service.commitId || imageTag),\n \"component-input-hash\": componentInputHash,\n \"environment-input-hash\": environmentInputHash,\n \"code-input-hash\": codeInputHash,\n \"runtime-mode\": envReuse ? \"env-reuse-git-mirror-checkout\" : \"service-image\",\n \"boot-repo\": planned.bootRepo || service.bootRepo || \"\",\n \"boot-commit\": envReuse ? revision : (service.bootCommit || \"\"),\n \"boot-sh\": planned.bootSh || service.bootSh || \"\",\n \"build-created-at\": service.buildCreatedAt || \"\",\n \"build-backend\": envReuse ? \"reused-env-catalog\" : \"reused-catalog\",\n \"reused-from\": (envReuse ? service.environmentInputHash : service.componentInputHash) || service.commitId || imageTag || \"catalog\"\n};\nfor (const [name, value] of Object.entries(values)) fs.writeFileSync(\"/tekton/results/\" + name, String(value || \"\"));\nNODE\n echo '{\"event\":\"service-build-skip\",\"serviceId\":\"'\"$(params.service-id)\"'\",\"reason\":\"component-inputs-unchanged\"}'\n exit 0\nfi\nrm -rf /workspace/service-work/repo\nmkdir -p /workspace/service-work/repo\ntar -C /workspace/source/repo -cf - . | tar -C /workspace/service-work/repo -xo -f -\ncd /workspace/service-work/repo\nexport HWLAB_CI_ARTIFACT_RUN_ID=\"$(context.pipelineRun.name)-$(params.service-id)\"\nexport HWLAB_CI_ARTIFACT_RUN_OWNER=\"tekton\"\nexport HWLAB_DEV_REGISTRY_PREFIX=\"$(params.registry-prefix)\"\nexport HWLAB_DEV_REGISTRY_K8S_NATIVE=1\nexport HWLAB_NODE_CICD_TIMING=1\nexport HWLAB_TEKTON_PIPELINERUN\nexport HWLAB_TEKTON_TASKRUN\nexport HWLAB_TEKTON_TASK\nexport HWLAB_SOURCE_REVISION\nexport PATH=\"/workspace/buildkit-bin:$PATH\"\nexport HWLAB_BUILDKIT_ADDR=\"unix:///workspace/buildkit-run/buildkitd.sock\"\nif [ -n \"$(params.base-image)\" ]; then export HWLAB_DEV_BASE_IMAGE=\"$(params.base-image)\"; fi\nif [ -n \"${HWLAB_DEV_BASE_IMAGE:-}\" ]; then\n echo '{\"event\":\"dependency-local-registry-probe-start\",\"phase\":\"service-image-publish-pre-base-image\",\"serviceId\":\"'\"$(params.service-id)\"'\",\"target\":\"http://127.0.0.1:5000/v2/\"}'\n registry_started_at=\"$(date +%s)\"\n curl -fsS --max-time 10 http://127.0.0.1:5000/v2/ >/dev/null\n registry_finished_at=\"$(date +%s)\"\n echo '{\"event\":\"dependency-local-registry-probe\",\"phase\":\"service-image-publish-pre-base-image\",\"serviceId\":\"'\"$(params.service-id)\"'\",\"ok\":true,\"durationSeconds\":'\"$((registry_finished_at - registry_started_at))\"'}'\nfi\nbuildkit_ready=0\nfor attempt in $(seq 1 60); do\n if /workspace/buildkit-bin/buildctl --addr \"$HWLAB_BUILDKIT_ADDR\" debug workers >/dev/null 2>&1; then\n buildkit_ready=1\n break\n fi\n sleep 1\ndone\nif [ \"$buildkit_ready\" != \"1\" ]; then\n echo '{\"event\":\"buildkit-sidecar-not-ready\",\"serviceId\":\"'\"$(params.service-id)\"'\",\"addr\":\"'\"$HWLAB_BUILDKIT_ADDR\"'\"}' >&2\n exit 1\nfi\nHWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node - <<'NODE_UNIDESK_DEPLOY_YAML_OVERLAY'\nconst fs = require('fs');\nconst YAML = require('yaml');\nconst overlay = {\"nodeId\":\"NC01\",\"lane\":\"production\",\"sourceBranch\":\"release\",\"gitopsBranch\":\"release-gitops\",\"gitopsRoot\":\"deploy/gitops/node/nc01\",\"runtimePath\":\"deploy/gitops/node/nc01/runtime-production\",\"runtimeRenderDir\":\"runtime-production\",\"runtimeNamespace\":\"hwlab-production\",\"environment\":\"production\",\"catalogPath\":\"deploy/artifact-catalog.nc01-production.json\",\"gitUrl\":\"git@github.com:pikasTech/HWLAB.git\",\"publicWebUrl\":\"https://lab.hwpod.com\",\"publicApiUrl\":\"https://lab.hwpod.com\",\"externalPostgres\":{\"enabled\":true,\"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\",\"migrationSecretName\":\"hwlab-cloud-api-production-db\",\"migrationSecretKey\":\"migration-database-url\"},\"runtimeStore\":{\"postgres\":{\"mode\":\"platform-service\",\"serviceName\":\"nc01-host-postgres\",\"poolMax\":16}},\"codeAgentRuntime\":{\"enabled\":true,\"adapter\":\"agentrun-v02\",\"managerUrl\":\"http://agentrun-mgr.agentrun-release.svc.cluster.local:8080\",\"apiKeySecretName\":\"hwlab-production-master-server-admin-api-key\",\"apiKeySecretKey\":\"api-key\",\"runnerNamespace\":\"agentrun-release\",\"secretNamespace\":\"agentrun-release\",\"providerSecretNames\":{\"codex\":\"agentrun-release-provider-codex\",\"gpt-pika\":\"agentrun-release-provider-gpt-pika\",\"grok\":\"agentrun-release-provider-grok\",\"dsflash-go\":\"agentrun-release-provider-dsflash-go\"},\"toolSecretNames\":{\"githubPr\":\"agentrun-release-tool-github-pr\",\"unideskSsh\":\"agentrun-release-tool-unidesk-ssh\"},\"repoUrlFrom\":\"runtimeGitReadUrl\",\"repoUrl\":\"http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git\",\"providerIdFrom\":\"runtimeNodeId\",\"providerId\":\"NC01\",\"defaultProviderProfile\":\"gpt.pika\",\"codexStdioSupervisor\":\"repo-owned\",\"kafkaShadowProducer\":null,\"kafkaEventBridge\":{\"enabled\":true,\"features\":{\"directPublish\":true,\"liveKafkaSse\":true,\"kafkaRefreshReplay\":true,\"transactionalProjector\":false,\"projectionOutboxRelay\":false,\"projectionRealtime\":false},\"refreshReplay\":{\"groupIdPrefix\":\"hwlab-production-cloud-api-sse\",\"timeoutMs\":30000,\"scanLimit\":1000000,\"matchedEventLimit\":2000,\"liveBufferLimit\":2000},\"configRef\":\"config/platform-infra/kafka.yaml#clients.hwlab-production-cloud-api\",\"bootstrapServers\":\"platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092\",\"stdioTopic\":\"codex-stdio.raw.v1\",\"agentRunEventTopic\":\"agentrun.event.v1\",\"hwlabEventTopic\":\"hwlab.event.v1\",\"clientId\":\"hwlab-production-cloud-api\",\"directPublishConsumerGroupId\":\"hwlab-production-agentrun-event-direct-publish\",\"transactionalProjectorConsumerGroupId\":\"hwlab-production-agentrun-event-projector\",\"hwlabEventConsumerGroupId\":\"hwlab-production-workbench-live-sse\"},\"valuesPrinted\":false},\"observability\":{\"prometheusOperator\":false,\"webProbe\":{\"sentinels\":[{\"id\":\"nc01-web-probe-sentinel\",\"enabled\":true,\"configRef\":\"config/hwlab-web-probe-sentinel/profiles.yaml#nodes.NC01.sentinels.nc01-web-probe-sentinel.sentinel\"}],\"monitor\":{\"configRef\":\"config/hwlab-web-probe-monitor/runtime.yaml#monitor\"},\"monitorRoot\":{\"enabled\":true,\"sentinelId\":\"nc01-web-probe-sentinel\",\"publicBaseUrl\":\"https://monitor.pikapython.com\",\"routePrefix\":\"/\",\"caddyManagedBlockOwner\":\"hwlab-web-probe-sentinel-active-root\"}},\"recordingRules\":[],\"warningAlerts\":[]},\"deployYamlGitMirror\":{\"id\":\"nc01-production\",\"node\":\"NC01\",\"lane\":\"production\",\"namespace\":\"devops-infra\",\"serviceReadName\":\"git-mirror-http\",\"serviceWriteName\":\"git-mirror-write\",\"cachePvcName\":\"hwlab-git-mirror-cache\",\"cachePvcStorage\":\"20Gi\",\"cacheHostPath\":null,\"servicePort\":8080,\"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\",\"toolsImage\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"toolsImagePullPolicy\":\"Always\",\"sourceRepository\":\"pikasTech/HWLAB\",\"sourceBranch\":\"release\",\"gitopsBranch\":\"release-gitops\",\"egressProxy\":{\"mode\":\"node-global\",\"required\":true,\"clientName\":\"nc01-host-proxy\",\"namespace\":\"platform-infra\",\"serviceName\":\"nc01-host-proxy\",\"port\":10808,\"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\"]},\"githubTransport\":{\"mode\":\"https\",\"username\":\"x-access-token\",\"tokenSecretName\":\"git-mirror-github-token\",\"tokenSecretKey\":\"GITHUB_TOKEN\",\"tokenSourceRef\":\"/root/.unidesk/.env/gh_token.txt\",\"tokenSourceKey\":\"GH_TOKEN\"}},\"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\"}],\"dockerProxyHttp\":\"http://127.0.0.1:10808\",\"dockerProxyHttps\":\"http://127.0.0.1:10808\",\"dockerNoProxyList\":[\"localhost\",\"127.0.0.1\",\"::1\",\"127.0.0.1:5000\",\"localhost:5000\",\".svc\",\".svc.cluster.local\",\".cluster.local\",\"hyueapi.com\",\".hyueapi.com\"],\"npmRegistry\":\"https://registry.npmmirror.com/\",\"npmFetchTimeoutMs\":120000,\"npmRetries\":3};\nconst file = 'deploy/deploy.yaml';\nif (!fs.existsSync(file)) {\n console.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: false, reason: 'deploy-yaml-missing', file }));\n process.exit(45);\n}\nconst doc = YAML.parse(fs.readFileSync(file, 'utf8'));\ndoc.nodes = doc.nodes || {};\ndoc.nodes[overlay.nodeId] = { ...(doc.nodes[overlay.nodeId] || {}), gitopsRoot: overlay.gitopsRoot, sourceRepo: overlay.gitUrl };\ndoc.lanes = doc.lanes || {};\nconst lane = doc.lanes[overlay.lane] || {};\nconst envRecipe = lane.envRecipe || {};\nconst downloadStack = {\n ...(envRecipe.downloadStack || {}),\n httpProxy: overlay.dockerProxyHttp,\n httpsProxy: overlay.dockerProxyHttps,\n noProxy: overlay.dockerNoProxyList,\n};\nif (overlay.npmRegistry) downloadStack.npmRegistry = overlay.npmRegistry;\nif (overlay.npmFetchTimeoutMs) downloadStack.npmFetchTimeoutMs = overlay.npmFetchTimeoutMs;\ndoc.lanes[overlay.lane] = {\n ...lane,\n node: overlay.nodeId,\n sourceBranch: overlay.sourceBranch,\n gitopsBranch: overlay.gitopsBranch,\n namespace: overlay.runtimeNamespace,\n endpoint: overlay.publicApiUrl,\n publicEndpoints: { frontend: overlay.publicWebUrl, api: overlay.publicApiUrl },\n artifactCatalog: overlay.catalogPath,\n runtimePath: overlay.runtimeRenderDir,\n imageTagMode: 'full',\n sourceRepo: overlay.gitUrl,\n observability: overlay.observability,\n envRecipe: { ...envRecipe, downloadStack },\n};\nif (overlay.externalPostgres === undefined || overlay.externalPostgres === null) delete doc.lanes[overlay.lane].externalPostgres;\nelse doc.lanes[overlay.lane].externalPostgres = overlay.externalPostgres;\nif (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;\nif (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;\nif (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;\nfs.writeFileSync(file, YAML.stringify(doc));\nconsole.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: true, lane: overlay.lane, httpProxy: overlay.dockerProxyHttp, noProxyCount: overlay.dockerNoProxyList.length }));\nNODE_UNIDESK_DEPLOY_YAML_OVERLAY\nnode scripts/artifact-publish.mjs --publish --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --deploy-config deploy/deploy.yaml --image-tag-mode \"$(params.image-tag-mode)\" --registry-prefix \"$(params.registry-prefix)\" --report \"/workspace/source/service-results/$(params.service-id).json\" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services \"$(params.service-id)\" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr \"$HWLAB_BUILDKIT_ADDR\" --build-cache-mode \"$(params.build-cache-mode)\"\n" + imagePullPolicy: Always + params: + - name: revision + value: $(params.revision) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: registry-prefix + value: $(params.registry-prefix) + - name: base-image + value: $(params.base-image) + - name: build-cache-mode + value: $(params.build-cache-mode) + matrix: + params: + - name: service-id + value: + - hwlab-cloud-api + - hwlab-workbench-runtime + - hwlab-user-billing + - hwlab-project-management + - hwlab-tasktree-api + - hwlab-tasktree-worker + - hwlab-harnessrl-api + - hwlab-harnessrl-worker + - hwlab-workbench-api + - hwlab-workbench-worker + - hwlab-cloud-web + - hwlab-gateway + - hwlab-edge-proxy + - hwlab-agent-skills + - name: collect-artifacts + runAfter: + - build-services + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: revision + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: registry-prefix + - name: services + - name: base-image + workspaces: + - name: source + steps: + - name: collect + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\necho '{\"event\":\"ci-base-image\",\"phase\":\"collect-artifacts\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"collect-artifacts\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ncd /workspace/source/repo\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nexport HWLAB_CI_ARTIFACT_RUN_ID=\"$(context.pipelineRun.name)-collect\"\nexport HWLAB_CI_ARTIFACT_RUN_OWNER=\"tekton\"\nexport HWLAB_DEV_REGISTRY_PREFIX=\"$(params.registry-prefix)\"\nexport HWLAB_DEV_REGISTRY_K8S_NATIVE=1\nif [ -n \"$(params.base-image)\" ]; then export HWLAB_DEV_BASE_IMAGE=\"$(params.base-image)\"; fi\nexport HWLAB_ARTIFACT_LANE=\"$(params.lane)\"\nexport HWLAB_ARTIFACT_CATALOG_PATH=\"$(params.catalog-path)\"\nexport HWLAB_DEPLOY_MANIFEST_PATH=\"deploy/deploy.yaml\"\nexport HWLAB_ARTIFACT_IMAGE_TAG_MODE=\"$(params.image-tag-mode)\"\nif [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(process.argv[2], \"utf8\"));\nconst buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : [];\nconst affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : [];\nconst willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true;\nprocess.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);\nNODE\nthen\n rm -f /workspace/source/dev-artifacts.json\n echo '{\"event\":\"collect-artifacts\",\"status\":\"skipped\",\"reason\":\"no-build-no-rollout-plan\"}'\n exit 0\nfi\nHWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node - <<'NODE_UNIDESK_DEPLOY_YAML_OVERLAY'\nconst fs = require('fs');\nconst YAML = require('yaml');\nconst overlay = {\"nodeId\":\"NC01\",\"lane\":\"production\",\"sourceBranch\":\"release\",\"gitopsBranch\":\"release-gitops\",\"gitopsRoot\":\"deploy/gitops/node/nc01\",\"runtimePath\":\"deploy/gitops/node/nc01/runtime-production\",\"runtimeRenderDir\":\"runtime-production\",\"runtimeNamespace\":\"hwlab-production\",\"environment\":\"production\",\"catalogPath\":\"deploy/artifact-catalog.nc01-production.json\",\"gitUrl\":\"git@github.com:pikasTech/HWLAB.git\",\"publicWebUrl\":\"https://lab.hwpod.com\",\"publicApiUrl\":\"https://lab.hwpod.com\",\"externalPostgres\":{\"enabled\":true,\"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\",\"migrationSecretName\":\"hwlab-cloud-api-production-db\",\"migrationSecretKey\":\"migration-database-url\"},\"runtimeStore\":{\"postgres\":{\"mode\":\"platform-service\",\"serviceName\":\"nc01-host-postgres\",\"poolMax\":16}},\"codeAgentRuntime\":{\"enabled\":true,\"adapter\":\"agentrun-v02\",\"managerUrl\":\"http://agentrun-mgr.agentrun-release.svc.cluster.local:8080\",\"apiKeySecretName\":\"hwlab-production-master-server-admin-api-key\",\"apiKeySecretKey\":\"api-key\",\"runnerNamespace\":\"agentrun-release\",\"secretNamespace\":\"agentrun-release\",\"providerSecretNames\":{\"codex\":\"agentrun-release-provider-codex\",\"gpt-pika\":\"agentrun-release-provider-gpt-pika\",\"grok\":\"agentrun-release-provider-grok\",\"dsflash-go\":\"agentrun-release-provider-dsflash-go\"},\"toolSecretNames\":{\"githubPr\":\"agentrun-release-tool-github-pr\",\"unideskSsh\":\"agentrun-release-tool-unidesk-ssh\"},\"repoUrlFrom\":\"runtimeGitReadUrl\",\"repoUrl\":\"http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git\",\"providerIdFrom\":\"runtimeNodeId\",\"providerId\":\"NC01\",\"defaultProviderProfile\":\"gpt.pika\",\"codexStdioSupervisor\":\"repo-owned\",\"kafkaShadowProducer\":null,\"kafkaEventBridge\":{\"enabled\":true,\"features\":{\"directPublish\":true,\"liveKafkaSse\":true,\"kafkaRefreshReplay\":true,\"transactionalProjector\":false,\"projectionOutboxRelay\":false,\"projectionRealtime\":false},\"refreshReplay\":{\"groupIdPrefix\":\"hwlab-production-cloud-api-sse\",\"timeoutMs\":30000,\"scanLimit\":1000000,\"matchedEventLimit\":2000,\"liveBufferLimit\":2000},\"configRef\":\"config/platform-infra/kafka.yaml#clients.hwlab-production-cloud-api\",\"bootstrapServers\":\"platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092\",\"stdioTopic\":\"codex-stdio.raw.v1\",\"agentRunEventTopic\":\"agentrun.event.v1\",\"hwlabEventTopic\":\"hwlab.event.v1\",\"clientId\":\"hwlab-production-cloud-api\",\"directPublishConsumerGroupId\":\"hwlab-production-agentrun-event-direct-publish\",\"transactionalProjectorConsumerGroupId\":\"hwlab-production-agentrun-event-projector\",\"hwlabEventConsumerGroupId\":\"hwlab-production-workbench-live-sse\"},\"valuesPrinted\":false},\"observability\":{\"prometheusOperator\":false,\"webProbe\":{\"sentinels\":[{\"id\":\"nc01-web-probe-sentinel\",\"enabled\":true,\"configRef\":\"config/hwlab-web-probe-sentinel/profiles.yaml#nodes.NC01.sentinels.nc01-web-probe-sentinel.sentinel\"}],\"monitor\":{\"configRef\":\"config/hwlab-web-probe-monitor/runtime.yaml#monitor\"},\"monitorRoot\":{\"enabled\":true,\"sentinelId\":\"nc01-web-probe-sentinel\",\"publicBaseUrl\":\"https://monitor.pikapython.com\",\"routePrefix\":\"/\",\"caddyManagedBlockOwner\":\"hwlab-web-probe-sentinel-active-root\"}},\"recordingRules\":[],\"warningAlerts\":[]},\"deployYamlGitMirror\":{\"id\":\"nc01-production\",\"node\":\"NC01\",\"lane\":\"production\",\"namespace\":\"devops-infra\",\"serviceReadName\":\"git-mirror-http\",\"serviceWriteName\":\"git-mirror-write\",\"cachePvcName\":\"hwlab-git-mirror-cache\",\"cachePvcStorage\":\"20Gi\",\"cacheHostPath\":null,\"servicePort\":8080,\"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\",\"toolsImage\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"toolsImagePullPolicy\":\"Always\",\"sourceRepository\":\"pikasTech/HWLAB\",\"sourceBranch\":\"release\",\"gitopsBranch\":\"release-gitops\",\"egressProxy\":{\"mode\":\"node-global\",\"required\":true,\"clientName\":\"nc01-host-proxy\",\"namespace\":\"platform-infra\",\"serviceName\":\"nc01-host-proxy\",\"port\":10808,\"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\"]},\"githubTransport\":{\"mode\":\"https\",\"username\":\"x-access-token\",\"tokenSecretName\":\"git-mirror-github-token\",\"tokenSecretKey\":\"GITHUB_TOKEN\",\"tokenSourceRef\":\"/root/.unidesk/.env/gh_token.txt\",\"tokenSourceKey\":\"GH_TOKEN\"}},\"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\"}],\"dockerProxyHttp\":\"http://127.0.0.1:10808\",\"dockerProxyHttps\":\"http://127.0.0.1:10808\",\"dockerNoProxyList\":[\"localhost\",\"127.0.0.1\",\"::1\",\"127.0.0.1:5000\",\"localhost:5000\",\".svc\",\".svc.cluster.local\",\".cluster.local\",\"hyueapi.com\",\".hyueapi.com\"],\"npmRegistry\":\"https://registry.npmmirror.com/\",\"npmFetchTimeoutMs\":120000,\"npmRetries\":3};\nconst file = 'deploy/deploy.yaml';\nif (!fs.existsSync(file)) {\n console.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: false, reason: 'deploy-yaml-missing', file }));\n process.exit(45);\n}\nconst doc = YAML.parse(fs.readFileSync(file, 'utf8'));\ndoc.nodes = doc.nodes || {};\ndoc.nodes[overlay.nodeId] = { ...(doc.nodes[overlay.nodeId] || {}), gitopsRoot: overlay.gitopsRoot, sourceRepo: overlay.gitUrl };\ndoc.lanes = doc.lanes || {};\nconst lane = doc.lanes[overlay.lane] || {};\nconst envRecipe = lane.envRecipe || {};\nconst downloadStack = {\n ...(envRecipe.downloadStack || {}),\n httpProxy: overlay.dockerProxyHttp,\n httpsProxy: overlay.dockerProxyHttps,\n noProxy: overlay.dockerNoProxyList,\n};\nif (overlay.npmRegistry) downloadStack.npmRegistry = overlay.npmRegistry;\nif (overlay.npmFetchTimeoutMs) downloadStack.npmFetchTimeoutMs = overlay.npmFetchTimeoutMs;\ndoc.lanes[overlay.lane] = {\n ...lane,\n node: overlay.nodeId,\n sourceBranch: overlay.sourceBranch,\n gitopsBranch: overlay.gitopsBranch,\n namespace: overlay.runtimeNamespace,\n endpoint: overlay.publicApiUrl,\n publicEndpoints: { frontend: overlay.publicWebUrl, api: overlay.publicApiUrl },\n artifactCatalog: overlay.catalogPath,\n runtimePath: overlay.runtimeRenderDir,\n imageTagMode: 'full',\n sourceRepo: overlay.gitUrl,\n observability: overlay.observability,\n envRecipe: { ...envRecipe, downloadStack },\n};\nif (overlay.externalPostgres === undefined || overlay.externalPostgres === null) delete doc.lanes[overlay.lane].externalPostgres;\nelse doc.lanes[overlay.lane].externalPostgres = overlay.externalPostgres;\nif (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;\nif (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;\nif (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;\nfs.writeFileSync(file, YAML.stringify(doc));\nconsole.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: true, lane: overlay.lane, httpProxy: overlay.dockerProxyHttp, noProxyCount: overlay.dockerNoProxyList.length }));\nNODE_UNIDESK_DEPLOY_YAML_OVERLAY\nnode scripts/artifact-publish.mjs --publish --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --deploy-config deploy/deploy.yaml --image-tag-mode \"$(params.image-tag-mode)\" --registry-prefix \"$(params.registry-prefix)\" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services \"$(params.services)\" --external-service-report-dir /workspace/source/service-results --ci-plan-path /workspace/source/affected-services.json\nnode scripts/refresh-artifact-catalog.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --deploy-config deploy/deploy.yaml --image-tag-mode \"$(params.image-tag-mode)\" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write\n" + imagePullPolicy: Always + params: + - name: revision + value: $(params.revision) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: registry-prefix + value: $(params.registry-prefix) + - name: services + value: $(params.services) + - name: base-image + value: $(params.base-image) + - name: gitops-promote + runAfter: + - collect-artifacts + workspaces: + - name: source + workspace: source + - name: git-ssh + workspace: git-ssh + taskSpec: + params: + - name: git-url + - name: git-read-url + - name: git-write-url + - name: source-branch + - name: gitops-branch + - name: lane + - name: catalog-path + - name: image-tag-mode + - name: runtime-path + - name: revision + - name: registry-prefix + results: + - name: runtime-ready-required + description: true when GitOps promotion changed runtime desired state and runtime readiness must be observed + workspaces: + - name: source + - name: git-ssh + steps: + - name: promote + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + env: + - name: HTTP_PROXY + value: http://127.0.0.1:10808 + - name: HTTPS_PROXY + value: http://127.0.0.1:10808 + - name: ALL_PROXY + value: http://127.0.0.1:10808 + - name: NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: http_proxy + value: http://127.0.0.1:10808 + - name: https_proxy + value: http://127.0.0.1:10808 + - name: all_proxy + value: http://127.0.0.1:10808 + - name: no_proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.16.0.0/12,192.168.0.0/16,82.156.23.220,74.48.78.17,hyueapi.com,.hyueapi.com" + - name: HWLAB_NODE_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_ALL_PROXY_URL + value: http://127.0.0.1:10808 + - name: HWLAB_NODE_NO_PROXY + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: HOME + value: /tekton/home + - name: XDG_CONFIG_HOME + value: /tekton/home/.config + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\nci_now_ms() {\n node -e 'console.log(Date.now())'\n}\n\nci_timing_emit() {\n stage=\"$1\"\n status=\"$2\"\n started_ms=\"$3\"\n finished_ms=\"$(ci_now_ms)\"\n duration_ms=\"$((finished_ms - started_ms))\"\n service_id=\"${HWLAB_TIMING_SERVICE_ID:-}\"\n node -e 'const [stage,status,durationMs,serviceId]=process.argv.slice(1); const payload={event:\"node-cicd-timing\",schemaVersion:\"v1\",stage,status,durationMs:Number(durationMs),pipelineRun:process.env.HWLAB_TEKTON_PIPELINERUN||null,taskRun:process.env.HWLAB_TEKTON_TASKRUN||null,task:process.env.HWLAB_TEKTON_TASK||null,revision:process.env.HWLAB_SOURCE_REVISION||null,serviceId:serviceId||null,source:\"scripts/gitops-render.mjs\",at:new Date().toISOString()}; console.log(JSON.stringify(payload));' \"$stage\" \"$status\" \"$duration_ms\" \"$service_id\"\n}\n\nexport HWLAB_TEKTON_PIPELINERUN=\"$(context.pipelineRun.name)\"\nexport HWLAB_TEKTON_TASKRUN=\"$(context.taskRun.name)\"\nexport HWLAB_TEKTON_TASK=\"gitops-promote\"\nexport HWLAB_SOURCE_REVISION=\"$(params.revision)\"\necho '{\"event\":\"ci-base-image\",\"phase\":\"gitops-promote\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apt\"}'\nfor tool in node git timeout; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"gitops-promote\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ngit_ssh_setup() {\n mkdir -p /root/.ssh\n cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa\n chmod 600 /root/.ssh/id_rsa\n timeout 10 ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true\n timeout 10 ssh-keyscan -p 443 ssh.github.com >> /root/.ssh/known_hosts 2>/dev/null || true\n write_github_proxy_command\n export GIT_SSH_COMMAND=\"ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 -o ProxyCommand='node /tmp/hwlab-github-proxy-connect.mjs 127.0.0.1 10808 %h %p'\"\n git config --global url.\"ssh://git@ssh.github.com:443/\".insteadOf \"git@github.com:\"\n git config --global url.\"ssh://git@ssh.github.com:443/\".insteadOf \"ssh://git@github.com/\"\n}\n\nwrite_github_proxy_command() {\n cat > /tmp/hwlab-github-proxy-connect.mjs <<'NODE_PROXY'\n#!/usr/bin/env node\nimport net from \"node:net\";\n\nconst [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2);\nconst proxyPort = Number.parseInt(proxyPortRaw || \"\", 10);\nconst targetPort = Number.parseInt(targetPortRaw || \"\", 10);\nif (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) {\n console.error(\"usage: hwlab-github-proxy-connect \");\n process.exit(64);\n}\n\nconst socket = net.createConnection({ host: proxyHost, port: proxyPort });\nlet buffer = Buffer.alloc(0);\n\nsocket.setTimeout(10000, () => {\n console.error(\"proxy connect timeout \" + proxyHost + \":\" + proxyPort + \" -> \" + targetHost + \":\" + targetPort);\n socket.destroy();\n process.exit(65);\n});\n\nsocket.on(\"connect\", () => {\n socket.write(\"CONNECT \" + targetHost + \":\" + targetPort + \" HTTP/1.1\\r\\nHost: \" + targetHost + \":\" + targetPort + \"\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n\");\n});\n\nsocket.on(\"error\", (error) => {\n console.error(\"proxy connect failed: \" + error.message);\n process.exit(66);\n});\n\nfunction onData(chunk) {\n buffer = Buffer.concat([buffer, chunk]);\n const headerEnd = buffer.indexOf(\"\\r\\n\\r\\n\");\n if (headerEnd === -1 && buffer.length < 8192) return;\n const head = buffer.slice(0, headerEnd + 4).toString(\"latin1\");\n const statusLine = head.split(\"\\r\\n\", 1)[0] || \"\";\n const statusCode = Number.parseInt(statusLine.split(\" \")[1] || \"\", 10);\n if (!statusLine.startsWith(\"HTTP/1.\") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) {\n console.error(\"proxy CONNECT rejected: \" + (statusLine || \"missing-status\"));\n socket.destroy();\n process.exit(67);\n }\n socket.off(\"data\", onData);\n socket.setTimeout(0);\n const rest = buffer.slice(headerEnd + 4);\n if (rest.length) process.stdout.write(rest);\n process.stdin.pipe(socket);\n socket.pipe(process.stdout);\n}\n\nsocket.on(\"data\", onData);\nsocket.on(\"close\", () => process.exit(0));\nNODE_PROXY\n chmod 0700 /tmp/hwlab-github-proxy-connect.mjs\n}\n\ngit_now_ms() {\n node -e 'console.log(Date.now())' 2>/dev/null || date +%s000\n}\n\ngit_timed() {\n phase=\"$1\"\n timeout_seconds=\"$2\"\n shift 2\n started_ms=\"$(git_now_ms)\"\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"started\",\"timeoutSeconds\":'\"$timeout_seconds\"'}' >&2\n set +e\n timeout \"$timeout_seconds\" \"$@\"\n status=\"$?\"\n set -e\n finished_ms=\"$(git_now_ms)\"\n duration_ms=\"$((finished_ms - started_ms))\"\n if [ \"$status\" -eq 0 ]; then\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"succeeded\",\"durationMs\":'\"$duration_ms\"'}' >&2\n return 0\n fi\n if [ \"$status\" -eq 124 ] || [ \"$status\" -eq 137 ]; then\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"failed\",\"reason\":\"timeout\",\"durationMs\":'\"$duration_ms\"',\"timeoutSeconds\":'\"$timeout_seconds\"'}' >&2\n else\n echo '{\"event\":\"git-operation\",\"phase\":\"'\"$phase\"'\",\"status\":\"failed\",\"exitCode\":'\"$status\"',\"durationMs\":'\"$duration_ms\"'}' >&2\n fi\n return \"$status\"\n}\n\ngit_url_requires_ssh() { case \"$1\" in git@*|ssh://*) return 0 ;; *) return 1 ;; esac; }\nlane=\"$(params.lane)\"\ncase \"$lane\" in\n node) runtime_lane=false ;;\n *) runtime_lane=true ;;\nesac\nif [ \"$runtime_lane\" = \"true\" ]; then\n printf 'false' > /tekton/results/runtime-ready-required\nelse\n printf 'true' > /tekton/results/runtime-ready-required\nfi\nsource_head_url_for_setup=\"$(params.git-url)\"\ngitops_read_url_for_setup=\"$(params.git-url)\"\nif [ \"$runtime_lane\" = \"true\" ]; then\n source_head_url_for_setup=\"$(params.git-read-url)\"\n gitops_read_url_for_setup=\"$(params.git-read-url)\"\nfi\ngitops_write_url_for_setup=\"$(params.git-write-url)\"\nif git_url_requires_ssh \"$source_head_url_for_setup\" || git_url_requires_ssh \"$gitops_read_url_for_setup\" || git_url_requires_ssh \"$gitops_write_url_for_setup\"; then\n for tool in ssh ssh-keyscan; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"gitops-promote\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\n git_ssh_setup\nelse\n echo '{\"event\":\"git-ssh-setup\",\"phase\":\"gitops-promote\",\"status\":\"skipped\",\"reason\":\"non-ssh-git-url\"}'\nfi\ncd /workspace/source/repo\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\n\ncheck_source_head() {\n phase=\"$1\"\n expected=\"${2:-$(params.revision)}\"\n source_head_url=\"$(params.git-url)\"\n if [ \"$runtime_lane\" = \"true\" ]; then\n source_head_url=\"$(params.git-read-url)\"\n fi\n latest_file=\"$(mktemp)\"\n if ! git_timed \"source-head-$phase\" 45 git ls-remote \"$source_head_url\" \"refs/heads/$(params.source-branch)\" > \"$latest_file\"; then\n echo '{\"status\":\"failed\",\"reason\":\"source-head-check-failed\",\"phase\":\"'\"$phase\"'\",\"sourceBranch\":\"'\"$(params.source-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\"}'\n exit 1\n fi\n latest=\"$(cut -f1 \"$latest_file\" | head -n 1)\"\n if [ -z \"$latest\" ]; then\n echo '{\"status\":\"failed\",\"reason\":\"source-head-unresolved\",\"phase\":\"'\"$phase\"'\",\"sourceBranch\":\"'\"$(params.source-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\"}'\n exit 1\n fi\n if [ \"$latest\" != \"$expected\" ]; then\n printf 'false' > /tekton/results/runtime-ready-required || true\n echo '{\"status\":\"skipped-stale-source\",\"verdict\":\"superseded\",\"phase\":\"'\"$phase\"'\",\"sourceBranch\":\"'\"$(params.source-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\",\"expectedRevision\":\"'\"$expected\"'\",\"latestRevision\":\"'\"$latest\"'\"}'\n exit 0\n fi\n}\n\ncheck_source_head before-render\nif [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(process.argv[2], \"utf8\"));\nconst buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : [];\nconst affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : [];\nprocess.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);\nNODE\nthen\n printf 'false' > /tekton/results/runtime-ready-required || true\n echo '{\"event\":\"gitops-promote\",\"status\":\"continuing\",\"reason\":\"no-build-no-rollout-plan-gitops-verify\"}' >&2\nfi\ngit config --global user.name \"HWLAB node GitOps Bot\"\ngit config --global user.email \"hwlab-node-gitops-bot@users.noreply.github.com\"\ncatalog_path=\"$(params.catalog-path)\"\nruntime_path=\"$(params.runtime-path)\"\nunidesk_legacy_runtime_path='deploy/gitops/node/runtime-production'\ngitops_root_from_runtime_path() {\n path=\"$1\"\n case \"$path\" in\n */*) printf '%s\n' \"${path%/*}\" ;;\n *) printf '.\n' ;;\n esac\n}\ngitops_root=\"$(gitops_root_from_runtime_path \"$runtime_path\")\"\n\nargo_hard_refresh_runtime_lane() {\n if [ \"$runtime_lane\" != \"true\" ]; then return 0; fi\n ARGO_APPLICATION=\"hwlab-node-$lane\" ARGO_FIELD_MANAGER=\"hwlab-$lane-gitops-promote\" node <<'NODE'\nconst fs = require(\"node:fs\");\nconst https = require(\"node:https\");\n\nconst host = process.env.KUBERNETES_SERVICE_HOST;\nconst port = process.env.KUBERNETES_SERVICE_PORT || \"443\";\nconst startedAt = Date.now();\nconst application = process.env.ARGO_APPLICATION || \"hwlab-node-v02\";\nconst fieldManager = process.env.ARGO_FIELD_MANAGER || \"hwlab-v02-gitops-promote\";\nconst pipelineRun = process.env.HWLAB_TEKTON_PIPELINERUN || null;\nconst taskRun = process.env.HWLAB_TEKTON_TASKRUN || null;\nconst task = process.env.HWLAB_TEKTON_TASK || null;\nconst revision = process.env.HWLAB_SOURCE_REVISION || null;\n\nfunction emit(payload) {\n console.log(JSON.stringify({\n event: \"node-cicd-timing\",\n schemaVersion: \"v1\",\n stage: \"argo-hard-refresh\",\n pipelineRun,\n taskRun,\n task,\n revision,\n application,\n source: \"scripts/gitops-render.mjs\",\n at: new Date().toISOString(),\n ...payload\n }));\n}\n\nfunction safeJson(text) {\n try {\n return JSON.parse(text);\n } catch {\n return null;\n }\n}\n\nfunction request(method, path, body, contentType = \"application/merge-patch+json\") {\n const token = fs.readFileSync(\"/var/run/secrets/kubernetes.io/serviceaccount/token\", \"utf8\");\n const ca = fs.readFileSync(\"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt\");\n const payload = body ? JSON.stringify(body) : \"\";\n const headers = { Authorization: \"Bearer \" + token };\n if (payload) {\n headers[\"Content-Type\"] = contentType;\n headers[\"Content-Length\"] = Buffer.byteLength(payload);\n }\n return new Promise((resolve, reject) => {\n const req = https.request({ host, port, method, path, ca, headers }, (res) => {\n let data = \"\";\n res.setEncoding(\"utf8\");\n res.on(\"data\", (chunk) => { data += chunk; });\n res.on(\"end\", () => resolve({ statusCode: res.statusCode || 0, body: data, json: safeJson(data) }));\n });\n req.on(\"error\", reject);\n if (payload) req.write(payload);\n req.end();\n });\n}\n\n(async () => {\n if (!host) {\n emit({ status: \"skipped\", reason: \"kubernetes-service-host-missing\", durationMs: Date.now() - startedAt });\n return;\n }\n const response = await request(\n \"PATCH\",\n \"/apis/argoproj.io/v1alpha1/namespaces/argocd/applications/\" + encodeURIComponent(application) + \"?fieldManager=\" + encodeURIComponent(fieldManager),\n { metadata: { annotations: { \"argocd.argoproj.io/refresh\": \"hard\" } } }\n );\n if (response.statusCode >= 200 && response.statusCode < 300) {\n emit({ status: \"succeeded\", durationMs: Date.now() - startedAt, statusCode: response.statusCode });\n return;\n }\n emit({ status: \"degraded\", reason: \"argo-refresh-patch-failed\", durationMs: Date.now() - startedAt, statusCode: response.statusCode, body: response.body.slice(0, 1000) });\n})().catch((error) => {\n emit({ status: \"degraded\", reason: \"argo-refresh-patch-error\", durationMs: Date.now() - startedAt, error: error.message });\n});\nNODE\n}\n\nif [ -s /workspace/source/dev-artifacts.json ]; then\n node scripts/refresh-artifact-catalog.mjs --lane \"$lane\" --catalog-path \"$catalog_path\" --deploy-config deploy/deploy.yaml --image-tag-mode \"$(params.image-tag-mode)\" --target-ref \"$(params.revision)\" --publish-report /workspace/source/dev-artifacts.json --write\nfi\ngitops_render_started_ms=\"$(ci_now_ms)\"\nnode - <<'NODE_UNIDESK_DEPLOY_YAML_OVERLAY'\nconst fs = require('fs');\nconst YAML = require('yaml');\nconst overlay = {\"nodeId\":\"NC01\",\"lane\":\"production\",\"sourceBranch\":\"release\",\"gitopsBranch\":\"release-gitops\",\"gitopsRoot\":\"deploy/gitops/node/nc01\",\"runtimePath\":\"deploy/gitops/node/nc01/runtime-production\",\"runtimeRenderDir\":\"runtime-production\",\"runtimeNamespace\":\"hwlab-production\",\"environment\":\"production\",\"catalogPath\":\"deploy/artifact-catalog.nc01-production.json\",\"gitUrl\":\"git@github.com:pikasTech/HWLAB.git\",\"publicWebUrl\":\"https://lab.hwpod.com\",\"publicApiUrl\":\"https://lab.hwpod.com\",\"externalPostgres\":{\"enabled\":true,\"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\",\"migrationSecretName\":\"hwlab-cloud-api-production-db\",\"migrationSecretKey\":\"migration-database-url\"},\"runtimeStore\":{\"postgres\":{\"mode\":\"platform-service\",\"serviceName\":\"nc01-host-postgres\",\"poolMax\":16}},\"codeAgentRuntime\":{\"enabled\":true,\"adapter\":\"agentrun-v02\",\"managerUrl\":\"http://agentrun-mgr.agentrun-release.svc.cluster.local:8080\",\"apiKeySecretName\":\"hwlab-production-master-server-admin-api-key\",\"apiKeySecretKey\":\"api-key\",\"runnerNamespace\":\"agentrun-release\",\"secretNamespace\":\"agentrun-release\",\"providerSecretNames\":{\"codex\":\"agentrun-release-provider-codex\",\"gpt-pika\":\"agentrun-release-provider-gpt-pika\",\"grok\":\"agentrun-release-provider-grok\",\"dsflash-go\":\"agentrun-release-provider-dsflash-go\"},\"toolSecretNames\":{\"githubPr\":\"agentrun-release-tool-github-pr\",\"unideskSsh\":\"agentrun-release-tool-unidesk-ssh\"},\"repoUrlFrom\":\"runtimeGitReadUrl\",\"repoUrl\":\"http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git\",\"providerIdFrom\":\"runtimeNodeId\",\"providerId\":\"NC01\",\"defaultProviderProfile\":\"gpt.pika\",\"codexStdioSupervisor\":\"repo-owned\",\"kafkaShadowProducer\":null,\"kafkaEventBridge\":{\"enabled\":true,\"features\":{\"directPublish\":true,\"liveKafkaSse\":true,\"kafkaRefreshReplay\":true,\"transactionalProjector\":false,\"projectionOutboxRelay\":false,\"projectionRealtime\":false},\"refreshReplay\":{\"groupIdPrefix\":\"hwlab-production-cloud-api-sse\",\"timeoutMs\":30000,\"scanLimit\":1000000,\"matchedEventLimit\":2000,\"liveBufferLimit\":2000},\"configRef\":\"config/platform-infra/kafka.yaml#clients.hwlab-production-cloud-api\",\"bootstrapServers\":\"platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092\",\"stdioTopic\":\"codex-stdio.raw.v1\",\"agentRunEventTopic\":\"agentrun.event.v1\",\"hwlabEventTopic\":\"hwlab.event.v1\",\"clientId\":\"hwlab-production-cloud-api\",\"directPublishConsumerGroupId\":\"hwlab-production-agentrun-event-direct-publish\",\"transactionalProjectorConsumerGroupId\":\"hwlab-production-agentrun-event-projector\",\"hwlabEventConsumerGroupId\":\"hwlab-production-workbench-live-sse\"},\"valuesPrinted\":false},\"observability\":{\"prometheusOperator\":false,\"webProbe\":{\"sentinels\":[{\"id\":\"nc01-web-probe-sentinel\",\"enabled\":true,\"configRef\":\"config/hwlab-web-probe-sentinel/profiles.yaml#nodes.NC01.sentinels.nc01-web-probe-sentinel.sentinel\"}],\"monitor\":{\"configRef\":\"config/hwlab-web-probe-monitor/runtime.yaml#monitor\"},\"monitorRoot\":{\"enabled\":true,\"sentinelId\":\"nc01-web-probe-sentinel\",\"publicBaseUrl\":\"https://monitor.pikapython.com\",\"routePrefix\":\"/\",\"caddyManagedBlockOwner\":\"hwlab-web-probe-sentinel-active-root\"}},\"recordingRules\":[],\"warningAlerts\":[]},\"deployYamlGitMirror\":{\"id\":\"nc01-production\",\"node\":\"NC01\",\"lane\":\"production\",\"namespace\":\"devops-infra\",\"serviceReadName\":\"git-mirror-http\",\"serviceWriteName\":\"git-mirror-write\",\"cachePvcName\":\"hwlab-git-mirror-cache\",\"cachePvcStorage\":\"20Gi\",\"cacheHostPath\":null,\"servicePort\":8080,\"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\",\"toolsImage\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"toolsImagePullPolicy\":\"Always\",\"sourceRepository\":\"pikasTech/HWLAB\",\"sourceBranch\":\"release\",\"gitopsBranch\":\"release-gitops\",\"egressProxy\":{\"mode\":\"node-global\",\"required\":true,\"clientName\":\"nc01-host-proxy\",\"namespace\":\"platform-infra\",\"serviceName\":\"nc01-host-proxy\",\"port\":10808,\"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\"]},\"githubTransport\":{\"mode\":\"https\",\"username\":\"x-access-token\",\"tokenSecretName\":\"git-mirror-github-token\",\"tokenSecretKey\":\"GITHUB_TOKEN\",\"tokenSourceRef\":\"/root/.unidesk/.env/gh_token.txt\",\"tokenSourceKey\":\"GH_TOKEN\"}},\"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\"}],\"dockerProxyHttp\":\"http://127.0.0.1:10808\",\"dockerProxyHttps\":\"http://127.0.0.1:10808\",\"dockerNoProxyList\":[\"localhost\",\"127.0.0.1\",\"::1\",\"127.0.0.1:5000\",\"localhost:5000\",\".svc\",\".svc.cluster.local\",\".cluster.local\",\"hyueapi.com\",\".hyueapi.com\"],\"npmRegistry\":\"https://registry.npmmirror.com/\",\"npmFetchTimeoutMs\":120000,\"npmRetries\":3};\nconst file = 'deploy/deploy.yaml';\nif (!fs.existsSync(file)) {\n console.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: false, reason: 'deploy-yaml-missing', file }));\n process.exit(45);\n}\nconst doc = YAML.parse(fs.readFileSync(file, 'utf8'));\ndoc.nodes = doc.nodes || {};\ndoc.nodes[overlay.nodeId] = { ...(doc.nodes[overlay.nodeId] || {}), gitopsRoot: overlay.gitopsRoot, sourceRepo: overlay.gitUrl };\ndoc.lanes = doc.lanes || {};\nconst lane = doc.lanes[overlay.lane] || {};\nconst envRecipe = lane.envRecipe || {};\nconst downloadStack = {\n ...(envRecipe.downloadStack || {}),\n httpProxy: overlay.dockerProxyHttp,\n httpsProxy: overlay.dockerProxyHttps,\n noProxy: overlay.dockerNoProxyList,\n};\nif (overlay.npmRegistry) downloadStack.npmRegistry = overlay.npmRegistry;\nif (overlay.npmFetchTimeoutMs) downloadStack.npmFetchTimeoutMs = overlay.npmFetchTimeoutMs;\ndoc.lanes[overlay.lane] = {\n ...lane,\n node: overlay.nodeId,\n sourceBranch: overlay.sourceBranch,\n gitopsBranch: overlay.gitopsBranch,\n namespace: overlay.runtimeNamespace,\n endpoint: overlay.publicApiUrl,\n publicEndpoints: { frontend: overlay.publicWebUrl, api: overlay.publicApiUrl },\n artifactCatalog: overlay.catalogPath,\n runtimePath: overlay.runtimeRenderDir,\n imageTagMode: 'full',\n sourceRepo: overlay.gitUrl,\n observability: overlay.observability,\n envRecipe: { ...envRecipe, downloadStack },\n};\nif (overlay.externalPostgres === undefined || overlay.externalPostgres === null) delete doc.lanes[overlay.lane].externalPostgres;\nelse doc.lanes[overlay.lane].externalPostgres = overlay.externalPostgres;\nif (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;\nif (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;\nif (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;\nfs.writeFileSync(file, YAML.stringify(doc));\nconsole.error(JSON.stringify({ event: 'unidesk-deploy-yaml-overlay', ok: true, lane: overlay.lane, httpProxy: overlay.dockerProxyHttp, noProxyCount: overlay.dockerNoProxyList.length }));\nNODE_UNIDESK_DEPLOY_YAML_OVERLAY\nnode scripts/run-bun.mjs scripts/gitops-render.mjs --lane \"$lane\" --catalog-path \"$catalog_path\" --image-tag-mode \"$(params.image-tag-mode)\" --source-revision \"$(params.revision)\" --source-repo \"$(params.git-url)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --gitops-root \"deploy/gitops/node/nc01\" --out \"deploy/gitops/node/nc01\" --registry-prefix \"$(params.registry-prefix)\" --use-deploy-images\nUNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-postprocess.mjs\nfeature_config_schema_started_ms=\"$(date +%s%3N 2>/dev/null || date +%s000)\"\nexport UNIDESK_FEATURE_CONFIG_REPO_DIR=\"$PWD\"\nexport UNIDESK_FEATURE_CONFIG_RENDERED_ROOT=\"deploy/gitops/node/nc01/runtime-production\"\nexport UNIDESK_PAC_CONSUMER='hwlab-nc01-production'\nexport OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces'\nexport OTEL_SERVICE_NAME='unidesk-cicd'\nexport OTEL_TRACES_SAMPLER_ARG='1'\nexport OTEL_EXPORTER_TIMEOUT_MS='300'\nfeature_config_validator_base=''\nfeature_config_validator=''\nif feature_config_validator_base=\"$(mktemp \"${TMPDIR:-/tmp}/unidesk-feature-config-validator.XXXXXX\" 2>/dev/null)\"; then\n feature_config_validator=\"${feature_config_validator_base}.mjs\"\n if ! printf '%s' 'H4sIAAAAAAACA80ba3PbyO17fgXDZjpkT6bttNNp5fo0iq3c+c6vkeS0HdeVaWplM6ZIlQ/bGp/+ewHsm6Rk5a4fmpkk2hcWr8ViAfB373erIt+9i9Ndlj45aTZl7+L5IstL59XJw3SazT8tS1Y4K2eWZ3PHxRndKF8uysw90FPZS1yUxWiZRh0nKcqw5D9zFk4/xwnTrWmcY8OGNys0rEVYPphj2DZ3igBKyYbsP1WcMxvMPJtWCTMn4+JxhhhcDU/tyVWewMx3UZYWJWDGwR3a4D0OKJizMgxggX8g5kfP0+GaJbhl8DWLU2+RZxErigAme37HcRdh9Bjes+BrkaWur2D9s392ClDuk+wuTMYPcRFUaTxlxeM/w3ni9HpOtijjLA0TuYO7hAHXXz/UMfBT23zpn54c98cXw8nVef9L/+S0/+l0APu6nwf98dVwMDm6OP988sOkdR5yir0QUzm02qLR0Y+Ds/7ksj/+EUHCnFl8vzsDvlQ52+HNoIge2Dzk5Gt4syqNkAjnKUziKTDyM191RItGtMbjdBYA+3XlO6/vHEfKbZEdxzn0E9tzVmTJE5PTAzkMnLKEAUyREDhSl6h0hxuo0gvCO9ijKtnIXKiFLrbsGIBpt3jmeO/1KfGaYHwfyAHKU/ivqJLSc23+7XCIO/O4KOL0HqTs8h5nBvrtxIWjR/Y7TlolqK+wdcIkmYhImS+JgY7oA+x/Gl2cB4swL5hnHtgWHGHPqpz9xeUcXDlRWEYPjsfyPMt9AXYrIuKUxA2o3go0EAmHkHBmIWAw7TofXgnwGcgNzo3YZXVrkgdIvFOiEVsNcB7qimgfsygJ8xBVgg95fEslF2tdIFALEpbeg3C/d/b8b6WpFeD13s2aEbGVQVQTq2m1SGJgNyu+AbGnMI/Du4TtqNUN5DTcFvwam9bVSp5ZW7G4NPpfnz7ufdwDOYT8l0ekqXP09QmGUvYsJ3qvTpgkfOcuQKsYHKIyj6NStpSFyPJ5WMKkWZiAuqwEXIAYwAXzM1s+Z/kUwD3yX13HfdkRNnVHEOjKAzpeLhhMwI3o5NAefEMFWO7LSQmibL4ABTW1aJuTQF2OQzN6AGQK8A4P19jlntOUJSGR5UBK+AQnBAXrOl3nLW0097WPkhzalz9QuPy3Ploo5ggcgTY5y25gjDS5ug+Mbs7SKcvZ9O9Z/phk4fRIDnqvYkc5Y5hlIGZtt3WvxA0M+ILl5fI8nDMQ/cXdVxaVAYhYHudAzIhZIShb/WorpZmNtojrSxsDa5aIH1yDLSBfHHZ++cV5HxdDFqFiqvEAtqkAWxw26WlMkEce4e1tR0GcLqrSvCskT51nIQxDfEUaLoqHrGxcImpKLyiq+TwE0YNYbdtLN5s8Ik3cfcsoMGmb+3keLoO4oP89uTzg4+DbOLUuUPTrm4PtRFcxJHyOUgcy7rIKKJ+ClSHbwkWH1s4XMlUWFcRg0ixJVsLd6haQdwCdJWevHd6q6f6weVy2uD5fOBtgxlon6ElNgaEtvCiiR/pDRQlakQfPeQzCu/3wSr4At4bxTAkGlvmrf6W3tFSwQQ+Z9IQFeveKqrxKv5Uo414h2KbFsUjdmmMbzIAF8H9pDH4Th1ct1ykc3wU4mKVha7OSJQPZbfi48JILLsaD08ngH5cXw/FgOLkYn15OxsP+0WA0GZwfX16cnI9xheuaV3IZz1lWlWcojUVWxGX8xE7Skt2z3DP3HKt5Gzcdn5wNLq7Gk7ORdfEX4Ry8ivR+iETDTlb7C55ea7ORNb1tQ0HXqH92eQrb9oc/iP3QMkm2tZpQxVvS3b+HeQobIYNhX9HyXMmvydnJaHRy/oM62+R9yIbindh7a/lbe2sVMAzdq6GlHcLtMlyiBe+KvQ10uzVaVhzWSvHDkLG4nL6JGUqmm3hhHIL/RzZwMg0A+qnA7ciFHvLMPSWfCpY/xRFDT6RrHcaRHmjV1NFg+OXkaDA5758N6PhJrzSKI+2pIX7VnOUatuypA706PzkejH6GF+oRvllHV2eDobygJTTreMnOMg8jBo8tlhpel9FZ34iO2GV/OOBmwxyqrXIVGWn2fFZo6NS09j+Z1vY+mSqkF2FqDvO26dWt09bwOYxl7+e6UJU1sPTHOLwdteOMwWVxAqxDoqhhGhVr0/fqIP1WXX9L09/W8fZbbhsrR1Z7AnZuMDzvn04+w0sEQiH1A05gO//j2+HbLsxfzb1t7cSqzT3bykCs89DE8Tjk8Y2xPjFey9kzglPiVBhXvuxB1tJss8eI13r7f/aDMhsR1zz3gb24ZtCLjpMBVnTUYPxlAwg6zgYE3gYAx+jpQssz9yvDvES9uErjl/MwxWtfwP0U34MSebTcd/7g7E/29vbwb2r6mvLFU2RVHrERYAuW5Vo/Inl/F2QflgD3DoJWaHpATH3VVm9OB5773FAHKVhq1zAztv1uN8+O6oeYWaRss9turk1TbKxE1AJpmicUnDLxqPs8a6DwJ9YEXnVhVLKpKwIlYiKo4EoZ1AjexnW+iW5kW0p3mQKMBAe2G4yxEZYXgB/Me9p3NWxurm3AysR3jC7TivM/QRB4Qo+lTsoncw/OkNOlGD6Oj8SNYM9e+SY0QYSJ/MSKPbvm7Mc4BXj7FoJ1PTUH4e4wh7qbZ2+lhyTMGqbP3BSBLLVlkZ2djSvvkix65Eu5td44GwNQ9ibYs3kN5+KEUjLWUh3s3gyArg9oVGlpA6CBI+w3AdjiZU8g+bqaOXR5bykUpSIbnu6Ej2uv2lqYv0Wcv05EzVWzOC/KCTHUXkwDFAOxQdhcXt3UTkRZFXgMcWs4Luax11PlL/7/igICtUjA2y7ZosUdU26YeZ3Cv2WeJQnLZQT5DkAfqV7z6hGw8J3JSuGaeJ7vHH5vgAlCBIBJOvsZV3+Dw0WzgB9M+ZkKPYMMqRGQNHzIMPh8eTEaK4V6gAQLy4mjmCYrQaV3SohBg6jccMHD7cCuXcqSaWbfZdNl16l5QoJhSnxFfA+5wK5JGO+yHWf0YCUlQfaokghodi1PynQQb38cjy8nH17VQq4ZmI2pdW18GG8Mxpr70bgTA9fDNGLZzCHFdX7/e+6C0s1Nl4VLwqdRF8Pmwq+kyLhwatGXHRwrV/ZXgVaub52eWQwMTpSeJCzMpZqJqb5yKuve5NrMFEGjwKqKGssRCFKKeDeqDT0+3IyCx64dZm5GxWvsfnVEjgBMqkwl5hBxd+YV6Podc0IQCUGWqcLnGCIoGqALCSOdIsIArXSg5YERG8B54cFbGbDkaani01LYA3GOz8IFP7szkIfHJ1+bgf+OSgPwWN+NA+IToXM4STkgtYFum6E2JF+HQWRWblEVD96tnAbZSBOTleRJg1k6cIHnME4r1gw+zBTZNhbXLZmqG31qUeJAsFxNsheZK5S96IcnQTz3/DURry2pI7KmpJ/MaSC1DY1KyhSVrks8uGelZyNMFQ0yyK9WcERN5PzaDA2zaMDs6Gm+rZpacVE7wRFtgSdVyr+hHeEGTUq4XrxrA+wN3SUaXZWd3ff5ojkotXct0GosvJXC/PAqfgHvIRvjlBnIICnjBeT2tS5jUlzvRRUHbseFpLj1WpJCNo+nuJV1ZH59bs7KynXszJuz0sZJqKM5u6GT5qClkWSsjIIIcyJZOV4Xo2IrtUuKC/E5hBt0KqzHCKRv4eqbJgdfSvQwPxA5BqyaII8Tevdkp0yONQYom8SmFJ+2Bg1TRXUYwJAEiILnbRWhOKmcorDJk6dRo/DdobPP1dqAN80ieEbCKMCkyMGx6Cg8XGmYLGMRBE3muOCxumN5ykDyJ9BTeBKYsUqFJ7LpaMEioAgXQ6oPGr0Afi4SnvqD9oFaU7OifC1AtS0B/rGZqUmsYXyfZ9UCUb4mjyiMU3CQMGUZpzF5daLnxsTcgqCWIRQ7syjwu6ZNbjCxaPdQXtEGbIHGqrgGULVfDwNbCFT1YEcrTME4rrHBQ1iAy/jUI2eD1F0cJuokbbWOUht7+R+u2dcYYENYNzLKNBQKxzMqOEwz/fr6pmLbgtJmvf5b/lop07+qR2w4ctz9FNnPrg6ji6CNSk3vSIXZAXSVx2yXCIg6L1BMCD/WS+ysIrDaiQMWu4ECqk6e7LB0VXY2eFMvRqDOrm0hZanMO1M+lyDFEmuajJf5Sr+VlFVuNR1kEfXzZx6+0ABI+iNGyg7MEag9Ax8kw2urOT4Ty0xnbAFs4vHga5t9uOuNNH9TAXZp2b7nB7R4ngBhFSaZz6aoymX8U8xcZAtZDVSDrJUPj0tt8Psahb5TPuTZM1l/nty/bZY5KBjA3DnlBiLGpry+zAa3km6Ncf7pMUCXnKpd9QQ9fj1zRvbfLAYUEzsExXSZeEwUOaKKZblJl3OQeBwBkzNazu8yuMRP4/TRa7GzxlRJyxLnKV6j/2QBZ1g1hct2/xV4ve4y7M2TX/DB6X/YjSsw/AUEZBFj04aR7rQAE8VqseX7SBXdSkDEtlbZEIyVdjZbLQ3fuqAHvH2Y2u5M4xyV7KWkrL9R8ohzVJGjrMQDNiF3NHOs25eCBBKZa6OWEuHDCVJvXj0JO800vxxAl4cv7ieJRpwAcT9S3eLoO8oGROl/GoHIlX/aOkvnqdaiZDJvrf9AZItlsrMXYDSVP5pPwYS5+Jq270yFLPoYVOFj9+ClKXtuWuRo32f8LiNULA4a7BfXXZNWGqiT21ol1PYO5wPKH3V1FdU0A+OaZsJXFO9D98B4DPOIg6gZlhc+xRzsMdOH7rXN6IrKDQ5Y1DnWYaruNnByEIP5OuxpXCW8tqUOU3W3wZSDGGClEl7XeJRAIs8iAe/jXXcFB12ggj8FhNVt/a1ChTkU/XSMMKZjhI11nZV5xp9VtlNPxQtKEyoEz2NAilq9SZ1UPYLl5QkkOcA+FN/t3ldgNxzXl6/vAkw286D+6+Of4DrsqoeL5R9RSHv7eLQVN5bhfsun0AFiHZLvbqhr53Pr0XdNY9fiT8cu++TNeVUSjhYe670eDPIZPiDkcQaCCVvkbcxAvJHOWxtzbwTa14TY7bj65rTHW8x8O+/RluzYNpmzWu871j8GSSlghlcsBcrFNy4tRpNP4Vd+i70kza2ZS1XSrSxk8/sVUdetTOXaGQd2PKGtwqX/0xecOfl0dX6Mn62YZxKumreXtDinAq070O+ELL/3NpiW8jxVFSo+wQHW7EzhDtylfwSjAr4L+BGuFQcXLgQ5SarO8EoXeHsu4OVQHb0ahrAc+nYsjZZYsGuWg7eEmg3ytANU/xpFODTfiAcHbRYNt2ZN+GdZGHsRSSA8vCtZk/VZIOq54vMtMKNimtvZ4Jz5Hp/fEfADsailOogiRPasYMpmIdwpGHK0RxrRVgWD3uOSr67p2uJHBNxtcAVPRPlIzB2CkKODzzpIJdjlKhL8+uwIVRxt89WARIkmH7ylWbcbJQq+Tl4tSscTH+E08yU9M12CyRbKk6z82xYlbEVAXPbmdc3ELazfC3ISwuT7cR60c8C4YAUTTCyUD2d4j2K2kDUPvByaORWwLjocQ5UJ0PO+Ub5OkRVzs5YKYXPDN5iqXSlRJ8Mh1PziWhWWQZaqOypI+c+r+Z2aYDCJ9wMdEgRf4ZNN5YvBYDo92ZBujIlFS/3ub0DkMwb9WBseGDPWzb9BnGAzXo1aqwZW3E8/dHb/vbe3413v7fw13JndvP7x48o3mvt/hqZqfVzRM5C9sMgTouFw7QC8KnCkPXrB9f4NRFSz0+yZ5UchvE58Vdajih3l1I/rpjYufLsUQX5NYmhZLTUmPyex8hjQJXMYdTXXPegDQqYTRCLyGeC0W4tFIQR2awew23Ku7rIMcqSpK/wZKPNxsOsLn88nyshi2/qUNMVVjlOP0h2lWG+JxIhVYr6d+wvtE8md8v0mf2U+OgqrAmKA+AB5KMvFSKS6McU7vajKlvSwKQfb2ydHlyrAuPG3vXuziKvdx1eovJM1VBqllgIqY1CWeii0tfvPP4TbztbrcjRdqmG4/UNRj6Yp4YqLF1nti2pC1/pKWzlhYX7/BMeGFxX7wUPOZvanvzq8ewI/X3gyVS2FBzN0Xsw8d2dHBZgxqMnvX16/8ebnKa+18HPLrkDADpoiYnrXwuG6Of07B0haWZ/ggB9WHvFLDVzh1X8BgBIs15M/AAA=' | base64 -d | gzip -d >\"$feature_config_validator\"; then\n rm -f \"$feature_config_validator_base\" \"$feature_config_validator\" || true\n feature_config_validator=''\n fi\n rm -f \"$feature_config_validator_base\" || true\nfi\nexport UNIDESK_AJV2020_BUNDLE='/etc/unidesk-cicd-runtime-gitops/ajv2020.min.js'\nexport UNIDESK_FEATURE_CONFIG_VALIDATOR=\"${feature_config_validator:-${TMPDIR:-/tmp}/unidesk-feature-config-validator-unavailable.mjs}\"\nif ! node --input-type=module <<'NODE_UNIDESK_FEATURE_CONFIG_SCHEMA'\nimport { pathToFileURL } from 'node:url';\nconst validator = await import(pathToFileURL(process.env.UNIDESK_FEATURE_CONFIG_VALIDATOR).href);\nawait validator.runFeatureConfigSchemaValidation({ repoDir: process.env.UNIDESK_FEATURE_CONFIG_REPO_DIR, renderedRoot: process.env.UNIDESK_FEATURE_CONFIG_RENDERED_ROOT, consumer: process.env.UNIDESK_PAC_CONSUMER });\nNODE_UNIDESK_FEATURE_CONFIG_SCHEMA\nthen\n printf '{\"event\":\"feature-config-schema-validation\",\"warning\":true,\"blocking\":false,\"code\":\"feature-config-validator-process-failure\",\"mutation\":false,\"valuesPrinted\":false}\\n' >&2\nfi\nif [ -n \"$feature_config_validator\" ]; then rm -f \"$feature_config_validator\" || true; fi\nif command -v ci_timing_emit >/dev/null 2>&1; then\n ci_timing_emit feature-config-schema-validation succeeded \"$feature_config_schema_started_ms\"\nelse\n printf '{\"event\":\"ci.stage.timing\",\"stage\":\"feature-config-schema-validation\",\"status\":\"succeeded\",\"blocking\":false}\\n' >&2\nfi\n\nUNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-verify.mjs\nci_timing_emit gitops-render succeeded \"$gitops_render_started_ms\"\ncheck_source_head before-push\nworkdir=\"$(mktemp -d)\"\ngitops_read_url=\"$(params.git-url)\"\ngitops_write_url=\"$(params.git-write-url)\"\nif [ \"$runtime_lane\" = \"true\" ]; then\n gitops_read_url=\"$(params.git-read-url)\"\nfi\ngitops_clone_started_ms=\"$(ci_now_ms)\"\ngit_timed gitops-clone 180 git clone --no-checkout \"$gitops_read_url\" \"$workdir/gitops\"\ncd \"$workdir/gitops\"\ngit remote set-url origin \"$gitops_write_url\"\nold_runtime_snapshot=\"$workdir/old-runtime-snapshot\"\nif git_timed gitops-branch-ls 45 git ls-remote --exit-code --heads origin \"$(params.gitops-branch)\" >/dev/null; then\n git_timed gitops-fetch 90 git fetch origin \"$(params.gitops-branch)\"\n git checkout -B \"$(params.gitops-branch)\" \"origin/$(params.gitops-branch)\"\n if [ \"$runtime_lane\" = \"true\" ] && [ -d \"$runtime_path\" ]; then\n mkdir -p \"$old_runtime_snapshot\"\n cp -a \"$runtime_path\"/. \"$old_runtime_snapshot\"/\n fi\n if [ \"$runtime_lane\" = \"true\" ]; then rm -rf \"$runtime_path\" \"$catalog_path\" \"$unidesk_legacy_runtime_path\"; else rm -rf deploy/gitops/node \"$catalog_path\"; fi\nelse\n git checkout --orphan \"$(params.gitops-branch)\"\n git rm -rf . >/dev/null 2>&1 || true\nfi\nci_timing_emit gitops-clone succeeded \"$gitops_clone_started_ms\"\nmkdir -p \"$(dirname \"$runtime_path\")\" \"$(dirname \"$catalog_path\")\"\nif [ \"$runtime_lane\" = \"true\" ]; then\n cp -a \"/workspace/source/repo/$runtime_path\" \"$runtime_path\"\n cp \"/workspace/source/repo/$catalog_path\" \"$catalog_path\"\n git add \"$catalog_path\" \"$runtime_path\"\n if [ -n \"${unidesk_legacy_runtime_path:-}\" ]; then git add -A \"$unidesk_legacy_runtime_path\" || true; fi\nelse\n mkdir -p deploy/gitops\n cp -a /workspace/source/repo/deploy/gitops/node deploy/gitops/node\n cp \"/workspace/source/repo/$catalog_path\" \"$catalog_path\"\n git add \"$catalog_path\" deploy/gitops/node\nfi\nif [ \"$runtime_lane\" = \"true\" ] && [ -s /workspace/source/affected-services.json ]; then\n runtime_noop_decision=\"$(node - \"$runtime_path\" \"$old_runtime_snapshot\" /workspace/source/affected-services.json <<'NODE'\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst [runtimePath, oldRuntimePath, planPath] = process.argv.slice(2);\n\nfunction readJson(filePath) {\n return JSON.parse(fs.readFileSync(filePath, \"utf8\"));\n}\n\nfunction stable(value) {\n if (Array.isArray(value)) return \"[\" + value.map(stable).join(\",\") + \"]\";\n if (value && typeof value === \"object\") {\n return \"{\" + Object.keys(value).sort().map((key) => JSON.stringify(key) + \":\" + stable(value[key])).join(\",\") + \"}\";\n }\n return JSON.stringify(value);\n}\n\nfunction scrub(value) {\n if (Array.isArray(value)) return value.map(scrub);\n if (!value || typeof value !== \"object\") return value;\n const result = {};\n for (const [key, child] of Object.entries(value)) {\n if (key === \"hwlab.pikastech.local/source-commit\" ||\n key === \"hwlab.pikastech.local/artifact-source-commit\" ||\n key === \"hwlab.pikastech.local/boot-commit\" ||\n key === \"sourceCommitId\" ||\n key === \"bootCommit\") {\n result[key] = \"\";\n continue;\n }\n const envName = String(value.name || \"\");\n if (key === \"value\" && /^HWLAB_.*COMMIT/.test(envName)) {\n result[key] = \"\";\n continue;\n }\n result[key] = scrub(child);\n }\n return result;\n}\n\nfunction listFiles(rootDir) {\n if (!rootDir || !fs.existsSync(rootDir)) return null;\n const files = [];\n function walk(current) {\n for (const entry of fs.readdirSync(current, { withFileTypes: true })) {\n const fullPath = path.join(current, entry.name);\n if (entry.isDirectory()) walk(fullPath);\n else if (entry.isFile()) files.push(path.relative(rootDir, fullPath).replaceAll(path.sep, \"/\"));\n }\n }\n walk(rootDir);\n return files.sort();\n}\n\nfunction normalizedTree(rootDir) {\n const files = listFiles(rootDir);\n if (!files) return null;\n const entries = {};\n for (const relativePath of files) {\n const filePath = path.join(rootDir, relativePath);\n const text = fs.readFileSync(filePath, \"utf8\");\n try {\n entries[relativePath] = stable(scrub(JSON.parse(text)));\n } catch {\n entries[relativePath] = text.replace(/[a-f0-9]{40}/gu, \"\");\n }\n }\n return stable(entries);\n}\n\nconst plan = readJson(planPath);\nconst buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : [];\nconst rolloutServices = Array.isArray(plan.rolloutServices) ? plan.rolloutServices : [];\nif (buildServices.length > 0 || rolloutServices.length > 0) {\n process.stdout.write(\"runtime-required\");\n process.exit(0);\n}\n\nconst oldTree = normalizedTree(oldRuntimePath);\nconst newTree = normalizedTree(runtimePath);\nprocess.stdout.write(oldTree && newTree && oldTree === newTree ? \"runtime-identity-only\" : \"runtime-required\");\nNODE\n)\"\n if [ \"$runtime_noop_decision\" = \"runtime-identity-only\" ]; then\n printf 'false' > /tekton/results/runtime-ready-required\n echo '{\"status\":\"skipped-runtime-unchanged\",\"reason\":\"runtime-identity-only\",\"gitopsBranch\":\"'\"$(params.gitops-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\"}'\n ci_timing_emit gitops-commit skipped \"$(ci_now_ms)\"\n ci_timing_emit gitops-push skipped \"$(ci_now_ms)\"\n exit 0\n fi\nfi\nif git diff --cached --quiet; then\n printf 'false' > /tekton/results/runtime-ready-required\n echo '{\"status\":\"unchanged\",\"gitopsBranch\":\"'\"$(params.gitops-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\"}'\n ci_timing_emit gitops-commit unchanged \"$(ci_now_ms)\"\n ci_timing_emit gitops-push unchanged \"$(ci_now_ms)\"\n exit 0\nfi\nshort=\"$(printf '%.7s' \"$(params.revision)\")\"\ngitops_commit_started_ms=\"$(ci_now_ms)\"\ngit commit -m \"chore: promote node GitOps source $short\"\nci_timing_emit gitops-commit succeeded \"$gitops_commit_started_ms\"\ngitops_push_started_ms=\"$(ci_now_ms)\"\ngit_timed gitops-push 120 git push origin \"HEAD:$(params.gitops-branch)\"\nci_timing_emit gitops-push succeeded \"$gitops_push_started_ms\"\nif [ \"$runtime_lane\" = \"true\" ]; then\n printf 'false' > /tekton/results/runtime-ready-required\n echo '{\"event\":\"runtime-ready\",\"phase\":\"gitops-promote\",\"status\":\"delegated-post-flush-closeout\",\"gitopsBranch\":\"'\"$(params.gitops-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\"}'\nfi\nargo_hard_refresh_runtime_lane\necho '{\"status\":\"pushed\",\"gitopsBranch\":\"'\"$(params.gitops-branch)\"'\",\"sourceRevision\":\"'\"$(params.revision)\"'\",\"gitopsWriteUrl\":\"'\"$gitops_write_url\"'\"}'\n" + imagePullPolicy: Always + volumeMounts: + - name: unidesk-runtime-gitops-scripts + mountPath: /etc/unidesk-cicd-runtime-gitops + readOnly: true + volumes: + - name: unidesk-runtime-gitops-scripts + configMap: + name: hwlab-nc01-production-ci-image-publish-runtime-gitops-scripts + defaultMode: 493 + params: + - name: git-url + value: $(params.git-url) + - name: git-read-url + value: $(params.git-read-url) + - name: git-write-url + value: $(params.git-write-url) + - name: source-branch + value: $(params.source-branch) + - name: gitops-branch + value: $(params.gitops-branch) + - name: lane + value: $(params.lane) + - name: catalog-path + value: $(params.catalog-path) + - name: image-tag-mode + value: $(params.image-tag-mode) + - name: runtime-path + value: $(params.runtime-path) + - name: revision + value: $(params.revision) + - name: registry-prefix + value: $(params.registry-prefix) diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 16be7c94..eae21d95 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -85,6 +85,9 @@ nodes: gitopsRoot: deploy/gitops/node sourceRepo: git@github.com:pikasTech/HWLAB.git publicHost: 74.48.78.17 + NC01: + gitopsRoot: deploy/gitops/node/nc01 + sourceRepo: git@github.com:pikasTech/HWLAB.git lanes: v02: name: v0.2 @@ -501,7 +504,7 @@ lanes: livenessFailure: false readMode: db-or-degraded fallbackStateSource: false - envReuseServices: + envReuseServices: &v03EnvReuseServices - hwlab-cloud-api - hwlab-workbench-runtime - hwlab-user-billing @@ -516,7 +519,7 @@ lanes: - hwlab-gateway - hwlab-edge-proxy - hwlab-agent-skills - bootScripts: + bootScripts: &v03BootScripts hwlab-cloud-api: deploy/runtime/boot/hwlab-cloud-api.sh hwlab-workbench-runtime: deploy/runtime/boot/hwlab-workbench-runtime.sh hwlab-user-billing: deploy/runtime/boot/hwlab-user-billing.sh @@ -531,7 +534,7 @@ lanes: hwlab-gateway: deploy/runtime/boot/hwlab-gateway.sh hwlab-edge-proxy: deploy/runtime/boot/hwlab-edge-proxy.sh hwlab-agent-skills: deploy/runtime/boot/hwlab-agent-skills.sh - serviceDeclarations: + serviceDeclarations: &v03ServiceDeclarations hwlab-cloud-api: runtimeKind: bun-command entrypoint: cmd/hwlab-cloud-api/main.ts @@ -816,7 +819,7 @@ lanes: env: TZ: Asia/Shanghai observable: true - envRecipe: + envRecipe: &v03EnvRecipe osPackages: - ca-certificates - git @@ -854,7 +857,7 @@ lanes: script: tools/hwpod-ctl.ts - command: hwpod-compiler script: tools/hwpod-compiler-cli.ts - bootConfig: + bootConfig: &v03BootConfig template: default overrides: {} configMaps: @@ -1158,6 +1161,143 @@ lanes: WORKBENCH_ACTIVITY_RETRY_MAXIMUM_INTERVAL_MS: "30000" WORKBENCH_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS: "5" OTEL_SERVICE_NAME: hwlab-workbench-worker + production: + name: Production + node: NC01 + sourceBranch: release + gitopsBranch: release-gitops + gitReadUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git + gitWriteUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git + artifactCatalog: deploy/artifact-catalog.nc01-production.json + runtimePath: runtime-production + namespace: hwlab-production + imageTagMode: full + endpoint: https://lab.hwpod.com + publicEndpoints: + frontend: https://lab.hwpod.com + api: https://lab.hwpod.com + publicServices: + nodes: + NC01: + - name: hwlab-cloud-web-public + selector: + app.kubernetes.io/name: hwlab-cloud-web + port: 80 + targetPort: 8080 + nodePort: 32010 + frp: + enabled: false + externalPostgres: + enabled: true + serviceName: nc01-host-postgres + endpointAddress: 10.42.0.1 + port: 5432 + migrationSecretName: hwlab-cloud-api-production-db + migrationSecretKey: migration-database-url + workbench: + rawHwlabEventWindow: + enabled: true + maxEntries: 200 + maxRetainedBytes: 1048576 + envReuseServices: *v03EnvReuseServices + bootScripts: *v03BootScripts + serviceDeclarations: *v03ServiceDeclarations + services: + - serviceId: hwlab-cloud-api + shutdownDrain: + enabled: true + path: /v1/internal/workbench/drain + sleepSeconds: 3 + timeoutMs: 2500 + env: + HWLAB_METRICS_NAMESPACE: hwlab-production + HWLAB_CLOUD_DB_URL: secretRef:hwlab-cloud-api-production-db/database-url + HWLAB_CLOUD_DB_SSL_MODE: require + HWLAB_CLOUD_DB_CONTRACT: production-redacted-presence-only + HWLAB_ACCESS_CONTROL_REQUIRED: "1" + HWLAB_SESSION_COOKIE_SAMESITE: None + HWLAB_BOOTSTRAP_ADMIN_ID: usr_production_admin + HWLAB_BOOTSTRAP_ADMIN_USERNAME: admin + HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME: HWLAB Production Admin + HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH: secretRef:hwlab-production-bootstrap-admin/password-hash + HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID: key_master_server_admin + HWLAB_BOOTSTRAP_ADMIN_API_KEY: secretRef:hwlab-production-master-server-admin-api-key/api-key + HWLAB_USER_BILLING_URL: http://hwlab-user-billing.hwlab-production.svc.cluster.local:6670 + HWLAB_WORKBENCH_RUNTIME_URL: http://hwlab-workbench-runtime.hwlab-production.svc.cluster.local:6671 + HWLAB_PROJECT_MANAGEMENT_URL: http://hwlab-project-management.hwlab-production.svc.cluster.local:6672 + HWLAB_TASKTREE_URL: http://hwlab-tasktree-api.hwlab-production.svc.cluster.local:6673 + HWLAB_USER_BILLING_LOGIN_RETRY_ATTEMPTS: "2" + HWLAB_USER_BILLING_LOGIN_RETRY_DELAY_MS: "250" + HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1" + HWLAB_USER_BILLING_CODE_AGENT_ESTIMATED_CREDITS: "1" + HWLAB_USER_BILLING_CODE_AGENT_USED_CREDITS: "1" + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces + OTEL_SERVICE_NAME: hwlab-cloud-api + - serviceId: hwlab-cloud-web + env: + HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL: http://opencode-server.hwlab-production.svc.cluster.local:4096 + - serviceId: hwlab-workbench-runtime + env: + HWLAB_WORKBENCH_RUNTIME_DB_URL: secretRef:hwlab-cloud-api-production-db/database-url + HWLAB_WORKBENCH_RUNTIME_PORT: "6671" + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces + OTEL_SERVICE_NAME: hwlab-workbench-runtime + - serviceId: hwlab-user-billing + env: + HWLAB_USER_BILLING_DB_URL: secretRef:hwlab-cloud-api-production-db/database-url + HWLAB_USER_BILLING_DB_POOL_MAX: "2" + HWLAB_USER_BILLING_MIGRATION_TIMEOUT_SECONDS: "180" + HWLAB_USER_BILLING_BOOTSTRAP_TIMEOUT_SECONDS: "60" + HWLAB_BOOTSTRAP_ADMIN_ID: usr_production_admin + HWLAB_BOOTSTRAP_ADMIN_USERNAME: admin + HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME: HWLAB Production Admin + HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH: secretRef:hwlab-production-bootstrap-admin/password-hash + HWLAB_USER_BILLING_STATE_AUTHORITY: nc01-postgres + HWLAB_USER_BILLING_EXTERNAL_DB_LABEL: nc01-external-postgres + HWLAB_USER_BILLING_PORT: "6670" + HWLAB_USER_BILLING_REGISTRATION_ENABLED: "1" + HWLAB_USER_BILLING_INITIAL_CREDITS: "0" + HWLAB_USER_BILLING_CREDIT_PER_1K_TOKENS: "1" + HWLAB_USER_BILLING_MIGRATE_ON_START: "1" + HWLAB_USER_BILLING_REDIS_URL: redis://hwlab-user-billing-redis.hwlab-production.svc.cluster.local:6379/0 + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces + OTEL_SERVICE_NAME: hwlab-user-billing + - serviceId: hwlab-project-management + replicas: 1 + env: + HWLAB_PROJECT_MANAGEMENT_DB_URL: secretRef:hwlab-cloud-api-production-db/database-url + HWLAB_PROJECT_MANAGEMENT_DB_CONNECT_TIMEOUT_MS: "5000" + HWLAB_PROJECT_MANAGEMENT_PORT: "6672" + HWLAB_PROJECT_MANAGEMENT_SOURCE_ROOT: /workspace/hwlab-boot/repo + HWLAB_PROJECT_MANAGEMENT_SOURCE_ID: hwlab-production-mdtodo + HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB Production MDTODO + HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_production + HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "200" + HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "500" + HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: http://hwlab-cloud-api.hwlab-production.svc.cluster.local:6667/v1/hwpod-node-ops + HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: secretRef:hwlab-production-master-server-admin-api-key/api-key + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces + OTEL_SERVICE_NAME: hwlab-project-management + - serviceId: hwlab-tasktree-api + replicas: 1 + env: + TASKTREE_DATABASE_URL: secretRef:hwlab-tasktree-db/database-url + TASKTREE_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233 + TASKTREE_TEMPORAL_NAMESPACE: unidesk + TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-production-tasktree + TASKTREE_API_PORT: "6673" + OTEL_SERVICE_NAME: hwlab-tasktree-api + - serviceId: hwlab-tasktree-worker + replicas: 1 + env: + TASKTREE_DATABASE_URL: secretRef:hwlab-tasktree-db/database-url + TASKTREE_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233 + TASKTREE_TEMPORAL_NAMESPACE: unidesk + TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-production-tasktree + TASKTREE_WORKER_HEALTH_PORT: "6674" + OTEL_SERVICE_NAME: hwlab-tasktree-worker + envRecipe: *v03EnvRecipe + bootConfig: *v03BootConfig services: - serviceId: hwlab-harnessrl-api namespace: hwlab-dev diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.8.2_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.8.2_Task_Report.md new file mode 100644 index 00000000..6a62a891 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.8.2_Task_Report.md @@ -0,0 +1,63 @@ +# R9.8.2 命名 runtime lane renderer 任务报告 + +## 任务定位 + +- MDTODO:`R9.8.2` +- Issue:`pikasTech/HWLAB#2548` +- Target:`NC01` +- targetWorkspace:`/root/hwlab-v03/.worktree/2548-production-runtime-lane` +- repository:`pikasTech/HWLAB` +- ref:`fix/2548-production-runtime-lane` +- base:`release` +- verifiedCommit:`0d9747fd5b458459349d4efa9735d3f4e459f0f8` +- PR:`pikasTech/HWLAB#2551` + +## Primary workspace 轻量准备 + +- 只读取 AgentRun resource bundle 与以下 skill: + - `dad-dev`、`unidesk-daddev`、`unidesk-trans`。 + - `git-spec`、`docs-spec`、`cli-spec`。 + - `unidesk-gh`、`unidesk-cicd`。 +- GitHub Issue 读取和进展写入使用 UniDesk `gh` 受控 CLI。 +- 未把 runner primary workspace 当作 HWLAB 源码或 Target 验证面。 + +## Target 源码修改 + +- 将稳定 runtime lane id 识别与 `vNN` 版本默认推导拆分。 +- `vNN` 继续沿用已有 branch、catalog、namespace、runtime path、endpoint 与端口默认。 +- 命名 lane 从 `deploy/deploy.yaml` 读取: + - node、source/GitOps branch 与 Git read/write URL。 + - artifact catalog、runtime path、namespace 与 image tag mode。 + - public endpoints、service/NodePort 与 FRP 端点。 +- 命名 lane 缺少必填 YAML 字段时 fail-closed,不从 lane id 推导 branch、namespace、路径、端口或 endpoint。 +- 保持 `scripts/gitops-render.mjs` 单 renderer,未增加数据库 projector、migration、HTTP/SSE fallback、锁、租约或第二状态库。 +- 未生成或提交 `.tekton/hwlab-nc01-production-pac.yaml`。 + +## Target 原入口证据 + +- `node scripts/run-bun.mjs scripts/src/runtime-lane.test.mjs` + - 结果:通过。 + - 覆盖:命名 lane 参数解析、YAML 覆盖、namespace/runtime path、public NodePort、FRP 本地与远端端点、缺字段失败、`v03` 默认。 +- 使用 Target 临时目录分别运行 `release@0d9747fd5b458459349d4efa9735d3f4e459f0f8` 原 renderer 与当前 renderer。 + - `v02`:生成目录逐文件一致。 + - `v03`:生成目录逐文件一致。 +- `git diff --check` + - 结果:通过。 +- 模块导入 smoke + - 结果:`runtime-lane.ts`、gitops-render core、runtime manifests 与 Tekton manifests 全部导入成功。 + +## 执行边界 + +- 未合并 PR。 +- 受控 PR preflight:`MERGEABLE/CLEAN`,无 blocker 或 pending check。 +- 未触发 PipelineRun、git mirror、Argo、runtime patch 或 rollout。 +- 未操作 PK01。 +- 未修改 `/root/hwlab-v03/.worktree/2548-production-pac-source`。 + +## 结论 + +`R9.8.2` 已完成以下收口: + +- runtime lane renderer 支持 YAML 显式声明的稳定 lane id。 +- `v02`、`v03` 既有生成结果保持不变。 +- 命名 lane 只有在 owning YAML 完整声明时才能进入现有 GitOps renderer。 diff --git a/docs/reference/node-gitops-cicd.md b/docs/reference/node-gitops-cicd.md index 882eec59..4cb66cc4 100644 --- a/docs/reference/node-gitops-cicd.md +++ b/docs/reference/node-gitops-cicd.md @@ -11,14 +11,54 @@ - [PJ2026-010604 公开入口](https://github.com/pikasTech/unidesk/blob/master/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md) - [PJ2026-010605 运维监控](https://github.com/pikasTech/unidesk/blob/master/project-management/PJ2026-01/specs/PJ2026-010605-observability-monitoring.md) -目标 node/lane 的具体实现、PipelineRun 观察和运行命令仍按本仓 `AGENTS.md` 与受控 CLI 执行;需求边界、node/lane 规则和运维职责只更新 UniDesk OA。 +职责边界: + +- 目标 node/lane 的具体实现、PipelineRun 观察和运行命令按本仓 `AGENTS.md` 与受控 CLI 执行。 +- 需求边界、node/lane 规则和运维职责只更新 UniDesk OA。 ## 运行参考 -node/lane rollout 的 120 秒阈值是性能告警和诊断分界,不是继续盲等的理由。`trigger-current --wait` 超过阈值或返回 pending 时,先用定点 `hwlab nodes control-plane status --node --lane --pipeline-run `、`git-mirror status` 和 runtime workload 摘要确认卡在哪一层:PipelineRun task、Argo sync、runtime-ready、public probe 或 git mirror flush。 +`scripts/gitops-render.mjs` 只保留一条 runtime lane 渲染链: -只需要给 branch-follower 或 control-plane closeout 提供一轮新的真实验证源时,优先使用文档、注释或等价的非运行面小 PR,避免把 timeout、runtime 策略或 CI/CD 逻辑改动混入验证样本。 +- `vNN` lane: + - 继续使用既有版本号默认,包括 branch、catalog、namespace、runtime path、端口与 endpoint 推导。 + - 修改 renderer 时必须用同一 source commit 对比生成目录,确认既有 lane 不漂移。 +- 命名 runtime lane: + - 必须先在 `deploy/deploy.yaml` 的 `lanes.` 中声明稳定小写 id 与 node。 + - 必须显式声明 source/GitOps branch、Git read/write URL、artifact catalog、runtime path、namespace 与 image tag mode。 + - 必须显式声明 public endpoints、服务声明与 FRP 端点。 +- 命名 lane 的 node: + - 必须在 `deploy.nodes` 中声明 `gitopsRoot` 和 `sourceRepo`。 + - renderer 不从 lane 名称猜 branch、namespace、路径、端口或公开入口。 +- 命名 lane 缺失 owning YAML 字段时立即失败;不得增加按具体 lane 字符串、branch 名称或端口号分支的 fallback。 +- NodePort 服务只从 `lanes..publicServices.nodes.` 生成;名称、selector、service port、target port 与 node port 都由 YAML 声明。 -PR 合并后如果目标分支被并行 PR 推进,closeout 要同时记录本 PR merge commit、当前 source head 和 ancestry 证据;只要当前 head 包含本 PR merge commit,后续 rollout 应按当前 head 收敛,避免回滚到旧 source。定点 status 可证明某个旧 PipelineRun 是否 succeeded,但最终用户入口验收必须以当前 node/lane source、GitOps revision、Argo 和 runtime 状态为准。 +node/lane rollout 的 120 秒阈值是性能告警和诊断分界,不是继续盲等的理由: -runtime-ready 卡住时先看 `STATUS` 输出里的 notReady workload,再按 workload 日志和事件定位;修复应回到 source truth、PR、GitOps 和受控 sync/refresh,不把手工 patch Deployment、裸删 Pod 或临时容器改动作为交付路径。若 runtime 已 ready 但 Argo 仍 OutOfSync/Progressing,先走受控 `hwlab nodes control-plane sync|refresh --node --lane --confirm` 收敛控制面,再复查 bounded status。 +- `trigger-current --wait` 超过阈值或返回 pending 时,先执行定点 status。 +- 使用 `hwlab nodes control-plane status --node --lane --pipeline-run ` 查看 PipelineRun。 +- 配合 `git-mirror status` 和 runtime workload 摘要判断卡点。 +- 卡点范围包括 PipelineRun task、Argo sync、runtime-ready、public probe 或 git mirror flush。 + +只需要给 branch-follower 或 control-plane closeout 提供一轮新的真实验证源时: + +- 优先使用文档、注释或等价的非运行面小 PR。 +- 不把 timeout、runtime 策略或 CI/CD 逻辑改动混入验证样本。 + +PR 合并后如果目标分支被并行 PR 推进: + +- closeout 同时记录本 PR merge commit、当前 source head 和 ancestry 证据。 +- 当前 head 包含本 PR merge commit 时,后续 rollout 按当前 head 收敛,避免回滚到旧 source。 +- 定点 status 只证明指定旧 PipelineRun 是否 succeeded。 +- 最终用户入口验收以当前 node/lane source、GitOps revision、Argo 和 runtime 状态为准。 + +runtime-ready 卡住时: + +- 先查看 `STATUS` 输出里的 notReady workload,再按 workload 日志和事件定位。 +- 修复回到 source truth、PR、GitOps 和受控 sync/refresh。 +- 不把手工 patch Deployment、裸删 Pod 或临时容器改动作为交付路径。 + +runtime 已 ready 但 Argo 仍 OutOfSync/Progressing 时: + +- 走受控 `hwlab nodes control-plane sync|refresh --node --lane --confirm` 收敛控制面。 +- 再复查 bounded status。 diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 32f779a0..2e9fe700 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1344,35 +1344,50 @@ function namespaceForAgentRunRuntimeLane(lane) { return null; } -function agentRunRuntimeAuthorityError(code, message) { +function agentRunRuntimeAuthorityError(code, message, details = {}) { return Object.assign(new Error(message), { code, statusCode: 500, + details, valuesPrinted: false }); } function agentRunToolCredentials(env = process.env, toolCapabilities = null) { const namespace = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TOOL_SECRET_NAMESPACE, env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE); - const githubSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME, "agentrun-v01-tool-github-pr"); const githubSecretKey = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_KEY, "GH_TOKEN"); - const unideskSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME, "agentrun-v01-tool-unidesk-ssh"); const credentials = []; - if (toolCapabilityAllowed(toolCapabilities, "github_pr")) credentials.push({ + if (toolCapabilityAllowed(toolCapabilities, "github_pr")) { + const githubSecretName = requireAgentRunToolSecretName(env, "HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME", "github"); + credentials.push({ tool: "github", purpose: "pull-request", secretRef: { namespace, name: githubSecretName, keys: [githubSecretKey] }, projection: { kind: "env", envName: githubSecretKey, secretKey: githubSecretKey } }); - if (toolCapabilityAllowed(toolCapabilities, "unidesk_ssh")) credentials.push({ + } + if (toolCapabilityAllowed(toolCapabilities, "unidesk_ssh")) { + const unideskSecretName = requireAgentRunToolSecretName(env, "HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME", "unidesk-ssh"); + credentials.push({ tool: "unidesk-ssh", purpose: "ssh-passthrough", secretRef: { namespace, name: unideskSecretName, keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" } }); + } return credentials; } +function requireAgentRunToolSecretName(env, envName, tool) { + const secretName = text(env[envName]); + if (secretName) return secretName; + throw agentRunRuntimeAuthorityError( + "agentrun_tool_secret_not_configured", + `${envName} is required when the ${tool} capability is enabled`, + { tool, envName, valuesPrinted: false } + ); +} + async function resolveToolCapabilities({ params = {}, options = {} } = {}) { const ownerUserId = text(params.ownerUserId); const accessController = options.accessController; diff --git a/internal/cloud/code-agent-agentrun-result.ts b/internal/cloud/code-agent-agentrun-result.ts index 905484b0..e181a679 100644 --- a/internal/cloud/code-agent-agentrun-result.ts +++ b/internal/cloud/code-agent-agentrun-result.ts @@ -146,11 +146,22 @@ export function agentRunFreshSessionFailure(result = {}) { export function providerCredentialSecretRef(profile, env) { const profileEnvKey = String(profile ?? "").toUpperCase().replace(/[^A-Z0-9]+/gu, "_"); + const secretEnvName = `HWLAB_CODE_AGENT_AGENTRUN_${profileEnvKey}_SECRET_NAME`; + const secretName = firstNonEmpty(env[secretEnvName]); + if (!secretName) { + throw Object.assign(new Error(`AgentRun provider Secret name is not configured for profile ${profile}`), { + code: "agentrun_provider_secret_not_configured", + statusCode: 500, + profile, + envName: secretEnvName, + valuesPrinted: false + }); + } return { profile, secretRef: { namespace: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE), - name: firstNonEmpty(env[`HWLAB_CODE_AGENT_AGENTRUN_${profileEnvKey}_SECRET_NAME`], env[`HWLAB_CODE_AGENT_AGENTRUN_${String(profile ?? "").toUpperCase()}_SECRET_NAME`], env.HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_SECRET_NAME, `agentrun-v01-provider-${profile}`), + name: secretName, keys: ["auth.json", "config.toml"], mountPath: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_CODEX_HOME_MOUNT, "~/.codex") } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 11c851e4..71051c51 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -422,6 +422,7 @@ test("AgentRun adapter filters resource tools and credentials through access cap HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_DEEPSEEK_SECRET_NAME: "agentrun-test-provider-deepseek", HWLAB_RUNTIME_API_URL: "http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667", HWLAB_RUNTIME_WEB_URL: "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080", HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", @@ -662,8 +663,8 @@ test("cloud api /v1/agent/chat delegates v0.3 turns to AgentRun v0.1 over adapte assert.equal(Object.hasOwn(body.resourceBundleRef, "skillRefs"), false); assert.equal(Object.hasOwn(body.resourceBundleRef, "workspaceFiles"), false); const toolCredentials = body.executionPolicy.secretScope.toolCredentials; - assert.equal(toolCredentials.some((item) => item.tool === "github" && item.projection.envName === "GH_TOKEN" && item.secretRef.name === "agentrun-v01-tool-github-pr"), true); - assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN" && item.secretRef.name === "agentrun-v01-tool-unidesk-ssh"), true); + assert.equal(toolCredentials.some((item) => item.tool === "github" && item.projection.envName === "GH_TOKEN" && item.secretRef.name === "agentrun-test-tool-github-pr"), true); + assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN" && item.secretRef.name === "agentrun-test-tool-unidesk-ssh"), true); assert.equal(body.sessionRef.sessionId, "ses_agentrun_server_test_agentrun"); assert.equal(body.sessionRef.metadata.hwlabProjectId, "prj_hwpod_workbench"); assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun"); @@ -855,6 +856,9 @@ test("cloud api /v1/agent/chat delegates v0.3 turns to AgentRun v0.1 over adapte HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_DEEPSEEK_SECRET_NAME: "agentrun-test-provider-deepseek", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_CODE_AGENT_DEEPSEEK_MODEL: "deepseek-chat", HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false", HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false", @@ -1138,6 +1142,7 @@ test("cloud api rejects billing failure before durable AgentRun admission withou HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_DEEPSEEK_SECRET_NAME: "agentrun-test-provider-deepseek", HWLAB_ENVIRONMENT: "v03", HWLAB_GITOPS_PROFILE: "v03", HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1" @@ -1241,6 +1246,9 @@ test("cloud api preserves typed schema-invalid admission failure without claimin HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "gpt-pika", + HWLAB_CODE_AGENT_AGENTRUN_GPT_PIKA_SECRET_NAME: "agentrun-test-provider-gpt-pika", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_ENVIRONMENT: "v03", HWLAB_GITOPS_PROFILE: "v03" }, @@ -1447,6 +1455,9 @@ test("cloud api AgentRun adapter reports persistent thread resume when a complet HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_DEEPSEEK_SECRET_NAME: "agentrun-test-provider-deepseek", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_ENVIRONMENT: "v02", HWLAB_GITOPS_PROFILE: "v02" }, @@ -1620,6 +1631,9 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution through K HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_DEEPSEEK_SECRET_NAME: "agentrun-test-provider-deepseek", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_ENVIRONMENT: "v02", HWLAB_GITOPS_PROFILE: "v02" }, @@ -1826,6 +1840,9 @@ test("cloud api surfaces manager-side evicted-session recovery through Kafka pro HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_DEEPSEEK_SECRET_NAME: "agentrun-test-provider-deepseek", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_ENVIRONMENT: "v02", HWLAB_GITOPS_PROFILE: "v02" }, @@ -1902,7 +1919,7 @@ test("cloud api AgentRun adapter admits direct live Kafka turns without outbox p assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "minimax-m3"); assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "hwlab-session-scoped"); assert.equal(body.executionPolicy.secretScope.providerCredentials[0].profile, "minimax-m3"); - assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.name, "agentrun-v01-provider-minimax-m3"); + assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.name, "agentrun-test-provider-minimax-m3"); assert.equal(body.executionPolicy.secretScope.providerCredentials[0].secretRef.namespace, "agentrun-v01"); return send({ id: "run_hwlab_minimax_m3", status: "pending", backendProfile: "minimax-m3", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); } @@ -1997,6 +2014,9 @@ test("cloud api AgentRun adapter admits direct live Kafka turns without outbox p HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_AGENTRUN_MINIMAX_M3_SECRET_NAME: "agentrun-test-provider-minimax-m3", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_ENVIRONMENT: "v02", HWLAB_GITOPS_PROFILE: "v02" }, diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index fd48c686..c434e2e2 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -52,7 +52,7 @@ const sourceStates = new Set([ "source-present", "intentionally-disabled" ]); -const runtimeArtifactLanePattern = /^v\d{2,}$/u; +const runtimeArtifactLanePattern = /^[a-z][a-z0-9-]*$/u; const ciArtifactIdentityEnvNames = [ "HWLAB_CI_ARTIFACT_RUN_ID", @@ -205,7 +205,7 @@ function printHelp() { "Commit-pinned artifact build/publish helper for controlled CI execution.", "", "options:", - " --lane LANE node or runtime lane vNN; default: node", + " --lane LANE node or a lane declared in deploy.lanes; default: node", ` --catalog-path PATH default: ${defaultCatalogPath}`, ` --deploy-config PATH default: ${defaultDeployPath}`, " --image-tag-mode MODE short or full; v02 uses full source commit IDs", diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index d1f47320..75c87607 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -711,9 +711,9 @@ test("artifact reuse paths preserve catalog provenance instead of current planne assert.match(artifactPublish, /envReuseRecipe: servicePlan\.envReuseRecipe \?\? null/u); assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u); - const gitopsRender = await readFile("scripts/gitops-render.mjs", "utf8"); - assert.match(gitopsRender, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u); - assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u); + const perServicePublish = await readFile("scripts/src/gitops-render/templates/per-service-publish.sh", "utf8"); + assert.match(perServicePublish, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u); + assert.doesNotMatch(perServicePublish, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u); }); test("artifact publish ignores stale external reports for envreuse services that do not need current build artifacts", async () => { diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 9c3e1d6e..f0811601 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -24,6 +24,7 @@ import { generatedPath, gitopsPathForProfile, imageTagForSource, + isNamedRuntimeLane, isRuntimeLane, jsonManifest, label, @@ -79,7 +80,7 @@ import { } from "./src/gitops-render/tekton-manifests.mjs"; import { CLOUD_CORE_MIGRATIONS } from "../internal/db/schema.ts"; -function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false, externalSecrets = false } = {}) { +function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false, externalSecrets = false, deploy = null } = {}) { const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"]; if (!isRuntimeLane(profile)) resources.splice(1, 0, "code-agent-codex-config.yaml"); if (runtimeConfigMaps) resources.splice(3, 0, "configmaps.yaml"); @@ -95,12 +96,12 @@ function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile == return { apiVersion: "kustomize.config.k8s.io/v1beta1", kind: "Kustomization", - namespace: namespaceNameForProfile(profile), + namespace: namespaceNameForProfile(profile, deploy), resources }; } -function readme({ source, args }) { +function readme({ source, args, deploy }) { if (args.lane === "v02") { return `# HWLAB v0.2 GitOps CI/CD @@ -117,6 +118,21 @@ This directory is generated by \`scripts/gitops-render.mjs --lane v02\` from sou - node DEV/PROD boundary: this lane does not modify archived DEV/PROD branches, \`hwlab-dev\`, \`hwlab-prod\`, 17666/17667 or 18666/18667. - CronJob policy: v0.2 does not generate \`hwlab-v02-branch-poller\` or \`hwlab-v02-control-plane-reconciler\`; obsolete live CronJobs should be deleted by the UniDesk control-plane apply command. - Gate policy: old DEV/D601/main gates do not enter this lane; new checks stay limited to the fixed branch, namespace, catalog, runtime path and Argo boundaries. +`; + } + if (isNamedRuntimeLane(args.lane)) { + const namespace = namespaceNameForProfile(args.lane, deploy); + const runtimePath = gitopsPathForProfile(args, args.lane, deploy); + return `# HWLAB ${args.lane} GitOps CI/CD + +This directory is generated by \`scripts/gitops-render.mjs --lane ${args.lane}\` from the owning runtime lane declaration in \`deploy/deploy.yaml\`. + +- Source: \`${args.sourceRepo}\` branch \`${args.sourceBranch}\` at \`${source.full}\`. +- GitOps: generated desired state is promoted to \`${args.gitWriteUrl}\` branch \`${args.gitopsBranch}\`. +- Artifact contract: runtime images come from \`${args.catalogPath}\`. +- Runtime: Argo CD consumes \`${args.gitReadUrl}:${args.gitopsBranch}:${runtimePath}\` and deploys only to namespace \`${namespace}\`. +- Public endpoints: web \`${args.webEndpoint}\`; API \`${args.runtimeEndpoint}\`. +- Authority: branch, catalog, runtime path, namespace, public services and endpoints are read from \`deploy/deploy.yaml\`; missing named-lane fields fail closed. `; } return `# HWLAB node GitOps CI/CD @@ -161,7 +177,7 @@ async function plannedFiles(args) { let artifactCatalog = await readJsonIfPresent(args.catalogPath, null); if (!artifactCatalog && isRuntimeLane(args.lane)) artifactCatalog = artifactCatalogSkeleton({ args, deploy, source }); if (!artifactCatalog) artifactCatalog = await readJson(args.catalogPath); - const settings = ciLaneSettings(args); + const settings = ciLaneSettings(args, deploy); const profiles = laneRuntimeProfiles(args); const migrationSources = isRuntimeLane(args.lane) ? await Promise.all(CLOUD_CORE_MIGRATIONS.map(async (migration) => ({ @@ -196,9 +212,9 @@ async function plannedFiles(args) { gitReadUrl: args.gitReadUrl, gitWriteUrl: args.gitWriteUrl, registryPrefix: args.registryPrefix, - runtimePaths: profiles.map((profile) => gitopsPathForProfile(args, profile)), + runtimePaths: profiles.map((profile) => gitopsPathForProfile(args, profile, deploy)), publicEndpoints, - frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"}`])), + frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile, deploy)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"}`])), tektonPipeline: `hwlab-ci/${settings.pipelineName}`, argoApplications: profiles.map((profile) => `argocd/${argoApplicationName(profile)}`) }; @@ -210,22 +226,22 @@ async function plannedFiles(args) { }; } putJson("source.json", sourceDescriptor); - putText("README.md", readme({ source, args })); + putText("README.md", readme({ source, args, deploy })); putJson("registry/registry.yaml", registryManifest()); if (isRuntimeLane(args.lane)) putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest(args, deploy)); - putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args)); + putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args, deploy)); putJson(`${settings.tektonDir}/tasks.yaml`, { apiVersion: "v1", kind: "List", items: tektonTasks(args, deploy) }); putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy)); if (!isRuntimeLane(args.lane)) { putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args, deploy)); putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args)); } - putJson(`${settings.tektonDir}/pipelinerun.sample.yaml`, tektonPipelineRunTemplate({ source, args })); - putJson("argocd/project.yaml", argoProject(args)); - for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile)); + putJson(`${settings.tektonDir}/pipelinerun.sample.yaml`, tektonPipelineRunTemplate({ source, args, deploy })); + putJson("argocd/project.yaml", argoProject(args, deploy)); + for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile, deploy)); for (const profile of profiles) { - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const includeDeviceAgent = profile === "dev"; const externalPostgres = externalPostgresConfigForLane(deploy, profile, args); const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile); @@ -236,9 +252,9 @@ async function plannedFiles(args) { runtimeNamespace.metadata.name = namespace; label(runtimeNamespace.metadata, profileLabels); annotate(runtimeNamespace.metadata, annotations); - const runtimePath = runtimePathForProfile(profile); + const runtimePath = runtimePathForProfile(profile, deploy); const endpoints = profileEndpoint(args, profile); - putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0, externalSecrets: Boolean(runtimeSecretPlane) })); + putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0, externalSecrets: Boolean(runtimeSecretPlane), deploy })); putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace); if (!isRuntimeLane(profile)) putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations)); putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy, nodeId: args.nodeId })); @@ -247,10 +263,19 @@ async function plannedFiles(args) { if (runtimeSecretPlane) putJson(`${runtimePath}/external-secrets.yaml`, runtimeSecretPlaneManifest({ config: runtimeSecretPlane, namespace, labels: profileLabels, annotations })); putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, nodeId: args.nodeId, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); - if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ profile, config: externalPostgres, source })); - if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSources, source, image: runtimePostgresImageForProfile(deploy, profile) })); - if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source })); - if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source })); + if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ + profile, + config: externalPostgres, + source, + deploy, + migrationSources, + catalog: artifactCatalog, + registryPrefix: args.registryPrefix, + useDeployImages: args.useDeployImages + })); + if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSources, source, image: runtimePostgresImageForProfile(deploy, profile), deploy })); + if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source, deploy })); + if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source, deploy })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source, deploy, catalog: artifactCatalog, registryPrefix: args.registryPrefix, useDeployImages: args.useDeployImages, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, sourceRepo: args.sourceRepo })); if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index ead68ebb..a8d71c70 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -8,10 +8,32 @@ import test from "node:test"; import { parse as parseYaml } from "yaml"; import { configString } from "./src/gitops-render/core.mjs"; -import { opencodeEgressProxyUrlForProfile } from "./src/gitops-render/runtime-manifests.mjs"; +import { externalPostgresConfigForLane, externalPostgresManifest, opencodeEgressProxyUrlForProfile } from "./src/gitops-render/runtime-manifests.mjs"; import { CLOUD_CORE_MIGRATIONS, CLOUD_TRANSACTIONAL_REALTIME_MIGRATION_ID } from "../internal/db/schema.ts"; +test("production migration uses its YAML-declared database Secret key", async () => { + const deploy = parseYaml(await readFile("deploy/deploy.yaml", "utf8")); + assert.deepEqual(deploy.lanes.production.workbench.rawHwlabEventWindow, { + enabled: true, + maxEntries: 200, + maxRetainedBytes: 1048576 + }); + const config = externalPostgresConfigForLane(deploy, "production", { nodeId: "NC01" }); + const manifest = externalPostgresManifest({ + profile: "production", + config, + source: { full: "a".repeat(40), short: "a".repeat(12), imageTag: "a".repeat(12) }, + deploy, + migrationSources: [{ path: "0001_test.sql", sql: "select 1;" }] + }); + const job = manifest.items.find((item) => item.kind === "Job"); + assert.deepEqual(job?.spec?.template?.spec?.containers?.[0]?.env?.[0]?.valueFrom?.secretKeyRef, { + name: "hwlab-cloud-api-production-db", + key: "migration-database-url" + }); +}); + function taskByName(pipeline, name) { const task = pipeline.spec.tasks.find((item) => item.name === name); assert.ok(task, `missing task ${name}`); @@ -296,6 +318,8 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots assert.match(gitMirrorScript, /refs\/heads\/v0\.2-gitops:refs\/mirror-stage\/heads\/v0\.2-gitops/u); assert.match(gitMirrorScript, /refs\/heads\/v0\.3:refs\/mirror-stage\/heads\/v0\.3/u); assert.match(gitMirrorScript, /refs\/heads\/v0\.3-gitops:refs\/mirror-stage\/heads\/v0\.3-gitops/u); + assert.match(gitMirrorScript, /refs\/heads\/release:refs\/mirror-stage\/heads\/release/u); + assert.match(gitMirrorScript, /refs\/heads\/release-gitops:refs\/mirror-stage\/heads\/release-gitops/u); assert.doesNotMatch(gitMirrorScript, /refs\/heads\/G14:refs\/mirror-stage\/heads\/G14/u); assert.doesNotMatch(gitMirrorScript, /refs\/heads\/G14-gitops:refs\/mirror-stage\/heads\/G14-gitops/u); assert.doesNotMatch(gitMirrorScript, /refs\/heads\/\*:refs\/mirror-stage\/heads\/\*/u); @@ -311,7 +335,7 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots assert.match(gitMirrorScript, /refs\/unidesk\/snapshots\/hwlab-node-runtime/u); assert.match(gitMirrorScript, /refs\/mirror-stage\/heads\/v0\.2/u); assert.match(gitMirrorScript, /refs\/mirror-stage\/heads\/v0\.3/u); - assert.match(gitMirrorScript, /for gitops_branch in 'v0\.2-gitops' 'v0\.3-gitops'/u); + assert.match(gitMirrorScript, /for gitops_branch in 'release-gitops' 'v0\.2-gitops' 'v0\.3-gitops'/u); assert.match(gitMirrorScript, /keeps local \$name ahead of GitHub/u); assert.doesNotMatch(gitMirror, /not allowlisted/u); assert.doesNotMatch(gitMirror, /path allowlist/u); @@ -731,7 +755,25 @@ test("v03 render keeps node identity as data instead of generated structure", as assert.ok((projectManagementContainer?.volumeMounts ?? []).some((mount) => mount.name === "project-management-bootstrap" && mount.mountPath === "/etc/hwlab/project-management")); assert.ok((projectManagementContainer?.env ?? []).some((entry) => entry.name === "HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH" && entry.value === "/etc/hwlab/project-management/sources.json")); const externalPostgres = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "external-postgres.yaml"), "utf8")); - assert.deepEqual((externalPostgres.items ?? []).map((item) => item.kind), ["Service", "EndpointSlice"]); + assert.deepEqual((externalPostgres.items ?? []).map((item) => item.kind), ["ConfigMap", "Job", "Service", "EndpointSlice"]); + const externalPostgresMigrationConfig = (externalPostgres.items ?? []).find((item) => item.kind === "ConfigMap"); + const externalPostgresMigrationJob = (externalPostgres.items ?? []).find((item) => item.kind === "Job"); + assert.deepEqual( + Object.keys(externalPostgresMigrationConfig?.data ?? {}).filter((name) => name.endsWith(".sql")), + CLOUD_CORE_MIGRATIONS.map((migration) => path.basename(migration.path)) + ); + assert.match(externalPostgresMigrationConfig?.data?.["run.mjs"] ?? "", /DATABASE_URL is required/u); + assert.match(externalPostgresMigrationConfig?.data?.["run.mjs"] ?? "", /hwlab-runtime-migration-started/u); + assert.equal(externalPostgresMigrationConfig?.metadata?.annotations?.["argocd.argoproj.io/sync-wave"], "-2"); + assert.equal(externalPostgresMigrationJob?.metadata?.annotations?.["argocd.argoproj.io/sync-wave"], "-1"); + assert.equal( + externalPostgresMigrationJob?.spec?.template?.spec?.containers?.[0]?.image, + `127.0.0.1:5000/hwlab/hwlab-cloud-api-env@sha256:${"1".repeat(64)}` + ); + assert.deepEqual(externalPostgresMigrationJob?.spec?.template?.spec?.containers?.[0]?.env?.[0]?.valueFrom?.secretKeyRef, { + name: "hwlab-cloud-api-v03-db", + key: "database-url" + }); const externalPostgresSlice = (externalPostgres.items ?? []).find((item) => item.kind === "EndpointSlice"); assert.equal(externalPostgresSlice?.metadata?.name, "jd01-pk01-platform-postgres-host"); assert.equal(externalPostgresSlice?.metadata?.labels?.["kubernetes.io/service-name"], "jd01-pk01-platform-postgres"); diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index f97d7450..e8bff069 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -23,7 +23,7 @@ const defaultPublishReportPath = tempReportPath("dev-artifacts.json"); const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; const digestPattern = /^sha256:[a-f0-9]{64}$/; const commitPattern = /^[a-f0-9]{7,40}$/; -const runtimeArtifactLanePattern = /^v\d{2,}$/u; +const runtimeArtifactLanePattern = /^[a-z][a-z0-9-]*$/u; const publishReadyStatuses = new Set(["published", "reused"]); const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout"; diff --git a/scripts/src/gitops-render/core.mjs b/scripts/src/gitops-render/core.mjs index 239b4cb5..c89ec81b 100644 --- a/scripts/src/gitops-render/core.mjs +++ b/scripts/src/gitops-render/core.mjs @@ -12,7 +12,11 @@ import { applyRuntimeLaneDeployConfig, defaultEndpointForRuntimeLane, defaultPortForRuntimeLane, + isNamedRuntimeLane, isRuntimeLane, + isVersionRuntimeLane, + runtimeNamespaceForRuntimeLane, + runtimeRelativePathForRuntimeLane, runtimeLaneMirrorSpecs, runtimeLaneConfig, versionNameForRuntimeLane @@ -245,7 +249,7 @@ function parseArgs(argv) { else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } - if (isRuntimeLane(args.lane)) { + if (isVersionRuntimeLane(args.lane)) { const versionName = versionNameForRuntimeLane(args.lane); const defaultLaneEndpoint = defaultEndpointForRuntimeLane(args.lane); if (args.sourceBranch === defaultBranch) args.sourceBranch = versionName; @@ -261,7 +265,7 @@ function parseArgs(argv) { if (!args.gitWriteUrl) args.gitWriteUrl = args.sourceRepo; assert.ok(args.lane === "node" || isRuntimeLane(args.lane), `unknown lane ${args.lane}`); assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); - if (isRuntimeLane(args.lane)) { + if (isVersionRuntimeLane(args.lane)) { const versionName = versionNameForRuntimeLane(args.lane); assert.equal(args.sourceBranch, versionName, `${args.lane} source branch must be ${versionName}`); assert.equal(args.gitopsBranch, `${versionName}-gitops`, `${args.lane} GitOps branch must be ${versionName}-gitops`); @@ -282,7 +286,7 @@ function usage() { "Render node-scoped Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.yaml, and deploy/k8s/*.", "", "options:", - " --lane LANE node or runtime lane such as v02/v03/v04; default: node", + " --lane LANE node or a deploy.lanes runtime lane id such as v02/v03/staging; default: node", ` --node NODE deployment node id from deploy.nodes; runtime lanes default from deploy.lanes..node`, ` --out DIR default: ${defaultOutDir}`, ` --gitops-root DIR path inside GitOps repo; default comes from deploy.nodes..gitopsRoot`, @@ -807,7 +811,7 @@ function isV02RemovedServiceId(serviceId) { function artifactCatalogSkeleton({ args, deploy, source }) { const environment = artifactEnvironment(args); - const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane) : "hwlab-dev"; + const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane, deploy) : "hwlab-dev"; const tag = imageTagForSource(args, source); return { catalogVersion: "v1", @@ -869,13 +873,13 @@ function artifactCatalogSkeletonService({ args, deploy, source, serviceId, envir }; } -function namespaceNameForProfile(profile) { - if (isRuntimeLane(profile)) return `hwlab-${profile}`; +function namespaceNameForProfile(profile, deploy = null) { + if (isRuntimeLane(profile)) return runtimeNamespaceForRuntimeLane(deploy, profile); return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; } -function runtimePathForProfile(profile) { - if (isRuntimeLane(profile)) return `runtime-${profile}`; +function runtimePathForProfile(profile, deploy = null) { + if (isRuntimeLane(profile)) return runtimeRelativePathForRuntimeLane(deploy, profile); return profile === "prod" ? "runtime-prod" : "runtime-dev"; } @@ -892,8 +896,8 @@ function gitopsRootForArgs(args) { return String(args.gitopsRoot || defaultOutDir).replace(/\/+$/u, ""); } -function gitopsPathForProfile(args, profile) { - return `${gitopsRootForArgs(args)}/${runtimePathForProfile(profile)}`; +function gitopsPathForProfile(args, profile, deploy = null) { + return `${gitopsRootForArgs(args)}/${runtimePathForProfile(profile, deploy)}`; } function gitopsPathFor(args, relativePath) { @@ -911,9 +915,9 @@ function laneRuntimeProfiles(args) { return isRuntimeLane(args.lane) ? [args.lane] : ["dev", "prod"]; } -function laneNames(args) { +function laneNames(args, deploy = null) { if (isRuntimeLane(args.lane)) { - const namespace = namespaceNameForProfile(args.lane); + const namespace = namespaceNameForProfile(args.lane, deploy); return { gitopsTarget: args.lane, pipeline: `${namespace}-ci-image-publish`, @@ -922,7 +926,7 @@ function laneNames(args) { serviceAccount: `${namespace}-tekton-runner`, pipelineRunPrefix: `${namespace}-ci-poll-`, runtimeNamespace: namespace, - runtimePath: gitopsPathForProfile(args, args.lane), + runtimePath: gitopsPathForProfile(args, args.lane, deploy), argoApplications: [`argocd/hwlab-node-${args.lane}`] }; } @@ -1177,9 +1181,11 @@ function gitopsRootNodeId(gitopsRoot) { } function runtimeFrpConfigForProfile(deploy, profile, args = {}) { - const fallbackWebRemotePort = isRuntimeLane(profile) ? defaultPortForRuntimeLane(profile) : profile === "prod" ? 18666 : 17666; + const namedRuntimeLane = isNamedRuntimeLane(profile); + const fallbackWebRemotePort = isVersionRuntimeLane(profile) ? defaultPortForRuntimeLane(profile) : profile === "prod" ? 18666 : 17666; const fallbackEdgeRemotePort = isRuntimeLane(profile) ? fallbackWebRemotePort + 1 : profile === "prod" ? 18667 : 17667; const baseFrp = isRuntimeLane(profile) ? runtimeLaneConfig(deploy, profile)?.frp : deploy?.frp; + if (namedRuntimeLane && baseFrp?.enabled === false) return { enabled: false }; const nodeId = effectiveSecretPlaneNodeId(args); const nodeFrp = nodeId ? baseFrp?.nodes?.[nodeId] : null; const frp = nodeFrp && typeof nodeFrp === "object" && !Array.isArray(nodeFrp) ? { ...baseFrp, ...nodeFrp } : baseFrp; @@ -1190,17 +1196,32 @@ function runtimeFrpConfigForProfile(deploy, profile, args = {}) { .map((proxy) => [String(proxy.endpointId || proxy.name || ""), proxy])); const proxyValue = (endpointId, key, fallback) => { const value = proxyByEndpoint.get(endpointId)?.[key]; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + assert.ok(!namedRuntimeLane, `deploy.lanes.${profile}.frp.proxies.${endpointId}.${key} is required`); + return fallback; }; - const proxyPort = (endpointId, fallback) => { - const value = proxyByEndpoint.get(endpointId)?.remotePort; - return Number.isInteger(value) ? value : fallback; + const proxyPort = (endpointId, fallback, key = "remotePort") => { + const value = proxyByEndpoint.get(endpointId)?.[key]; + if (Number.isInteger(value) && value > 0) return value; + assert.ok(!namedRuntimeLane, `deploy.lanes.${profile}.frp.proxies.${endpointId}.${key} must be a positive integer`); + return fallback; }; + if (namedRuntimeLane) { + assert.ok(server && typeof server === "object" && !Array.isArray(server), `deploy.lanes.${profile}.frp.server is required`); + assert.ok(typeof server.address === "string" && server.address.trim().length > 0, `deploy.lanes.${profile}.frp.server.address is required`); + assert.ok(Number.isInteger(server.bindPort) && server.bindPort > 0, `deploy.lanes.${profile}.frp.server.bindPort must be a positive integer`); + for (const endpointId of ["frontend", "api"]) assert.ok(proxyByEndpoint.has(endpointId), `deploy.lanes.${profile}.frp.proxies must declare endpointId ${endpointId}`); + } return { + enabled: true, serverAddr: typeof server?.address === "string" && server.address.trim().length > 0 ? server.address.trim() : "74.48.78.17", serverPort: Number.isInteger(server?.bindPort) ? server.bindPort : 7000, webRemotePort: proxyPort("frontend", fallbackWebRemotePort), edgeRemotePort: proxyPort("api", fallbackEdgeRemotePort), + webLocalHost: proxyValue("frontend", "localHost", null), + edgeLocalHost: proxyValue("api", "localHost", null), + webLocalPort: proxyPort("frontend", 8080, "localPort"), + edgeLocalPort: proxyPort("api", 6667, "localPort"), webProxyName: proxyValue("frontend", "name", null), edgeProxyName: proxyValue("api", "name", null), authSecretName: typeof frp?.auth?.secretName === "string" && frp.auth.secretName.trim().length > 0 ? frp.auth.secretName.trim() : null, @@ -1347,7 +1368,7 @@ function workbenchRuntimeRedisConfigForProfile(deploy, profile) { if (!redis || redis.enabled !== true) return null; configObject(redis, label); const namespace = requiredConfigString(redis, "namespace", label); - assert.equal(namespace, namespaceNameForProfile(profile), `${label}.namespace must match ${namespaceNameForProfile(profile)}`); + assert.equal(namespace, namespaceNameForProfile(profile, deploy), `${label}.namespace must match ${namespaceNameForProfile(profile, deploy)}`); const serviceName = requiredConfigString(redis, "serviceName", label); const image = requiredConfigString(redis, "image", label); const port = requiredConfigPositiveInteger(redis, "port", label); @@ -1446,7 +1467,7 @@ function upsertV02MetricsPort(service) { function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = defaultBranch, gitReadUrl, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", nodeId = "node", useDeployImages = false, metricsSidecarSha256 = null }) { const result = cloneJson(workloads); const deployServices = deployServicesForProfile(deploy, profile); - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const profileLabel = runtimeLabelForProfile(profile); const gitopsTarget = gitopsTargetForProfile(profile); const runtimeLane = isRuntimeLane(profile); @@ -1737,6 +1758,7 @@ function transformServices({ services, namespace, labels, annotations, profile = function observabilityManifest({ deploy, profile, namespace, labels, annotations, metricsSidecarScript }) { assert.ok(isRuntimeLane(profile), `observability profile must be a runtime lane, got ${profile}`); + const laneDisplayName = runtimeLaneConfig(deploy, profile).name ?? versionNameForRuntimeLane(profile); const includePrometheusOperatorResources = runtimePrometheusOperatorResourcesEnabled(deploy, profile); const baseLabels = { ...labels, @@ -1792,7 +1814,7 @@ function observabilityManifest({ deploy, profile, namespace, labels, annotations expr: `up{namespace="${namespace}"} == 0`, for: "5m", labels: { severity: "warning", lane: profile }, - annotations: { summary: `HWLAB ${versionNameForRuntimeLane(profile)} metrics target is down`, description: `Prometheus cannot scrape a HWLAB ${versionNameForRuntimeLane(profile)} metrics target.` } + annotations: { summary: `HWLAB ${laneDisplayName} metrics target is down`, description: `Prometheus cannot scrape a HWLAB ${laneDisplayName} metrics target.` } }, { alert: `HWLAB${profile.toUpperCase()}ServiceHealthProbeFailed`, @@ -1829,7 +1851,7 @@ export { effectiveSecretPlaneNodeId, ensureObject, envReuseBootShForContainer, envReuseServiceIdsForLane, generatedPath, gitopsPathFor, gitopsPathForProfile, gitopsRootForArgs, gitopsRootNodeId, gitopsTargetForProfile, gitValue, imageFor, imageTagForSource, - imageTagFromReference, isRuntimeLane, isV02RemovedServiceId, jsonManifest, + imageTagFromReference, isNamedRuntimeLane, isRuntimeLane, isV02RemovedServiceId, jsonManifest, k8sLabelHash, label, laneNames, laneRuntimeProfiles, mergeEnvMaps, moonBridgeImage, moonBridgeSourceRef, namespaceNameForProfile, namespaceScopedHost, normalizeAccessControlUser, normalizeNavProfile, normalizeProbeTiming, diff --git a/scripts/src/gitops-render/runtime-manifests.mjs b/scripts/src/gitops-render/runtime-manifests.mjs index 13685771..6cb59f78 100644 --- a/scripts/src/gitops-render/runtime-manifests.mjs +++ b/scripts/src/gitops-render/runtime-manifests.mjs @@ -44,15 +44,16 @@ import { versionNameForRuntimeLane } from "./core.mjs"; -function argoProject(args = { lane: "node" }) { +function argoProject(args = { lane: "node" }, deploy = null) { if (isRuntimeLane(args.lane)) { - const namespace = namespaceNameForProfile(args.lane); + const namespace = namespaceNameForProfile(args.lane, deploy); + const laneName = runtimeLaneConfig(deploy, args.lane).name ?? versionNameForRuntimeLane(args.lane); return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", metadata: { name: namespace, namespace: "argocd" }, spec: { - description: `HWLAB ${versionNameForRuntimeLane(args.lane)} additive GitOps project on target node; DEV/PROD remain outside this lane.`, + description: `HWLAB ${laneName} additive GitOps project on target node; DEV/PROD remain outside this lane.`, sourceRepos: [args.gitReadUrl], destinations: [{ server: "https://kubernetes.default.svc", namespace }], clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], @@ -77,9 +78,9 @@ function argoProject(args = { lane: "node" }) { }; } -function argoApplication(args, profile = "dev") { - const namespace = namespaceNameForProfile(profile); - const runtimePath = runtimePathForProfile(profile); +function argoApplication(args, profile = "dev", deploy = null) { + const namespace = namespaceNameForProfile(profile, deploy); + const runtimePath = runtimePathForProfile(profile, deploy); const gitopsTarget = gitopsTargetForProfile(profile); const project = isRuntimeLane(profile) ? namespace : "hwlab-node"; const repoURL = isRuntimeLane(profile) ? args.gitReadUrl : args.sourceRepo; @@ -93,7 +94,7 @@ function argoApplication(args, profile = "dev") { }, spec: { project, - source: { repoURL, targetRevision: args.gitopsBranch, path: gitopsPathForProfile(args, profile) }, + source: { repoURL, targetRevision: args.gitopsBranch, path: gitopsPathForProfile(args, profile, deploy) }, destination: { server: "https://kubernetes.default.svc", namespace }, syncPolicy: { automated: { prune: isRuntimeLane(profile), selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } } @@ -101,12 +102,13 @@ function argoApplication(args, profile = "dev") { } function nodeFrpcManifest({ profile = "dev", source = null, deploy = null, args = {} } = {}) { - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const profileLabel = runtimeLabelForProfile(profile); const deploymentName = isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"; const configName = `${deploymentName}-config`; const proxyPrefix = isRuntimeLane(profile) ? `hwlab-${profile}` : profile === "prod" ? "hwlab-node-prod" : "hwlab-node"; const frpConfig = runtimeFrpConfigForProfile(deploy, profile, args); + if (frpConfig.enabled === false) return { apiVersion: "v1", kind: "List", items: [] }; const webProxyName = frpConfig.webProxyName || `${proxyPrefix}-cloud-web`; const edgeProxyName = frpConfig.edgeProxyName || `${proxyPrefix}-edge-proxy`; const labels = { @@ -131,15 +133,15 @@ ${authConfigText} [[proxies]] name = "${webProxyName}" type = "tcp" -localIP = "hwlab-cloud-web.${namespace}.svc.cluster.local" -localPort = 8080 +localIP = "${frpConfig.webLocalHost || `hwlab-cloud-web.${namespace}.svc.cluster.local`}" +localPort = ${frpConfig.webLocalPort} remotePort = ${frpConfig.webRemotePort} [[proxies]] name = "${edgeProxyName}" type = "tcp" -localIP = "hwlab-edge-proxy.${namespace}.svc.cluster.local" -localPort = 6667 +localIP = "${frpConfig.edgeLocalHost || `hwlab-edge-proxy.${namespace}.svc.cluster.local`}" +localPort = ${frpConfig.edgeLocalPort} remotePort = ${frpConfig.edgeRemotePort} `; const configSha256 = createHash("sha256").update(configText).digest("hex"); @@ -194,7 +196,7 @@ remotePort = ${frpConfig.edgeRemotePort} } function deepSeekProxyManifest({ profile = "dev", source, sourceBranch = defaultBranch, sourceRepo = defaultSourceRepo, gitReadUrl, deploy = null, registryPrefix = defaultRegistryPrefix, catalog = null, useDeployImages = false, metricsSidecarSha256 = null } = {}) { - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const bridgeServiceId = "hwlab-cloud-api"; const runtimeLane = isRuntimeLane(profile); const digestPin = runtimeLane; @@ -642,8 +644,8 @@ function runtimePostgresImageForProfile(deploy, profile) { return typeof image === "string" && image.trim().length > 0 ? image.trim() : "postgres:16-alpine"; } -function v02PostgresManifest({ profile = "v02", migrationSources, source, image = "postgres:16-alpine" }) { - const namespace = namespaceNameForProfile(profile); +function v02PostgresManifest({ profile = "v02", migrationSources, source, image = "postgres:16-alpine", deploy = null }) { + const namespace = namespaceNameForProfile(profile, deploy); const name = `${namespace}-postgres`; const dbName = `hwlab_${profile}`; const labels = { @@ -735,11 +737,35 @@ function externalPostgresConfigForLane(deploy, profile, args = {}) { assert.ok(typeof effective.endpointAddress === "string" && effective.endpointAddress.length > 0, `deploy.lanes.${profile}.externalPostgres.endpointAddress is required`); const port = Number(effective.port ?? 5432); assert.ok(Number.isInteger(port) && port > 0 && port <= 65535, `deploy.lanes.${profile}.externalPostgres.port must be a valid TCP port`); - return { serviceName: effective.serviceName, endpointAddress: effective.endpointAddress, port }; + const migrationSecretName = effective.migrationSecretName; + const migrationSecretKey = effective.migrationSecretKey; + assert.equal(typeof migrationSecretName === "string", typeof migrationSecretKey === "string", `deploy.lanes.${profile}.externalPostgres migration Secret name and key must be declared together`); + return { + serviceName: effective.serviceName, + endpointAddress: effective.endpointAddress, + port, + ...(typeof migrationSecretName === "string" ? { migrationSecretName, migrationSecretKey } : {}) + }; } -function externalPostgresManifest({ profile = "v03", config, source }) { - const namespace = namespaceNameForProfile(profile); +function externalPostgresManifest({ profile = "v03", config, source, deploy = null, migrationSources = [], catalog = null, registryPrefix = defaultRegistryPrefix, useDeployImages = false }) { + const namespace = namespaceNameForProfile(profile, deploy); + assert.ok(Array.isArray(migrationSources) && migrationSources.length > 0, "external Postgres migration chain is required"); + const migrationData = Object.fromEntries(migrationSources.map((migration) => [path.basename(migration.path), migration.sql])); + const migrationSha256 = createHash("sha256").update(migrationSources.map((migration) => migration.sql).join("\n")).digest("hex"); + const migrationRunner = externalPostgresMigrationRunner(); + const migrationImage = runtimeImageForService({ + catalog, + deploy, + serviceId: "hwlab-cloud-api", + source, + registryPrefix, + useDeployImages, + digestPin: true, + envReuseServiceIds: envReuseServiceIdsForLane(deploy, profile) + }); + const executionSha256 = createHash("sha256").update(`${migrationSha256}\n${migrationImage}\n${migrationRunner}`).digest("hex"); + const migrationName = `${namespace}-external-postgres-migrate-${executionSha256.slice(0, 12)}`; const labels = { "app.kubernetes.io/name": config.serviceName, "app.kubernetes.io/part-of": "hwlab", @@ -754,6 +780,64 @@ function externalPostgresManifest({ profile = "v03", config, source }) { apiVersion: "v1", kind: "List", items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: migrationName, + namespace, + labels, + annotations: { + ...annotations, + "argocd.argoproj.io/sync-wave": "-2", + "hwlab.pikastech.local/migration-sha256": migrationSha256 + } + }, + data: { + "run.mjs": migrationRunner, + ...migrationData + } + }, + { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name: migrationName, + namespace, + labels, + annotations: { + ...annotations, + "argocd.argoproj.io/sync-wave": "-1", + "hwlab.pikastech.local/migration-sha256": migrationSha256 + } + }, + spec: { + backoffLimit: 2, + template: { + metadata: { labels: { ...labels, "app.kubernetes.io/name": migrationName } }, + spec: { + restartPolicy: "Never", + containers: [{ + name: "migrate", + image: migrationImage, + imagePullPolicy: "IfNotPresent", + command: ["node", "/opt/hwlab-migrations/run.mjs"], + env: [{ + name: "DATABASE_URL", + valueFrom: { + secretKeyRef: { + name: config.migrationSecretName ?? `hwlab-cloud-api-${profile}-db`, + key: config.migrationSecretKey ?? "database-url" + } + } + }], + volumeMounts: [{ name: "migrations", mountPath: "/opt/hwlab-migrations", readOnly: true }] + }], + volumes: [{ name: "migrations", configMap: { name: migrationName } }] + } + } + } + }, { apiVersion: "v1", kind: "Service", @@ -772,8 +856,32 @@ function externalPostgresManifest({ profile = "v03", config, source }) { }; } -function v02OpenFgaManifest({ profile = "v02", source }) { - const namespace = namespaceNameForProfile(profile); +function externalPostgresMigrationRunner() { + return [ + 'import { readdir, readFile } from "node:fs/promises";', + 'import { createRequire } from "node:module";', + 'const require = createRequire("/opt/hwlab-env/package.json");', + 'const { Pool } = require("/opt/hwlab-env/node_modules/pg");', + 'const root = "/opt/hwlab-migrations";', + 'const files = (await readdir(root)).filter((name) => /^\\d+_.*\\.sql$/u.test(name)).sort();', + 'const connectionString = process.env.DATABASE_URL;', + 'if (!connectionString) throw new Error("DATABASE_URL is required");', + 'console.error(JSON.stringify({ event: "hwlab-runtime-migration-started", fileCount: files.length, valuesPrinted: false }));', + 'const pool = new Pool({ connectionString, max: 1 });', + 'try {', + ' for (const file of files) {', + ' await pool.query(await readFile(`${root}/${file}`, "utf8"));', + ' console.error(JSON.stringify({ event: "hwlab-runtime-migration-applied", file, valuesPrinted: false }));', + ' }', + '} finally {', + ' await pool.end();', + '}', + '' + ].join("\n"); +} + +function v02OpenFgaManifest({ profile = "v02", source, deploy = null }) { + const namespace = namespaceNameForProfile(profile, deploy); const name = "hwlab-openfga"; const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0"; const labels = { @@ -1200,7 +1308,7 @@ function opencodeConfigContent(config) { function opencodeServerManifest({ profile = "v03", source, deploy = null, catalog = null, registryPrefix = defaultRegistryPrefix, useDeployImages = false, sourceBranch = defaultBranch, gitReadUrl = defaultV02GitReadUrl, sourceRepo = defaultSourceRepo }) { assert.ok(isRuntimeLane(profile), `opencode-server profile must be a runtime lane, got ${profile}`); - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const name = "opencode-server"; const helperServiceId = "hwlab-cloud-web"; const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile); diff --git a/scripts/src/gitops-render/tekton-manifests.mjs b/scripts/src/gitops-render/tekton-manifests.mjs index f7fc8110..b5803c10 100644 --- a/scripts/src/gitops-render/tekton-manifests.mjs +++ b/scripts/src/gitops-render/tekton-manifests.mjs @@ -29,10 +29,10 @@ import { primitiveValidationTaskNames } from "./tekton-scripts.mjs"; -function ciLaneSettings(argsOrLane = "node") { +function ciLaneSettings(argsOrLane = "node", deploy = null) { const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane; if (isRuntimeLane(lane)) { - const namespace = namespaceNameForProfile(lane); + const namespace = namespaceNameForProfile(lane, deploy); return { lane, profile: lane, @@ -64,8 +64,8 @@ function ciLaneSettings(argsOrLane = "node") { }; } -function tektonRbac(args = { lane: "node" }) { - const settings = ciLaneSettings(args); +function tektonRbac(args = { lane: "node" }, deploy = null) { + const settings = ciLaneSettings(args, deploy); if (isRuntimeLane(settings.lane)) { const runtimeObserverName = `${settings.runtimeNamespace}-runtime-observer`; const pipelineRunWriterName = `${settings.runtimeNamespace}-pipelinerun-writer`; @@ -444,7 +444,7 @@ node scripts/ci/runtime-ready.mjs `; } -function runtimeReadyTask({ profile = "dev" } = {}) { +function runtimeReadyTask({ profile = "dev", deploy = null } = {}) { return { name: "runtime-ready", runAfter: ["gitops-promote"], @@ -466,7 +466,7 @@ function runtimeReadyTask({ profile = "dev" } = {}) { }, params: [ { name: "revision", value: "$(params.revision)" }, - { name: "runtime-namespace", value: namespaceNameForProfile(profile) }, + { name: "runtime-namespace", value: namespaceNameForProfile(profile, deploy) }, { name: "gitops-target", value: gitopsTargetForProfile(profile) }, { name: "argo-application", value: argoApplicationName(profile) }, { name: "timeout-ms", value: "$(params.runtime-ready-timeout-ms)" } @@ -505,7 +505,7 @@ function reusableTaskResource(pipelineName, task) { } function tektonTasks(args = { lane: "node" }, deploy = null) { - const settings = ciLaneSettings(args); + const settings = ciLaneSettings(args, deploy); const prepareSource = { name: "prepare-source", taskSpec: { @@ -542,12 +542,12 @@ function tektonTasks(args = { lane: "node" }, deploy = null) { } function tektonPipeline(args = { lane: "node" }, deploy = null) { - const settings = ciLaneSettings(args); - const runtimePath = gitopsPathForProfile(args, settings.profile); + const settings = ciLaneSettings(args, deploy); + const runtimePath = gitopsPathForProfile(args, settings.profile, deploy); const preFlushRuntimeReadyTasks = isRuntimeLane(settings.lane) ? [] : [{ - ...runtimeReadyTask({ profile: settings.profile }), + ...runtimeReadyTask({ profile: settings.profile, deploy }), when: [{ input: "$(tasks.gitops-promote.results.runtime-ready-required)", operator: "in", values: ["true"] }] }]; return { @@ -671,9 +671,9 @@ function tektonPipeline(args = { lane: "node" }, deploy = null) { }; } -function tektonPipelineRunTemplate({ source, args }) { - const settings = ciLaneSettings(args); - const runtimePath = gitopsPathForProfile(args, settings.profile); +function tektonPipelineRunTemplate({ source, args, deploy = null }) { + const settings = ciLaneSettings(args, deploy); + const runtimePath = gitopsPathForProfile(args, settings.profile, deploy); return { apiVersion: "tekton.dev/v1", kind: "PipelineRun", @@ -720,7 +720,7 @@ function tektonPipelineRunTemplate({ source, args }) { } function tektonPollerCronJob(args, deploy) { - const settings = ciLaneSettings(args); + const settings = ciLaneSettings(args, deploy); if (settings.lane === "v02") throw new Error("v02 CI/CD is manually triggered by UniDesk CLI and must not render a branch poller CronJob"); return { apiVersion: "batch/v1", @@ -771,7 +771,7 @@ function tektonPollerCronJob(args, deploy) { { name: "LANE", value: settings.lane }, { name: "CATALOG_PATH", value: args.catalogPath || settings.catalogPath }, { name: "IMAGE_TAG_MODE", value: args.imageTagMode || settings.imageTagMode }, - { name: "RUNTIME_PATH", value: gitopsPathForProfile(args, settings.profile) }, + { name: "RUNTIME_PATH", value: gitopsPathForProfile(args, settings.profile, deploy) }, { name: "GIT_URL", value: args.sourceRepo }, { name: "GIT_READ_URL", value: args.gitReadUrl }, { name: "SOURCE_BRANCH", value: args.sourceBranch }, diff --git a/scripts/src/gitops-render/templates/gitops-promote.sh b/scripts/src/gitops-render/templates/gitops-promote.sh index 1c2431c4..595b5b76 100644 --- a/scripts/src/gitops-render/templates/gitops-promote.sh +++ b/scripts/src/gitops-render/templates/gitops-promote.sh @@ -13,8 +13,8 @@ for tool in node git timeout; do command -v "$tool" >/dev/null 2>&1 || { echo '{ git_url_requires_ssh() { case "$1" in git@*|ssh://*) return 0 ;; *) return 1 ;; esac; } lane="$(params.lane)" case "$lane" in - v[0-9][0-9]*) runtime_lane=true ;; - *) runtime_lane=false ;; + node) runtime_lane=false ;; + *) runtime_lane=true ;; esac if [ "$runtime_lane" = "true" ]; then printf 'false' > /tekton/results/runtime-ready-required diff --git a/scripts/src/runtime-lane.test.mjs b/scripts/src/runtime-lane.test.mjs new file mode 100644 index 00000000..fad05c70 --- /dev/null +++ b/scripts/src/runtime-lane.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; + +import { + applyRuntimeLaneDeployConfig, + isNamedRuntimeLane, + isRuntimeLane, + isVersionRuntimeLane, + runtimeNamespaceForRuntimeLane, + runtimeRelativePathForRuntimeLane +} from "./runtime-lane.ts"; +import { + gitopsPathForProfile, + namespaceNameForProfile, + parseArgs, + runtimeFrpConfigForProfile, + runtimePathForProfile, + transformServices +} from "./gitops-render/core.mjs"; +import { nodeFrpcManifest } from "./gitops-render/runtime-manifests.mjs"; + +const namedLaneDeploy = { + nodes: { + NC01: { + gitopsRoot: "deploy/gitops/node/nc01", + sourceRepo: "git@github.com:pikasTech/HWLAB.git" + } + }, + lanes: { + production: { + name: "Production", + node: "NC01", + sourceBranch: "release", + gitopsBranch: "hwlab-production", + gitReadUrl: "http://gitea.example/mirrors/hwlab.git", + gitWriteUrl: "http://git-mirror-write.example/pikasTech/HWLAB.git", + artifactCatalog: "deploy/artifact-catalog.production.json", + runtimePath: "runtime-production", + namespace: "hwlab-production", + imageTagMode: "full", + publicEndpoints: { + frontend: "https://hwlab.example", + api: "https://api.hwlab.example" + }, + publicServices: { + nodes: { + NC01: [{ + name: "hwlab-cloud-web-public", + selector: { "app.kubernetes.io/name": "hwlab-cloud-web" }, + port: 80, + targetPort: 8080, + nodePort: 32010 + }] + } + }, + frp: { + server: { address: "203.0.113.10", bindPort: 22000 }, + proxies: [ + { name: "production-web", endpointId: "frontend", localHost: "web.hwlab-production.svc.cluster.local", localPort: 8080, remotePort: 22090 }, + { name: "production-api", endpointId: "api", localHost: "api.hwlab-production.svc.cluster.local", localPort: 6667, remotePort: 22088 } + ] + }, + envReuseServices: ["hwlab-cloud-web"], + serviceDeclarations: { + "hwlab-cloud-web": { healthPort: 8080, healthPath: "/health/live" } + } + } + } +}; + +const defaultArgs = { + lane: "production", + nodeId: "node", + outDir: "deploy/gitops/node", + gitopsRoot: "deploy/gitops/node", + sourceBranch: "node", + gitopsBranch: "node-gitops", + catalogPath: "deploy/artifact-catalog.dev.json", + imageTagMode: "full", + runtimeEndpoint: "http://74.48.78.17:17667", + webEndpoint: "http://74.48.78.17:17666", + sourceRepo: "git@github.com:pikasTech/HWLAB.git" +}; + +assert.equal(isRuntimeLane("production"), true); +assert.equal(isNamedRuntimeLane("production"), true); +assert.equal(isVersionRuntimeLane("production"), false); +assert.equal(isVersionRuntimeLane("v03"), true); +assert.equal(parseArgs(["--lane", "production", "--no-write"]).lane, "production"); + +const resolved = applyRuntimeLaneDeployConfig(defaultArgs, namedLaneDeploy); +assert.deepEqual({ + nodeId: resolved.nodeId, + gitopsRoot: resolved.gitopsRoot, + sourceBranch: resolved.sourceBranch, + gitopsBranch: resolved.gitopsBranch, + gitReadUrl: resolved.gitReadUrl, + gitWriteUrl: resolved.gitWriteUrl, + catalogPath: resolved.catalogPath, + runtimeEndpoint: resolved.runtimeEndpoint, + webEndpoint: resolved.webEndpoint +}, { + nodeId: "NC01", + gitopsRoot: "deploy/gitops/node/nc01", + sourceBranch: "release", + gitopsBranch: "hwlab-production", + gitReadUrl: "http://gitea.example/mirrors/hwlab.git", + gitWriteUrl: "http://git-mirror-write.example/pikasTech/HWLAB.git", + catalogPath: "deploy/artifact-catalog.production.json", + runtimeEndpoint: "https://api.hwlab.example", + webEndpoint: "https://hwlab.example" +}); +assert.equal(runtimeNamespaceForRuntimeLane(namedLaneDeploy, "production"), "hwlab-production"); +assert.equal(runtimeRelativePathForRuntimeLane(namedLaneDeploy, "production"), "runtime-production"); +assert.equal(namespaceNameForProfile("production", namedLaneDeploy), "hwlab-production"); +assert.equal(runtimePathForProfile("production", namedLaneDeploy), "runtime-production"); +assert.equal( + gitopsPathForProfile(resolved, "production", namedLaneDeploy), + "deploy/gitops/node/nc01/runtime-production" +); + +const services = transformServices({ + services: { apiVersion: "v1", kind: "List", items: [] }, + namespace: "hwlab-production", + labels: {}, + annotations: {}, + profile: "production", + deploy: namedLaneDeploy, + nodeId: "NC01" +}); +assert.deepEqual(services.items[0].spec.ports[0], { name: "http", port: 80, targetPort: 8080, nodePort: 32010 }); + +const frp = runtimeFrpConfigForProfile(namedLaneDeploy, "production", { nodeId: "NC01" }); +assert.deepEqual({ + serverAddr: frp.serverAddr, + serverPort: frp.serverPort, + webLocalHost: frp.webLocalHost, + webLocalPort: frp.webLocalPort, + webRemotePort: frp.webRemotePort, + edgeLocalHost: frp.edgeLocalHost, + edgeLocalPort: frp.edgeLocalPort, + edgeRemotePort: frp.edgeRemotePort +}, { + serverAddr: "203.0.113.10", + serverPort: 22000, + webLocalHost: "web.hwlab-production.svc.cluster.local", + webLocalPort: 8080, + webRemotePort: 22090, + edgeLocalHost: "api.hwlab-production.svc.cluster.local", + edgeLocalPort: 6667, + edgeRemotePort: 22088 +}); + +const frpDisabledDeploy = structuredClone(namedLaneDeploy); +frpDisabledDeploy.lanes.production.frp = { enabled: false }; +assert.deepEqual(nodeFrpcManifest({ profile: "production", deploy: frpDisabledDeploy }).items, []); + +const incompleteDeploy = structuredClone(namedLaneDeploy); +delete incompleteDeploy.lanes.production.namespace; +assert.throws( + () => applyRuntimeLaneDeployConfig(defaultArgs, incompleteDeploy), + /deploy\.lanes\.production\.namespace must be a non-empty string/u +); + +const versionLaneDeploy = { + nodes: { G14: { gitopsRoot: "deploy/gitops/node/G14", publicHost: "74.48.78.17" } }, + lanes: { v03: { node: "G14" } } +}; +const versionResolved = applyRuntimeLaneDeployConfig({ ...defaultArgs, lane: "v03" }, versionLaneDeploy); +assert.equal(versionResolved.sourceBranch, "v0.3"); +assert.equal(versionResolved.gitopsBranch, "v0.3-gitops"); +assert.equal(versionResolved.catalogPath, "deploy/artifact-catalog.v03.json"); +assert.equal(runtimeNamespaceForRuntimeLane(versionLaneDeploy, "v03"), "hwlab-v03"); +assert.equal(runtimeRelativePathForRuntimeLane(versionLaneDeploy, "v03"), "runtime-v03"); + +console.log(JSON.stringify({ ok: true, tests: 20, namedLane: "production", versionLane: "v03" })); diff --git a/scripts/src/runtime-lane.ts b/scripts/src/runtime-lane.ts index dcd1800f..2f31cb58 100644 --- a/scripts/src/runtime-lane.ts +++ b/scripts/src/runtime-lane.ts @@ -26,11 +26,15 @@ export type GitOpsRenderArgs = { }; export type RuntimeLaneConfig = { + name?: string; node?: string; sourceBranch?: string; gitopsBranch?: string; + gitReadUrl?: string; + gitWriteUrl?: string; artifactCatalog?: string; runtimePath?: string; + namespace?: string; imageTagMode?: string; endpoint?: string; externalPostgres?: { @@ -75,11 +79,20 @@ const defaultGitopsRoot = "deploy/gitops/node"; const defaultPublicHost = "74.48.78.17"; export function isRuntimeLane(lane: unknown): lane is string { + const value = String(lane ?? ""); + return !["node", "dev", "prod"].includes(value) && /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(value); +} + +export function isVersionRuntimeLane(lane: unknown): lane is string { return /^v[0-9]{2,}$/u.test(String(lane ?? "")); } +export function isNamedRuntimeLane(lane: unknown): lane is string { + return isRuntimeLane(lane) && !isVersionRuntimeLane(lane); +} + export function runtimeLaneMinor(lane: string): number { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`); return Number.parseInt(lane.slice(1), 10); } @@ -88,12 +101,12 @@ export function versionNameForRuntimeLane(lane: string): string { } export function namespaceNameForRuntimeLane(lane: string): string { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`); return `hwlab-${lane}`; } export function runtimePathForRuntimeLane(lane: string): string { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`); return `runtime-${lane}`; } @@ -133,9 +146,10 @@ export function defaultEndpointForRuntimeLane(lane: string, options: { host?: st } export function runtimeLaneConfig(deploy: DeployConfig, lane: string): RuntimeLaneConfig { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isRuntimeLane(lane), `runtime lane must be a stable lowercase id, got ${lane}`); const config = deploy.lanes?.[lane]; invariant(isObject(config), `deploy.lanes.${lane} is required`); + if (isNamedRuntimeLane(lane)) validateNamedRuntimeLaneConfig(config, lane); return config; } @@ -145,13 +159,14 @@ export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: Dep const laneConfig = runtimeLaneConfig(deploy, args.lane); const configuredNodeId = nodeIdForRuntimeLane(deploy, args.lane, defaults); const rootNodeId = nodeIdFromGitopsRoot(result.gitopsRoot); - const nodeId = result.nodeId && result.nodeId !== defaultNodeId ? result.nodeId : rootNodeId ?? configuredNodeId; + const requestedNodeId = result.nodeId && result.nodeId !== defaultNodeId ? result.nodeId : rootNodeId ?? configuredNodeId; + const nodeId = isNamedRuntimeLane(args.lane) ? configuredNodeId : requestedNodeId; const node = nodeConfig(deploy, nodeId); - const versionName = versionNameForRuntimeLane(args.lane); + const versionName = isVersionRuntimeLane(args.lane) ? versionNameForRuntimeLane(args.lane) : null; const defaultEndpointOptions: { host?: string; v02Endpoint?: string } = {}; if (node.publicHost) defaultEndpointOptions.host = node.publicHost; if (defaults.defaultV02RuntimeEndpoint) defaultEndpointOptions.v02Endpoint = defaults.defaultV02RuntimeEndpoint; - const defaultEndpoint = defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions); + const defaultEndpoint = versionName ? defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions) : null; const configuredEndpoint = laneConfig.endpoint ?? defaultEndpoint; const configuredWebEndpoint = laneConfig.publicEndpoints?.frontend ?? configuredEndpoint; const configuredRuntimeEndpoint = laneConfig.publicEndpoints?.api ?? configuredEndpoint; @@ -163,14 +178,32 @@ export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: Dep const defaultSourceRepo = defaults.defaultSourceRepo ?? "git@github.com:pikasTech/HWLAB.git"; const defaultCatalog = `deploy/artifact-catalog.${args.lane}.json`; + if (isNamedRuntimeLane(args.lane)) { + const configuredSourceRepo = laneConfig.sourceRepo ?? node.sourceRepo; + invariant(typeof node.gitopsRoot === "string" && node.gitopsRoot.trim().length > 0, `deploy.nodes.${nodeId}.gitopsRoot is required for named runtime lane ${args.lane}`); + requiredString(configuredSourceRepo, `deploy.lanes.${args.lane}.sourceRepo or deploy.nodes.${nodeId}.sourceRepo`); + result.nodeId = configuredNodeId; + result.gitopsRoot = normalizeRepoPath(node.gitopsRoot); + result.sourceBranch = requiredString(laneConfig.sourceBranch, `deploy.lanes.${args.lane}.sourceBranch`); + result.gitopsBranch = requiredString(laneConfig.gitopsBranch, `deploy.lanes.${args.lane}.gitopsBranch`); + result.catalogPath = normalizeRepoPath(requiredString(laneConfig.artifactCatalog, `deploy.lanes.${args.lane}.artifactCatalog`)); + result.imageTagMode = requiredString(laneConfig.imageTagMode, `deploy.lanes.${args.lane}.imageTagMode`); + result.runtimeEndpoint = requiredString(laneConfig.publicEndpoints?.api, `deploy.lanes.${args.lane}.publicEndpoints.api`); + result.webEndpoint = requiredString(laneConfig.publicEndpoints?.frontend, `deploy.lanes.${args.lane}.publicEndpoints.frontend`); + result.sourceRepo = configuredSourceRepo as string; + result.gitReadUrl = requiredString(laneConfig.gitReadUrl, `deploy.lanes.${args.lane}.gitReadUrl`); + result.gitWriteUrl = requiredString(laneConfig.gitWriteUrl, `deploy.lanes.${args.lane}.gitWriteUrl`); + return result; + } + if (!result.nodeId || result.nodeId === defaultNodeId) result.nodeId = nodeId; if (!result.gitopsRoot || result.gitopsRoot === (defaults.defaultGitopsRoot ?? defaultGitopsRoot)) result.gitopsRoot = gitopsRootForNode(deploy, nodeId, defaults); - if (result.sourceBranch === defaultBranch || result.sourceBranch === versionName) result.sourceBranch = laneConfig.sourceBranch ?? versionName; - if (result.gitopsBranch === defaultGitopsBranch || result.gitopsBranch === `${versionName}-gitops`) result.gitopsBranch = laneConfig.gitopsBranch ?? `${versionName}-gitops`; + if (result.sourceBranch === defaultBranch || result.sourceBranch === versionName) result.sourceBranch = laneConfig.sourceBranch ?? versionName ?? result.sourceBranch; + if (result.gitopsBranch === defaultGitopsBranch || result.gitopsBranch === `${versionName}-gitops`) result.gitopsBranch = laneConfig.gitopsBranch ?? (versionName ? `${versionName}-gitops` : result.gitopsBranch); if (result.catalogPath === defaultCatalogPath || result.catalogPath === defaultCatalog) result.catalogPath = laneConfig.artifactCatalog ?? defaultCatalog; if (result.imageTagMode === "full") result.imageTagMode = laneConfig.imageTagMode ?? result.imageTagMode; - if (result.runtimeEndpoint === defaultRuntimeEndpoint || result.runtimeEndpoint === defaultEndpoint) result.runtimeEndpoint = configuredRuntimeEndpoint; - if (result.webEndpoint === defaultWebEndpoint || result.webEndpoint === defaultEndpoint) result.webEndpoint = configuredWebEndpoint; + if (result.runtimeEndpoint === defaultRuntimeEndpoint || result.runtimeEndpoint === defaultEndpoint) result.runtimeEndpoint = configuredRuntimeEndpoint ?? result.runtimeEndpoint; + if (result.webEndpoint === defaultWebEndpoint || result.webEndpoint === defaultEndpoint) result.webEndpoint = configuredWebEndpoint ?? result.webEndpoint; if (result.sourceRepo === defaultSourceRepo) result.sourceRepo = laneConfig.sourceRepo ?? node.sourceRepo ?? result.sourceRepo; return result; } @@ -185,20 +218,48 @@ function nodeIdFromGitopsRoot(gitopsRoot: unknown): string | undefined { export function runtimeLaneMirrorSpecs(deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): RuntimeLaneMirrorSpec[] { return Object.entries(deploy.lanes ?? {}) .filter(([lane, config]) => isRuntimeLane(lane) && isObject(config)) - .sort(([left], [right]) => runtimeLaneMinor(left) - runtimeLaneMinor(right)) + .sort(([left], [right]) => left.localeCompare(right, "en")) .map(([lane, config]) => { const laneConfig = config as RuntimeLaneConfig; - const versionName = versionNameForRuntimeLane(lane); + const versionName = isVersionRuntimeLane(lane) ? versionNameForRuntimeLane(lane) : null; return { lane, - sourceBranch: laneConfig.sourceBranch ?? versionName, - gitopsBranch: laneConfig.gitopsBranch ?? `${versionName}-gitops`, + sourceBranch: laneConfig.sourceBranch ?? versionName ?? requiredString(laneConfig.sourceBranch, `deploy.lanes.${lane}.sourceBranch`), + gitopsBranch: laneConfig.gitopsBranch ?? (versionName ? `${versionName}-gitops` : requiredString(laneConfig.gitopsBranch, `deploy.lanes.${lane}.gitopsBranch`)), artifactCatalog: laneConfig.artifactCatalog ?? `deploy/artifact-catalog.${lane}.json`, runtimePath: runtimeGitopsPathForRuntimeLane(deploy, lane, defaults), }; }); } +export function runtimeNamespaceForRuntimeLane(deploy: DeployConfig, lane: string): string { + const laneConfig = runtimeLaneConfig(deploy, lane); + return laneConfig.namespace ?? namespaceNameForRuntimeLane(lane); +} + +export function runtimeRelativePathForRuntimeLane(deploy: DeployConfig, lane: string): string { + const laneConfig = runtimeLaneConfig(deploy, lane); + return normalizeRepoPath(laneConfig.runtimePath ?? runtimePathForRuntimeLane(lane)); +} + +function validateNamedRuntimeLaneConfig(config: RuntimeLaneConfig, lane: string): void { + const label = `deploy.lanes.${lane}`; + for (const key of ["name", "node", "sourceBranch", "gitopsBranch", "gitReadUrl", "gitWriteUrl", "artifactCatalog", "runtimePath", "namespace", "imageTagMode"] as const) { + requiredString(config[key], `${label}.${key}`); + } + invariant(config.imageTagMode === "short" || config.imageTagMode === "full", `${label}.imageTagMode must be short or full`); + invariant(isObject(config.publicEndpoints), `${label}.publicEndpoints is required`); + requiredString(config.publicEndpoints.frontend, `${label}.publicEndpoints.frontend`); + requiredString(config.publicEndpoints.api, `${label}.publicEndpoints.api`); + invariant(Array.isArray((config as Record).envReuseServices) && ((config as Record).envReuseServices as unknown[]).length > 0, `${label}.envReuseServices must not be empty`); + invariant(isObject((config as Record).serviceDeclarations), `${label}.serviceDeclarations is required`); +} + +function requiredString(value: unknown, label: string): string { + invariant(typeof value === "string" && value.trim().length > 0, `${label} must be a non-empty string`); + return value.trim(); +} + function normalizeRepoPath(value: string): string { return value.replaceAll("\\\\", "/").replace(/^\.\//u, "").replace(/\/+$/u, ""); }