#!/usr/bin/env node import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultOutDir = "deploy/gitops/g14"; const defaultRegistryPrefix = process.env.HWLAB_G14_REGISTRY_PREFIX || "127.0.0.1:5000/hwlab"; const defaultSourceRepo = process.env.HWLAB_G14_GIT_URL || "git@github.com:pikasTech/HWLAB.git"; const defaultBranch = process.env.HWLAB_G14_BRANCH || "G14"; const defaultGitopsBranch = process.env.HWLAB_G14_GITOPS_BRANCH || "G14-gitops"; const defaultRuntimeEndpoint = process.env.HWLAB_G14_RUNTIME_ENDPOINT || "http://74.48.78.17:17667"; const defaultWebEndpoint = process.env.HWLAB_G14_WEB_ENDPOINT || "http://74.48.78.17:17666"; const defaultProxyUrl = process.env.HWLAB_G14_PROXY_URL || "http://127.0.0.1:10808"; const defaultAllProxyUrl = process.env.HWLAB_G14_ALL_PROXY_URL || "socks5h://127.0.0.1:10808"; const defaultNoProxy = process.env.HWLAB_G14_NO_PROXY || "127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,192.168.0.0/16,.svc,.cluster.local,kubernetes.default.svc,registry.hwlab-ci.svc,hwlab-registry.hwlab-ci.svc"; const sourceCommitPattern = /^[a-f0-9]{7,40}$/u; function proxyEnv() { return [ { name: "HTTP_PROXY", value: defaultProxyUrl }, { name: "HTTPS_PROXY", value: defaultProxyUrl }, { name: "ALL_PROXY", value: defaultAllProxyUrl }, { name: "NO_PROXY", value: defaultNoProxy }, { name: "http_proxy", value: defaultProxyUrl }, { name: "https_proxy", value: defaultProxyUrl }, { name: "all_proxy", value: defaultAllProxyUrl }, { name: "no_proxy", value: defaultNoProxy } ]; } function parseArgs(argv) { const args = { outDir: defaultOutDir, registryPrefix: defaultRegistryPrefix, sourceRevision: null, sourceBranch: defaultBranch, gitopsBranch: defaultGitopsBranch, sourceRepo: defaultSourceRepo, runtimeEndpoint: defaultRuntimeEndpoint, webEndpoint: defaultWebEndpoint, check: false, write: true, help: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--out") args.outDir = readOption(argv, ++index, arg); else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg).replace(/\/+$/u, ""); else if (arg === "--source-revision") args.sourceRevision = readOption(argv, ++index, arg); else if (arg === "--source-branch") args.sourceBranch = readOption(argv, ++index, arg); else if (arg === "--gitops-branch") args.gitopsBranch = readOption(argv, ++index, arg); else if (arg === "--source-repo") args.sourceRepo = readOption(argv, ++index, arg); else if (arg === "--runtime-endpoint") args.runtimeEndpoint = readOption(argv, ++index, arg); else if (arg === "--web-endpoint") args.webEndpoint = readOption(argv, ++index, arg); else if (arg === "--check") { args.check = true; args.write = false; } else if (arg === "--no-write") args.write = false; else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } return args; } function readOption(argv, index, name) { const value = argv[index]; if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } function usage() { return [ "usage: node scripts/g14-gitops-render.mjs [options]", "", "Render G14-only Kubernetes-native CI/CD manifests from CI.json and deploy/deploy.json.", "", "options:", ` --out DIR default: ${defaultOutDir}`, ` --source-repo URL default: ${defaultSourceRepo}`, ` --source-branch BRANCH default: ${defaultBranch}`, ` --gitops-branch BRANCH default: ${defaultGitopsBranch}`, " --source-revision SHA source commit used for image tags and runtime annotations; default: git rev-parse HEAD", ` --registry-prefix PREFIX default: ${defaultRegistryPrefix}`, ` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}`, ` --web-endpoint URL default: ${defaultWebEndpoint}`, " --check verify generated files are current without writing" ].join("\n"); } async function readJson(relativePath) { return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); } async function gitValue(args) { const result = await execFileAsync("git", args, { cwd: repoRoot, timeout: 10000, maxBuffer: 1024 * 1024 }); return result.stdout.trim(); } async function resolveSourceRevision(value) { const revision = value || await gitValue(["rev-parse", "HEAD"]); const full = sourceCommitPattern.test(revision) && revision.length === 40 ? revision : await gitValue(["rev-parse", revision]); assert.match(full, /^[a-f0-9]{40}$/u, "source revision must resolve to a 40-char git commit"); return { full, short: full.slice(0, 7) }; } async function defaultCheckSourceRevision(args) { if (!args.check || args.sourceRevision) return args; try { const raw = await readFile(path.join(repoRoot, args.outDir, "source.json"), "utf8"); const parsed = JSON.parse(raw); if (typeof parsed.sourceCommit === "string" && sourceCommitPattern.test(parsed.sourceCommit)) { return { ...args, sourceRevision: parsed.sourceCommit }; } } catch (error) { if (error?.code !== "ENOENT") throw error; } return args; } function jsonManifest(value) { return `${JSON.stringify(value, null, 2)}\n`; } function textFile(value) { return value.endsWith("\n") ? value : `${value}\n`; } function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } function ensureObject(value, name) { assert.ok(value && typeof value === "object" && !Array.isArray(value), `${name} must be an object`); return value; } function asItems(list, name) { assert.equal(list?.kind, "List", `${name} must be a Kubernetes List`); assert.ok(Array.isArray(list.items), `${name}.items must be an array`); return list.items; } function serviceIdForWorkload(item, container) { return ( item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || container?.name ); } function upsertEnv(envList, name, value) { if (!Array.isArray(envList)) return; const existing = envList.find((entry) => entry?.name === name); if (existing) { delete existing.valueFrom; existing.value = value; } else { envList.push({ name, value }); } } function annotate(metadata, annotations) { metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations }; } function label(metadata, labels) { metadata.labels = { ...(metadata.labels ?? {}), ...labels }; } function imageFor(registryPrefix, serviceId, shortCommit) { return `${registryPrefix}/${serviceId}:${shortCommit}`; } function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint }) { const result = cloneJson(workloads); const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service])); const stableRuntimeLabels = { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/environment": "dev", "hwlab.pikastech.local/profile": "dev" }; for (const item of asItems(result, "workloads")) { item.metadata.namespace = deploy.namespace; label(item.metadata, { ...stableRuntimeLabels, "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": source.full, "unidesk.ai/g14-staging": "true" }); annotate(item.metadata, { "hwlab.pikastech.local/gitops-note": "G14 GitOps target only; production traffic remains outside this target.", "hwlab.pikastech.local/source-commit": source.full }); if (item.kind === "Job") { annotate(item.metadata, { "argocd.argoproj.io/sync-options": "Force=true,Replace=true" }); } if (serviceIdForWorkload(item, null) === "hwlab-tunnel-client" && typeof item.spec?.replicas === "number") { item.spec.replicas = 0; } const podTemplate = item?.spec?.template; if (!podTemplate?.spec?.containers) continue; label(podTemplate.metadata ??= {}, { ...stableRuntimeLabels, "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": source.full }); if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) { item.spec.selector.matchLabels = { ...item.spec.selector.matchLabels, ...stableRuntimeLabels }; } annotate(podTemplate.metadata, { "hwlab.pikastech.local/source-commit": source.full, "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" }); for (const container of podTemplate.spec.containers) { const serviceId = serviceIdForWorkload(item, container); if (!deployServices.has(serviceId)) continue; const image = imageFor(registryPrefix, serviceId, source.short); container.image = image; container.imagePullPolicy = "IfNotPresent"; container.env ??= []; upsertEnv(container.env, "HWLAB_COMMIT_ID", source.full); upsertEnv(container.env, "HWLAB_IMAGE", image); upsertEnv(container.env, "HWLAB_IMAGE_TAG", source.short); upsertEnv(container.env, "HWLAB_GITOPS_TARGET", "g14"); upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", source.full); if (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-edge-proxy") { upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint); } if (serviceId === "hwlab-cloud-web") { upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); } if (serviceId === "hwlab-cli") { upsertEnv(container.env, "HWLAB_CLI_ENDPOINT", runtimeEndpoint); } if (serviceId === "hwlab-tunnel-client") { upsertEnv(container.env, "HWLAB_TUNNEL_MODE", "disabled-g14-gitops"); upsertEnv(container.env, "HWLAB_FRP_SERVER_ADDR", ""); upsertEnv(container.env, "HWLAB_FRP_PUBLIC_PORT", "0"); upsertEnv(container.env, "HWLAB_FRP_WEB_PUBLIC_PORT", "0"); } } } return result; } function transformHealthContract(value, namespace, labels, annotations, runtimeEndpoint, webEndpoint) { const result = transformListNamespace(value, namespace, labels, annotations); if (result.data && typeof result.data === "object") { if (Object.hasOwn(result.data, "endpoint")) result.data.endpoint = runtimeEndpoint; if (Object.hasOwn(result.data, "cloud-web")) { result.data["cloud-web"] = `GET /health/live on ${webEndpoint}; consumes cloud-api only`; } if (Object.hasOwn(result.data, "cloud-api")) { result.data["cloud-api"] = `GET /health/live through ${runtimeEndpoint}`; } if (Object.hasOwn(result.data, "tunnel-client")) { result.data["tunnel-client"] = "disabled for G14 GitOps; no FRP or production traffic tunnel is required"; } } return result; } function transformListNamespace(value, namespace, labels, annotations) { const result = cloneJson(value); if (result.kind === "List") { for (const item of result.items ?? []) { item.metadata ??= {}; item.metadata.namespace = namespace; label(item.metadata, labels); annotate(item.metadata, annotations); } } else { result.metadata ??= {}; result.metadata.namespace = namespace; label(result.metadata, labels); annotate(result.metadata, annotations); } return result; } function shellSingleQuote(value) { return `'${String(value).replace(/'/g, `'"'"'`)}'`; } function dependencyProxyProbeScript(phase, targetUrl) { return `HWLAB_PROXY_PROBE_PHASE=${shellSingleQuote(phase)} HWLAB_PROXY_PROBE_URL=${shellSingleQuote(targetUrl)} node <<'NODE' || echo '{"event":"dependency-proxy-probe-nonblocking","phase":"${phase}","ok":false,"reason":"probe-failed-but-continuing"}' const net = require("node:net"); const phase = process.env.HWLAB_PROXY_PROBE_PHASE || "unknown"; const target = new URL(process.env.HWLAB_PROXY_PROBE_URL || "http://deb.debian.org/debian/dists/bookworm/InRelease"); const proxyRaw = process.env.HTTP_PROXY || process.env.http_proxy || ""; function redactProxy(value) { if (!value) return ""; try { const parsed = new URL(value); if (parsed.username || parsed.password) { parsed.username = "***"; parsed.password = ""; } return parsed.toString(); } catch { return ""; } } function emit(payload, exitCode = 0) { console.log(JSON.stringify({ event: "dependency-proxy-probe", phase, target: target.href, proxy: redactProxy(proxyRaw), ...payload })); if (exitCode) process.exit(exitCode); } if (!proxyRaw) emit({ ok: false, reason: "proxy-env-missing" }, 21); let proxy; try { proxy = new URL(proxyRaw); } catch (error) { emit({ ok: false, reason: "proxy-url-invalid", error: error.message }, 22); } if (proxy.protocol !== "http:" && proxy.protocol !== "https:") { emit({ ok: false, reason: "http-proxy-required", protocol: proxy.protocol }, 23); } const startedAt = Date.now(); const socket = net.createConnection({ host: proxy.hostname, port: Number(proxy.port || (proxy.protocol === "https:" ? 443 : 80)) }); let bytes = 0; let firstByteMs = null; let responseHead = ""; let finished = false; const timeout = setTimeout(() => { if (finished) return; finished = true; socket.destroy(); emit({ ok: false, reason: "timeout", timeoutMs: 15000 }, 24); }, 15000); socket.on("connect", () => { socket.write("GET " + target.href + " HTTP/1.1\\r\\nHost: " + target.host + "\\r\\nUser-Agent: hwlab-g14-proxy-probe\\r\\nConnection: close\\r\\n\\r\\n"); }); socket.on("data", (chunk) => { if (firstByteMs === null) firstByteMs = Date.now() - startedAt; bytes += chunk.length; if (responseHead.length < 240) responseHead += chunk.toString("utf8", 0, Math.min(chunk.length, 240 - responseHead.length)); }); socket.on("end", () => { if (finished) return; finished = true; clearTimeout(timeout); const totalMs = Date.now() - startedAt; const statusLine = responseHead.split("\\r\\n", 1)[0] || ""; const statusCode = Number(statusLine.split(" ")[1] || 0); const ok = statusLine.startsWith("HTTP/") && statusCode >= 200 && statusCode < 400; emit({ ok, reason: ok ? null : "bad-http-status", statusLine, statusCode, firstByteMs, totalMs, bytes, speedBytesPerSecond: totalMs > 0 ? Math.round(bytes * 1000 / totalMs) : 0 }, ok ? 0 : 25); }); socket.on("error", (error) => { if (finished) return; finished = true; clearTimeout(timeout); emit({ ok: false, reason: "proxy-connect-failed", error: error.message, totalMs: Date.now() - startedAt }, 26); }); NODE`; } function curlDownloadProbeFunction() { return `redact_proxy_value() { printf '%s' "\${1:-}" | sed -E 's#(https?://)[^/@]+@#\\1***@#; s#(socks5h?://)[^/@]+@#\\1***@#' } curl_dependency_probe() { phase="$1" url="$2" proxy="\${HTTPS_PROXY:-\${https_proxy:-\${HTTP_PROXY:-\${http_proxy:-}}}}" if [ -z "$proxy" ]; then echo '{"event":"dependency-curl-probe","phase":"'"$phase"'","ok":false,"reason":"proxy-env-missing"}' return 21 fi safe_proxy="$(redact_proxy_value "$proxy")" echo '{"event":"dependency-curl-probe-start","phase":"'"$phase"'","proxy":"'"$safe_proxy"'","target":"'"$url"'"}' curl -sS -L --range 0-1048575 -o /dev/null --connect-timeout 15 --max-time 60 --proxy "$proxy" \ -w '{"event":"dependency-curl-probe","phase":"'"$phase"'","http_code":"%{http_code}","time_namelookup":%{time_namelookup},"time_connect":%{time_connect},"time_starttransfer":%{time_starttransfer},"time_total":%{time_total},"speed_download":%{speed_download},"size_download":%{size_download},"remote_ip":"%{remote_ip}"}\n' \ "$url" }`; } function ciJsonScript(ci) { const commands = ci.commands ?? []; const lines = [ "#!/bin/sh", "set -eu", dependencyProxyProbeScript("ci-json-pre-apt", "http://deb.debian.org/debian/dists/bookworm/InRelease"), "if ! command -v git >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then", " export DEBIAN_FRONTEND=noninteractive", " apt-get update", " apt-get install -y --no-install-recommends git python3 openssh-client ca-certificates curl", " rm -rf /var/lib/apt/lists/*", "fi", curlDownloadProbeFunction(), "curl_dependency_probe \"ci-json-pre-npm\" \"https://registry.npmjs.org/npm/latest\"", "mkdir -p /root/.ssh", "cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa", "chmod 600 /root/.ssh/id_rsa", "ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null", "rm -rf /workspace/source/repo", "git clone --branch \"$(params.source-branch)\" \"$(params.git-url)\" /workspace/source/repo", "cd /workspace/source/repo", "git checkout \"$(params.revision)\"", "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", "npm ci --ignore-scripts", "playwright_browser_probe_url=\"$(node -e 'const fs=require(\"node:fs\"); let url=\"\"; try { const p=require.resolve(\"playwright-core/browsers.json\"); const data=JSON.parse(fs.readFileSync(p,\"utf8\")); const b=data.browsers.find((item)=>item.name===\"chromium\"); if (b?.revision) url=\"https://playwright.azureedge.net/builds/chromium/\"+b.revision+\"/chromium-linux.zip\"; } catch {} process.stdout.write(url);' 2>/dev/null || true)\"", "if [ -n \"$playwright_browser_probe_url\" ]; then curl_dependency_probe \"ci-json-pre-playwright\" \"$playwright_browser_probe_url\"; fi", "npx playwright install --with-deps chromium", `echo ${shellSingleQuote(`CI.json ${ci.name ?? "hwlab"} command count=${commands.length}`)}` ]; for (const command of commands) { lines.push(`echo ${shellSingleQuote(`CI.json command: ${command.name}`)}`); lines.push(command.run); } return `${lines.join("\n")}\n`; } function publishScript() { return `#!/bin/sh set -eu ${dependencyProxyProbeScript("image-publish-pre-apk", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/x86_64/APKINDEX.tar.gz")} apk add --no-cache docker-cli git openssh-client python3 export DOCKER_HOST=unix:///var/run/docker.sock export HWLAB_CI_ARTIFACT_RUN_ID="$(context.pipelineRun.name)" export HWLAB_CI_ARTIFACT_RUN_OWNER="tekton" export HWLAB_DEV_REGISTRY_PREFIX="$(params.registry-prefix)" export HWLAB_DEV_REGISTRY_K8S_NATIVE=1 if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi for i in $(seq 1 90); do docker info >/dev/null 2>&1 && break; sleep 2; done docker info >/dev/null if [ -n "\${HWLAB_DEV_BASE_IMAGE:-}" ]; then ${dependencyProxyProbeScript("image-publish-pre-base-image", "https://registry-1.docker.io/v2/")} started_at="$(date +%s)" echo '{"event":"dependency-download-start","phase":"image-publish-docker-pull","image":"'"$HWLAB_DEV_BASE_IMAGE"'"}' docker pull "$HWLAB_DEV_BASE_IMAGE" finished_at="$(date +%s)" echo '{"event":"dependency-download-complete","phase":"image-publish-docker-pull","image":"'"$HWLAB_DEV_BASE_IMAGE"'","durationSeconds":'"$((finished_at - started_at))"'}' fi cd /workspace/source/repo test "$(git rev-parse HEAD)" = "$(params.revision)" node scripts/dev-artifact-publish.mjs --publish --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency "$(params.concurrency)" --services "$(params.services)" node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write `; } function gitopsPromoteScript() { return `#!/bin/sh set -eu ${dependencyProxyProbeScript("gitops-promote-pre-apt", "http://deb.debian.org/debian/dists/bookworm/InRelease")} if ! command -v git >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends git openssh-client ca-certificates rm -rf /var/lib/apt/lists/* fi mkdir -p /root/.ssh cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa chmod 600 /root/.ssh/id_rsa ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true export GIT_SSH_COMMAND="ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" cd /workspace/source/repo test "$(git rev-parse HEAD)" = "$(params.revision)" check_source_head() { phase="$1" latest="$(git ls-remote "$(params.git-url)" "refs/heads/$(params.source-branch)" | cut -f1)" if [ -z "$latest" ]; then echo '{"status":"failed","reason":"source-head-unresolved","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'"}' exit 1 fi if [ "$latest" != "$(params.revision)" ]; then echo '{"status":"skipped-stale-source","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'","latestRevision":"'"$latest"'"}' exit 0 fi } check_source_head before-render node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --check check_source_head before-push git config --global user.name "HWLAB G14 GitOps Bot" git config --global user.email "hwlab-g14-gitops-bot@users.noreply.github.com" workdir="$(mktemp -d)" git clone --no-checkout "$(params.git-url)" "$workdir/gitops" cd "$workdir/gitops" if git ls-remote --exit-code --heads origin "$(params.gitops-branch)" >/dev/null 2>&1; then git fetch origin "$(params.gitops-branch)" git checkout -B "$(params.gitops-branch)" "origin/$(params.gitops-branch)" rm -rf deploy/gitops/g14 else git checkout --orphan "$(params.gitops-branch)" git rm -rf . >/dev/null 2>&1 || true fi mkdir -p deploy/gitops cp -a /workspace/source/repo/deploy/gitops/g14 deploy/gitops/g14 git add deploy/gitops/g14 if git diff --cached --quiet; then echo '{"status":"unchanged","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' exit 0 fi short="$(printf '%.7s' "$(params.revision)")" git commit -m "chore: promote G14 GitOps source $short" git push origin "HEAD:$(params.gitops-branch)" echo '{"status":"pushed","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' `; } function controlPlaneReconcileScript() { return `#!/bin/sh set -eu ${dependencyProxyProbeScript("control-plane-pre-apk", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/x86_64/APKINDEX.tar.gz")} apk add --no-cache git openssh-client ca-certificates mkdir -p /root/.ssh cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa chmod 600 /root/.ssh/id_rsa ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true export GIT_SSH_COMMAND="ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" workdir="$(mktemp -d)" git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_URL" "$workdir/repo" cd "$workdir/repo" revision="$(git rev-parse HEAD)" node scripts/g14-gitops-render.mjs --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" node scripts/g14-gitops-render.mjs --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" --check node <<'NODE' const fs = require("node:fs"); const https = require("node:https"); const namespace = process.env.POD_NAMESPACE || "hwlab-ci"; const host = process.env.KUBERNETES_SERVICE_HOST; const port = process.env.KUBERNETES_SERVICE_PORT || "443"; if (!host) throw new Error("KUBERNETES_SERVICE_HOST is not available"); const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"); const ca = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); const manifestFiles = [ "deploy/gitops/g14/tekton/rbac.yaml", "deploy/gitops/g14/tekton/pipeline.yaml", "deploy/gitops/g14/tekton/poller.yaml", "deploy/gitops/g14/tekton/control-plane-reconciler.yaml" ]; const plurals = new Map([ ["ServiceAccount", "serviceaccounts"], ["Role", "roles"], ["RoleBinding", "rolebindings"], ["Pipeline", "pipelines"], ["CronJob", "cronjobs"] ]); function encode(value) { return encodeURIComponent(String(value)); } function itemsFrom(file) { const parsed = JSON.parse(fs.readFileSync(file, "utf8")); return parsed.kind === "List" ? parsed.items || [] : [parsed]; } function apiPath(item) { if (item.kind === "Namespace") return null; const name = item.metadata?.name; if (!name) throw new Error("manifest item is missing metadata.name"); const ns = item.metadata?.namespace || namespace; const plural = plurals.get(item.kind); if (!plural) throw new Error("unsupported reconciler manifest kind " + item.kind); if (item.apiVersion === "v1") return "/api/v1/namespaces/" + encode(ns) + "/" + plural + "/" + encode(name); const parts = String(item.apiVersion || "").split("/"); if (parts.length !== 2) throw new Error("unsupported apiVersion " + item.apiVersion + " for " + item.kind); return "/apis/" + encode(parts[0]) + "/" + encode(parts[1]) + "/namespaces/" + encode(ns) + "/" + plural + "/" + encode(name); } function request(method, path, body) { const payload = body ? JSON.stringify(body) : ""; const headers = { Authorization: "Bearer " + token }; if (payload) { headers["Content-Type"] = "application/apply-patch+yaml"; headers["Content-Length"] = Buffer.byteLength(payload); } return new Promise((resolve, reject) => { const req = https.request({ host, port, method, path, ca, headers }, (res) => { let data = ""; res.setEncoding("utf8"); res.on("data", (chunk) => { data += chunk; }); res.on("end", () => resolve({ statusCode: res.statusCode || 0, body: data })); }); req.on("error", reject); if (payload) req.write(payload); req.end(); }); } (async () => { const results = []; for (const file of manifestFiles) { for (const item of itemsFrom(file)) { const path = apiPath(item); if (!path) { results.push({ file, kind: item.kind, name: item.metadata?.name || null, status: "skipped-cluster-scoped" }); continue; } const response = await request("PATCH", path + "?fieldManager=hwlab-g14-control-plane-reconciler&force=true", item); if (response.statusCode < 200 || response.statusCode > 299) { throw new Error("apply failed for " + item.kind + "/" + item.metadata.name + " status=" + response.statusCode + " body=" + response.body.slice(0, 1000)); } results.push({ file, kind: item.kind, name: item.metadata.name, status: "applied", statusCode: response.statusCode }); } } console.log(JSON.stringify({ event: "g14-control-plane-reconciled", sourceRevision: process.env.RECONCILE_REVISION || null, results }, null, 2)); })().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; }); NODE `; } function pollerScript() { return `#!/bin/sh set -eu ${dependencyProxyProbeScript("poller-pre-apk", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/x86_64/APKINDEX.tar.gz")} apk add --no-cache git openssh-client ca-certificates mkdir -p /root/.ssh cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa chmod 600 /root/.ssh/id_rsa ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true export GIT_SSH_COMMAND="ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" workdir="$(mktemp -d)" git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_URL" "$workdir/repo" cd "$workdir/repo" revision="$(git rev-parse HEAD)" subject="$(git log -1 --pretty=%s)" case "$subject" in "chore: promote G14 GitOps source "*) echo "Skipping generated GitOps promotion commit $revision" exit 0 ;; esac short="$(printf '%.12s' "$revision")" name="hwlab-g14-ci-poll-$short" export POLLER_REVISION="$revision" export POLLER_PIPELINERUN="$name" export POLLER_SOURCE_SUBJECT="$subject" echo "Checking G14 source $revision with PipelineRun $name" node <<'NODE' const fs = require("node:fs"); const https = require("node:https"); function env(name, fallback) { return process.env[name] || fallback; } const namespace = env("POD_NAMESPACE", "hwlab-ci"); const name = env("POLLER_PIPELINERUN", ""); const revision = env("POLLER_REVISION", ""); const host = process.env.KUBERNETES_SERVICE_HOST; const port = process.env.KUBERNETES_SERVICE_PORT || "443"; if (!name || !revision) throw new Error("poller did not resolve a PipelineRun name and revision"); if (!host) throw new Error("KUBERNETES_SERVICE_HOST is not available"); const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"); const ca = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); function request(method, path, body) { const payload = body ? JSON.stringify(body) : ""; const headers = { Authorization: "Bearer " + token }; if (payload) { headers["Content-Type"] = "application/json"; headers["Content-Length"] = Buffer.byteLength(payload); } return new Promise((resolve, reject) => { const req = https.request({ host, port, method, path, ca, headers }, (res) => { let data = ""; res.setEncoding("utf8"); res.on("data", (chunk) => { data += chunk; }); res.on("end", () => resolve({ statusCode: res.statusCode || 0, body: data })); }); req.on("error", reject); if (payload) req.write(payload); req.end(); }); } const labels = { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": revision, "hwlab.pikastech.local/trigger": "polling" }; const pipelineRun = { apiVersion: "tekton.dev/v1", kind: "PipelineRun", metadata: { name, namespace, labels, annotations: { "hwlab.pikastech.local/source-subject": env("POLLER_SOURCE_SUBJECT", ""), "hwlab.pikastech.local/source-branch": env("SOURCE_BRANCH", "G14"), "hwlab.pikastech.local/gitops-branch": env("GITOPS_BRANCH", "G14-gitops") } }, spec: { pipelineRef: { name: env("PIPELINE_NAME", "hwlab-g14-ci-image-publish") }, taskRunTemplate: { serviceAccountName: "hwlab-tekton-runner", podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" } }, params: [ { name: "git-url", value: env("GIT_URL", "git@github.com:pikasTech/HWLAB.git") }, { name: "source-branch", value: env("SOURCE_BRANCH", "G14") }, { name: "gitops-branch", value: env("GITOPS_BRANCH", "G14-gitops") }, { name: "revision", value: revision }, { name: "registry-prefix", value: env("REGISTRY_PREFIX", "127.0.0.1:5000/hwlab") }, { name: "services", value: env("SERVICES", "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills") }, { name: "base-image", value: env("BASE_IMAGE", "node:20-bookworm-slim") }, { name: "concurrency", value: env("CONCURRENCY", "4") } ], workspaces: [ { name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } }, { name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } } ] } }; const base = "/apis/tekton.dev/v1/namespaces/" + encodeURIComponent(namespace) + "/pipelineruns"; const item = base + "/" + encodeURIComponent(name); (async () => { const existing = await request("GET", item); if (existing.statusCode === 200) { console.log(JSON.stringify({ status: "exists", pipelineRun: name, revision })); return; } if (existing.statusCode !== 404) { throw new Error("unexpected PipelineRun lookup status " + existing.statusCode + ": " + existing.body.slice(0, 500)); } const created = await request("POST", base, pipelineRun); if (created.statusCode < 200 || created.statusCode > 299) { throw new Error("PipelineRun create failed with status " + created.statusCode + ": " + created.body.slice(0, 1000)); } console.log(JSON.stringify({ status: "created", pipelineRun: name, revision })); })().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; }); NODE `; } function tektonRbac() { return { apiVersion: "v1", kind: "List", items: [ { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: "hwlab-tekton-runner", namespace: "hwlab-ci" } }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", metadata: { name: "hwlab-tekton-runtime-observer", namespace: "hwlab-dev" }, rules: [ { apiGroups: [""], resources: ["pods", "services", "configmaps"], verbs: ["get", "list", "watch"] }, { apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] }, { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch"] } ] }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", metadata: { name: "hwlab-tekton-runtime-observer", namespace: "hwlab-dev" }, subjects: [{ kind: "ServiceAccount", name: "hwlab-tekton-runner", namespace: "hwlab-ci" }], roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-tekton-runtime-observer" } }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", metadata: { name: "hwlab-tekton-pipelinerun-writer", namespace: "hwlab-ci" }, rules: [ { apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "create"] }, { apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "patch"] }, { apiGroups: ["batch"], resources: ["cronjobs"], verbs: ["get", "patch"] }, { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch"] }, { apiGroups: [""], resources: ["serviceaccounts"], verbs: ["get", "patch"] } ] }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", metadata: { name: "hwlab-tekton-pipelinerun-writer", namespace: "hwlab-ci" }, subjects: [{ kind: "ServiceAccount", name: "hwlab-tekton-runner", namespace: "hwlab-ci" }], roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-tekton-pipelinerun-writer" } } ] }; } function tektonPipeline(ci) { return { apiVersion: "tekton.dev/v1", kind: "Pipeline", metadata: { name: "hwlab-g14-ci-image-publish", namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" }, annotations: { "hwlab.pikastech.local/source-config": "CI.json", "hwlab.pikastech.local/policy": "parallel-no-lock-ci-image-publish" } }, spec: { params: [ { name: "git-url", type: "string", default: defaultSourceRepo }, { name: "source-branch", type: "string", default: defaultBranch }, { name: "gitops-branch", type: "string", default: defaultGitopsBranch }, { name: "revision", type: "string", description: "Full git commit SHA; the only source truth for image tags." }, { name: "registry-prefix", type: "string", default: defaultRegistryPrefix }, { name: "services", type: "string", default: "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills" }, { name: "base-image", type: "string", default: "node:20-bookworm-slim" }, { name: "concurrency", type: "string", default: "4" } ], workspaces: [ { name: "source" }, { name: "git-ssh" } ], tasks: [ { name: "ci-json", workspaces: [ { name: "source", workspace: "source" }, { name: "git-ssh", workspace: "git-ssh" } ], taskSpec: { params: [ { name: "git-url" }, { name: "source-branch" }, { name: "revision" } ], workspaces: [{ name: "source" }, { name: "git-ssh" }], steps: [{ name: "ci-json", image: "node:22-bookworm-slim", env: proxyEnv(), script: ciJsonScript(ci) }] }, params: [ { name: "git-url", value: "$(params.git-url)" }, { name: "source-branch", value: "$(params.source-branch)" }, { name: "revision", value: "$(params.revision)" } ] }, { name: "image-publish", runAfter: ["ci-json"], workspaces: [{ name: "source", workspace: "source" }], taskSpec: { params: [ { name: "revision" }, { name: "registry-prefix" }, { name: "services" }, { name: "base-image" }, { name: "concurrency" } ], workspaces: [{ name: "source" }], volumes: [{ name: "docker-run", emptyDir: {} }], sidecars: [{ name: "docker", image: "docker:29-dind", args: ["--host=unix:///var/run/docker.sock", "--storage-driver=vfs"], env: [{ name: "DOCKER_TLS_CERTDIR", value: "" }, ...proxyEnv()], securityContext: { privileged: true }, volumeMounts: [{ name: "docker-run", mountPath: "/var/run" }] }], steps: [{ name: "publish", image: "node:22-alpine", env: [{ name: "DOCKER_HOST", value: "unix:///var/run/docker.sock" }, ...proxyEnv()], volumeMounts: [{ name: "docker-run", mountPath: "/var/run" }], script: publishScript() }] }, params: [ { name: "revision", value: "$(params.revision)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" }, { name: "services", value: "$(params.services)" }, { name: "base-image", value: "$(params.base-image)" }, { name: "concurrency", value: "$(params.concurrency)" } ] }, { name: "gitops-promote", runAfter: ["image-publish"], workspaces: [ { name: "source", workspace: "source" }, { name: "git-ssh", workspace: "git-ssh" } ], taskSpec: { params: [ { name: "git-url" }, { name: "source-branch" }, { name: "gitops-branch" }, { name: "revision" }, { name: "registry-prefix" } ], workspaces: [{ name: "source" }, { name: "git-ssh" }], steps: [{ name: "promote", image: "node:22-bookworm-slim", env: proxyEnv(), script: gitopsPromoteScript() }] }, params: [ { name: "git-url", value: "$(params.git-url)" }, { name: "source-branch", value: "$(params.source-branch)" }, { name: "gitops-branch", value: "$(params.gitops-branch)" }, { name: "revision", value: "$(params.revision)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" } ] } ] } }; } function tektonPipelineRunTemplate({ source, args }) { return { apiVersion: "tekton.dev/v1", kind: "PipelineRun", metadata: { generateName: "hwlab-g14-ci-", namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": source.full } }, spec: { pipelineRef: { name: "hwlab-g14-ci-image-publish" }, taskRunTemplate: { serviceAccountName: "hwlab-tekton-runner", podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" } }, params: [ { name: "git-url", value: args.sourceRepo }, { name: "source-branch", value: args.sourceBranch }, { name: "gitops-branch", value: args.gitopsBranch }, { name: "revision", value: source.full }, { name: "registry-prefix", value: args.registryPrefix }, { name: "base-image", value: "node:20-bookworm-slim" }, { name: "concurrency", value: "4" } ], workspaces: [ { name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } }, { name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } } ] } }; } function tektonPollerCronJob(args) { return { apiVersion: "batch/v1", kind: "CronJob", metadata: { name: "hwlab-g14-branch-poller", namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" }, annotations: { "hwlab.pikastech.local/source-branch": args.sourceBranch, "hwlab.pikastech.local/gitops-branch": args.gitopsBranch } }, spec: { schedule: "*/5 * * * *", concurrencyPolicy: "Forbid", successfulJobsHistoryLimit: 3, failedJobsHistoryLimit: 3, jobTemplate: { spec: { backoffLimit: 1, template: { metadata: { labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" } }, spec: { serviceAccountName: "hwlab-tekton-runner", restartPolicy: "Never", hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", containers: [{ name: "poll", image: "node:22-alpine", imagePullPolicy: "IfNotPresent", env: [ ...proxyEnv(), { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, { name: "PIPELINE_NAME", value: "hwlab-g14-ci-image-publish" }, { name: "GIT_URL", value: args.sourceRepo }, { name: "SOURCE_BRANCH", value: args.sourceBranch }, { name: "GITOPS_BRANCH", value: args.gitopsBranch }, { name: "REGISTRY_PREFIX", value: args.registryPrefix }, { name: "SERVICES", value: "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills" }, { name: "BASE_IMAGE", value: "node:20-bookworm-slim" }, { name: "CONCURRENCY", value: "4" } ], volumeMounts: [{ name: "git-ssh", mountPath: "/workspace/git-ssh", readOnly: true }], command: ["/bin/sh", "-c"], args: [pollerScript()] }], volumes: [{ name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } }] } } } } } }; } function tektonControlPlaneReconcilerCronJob(args) { return { apiVersion: "batch/v1", kind: "CronJob", metadata: { name: "hwlab-g14-control-plane-reconciler", namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" }, annotations: { "hwlab.pikastech.local/source-branch": args.sourceBranch, "hwlab.pikastech.local/gitops-branch": args.gitopsBranch, "hwlab.pikastech.local/purpose": "render-and-apply-g14-ci-control-plane" } }, spec: { schedule: "*/10 * * * *", concurrencyPolicy: "Forbid", successfulJobsHistoryLimit: 3, failedJobsHistoryLimit: 3, jobTemplate: { spec: { backoffLimit: 1, template: { metadata: { labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" } }, spec: { serviceAccountName: "hwlab-tekton-runner", restartPolicy: "Never", hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", containers: [{ name: "reconcile", image: "node:22-alpine", imagePullPolicy: "IfNotPresent", env: [ ...proxyEnv(), { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, { name: "GIT_URL", value: args.sourceRepo }, { name: "SOURCE_BRANCH", value: args.sourceBranch }, { name: "GITOPS_BRANCH", value: args.gitopsBranch }, { name: "REGISTRY_PREFIX", value: args.registryPrefix } ], volumeMounts: [{ name: "git-ssh", mountPath: "/workspace/git-ssh", readOnly: true }], command: ["/bin/sh", "-c"], args: [controlPlaneReconcileScript()] }], volumes: [{ name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } }] } } } } } }; } function registryManifest() { return { apiVersion: "v1", kind: "List", items: [ { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "hwlab-registry", namespace: "hwlab-ci", labels: { "app.kubernetes.io/name": "hwlab-registry" } }, spec: { replicas: 1, selector: { matchLabels: { "app.kubernetes.io/name": "hwlab-registry" } }, template: { metadata: { labels: { "app.kubernetes.io/name": "hwlab-registry" } }, spec: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", containers: [{ name: "registry", image: "registry:2.8.3", imagePullPolicy: "IfNotPresent", ports: [{ name: "registry", containerPort: 5000, hostPort: 5000 }], env: [{ name: "REGISTRY_STORAGE_DELETE_ENABLED", value: "true" }], volumeMounts: [{ name: "storage", mountPath: "/var/lib/registry" }], readinessProbe: { httpGet: { path: "/v2/", port: "registry" } }, livenessProbe: { httpGet: { path: "/v2/", port: "registry" } } }], volumes: [{ name: "storage", hostPath: { path: "/var/lib/hwlab/registry", type: "DirectoryOrCreate" } }] } } } }, { apiVersion: "v1", kind: "Service", metadata: { name: "hwlab-registry", namespace: "hwlab-ci", labels: { "app.kubernetes.io/name": "hwlab-registry" } }, spec: { selector: { "app.kubernetes.io/name": "hwlab-registry" }, ports: [{ name: "registry", port: 5000, targetPort: "registry" }] } } ] }; } function argoProject() { return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", metadata: { name: "hwlab-g14", namespace: "argocd" }, spec: { description: "HWLAB G14 GitOps project; D601 remains outside this project.", sourceRepos: [defaultSourceRepo], destinations: [{ server: "https://kubernetes.default.svc", namespace: "hwlab-dev" }], clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], namespaceResourceWhitelist: [{ group: "*", kind: "*" }] } }; } function argoApplication(args) { return { apiVersion: "argoproj.io/v1alpha1", kind: "Application", metadata: { name: "hwlab-g14-dev", namespace: "argocd", labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" } }, spec: { project: "hwlab-g14", source: { repoURL: args.sourceRepo, targetRevision: args.gitopsBranch, path: "deploy/gitops/g14/runtime" }, destination: { server: "https://kubernetes.default.svc", namespace: "hwlab-dev" }, syncPolicy: { automated: { prune: false, selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } } }; } function g14FrpcManifest() { const labels = { "app.kubernetes.io/name": "hwlab-g14-frpc", "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/environment": "dev", "hwlab.pikastech.local/gitops-target": "g14" }; return { apiVersion: "v1", kind: "List", items: [ { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "hwlab-g14-frpc-config", namespace: "hwlab-dev", labels }, data: { "frpc.toml": `serverAddr = "74.48.78.17" serverPort = 7000 loginFailExit = true [[proxies]] name = "hwlab-g14-cloud-web" type = "tcp" localIP = "hwlab-cloud-web.hwlab-dev.svc.cluster.local" localPort = 8080 remotePort = 17666 [[proxies]] name = "hwlab-g14-edge-proxy" type = "tcp" localIP = "hwlab-edge-proxy.hwlab-dev.svc.cluster.local" localPort = 6667 remotePort = 17667 ` } }, { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "hwlab-g14-frpc", namespace: "hwlab-dev", labels }, spec: { replicas: 1, selector: { matchLabels: { "app.kubernetes.io/name": "hwlab-g14-frpc" } }, template: { metadata: { labels }, spec: { containers: [{ name: "frpc", image: "fatedier/frpc:v0.68.1", imagePullPolicy: "IfNotPresent", args: ["-c", "/etc/frp/frpc.toml"], volumeMounts: [{ name: "config", mountPath: "/etc/frp", readOnly: true }] }], volumes: [{ name: "config", configMap: { name: "hwlab-g14-frpc-config" } }] } } } } ] }; } function runtimeKustomization({ source }) { return { apiVersion: "kustomize.config.k8s.io/v1beta1", kind: "Kustomization", namespace: "hwlab-dev", resources: ["namespace.yaml", "code-agent-codex-config.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "g14-frpc.yaml"] }; } function readme({ source, args }) { return `# HWLAB G14 GitOps CI/CD This directory is generated from \`CI.json\`, \`deploy/deploy.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`. - Target: G14 k3s only. - CI: Tekton Pipeline \`hwlab-ci/hwlab-g14-ci-image-publish\`. - Artifact contract: images are tagged by source git commit \`${source.short}\` and pushed to \`${args.registryPrefix}\`. - Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`. - CD: Argo CD Application \`argocd/hwlab-g14-dev\` consumes \`${args.gitopsBranch}:deploy/gitops/g14/runtime\`. - Public preview: FRP exposes only G14 web \`${args.webEndpoint}\` and edge/API \`${args.runtimeEndpoint}\` through \`hwlab-g14-frpc\`; D601 keeps \`:16666/:16667\`. - Manual polling: run \`kubectl -n hwlab-ci create job --from=cronjob/hwlab-g14-branch-poller hwlab-g14-branch-poller-manual-$(date -u +%Y%m%d%H%M%S)\`; this reuses the same poller path and creates a commit-pinned Tekton PipelineRun only when needed. - Control-plane reconcile: CronJob \`hwlab-ci/hwlab-g14-control-plane-reconciler\` polls \`${args.sourceBranch}\`, runs \`scripts/g14-gitops-render.mjs\`, and server-side-applies the generated Tekton RBAC/Pipeline/Poller/Reconciler manifests so CI control-plane changes do not require a manual render/apply step. - D601 boundary: this path does not target D601, does not use UniDesk Code Queue, and does not change D601 production traffic. - Concurrency: CI builds are per source commit and do not acquire the legacy DEV CD Lease; Argo CD reconciles the Git desired state. `; } async function plannedFiles(args) { const [ci, deploy, namespace, config, services, health, workloads] = await Promise.all([ readJson("CI.json"), readJson("deploy/deploy.json"), readJson("deploy/k8s/base/namespace.yaml"), readJson("deploy/k8s/base/code-agent-codex-config.yaml"), readJson("deploy/k8s/base/services.yaml"), readJson("deploy/k8s/dev/health-contract.yaml"), readJson("deploy/k8s/base/workloads.yaml") ]); const source = await resolveSourceRevision(args.sourceRevision); const labels = { "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": source.full }; const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" }; const runtimeNamespace = cloneJson(namespace); runtimeNamespace.metadata.name = deploy.namespace; label(runtimeNamespace.metadata, labels); annotate(runtimeNamespace.metadata, annotations); const files = new Map(); const putJson = (relative, value) => files.set(path.join(args.outDir, relative), jsonManifest(value)); const putText = (relative, value) => files.set(path.join(args.outDir, relative), textFile(value)); putJson("source.json", { kind: "hwlab-g14-gitops-source", sourceCommit: source.full, sourceShortCommit: source.short, sourceBranch: args.sourceBranch, gitopsBranch: args.gitopsBranch, sourceRepo: args.sourceRepo, registryPrefix: args.registryPrefix, runtimePath: "deploy/gitops/g14/runtime", publicEndpoints: { web: args.webEndpoint, runtime: args.runtimeEndpoint }, frpDeployment: "hwlab-dev/hwlab-g14-frpc", tektonPipeline: "hwlab-ci/hwlab-g14-ci-image-publish", argoApplication: "argocd/hwlab-g14-dev" }); putText("README.md", readme({ source, args })); putJson("registry/registry.yaml", registryManifest()); putJson("tekton/rbac.yaml", tektonRbac()); putJson("tekton/pipeline.yaml", tektonPipeline(ci)); putJson("tekton/poller.yaml", tektonPollerCronJob(args)); putJson("tekton/control-plane-reconciler.yaml", tektonControlPlaneReconcilerCronJob(args)); putJson("tekton/pipelinerun.sample.yaml", tektonPipelineRunTemplate({ source, args })); putJson("argocd/project.yaml", argoProject()); putJson("argocd/application.yaml", argoApplication(args)); putJson("runtime/kustomization.yaml", runtimeKustomization({ source })); putJson("runtime/namespace.yaml", runtimeNamespace); putJson("runtime/code-agent-codex-config.yaml", transformListNamespace(config, deploy.namespace, labels, annotations)); putJson("runtime/services.yaml", transformListNamespace(services, deploy.namespace, labels, annotations)); putJson("runtime/health-contract.yaml", transformHealthContract(health, deploy.namespace, labels, annotations, args.runtimeEndpoint, args.webEndpoint)); putJson("runtime/workloads.yaml", transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint })); putJson("runtime/g14-frpc.yaml", g14FrpcManifest()); return { files, source }; } async function writeFiles(files) { for (const [relativePath, content] of files) { const absolutePath = path.join(repoRoot, relativePath); await mkdir(path.dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, content); } } async function checkFiles(files) { const mismatches = []; for (const [relativePath, expected] of files) { let actual = null; try { actual = await readFile(path.join(repoRoot, relativePath), "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; } if (actual !== expected) mismatches.push(relativePath); } return mismatches; } async function main() { let args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } args = await defaultCheckSourceRevision(args); ensureObject(args, "args"); const { files, source } = await plannedFiles(args); if (args.check) { const mismatches = await checkFiles(files); console.log(JSON.stringify({ ok: mismatches.length === 0, sourceCommit: source.full, outDir: args.outDir, checked: files.size, mismatches }, null, 2)); process.exitCode = mismatches.length === 0 ? 0 : 1; return; } if (args.write) await writeFiles(files); console.log(JSON.stringify({ ok: true, sourceCommit: source.full, sourceShort: source.short, outDir: args.outDir, files: [...files.keys()] }, null, 2)); } main().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; });