From b75dac8f75e73207cb3b90eb8bf942901e30b333 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 10 Jul 2026 08:22:44 +0200 Subject: [PATCH] refactor: split GitOps renderer by responsibility --- scripts/gitops-render.mjs | 5885 +---------------- scripts/gitops-render.test.ts | 37 +- scripts/src/gitops-render/core.mjs | 1782 +++++ scripts/src/gitops-render/infra-manifests.mjs | 254 + .../src/gitops-render/runtime-manifests.mjs | 1418 ++++ .../src/gitops-render/tekton-manifests.mjs | 830 +++ scripts/src/gitops-render/tekton-scripts.mjs | 187 + .../templates/ci-timing-functions.sh | 13 + .../templates/collect-artifacts.sh | 30 + .../templates/control-plane-reconcile.sh | 139 + .../templates/curl-download-probe.sh | 17 + .../templates/dependency-proxy-probe.sh | 96 + .../templates/git-mirror-flush.sh | 76 + .../templates/git-mirror-http.mjs | 196 + .../templates/git-mirror-install-hooks.sh | 65 + .../templates/git-mirror-proxy-connect.mjs | 48 + .../templates/git-mirror-ssh-direct.sh | 11 + .../templates/git-mirror-ssh-proxied.sh | 20 + .../templates/git-mirror-sync.sh | 181 + .../templates/git-ssh-functions.sh | 46 + .../templates/github-proxy-connect.mjs | 51 + .../gitops-render/templates/gitops-promote.sh | 331 + .../templates/per-service-publish.sh | 99 + .../gitops-render/templates/plan-artifacts.sh | 58 + scripts/src/gitops-render/templates/poller.sh | 142 + .../gitops-render/templates/prepare-source.sh | 117 + 26 files changed, 6282 insertions(+), 5847 deletions(-) create mode 100644 scripts/src/gitops-render/core.mjs create mode 100644 scripts/src/gitops-render/infra-manifests.mjs create mode 100644 scripts/src/gitops-render/runtime-manifests.mjs create mode 100644 scripts/src/gitops-render/tekton-manifests.mjs create mode 100644 scripts/src/gitops-render/tekton-scripts.mjs create mode 100644 scripts/src/gitops-render/templates/ci-timing-functions.sh create mode 100644 scripts/src/gitops-render/templates/collect-artifacts.sh create mode 100644 scripts/src/gitops-render/templates/control-plane-reconcile.sh create mode 100644 scripts/src/gitops-render/templates/curl-download-probe.sh create mode 100644 scripts/src/gitops-render/templates/dependency-proxy-probe.sh create mode 100644 scripts/src/gitops-render/templates/git-mirror-flush.sh create mode 100644 scripts/src/gitops-render/templates/git-mirror-http.mjs create mode 100644 scripts/src/gitops-render/templates/git-mirror-install-hooks.sh create mode 100644 scripts/src/gitops-render/templates/git-mirror-proxy-connect.mjs create mode 100644 scripts/src/gitops-render/templates/git-mirror-ssh-direct.sh create mode 100644 scripts/src/gitops-render/templates/git-mirror-ssh-proxied.sh create mode 100644 scripts/src/gitops-render/templates/git-mirror-sync.sh create mode 100644 scripts/src/gitops-render/templates/git-ssh-functions.sh create mode 100644 scripts/src/gitops-render/templates/github-proxy-connect.mjs create mode 100644 scripts/src/gitops-render/templates/gitops-promote.sh create mode 100644 scripts/src/gitops-render/templates/per-service-publish.sh create mode 100644 scripts/src/gitops-render/templates/plan-artifacts.sh create mode 100644 scripts/src/gitops-render/templates/poller.sh create mode 100644 scripts/src/gitops-render/templates/prepare-source.sh diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 078c65fb..001a1153 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -1,5832 +1,83 @@ #!/usr/bin/env node // SPEC: PJ2026-01060505 Workbench Performance; PJ2026-0106 平台运维 import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; import { applyRuntimeLaneDeployConfig, - defaultEndpointForRuntimeLane, - defaultPortForRuntimeLane, + argoApplicationName, + artifactCatalogSkeleton, + attachAccessControlEnv, + annotate, + cloneJson, + defaultBranch, + defaultCatalogPath, + defaultGitopsBranch, + defaultOutDir, + defaultRuntimeEndpoint, + defaultSourceRepo, + defaultV02RuntimeEndpoint, + defaultWebEndpoint, + ensureObject, + generatedPath, + gitopsPathForProfile, + imageTagForSource, isRuntimeLane, - runtimeLaneMirrorSpecs, - runtimeLaneConfig, - versionNameForRuntimeLane -} from "./src/runtime-lane.ts"; -import { readStructuredFile } from "./src/structured-config.mjs"; + jsonManifest, + label, + laneRuntimeProfiles, + namespaceNameForProfile, + observabilityManifest, + parseArgs, + profileEndpoint, + readJson, + readJsonIfPresent, + repoRoot, + resolveSourceRevision, + runtimeLabelForProfile, + runtimePathForProfile, + textFile, + transformHealthContract, + transformListNamespace, + transformServices, + transformWorkloads, + usage, + workbenchRuntimeRedisConfigForProfile +} from "./src/gitops-render/core.mjs"; +import { + devopsInfraGitMirrorManifest, + registryManifest +} from "./src/gitops-render/infra-manifests.mjs"; +import { + argoApplication, + argoProject, + deepSeekProxyManifest, + deviceAgent71FreqManifest, + externalPostgresConfigForLane, + externalPostgresManifest, + nodeFrpcManifest, + opencodeServerManifest, + runtimeConfigMapsForProfile, + runtimeConfigMapsManifest, + runtimePostgresImageForProfile, + runtimeSecretPlaneConfigForProfile, + runtimeSecretPlaneManifest, + v02OpenFgaManifest, + v02PostgresManifest, + workbenchRuntimeRedisManifest +} from "./src/gitops-render/runtime-manifests.mjs"; +import { + ciLaneSettings, + tektonControlPlaneReconcilerCronJob, + tektonPipeline, + tektonPipelineRunTemplate, + tektonPollerCronJob, + tektonRbac +} from "./src/gitops-render/tekton-manifests.mjs"; import { CLOUD_CORE_MIGRATIONS } from "../internal/db/schema.ts"; -const execFileAsync = promisify(execFile); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const defaultOutDir = "deploy/gitops/node"; -const defaultRegistryPrefix = process.env.HWLAB_NODE_REGISTRY_PREFIX || "127.0.0.1:5000/hwlab"; -const defaultSourceRepo = process.env.HWLAB_NODE_GIT_URL || "git@github.com:pikasTech/HWLAB.git"; -const defaultGitReadUrl = process.env.HWLAB_NODE_GIT_READ_URL || null; -const defaultV02GitReadUrl = process.env.HWLAB_V02_GIT_READ_URL || "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; -const defaultV02GitWriteUrl = process.env.HWLAB_V02_GIT_WRITE_URL || "http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; -const defaultRuntimeLaneGitReadUrl = process.env.HWLAB_RUNTIME_LANE_GIT_READ_URL || "http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git"; -const defaultRuntimeLaneGitWriteUrl = process.env.HWLAB_RUNTIME_LANE_GIT_WRITE_URL || defaultV02GitWriteUrl; -const defaultBranch = process.env.HWLAB_NODE_BRANCH || "node"; -const defaultGitopsBranch = process.env.HWLAB_NODE_GITOPS_BRANCH || "node-gitops"; -const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; -const defaultRuntimeEndpoint = process.env.HWLAB_NODE_RUNTIME_ENDPOINT || "http://74.48.78.17:17667"; -const defaultWebEndpoint = process.env.HWLAB_NODE_WEB_ENDPOINT || "http://74.48.78.17:17666"; -const defaultProdRuntimeEndpoint = process.env.HWLAB_NODE_PROD_RUNTIME_ENDPOINT || "http://74.48.78.17:18667"; -const defaultProdWebEndpoint = process.env.HWLAB_NODE_PROD_WEB_ENDPOINT || "http://74.48.78.17:18666"; -const defaultV02RuntimeEndpoint = process.env.HWLAB_V02_RUNTIME_ENDPOINT || "https://hwlab.74-48-78-17.nip.io"; -const defaultV02WebEndpoint = process.env.HWLAB_V02_WEB_ENDPOINT || "https://hwlab.74-48-78-17.nip.io"; -const defaultProxyUrl = process.env.HWLAB_NODE_PROXY_URL || "http://127.0.0.1:10808"; -const defaultAllProxyUrl = process.env.HWLAB_NODE_ALL_PROXY_URL || "socks5h://127.0.0.1:10808"; -const defaultNoProxy = process.env.HWLAB_NODE_NO_PROXY || "hyueapi.com,.hyueapi.com,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,git-mirror-http,git-mirror-http.devops-infra,git-mirror-http.devops-infra.svc,git-mirror-http.devops-infra.svc.cluster.local,git-mirror-write,git-mirror-write.devops-infra,git-mirror-write.devops-infra.svc,git-mirror-write.devops-infra.svc.cluster.local"; -const ciToolsRunnerImage = process.env.HWLAB_NODE_CI_TOOLS_IMAGE || "127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1"; -const buildkitRunnerImage = process.env.HWLAB_NODE_BUILDKIT_IMAGE || "moby/buildkit:rootless"; -const defaultDevBaseImage = process.env.HWLAB_NODE_DEV_BASE_IMAGE || "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim"; -const defaultServiceIds = Object.freeze([ - "hwlab-cloud-api", - "hwlab-cloud-web", - "hwlab-gateway", - "hwlab-edge-proxy", - "hwlab-agent-skills" -]); -const v02ExtraObservableServices = Object.freeze([ - { serviceId: "hwlab-deepseek-proxy", port: 4000, healthPath: "/health/live" } -]); -const v02RemovedServiceIds = new Set([ - "hwlab-agent-mgr", - "hwlab-agent-worker", - "hwlab-agent-worker-template", - "hwlab-code-agent-workspace", - "hwlab-router", - "hwlab-tunnel-client", - "hwlab-gateway-simu", - "hwlab-box-simu", - "hwlab-patch-panel" -]); -const v02RemovedServiceEnvNames = new Set([ - "CODEX_HOME", - "OPENAI_API_KEY", - "HWLAB_CODE_AGENT_PROVIDER", - "HWLAB_CODE_AGENT_MODEL", - "HWLAB_CODE_AGENT_OPENAI_BASE_URL", - "HWLAB_CODE_AGENT_WORKSPACE", - "HWLAB_CODE_AGENT_CODEX_HOME", - "HWLAB_CODE_AGENT_CODEX_WORKSPACE", - "HWLAB_CODE_AGENT_CODEX_SANDBOX", - "HWLAB_CODE_AGENT_CODEX_COMMAND", - "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", - "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", - "HWLAB_M3_GATEWAY_SIMU_1_URL", - "HWLAB_M3_GATEWAY_SIMU_2_URL", - "HWLAB_M3_PATCH_PANEL_URL", - "HWLAB_GATEWAY_SIMU_URL", - "HWLAB_BOX_SIMU_URL", - "HWLAB_PATCH_PANEL_URL", - "HWLAB_ROUTER_URL", - "HWLAB_TUNNEL_CLIENT_URL" -]); -const moonBridgeSourceRef = process.env.HWLAB_NODE_MOONBRIDGE_REF || "1b99888d3dae889b79ee602cb875c7907f7e76f2"; -const moonBridgeImage = process.env.HWLAB_NODE_MOONBRIDGE_IMAGE || `${defaultRegistryPrefix}/moonbridge:${moonBridgeSourceRef.slice(0, 12)}`; -const deepSeekProfileModel = process.env.HWLAB_NODE_DEEPSEEK_PROFILE_MODEL || "deepseek-chat"; -const codexApiProfileModel = process.env.HWLAB_NODE_CODEX_API_PROFILE_MODEL || "gpt-5.5"; -const codexApiProfileBaseUrl = process.env.HWLAB_NODE_CODEX_API_PROFILE_BASE_URL || "http://127.0.0.1:49280/responses"; -const codexApiForwarderPort = process.env.HWLAB_NODE_CODEX_API_FORWARDER_PORT || "49280"; -const codexApiForwarderUpstreamBaseUrl = process.env.HWLAB_NODE_CODEX_API_UPSTREAM_BASE_URL || "https://hyueapi.com"; -const sourceCommitPattern = /^[a-f0-9]{7,40}$/u; -const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout"; - -function k8sLabelHash(value) { - return String(value || "").slice(0, 16); -} - -const primitiveValidationTasks = Object.freeze([ - { - name: "repo-reports-guard", - commands: ["node scripts/repo-reports-guard.mjs"] - }, - { - name: "node-contract-check", - commands: [ - "node --check scripts/gitops-render.mjs", - `node --input-type=module <<'NODE' -import { readdirSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; - -const root = process.cwd(); -const ciFileName = "CI" + ".json"; -const forbiddenCiFragments = [ - "ci" + "-json", - "HWLAB_NODE_TEKTON" + "_SINGLE_IMAGE_PUBLISH", - "single" + "-dind", - "docker" + ":29", - "DOCKER" + "_HOST", - "Docker" + "-in-Docker", - "D" + "IND", - "docker" + " push", - "--build" + "-backend", - "HWLAB_ARTIFACT" + "_BUILD_BACKEND", - "--legacy" + "-source-images", - "HWLAB_NODE_USE_DEPLOY_IMAGES" + "=0" -]; -const scanTargets = [ - "scripts/gitops-render.mjs", - "scripts/src/runtime-lane.ts", - "scripts/artifact-publish.mjs", - "scripts/src/ci-plan-lib.mjs", - "deploy/gitops/node/tekton" -]; -const skipDirs = new Set([".git", "node_modules", ".worktree"]); - -function walkFiles(relativePath) { - const absolutePath = path.join(root, relativePath); - let stat; - try { - stat = statSync(absolutePath); - } catch { - return []; - } - if (stat.isFile()) return [relativePath]; - if (!stat.isDirectory()) return []; - const files = []; - for (const entry of readdirSync(absolutePath, { withFileTypes: true })) { - if (entry.isDirectory() && skipDirs.has(entry.name)) continue; - files.push(...walkFiles(path.join(relativePath, entry.name))); - } - return files; -} - -const ciFiles = walkFiles(".").filter((filePath) => path.basename(filePath) === ciFileName); -const violations = []; -for (const filePath of scanTargets.flatMap(walkFiles)) { - const text = readFileSync(path.join(root, filePath), "utf8"); - for (const fragment of forbiddenCiFragments) { - if (text.includes(fragment)) violations.push({ filePath, fragment }); - } -} -if (ciFiles.length || violations.length) { - console.error(JSON.stringify({ status: "failed", ciFiles, violations }, null, 2)); - process.exit(1); -} -NODE`, - "node --check scripts/artifact-publish.mjs" - ] - }, - { - name: "codex-api-forwarder-check", - commands: [ - "node scripts/run-bun.mjs test cmd/hwlab-codex-api-responses-forwarder/main.test.ts" - ] - } -]); - -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 = { - lane: process.env.HWLAB_GITOPS_LANE || "node", - nodeId: process.env.HWLAB_NODE_ID || "node", - outDir: defaultOutDir, - gitopsRoot: defaultOutDir, - catalogPath: defaultCatalogPath, - imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short", - registryPrefix: defaultRegistryPrefix, - sourceRevision: null, - sourceBranch: defaultBranch, - gitopsBranch: defaultGitopsBranch, - sourceRepo: defaultSourceRepo, - gitReadUrl: defaultGitReadUrl, - gitWriteUrl: null, - runtimeEndpoint: defaultRuntimeEndpoint, - webEndpoint: defaultWebEndpoint, - prodRuntimeEndpoint: defaultProdRuntimeEndpoint, - prodWebEndpoint: defaultProdWebEndpoint, - useDeployImages: true, - check: false, - write: true, - help: false - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--lane") args.lane = readOption(argv, ++index, arg); - else if (arg === "--node") args.nodeId = readOption(argv, ++index, arg); - else if (arg === "--out") args.outDir = readOption(argv, ++index, arg); - else if (arg === "--gitops-root") args.gitopsRoot = readOption(argv, ++index, arg); - else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg); - else if (arg === "--image-tag-mode") args.imageTagMode = 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 === "--git-read-url") args.gitReadUrl = readOption(argv, ++index, arg); - else if (arg === "--git-write-url") args.gitWriteUrl = 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 === "--prod-runtime-endpoint") args.prodRuntimeEndpoint = readOption(argv, ++index, arg); - else if (arg === "--prod-web-endpoint") args.prodWebEndpoint = readOption(argv, ++index, arg); - else if (arg === "--use-deploy-images") args.useDeployImages = true; - 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}`); - } - if (isRuntimeLane(args.lane)) { - const versionName = versionNameForRuntimeLane(args.lane); - const defaultLaneEndpoint = defaultEndpointForRuntimeLane(args.lane); - if (args.sourceBranch === defaultBranch) args.sourceBranch = versionName; - if (args.gitopsBranch === defaultGitopsBranch) args.gitopsBranch = `${versionName}-gitops`; - if (args.catalogPath === defaultCatalogPath) args.catalogPath = `deploy/artifact-catalog.${args.lane}.json`; - if (args.runtimeEndpoint === defaultRuntimeEndpoint) args.runtimeEndpoint = defaultLaneEndpoint; - if (args.webEndpoint === defaultWebEndpoint) args.webEndpoint = defaultLaneEndpoint; - if (args.imageTagMode === "short") args.imageTagMode = "full"; - if (!args.gitReadUrl) args.gitReadUrl = defaultRuntimeLaneGitReadUrl; - if (!args.gitWriteUrl) args.gitWriteUrl = defaultRuntimeLaneGitWriteUrl; - } - if (!args.gitReadUrl) args.gitReadUrl = args.sourceRepo; - 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)) { - 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`); - } - 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/run-bun.mjs scripts/gitops-render.mjs [options]", - "", - "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", - ` --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`, - ` --catalog-path PATH default: ${defaultCatalogPath}`, - " --image-tag-mode MODE short or full; v02 uses full source commit IDs", - ` --source-repo URL default: ${defaultSourceRepo}`, - " --git-read-url URL read-only source URL; runtime lanes default to devops-infra git mirror", - " --git-write-url URL GitOps write URL; runtime lanes default to devops-infra git mirror write relay", - ` --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}; runtime lane default is derived from deploy.yaml/public URL`, - ` --web-endpoint URL default: ${defaultWebEndpoint}; runtime lane default is derived from deploy.yaml/public URL`, - ` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`, - ` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`, - " --use-deploy-images default and only supported mode: render workload images from deploy/artifact-catalog.dev.json per service", - " --check verify generated files are current without writing", - " --no-write plan and print generated file list without writing" - ].join("\n"); -} - -function generatedPath(filePath) { - return path.isAbsolute(filePath) ? filePath : path.join(repoRoot, filePath); -} - -async function readJson(relativePath) { - return readStructuredFile(repoRoot, relativePath); -} - -async function readJsonIfPresent(relativePath, fallback = null) { - try { - return await readJson(relativePath); - } catch (error) { - if (error && error.code === "ENOENT") return fallback; - throw error; - } -} - -async function attachAccessControlEnv(deploy) { - const byProfile = {}; - for (const [profile, laneConfig] of Object.entries(deploy?.lanes ?? {})) { - if (!isRuntimeLane(profile)) continue; - const accessControl = laneConfig?.accessControl; - if (!accessControl || typeof accessControl !== "object" || Array.isArray(accessControl)) continue; - const users = await readConfigRef(accessControl.usersRef, `${profile}.accessControl.usersRef`); - const navProfiles = await readConfigRef(accessControl.navProfilesRef, `${profile}.accessControl.navProfilesRef`); - const env = accessControlEnvFromConfig({ users, navProfiles }); - if (Object.keys(env).length > 0) byProfile[profile] = { "hwlab-cloud-api": env }; - } - Object.defineProperty(deploy, "__accessControlEnvByProfile", { value: byProfile, enumerable: false, configurable: true }); -} - -async function readConfigRef(ref, label) { - assert.equal(typeof ref, "string", `${label} must be a file reference`); - const [filePath, fragment = ""] = ref.split("#"); - assert.ok(filePath, `${label} must include a file path`); - const root = await readJson(filePath); - return configFragment(root, fragment || "", label); -} - -function configFragment(value, fragment, label) { - const pathText = String(fragment ?? "").replace(/^\//u, ""); - if (!pathText) return value; - return pathText.split(/[./]/u).filter(Boolean).reduce((current, segment) => { - assert.ok(current && typeof current === "object" && Object.hasOwn(current, segment), `${label} fragment is missing ${segment}`); - return current[segment]; - }, value); -} - -function accessControlEnvFromConfig({ users, navProfiles }) { - assert.ok(Array.isArray(users), "accessControl usersRef must resolve to an array"); - assert.ok(Array.isArray(navProfiles), "accessControl navProfilesRef must resolve to an array"); - const env = {}; - const renderedUsers = users.map((user, index) => normalizeAccessControlUser(user, index, env)); - const renderedProfiles = navProfiles.map(normalizeNavProfile); - env.HWLAB_ACCESS_CONTROL_USERS_JSON = JSON.stringify(renderedUsers); - env.HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON = JSON.stringify(renderedProfiles); - return env; -} - -function normalizeAccessControlUser(user, index, env) { - assert.ok(user && typeof user === "object" && !Array.isArray(user), `accessControl user ${index} must be an object`); - const passwordHashRef = user.passwordHashRef && typeof user.passwordHashRef === "object" && !Array.isArray(user.passwordHashRef) ? user.passwordHashRef : null; - const passwordHashEnv = configString(user.passwordHashEnv) || configString(passwordHashRef?.env); - const secretRef = configString(passwordHashRef?.secretRef) || secretRefFromParts(passwordHashRef); - if (passwordHashEnv && secretRef) env[passwordHashEnv] = `secretRef:${secretRef}`; - return { - id: requiredAccessConfigString(user.id, `accessControl user ${index}.id`), - username: requiredAccessConfigString(user.username, `accessControl user ${index}.username`), - displayName: configString(user.displayName) || configString(user.username), - email: configString(user.email) || null, - role: configString(user.role) === "admin" ? "admin" : "user", - status: configString(user.status) === "disabled" ? "disabled" : "active", - navProfile: requiredAccessConfigString(user.navProfile, `accessControl user ${index}.navProfile`), - passwordHashEnv: passwordHashEnv || null - }; -} - -function normalizeNavProfile(profile, index) { - assert.ok(profile && typeof profile === "object" && !Array.isArray(profile), `accessControl nav profile ${index} must be an object`); - const allowedIds = Array.isArray(profile.allowedIds) ? profile.allowedIds.map(configString).filter(Boolean) : []; - assert.ok(allowedIds.length > 0, `accessControl nav profile ${index}.allowedIds must not be empty`); - return { - id: requiredAccessConfigString(profile.id, `accessControl nav profile ${index}.id`), - displayName: configString(profile.displayName) || configString(profile.id), - allowedIds - }; -} - -function secretRefFromParts(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return ""; - const sourceRef = configString(value.sourceRef ?? value.secretName); - const targetKey = configString(value.targetKey ?? value.key); - return sourceRef && targetKey ? `${sourceRef}/${targetKey}` : ""; -} - -function configString(value) { - return typeof value === "string" ? value.trim() : ""; -} - -function requiredAccessConfigString(value, label) { - const text = configString(value); - assert.ok(text, `${label} is required`); - return text; -} - -function optionalObject(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; -} - -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) }; -} - -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 upsertEnvEntry(envList, entry) { - if (!Array.isArray(envList) || !entry?.name) return; - const existing = envList.find((item) => item?.name === entry.name); - if (existing) { - delete existing.value; - delete existing.valueFrom; - Object.assign(existing, cloneJson(entry)); - } else { - envList.push(cloneJson(entry)); - } -} - -function syncCodeAgentWorkspaceInitContainer(initContainers, { image, runtimeCommit, runtimeImageTag }) { - const initContainer = Array.isArray(initContainers) - ? initContainers.find((entry) => entry?.name === "hwlab-code-agent-workspace-init") - : null; - if (!initContainer) return; - initContainer.image = image; - initContainer.imagePullPolicy = "IfNotPresent"; - initContainer.env ??= []; - upsertEnv(initContainer.env, "HWLAB_WORKSPACE_SOURCE_COMMIT", runtimeCommit); - upsertEnv(initContainer.env, "HWLAB_IMAGE", image); - upsertEnv(initContainer.env, "HWLAB_IMAGE_TAG", runtimeImageTag); -} - -function annotate(metadata, annotations) { - metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations }; -} - -function label(metadata, labels) { - metadata.labels = { ...(metadata.labels ?? {}), ...labels }; -} - -function imageFor(registryPrefix, serviceId, imageTag) { - return `${registryPrefix}/${serviceId}:${imageTag}`; -} - -function imageTagFromReference(image) { - const value = String(image ?? ""); - const slashIndex = value.lastIndexOf("/"); - const colonIndex = value.lastIndexOf(":"); - if (colonIndex <= slashIndex) return null; - return value.slice(colonIndex + 1).split("@", 1)[0] || null; -} - -function repositoryFromImage(image) { - const value = String(image ?? "").split("@", 1)[0]; - const slashIndex = value.lastIndexOf("/"); - const colonIndex = value.lastIndexOf(":"); - return colonIndex > slashIndex ? value.slice(0, colonIndex) : value; -} - -function digestPinnedImage(image, digest) { - if (!/^sha256:[a-f0-9]{64}$/u.test(String(digest ?? ""))) return image; - const repository = repositoryFromImage(image); - return repository ? `${repository}@${digest}` : image; -} - -function catalogServiceById(catalog, serviceId) { - return (catalog?.services ?? []).find((service) => service?.serviceId === serviceId) ?? null; -} - -function v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds = null) { - if (envReuseServiceIds?.has(serviceId)) return true; - const service = catalogServiceById(catalog, serviceId); - return service?.runtimeMode === v02EnvReuseRuntimeMode || service?.envReuse === true; -} - -function bootShForService(deployService, serviceId) { - assert.ok(deployService?.bootSh, `deploy lane bootScripts.${serviceId} is required`); - const value = deployService.bootSh; - const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, ""); - assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`); - return text; -} - -function envReuseServiceIdsForLane(deploy, lane) { - if (!isRuntimeLane(lane)) return new Set(); - const configured = deploy?.lanes?.[lane]?.envReuseServices; - const allowed = new Set(serviceIdsForLane(lane, deploy)); - return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId))); -} - -function deployServiceForBoot(deploy, serviceId, lane = "v02") { - return { - serviceId, - bootSh: deploy?.lanes?.[lane]?.bootScripts?.[serviceId] - }; -} - -function bootMetadataForService({ args, catalog, deployService, serviceId, source }) { - const catalogService = catalogServiceById(catalog, serviceId); - const environmentImage = catalogService?.environmentImage ?? imageFor(args.registryPrefix, `${serviceId}-env`, `env-${String(catalogService?.environmentInputHash ?? source.full).slice(0, 12)}`); - const environmentDigest = catalogService?.environmentDigest ?? catalogService?.digest ?? null; - return { - runtimeMode: v02EnvReuseRuntimeMode, - environmentImage, - environmentDigest, - environmentInputHash: catalogService?.environmentInputHash ?? null, - codeInputHash: catalogService?.codeInputHash ?? null, - bootRepo: catalogService?.bootRepo ?? args.sourceRepo, - bootCommit: source.full, - bootSh: catalogService?.bootSh ?? bootShForService(deployService, serviceId) - }; -} - -function envReuseBootShForContainer({ serviceId, container, defaultBootSh }) { - if (serviceId === "hwlab-cloud-api" && container?.name === "hwlab-codex-api-forwarder") { - return "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh"; - } - return defaultBootSh; -} - -function applyEnvReuseBootEnv(container, bootMetadata, { sourceBranch, gitReadUrl, bootSh = bootMetadata?.bootSh } = {}) { - if (!container || !bootMetadata) return; - delete container.command; - delete container.args; - container.env ??= []; - upsertEnv(container.env, "HWLAB_RUNTIME_MODE", bootMetadata.runtimeMode); - upsertEnv(container.env, "HWLAB_BOOT_REPO", bootMetadata.bootRepo); - upsertEnv(container.env, "HWLAB_BOOT_COMMIT", bootMetadata.bootCommit); - upsertEnv(container.env, "HWLAB_BOOT_REF", sourceBranch); - upsertEnv(container.env, "HWLAB_BOOT_SH", bootSh); - upsertEnv(container.env, "HWLAB_BOOT_READ_URL", gitReadUrl ?? defaultV02GitReadUrl); - upsertEnv(container.env, "HWLAB_ENVIRONMENT_IMAGE", bootMetadata.environmentImage ?? ""); - upsertEnv(container.env, "HWLAB_ENVIRONMENT_DIGEST", bootMetadata.environmentDigest ?? ""); - upsertEnv(container.env, "HWLAB_ENVIRONMENT_INPUT_HASH", bootMetadata.environmentInputHash ?? ""); - upsertEnv(container.env, "HWLAB_CODE_INPUT_HASH", bootMetadata.codeInputHash ?? ""); - upsertEnv(container.env, "HWLAB_IMAGE_DIGEST", bootMetadata.environmentDigest ?? "unknown"); - applyEnvReuseStartupProbe(container); -} - -function applyEnvReuseStartupProbe(container) { - const probe = container.readinessProbe ?? container.livenessProbe; - if (!probe?.httpGet && !probe?.tcpSocket && !probe?.exec) return; - container.startupProbe ??= { - ...cloneJson(probe), - periodSeconds: 10, - timeoutSeconds: Math.max(Number(probe.timeoutSeconds ?? 1), 2), - failureThreshold: 30, - successThreshold: 1 - }; -} - -function applyServiceHealthProbe(container, healthProbe) { - if (!healthProbe || typeof healthProbe !== "object" || Array.isArray(healthProbe)) return; - for (const [probeName, key] of [["readinessProbe", "readiness"], ["livenessProbe", "liveness"], ["startupProbe", "startup"]]) { - const probe = container?.[probeName]; - const timing = normalizeProbeTiming(healthProbe[key]); - if (!probe || Object.keys(timing).length === 0) continue; - Object.assign(probe, timing); - } -} - -function applyServiceShutdownDrainLifecycle(container, shutdownDrain) { - if (!container || !shutdownDrain || shutdownDrain.enabled !== true) return; - const pathValue = typeof shutdownDrain.path === "string" && /^\/[A-Za-z0-9/_:.-]+$/u.test(shutdownDrain.path) ? shutdownDrain.path : "/internal/workbench/drain"; - const sleepSeconds = boundedInteger(shutdownDrain.sleepSeconds, 3, 0, 30); - const timeoutMs = boundedInteger(shutdownDrain.timeoutMs, 2500, 100, 30000); - container.env ??= []; - upsertEnv(container.env, "HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS", String(timeoutMs)); - const command = [ - 'port="${PORT:-${HWLAB_PORT:-${HWLAB_CLOUD_API_PORT:-6667}}}"', - `url="http://127.0.0.1:\${port}${pathValue}"`, - '/usr/local/bin/bun -e \'const [url, hostname] = process.argv.slice(2); await fetch(url, { method: "POST", headers: { "x-hwlab-drain-hostname": hostname || "" } }).catch(() => {});\' "$url" "${HOSTNAME:-}" >/dev/null 2>&1 || true', - `sleep ${sleepSeconds}` - ].join("; "); - container.lifecycle ??= {}; - container.lifecycle.preStop = { exec: { command: ["sh", "-c", command] } }; -} - -function boundedInteger(value, fallback, minimum, maximum) { - const parsed = Number(value); - if (!Number.isInteger(parsed)) return fallback; - return Math.max(minimum, Math.min(maximum, parsed)); -} - -function normalizeProbeTiming(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - const result = {}; - for (const field of ["initialDelaySeconds", "periodSeconds", "timeoutSeconds", "failureThreshold", "successThreshold"]) { - const raw = value[field]; - const minimum = field === "initialDelaySeconds" ? 0 : 1; - if (Number.isInteger(raw) && raw >= minimum) result[field] = raw; - } - return result; -} - -function useLocalHealthProbe(container, pathValue = "/health") { - if (!container) return; - for (const probeName of ["readinessProbe", "livenessProbe"]) { - const probe = container[probeName]; - if (!probe?.httpGet) continue; - probe.httpGet.path = pathValue; - } -} - -function annotateEnvReusePodTemplate(podMetadata, bootMetadata) { - if (!podMetadata || !bootMetadata) return; - annotate(podMetadata, { - "hwlab.pikastech.local/runtime-mode": bootMetadata.runtimeMode, - "hwlab.pikastech.local/boot-repo": bootMetadata.bootRepo, - "hwlab.pikastech.local/boot-commit": bootMetadata.bootCommit, - "hwlab.pikastech.local/boot-sh": bootMetadata.bootSh, - "hwlab.pikastech.local/environment-digest": bootMetadata.environmentDigest ?? "not_published", - "hwlab.pikastech.local/environment-input-hash": bootMetadata.environmentInputHash ?? "unknown", - "hwlab.pikastech.local/code-input-hash": bootMetadata.codeInputHash ?? "unknown" - }); - label(podMetadata, { - "hwlab.pikastech.local/runtime-mode": bootMetadata.runtimeMode, - "hwlab.pikastech.local/boot-commit": bootMetadata.bootCommit - }); -} - -function runtimeArtifactForService({ catalog, deploy, serviceId, source, registryPrefix, useDeployImages = false, digestPin = false, envReuseServiceIds = null }) { - const catalogService = catalogServiceById(catalog, serviceId); - if (v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds)) { - const environmentImage = catalogService?.environmentImage ?? imageFor(registryPrefix, `${serviceId}-env`, `env-${String(catalogService?.environmentInputHash ?? source.full).slice(0, 12)}`); - const runtimeImage = digestPin && useDeployImages ? digestPinnedImage(environmentImage, catalogService?.environmentDigest ?? catalogService?.digest) : environmentImage; - return { - image: runtimeImage, - imageTag: imageTagFromReference(environmentImage) ?? source.imageTag ?? source.short, - commit: source.full, - runtimeMode: v02EnvReuseRuntimeMode, - environmentDigest: catalogService?.environmentDigest ?? catalogService?.digest ?? null - }; - } - const fallbackImageTag = source.imageTag ?? source.short; - const image = useDeployImages && catalogService?.image - ? catalogService.image - : imageFor(registryPrefix, serviceId, fallbackImageTag); - const runtimeImage = digestPin && useDeployImages ? digestPinnedImage(image, catalogService?.digest) : image; - const imageTag = useDeployImages && catalogService?.imageTag - ? catalogService.imageTag - : imageTagFromReference(image) ?? fallbackImageTag; - const commit = useDeployImages - ? catalogService?.sourceCommitId ?? catalogService?.commitId ?? imageTag ?? source.full - : source.full; - return { image: runtimeImage, imageTag, commit }; -} - -function runtimeImageForService(options) { - return runtimeArtifactForService(options).image; -} - -function runtimeCommitForService(options) { - return runtimeArtifactForService(options).commit; -} - -function runtimeImageTagForService(options) { - return runtimeArtifactForService(options).imageTag; -} - -function artifactEnvironment(args) { - return isRuntimeLane(args.lane) ? args.lane : "dev"; -} - -function artifactEndpoint(args) { - return isRuntimeLane(args.lane) ? args.runtimeEndpoint : defaultRuntimeEndpoint; -} - -function serviceIdsForLane(lane, deploy = null) { - return isRuntimeLane(lane) ? runtimeLaneServiceIdsFromDeploy(deploy, lane) : defaultServiceIds; -} - -function serviceIdsForProfile(profile, deploy = null) { - return isRuntimeLane(profile) ? runtimeLaneServiceIdsFromDeploy(deploy, profile) : defaultServiceIds; -} - -function runtimeLaneServiceIdsFromDeploy(deploy, lane) { - const laneConfig = runtimeLaneConfig(deploy, lane); - const serviceIds = uniquePreserveOrder((Array.isArray(laneConfig.envReuseServices) ? laneConfig.envReuseServices : []) - .map((item) => String(item ?? "").trim()) - .filter(Boolean)); - assert.ok(serviceIds.length > 0, `deploy.lanes.${lane}.envReuseServices must not be empty`); - return serviceIds; -} - -function v02ServiceIdsFromDeploy(deploy) { - return runtimeLaneServiceIdsFromDeploy(deploy, "v02"); -} - -function runtimeLaneObservableServicesForDeploy(deploy, lane) { - const laneConfig = runtimeLaneConfig(deploy, lane); - const declarations = laneConfig.serviceDeclarations ?? {}; - const serviceIds = new Set(runtimeLaneServiceIdsFromDeploy(deploy, lane)); - const declared = Object.entries(declarations) - .filter(([serviceId, declaration]) => serviceIds.has(serviceId) && declaration?.observable !== false && Number.isInteger(declaration?.healthPort)) - .map(([serviceId, declaration]) => ({ - serviceId, - port: declaration.healthPort, - healthPath: declaration.healthPath || "/health/live" - })); - return [...declared, ...v02ExtraObservableServices]; -} - -function v02ObservableServicesForDeploy(deploy) { - return runtimeLaneObservableServicesForDeploy(deploy, "v02"); -} - -function v02ObservableService(deploy, serviceId) { - return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null; -} - -function runtimeLaneObservableService(deploy, lane, serviceId) { - return runtimeLaneObservableServicesForDeploy(deploy, lane).find((item) => item.serviceId === serviceId) ?? null; -} - -function runtimeLaneObservableServiceIdsForDeploy(deploy, lane) { - return new Set(runtimeLaneObservableServicesForDeploy(deploy, lane).map((item) => item.serviceId)); -} - -function v02ObservableServiceIdsForDeploy(deploy) { - return runtimeLaneObservableServiceIdsForDeploy(deploy, "v02"); -} - -function servicesParamForLane(lane, deploy = null) { - return serviceIdsForLane(lane, deploy).join(","); -} - -function uniquePreserveOrder(values) { - const seen = new Set(); - const result = []; - for (const value of values) { - if (seen.has(value)) continue; - seen.add(value); - result.push(value); - } - return result; -} - -function isV02RemovedServiceId(serviceId) { - return v02RemovedServiceIds.has(serviceId); -} - -function artifactCatalogSkeleton({ args, deploy, source }) { - const environment = artifactEnvironment(args); - const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane) : "hwlab-dev"; - const tag = imageTagForSource(args, source); - return { - catalogVersion: "v1", - kind: "hwlab-artifact-catalog", - environment, - profile: environment, - namespace, - endpoint: artifactEndpoint(args), - commitId: tag, - artifactState: "contract-skeleton", - publish: { - registryPrefix: args.registryPrefix, - sourceCommitId: source.full, - imageTag: tag, - publishedAt: null - }, - allowedProfiles: [environment], - forbiddenProfiles: isRuntimeLane(args.lane) ? ["dev", "prod"] : ["prod"], - services: serviceIdsForLane(args.lane, deploy).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag })) - }; -} - -function artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }) { - const base = { - serviceId, - profile: environment, - namespace, - commitId: tag, - sourceCommitId: source.full, - image: imageFor(args.registryPrefix, serviceId, tag), - imageTag: tag, - digest: null, - buildBackend: "contract-skeleton" - }; - const envReuseServiceIds = envReuseServiceIdsForLane(deploy, args.lane); - if (!envReuseServiceIds.has(serviceId)) return base; - const bootSh = bootShForService(deployServiceForBoot(deploy, serviceId, args.lane), serviceId); - const environmentImage = imageFor(args.registryPrefix, `${serviceId}-env`, `env-${String(source.full).slice(0, 12)}`); - return { - ...base, - image: environmentImage, - imageTag: imageTagFromReference(environmentImage), - runtimeMode: v02EnvReuseRuntimeMode, - envReuse: true, - environmentImage, - environmentDigest: null, - environmentInputHash: null, - bootRepo: args.sourceRepo, - bootCommit: source.full, - bootSh, - codeInputHash: null, - bootEnvEvidence: { - env: { - HWLAB_BOOT_REPO: args.sourceRepo, - HWLAB_BOOT_COMMIT: source.full, - HWLAB_BOOT_SH: bootSh - } - } - }; -} - -function namespaceNameForProfile(profile) { - if (isRuntimeLane(profile)) return `hwlab-${profile}`; - return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; -} - -function runtimePathForProfile(profile) { - if (isRuntimeLane(profile)) return `runtime-${profile}`; - return profile === "prod" ? "runtime-prod" : "runtime-dev"; -} - -function runtimeLabelForProfile(profile) { - if (isRuntimeLane(profile)) return profile; - return profile === "prod" ? "prod" : "dev"; -} - -function gitopsTargetForProfile(profile) { - return isRuntimeLane(profile) ? profile : "node"; -} - -function gitopsRootForArgs(args) { - return String(args.gitopsRoot || defaultOutDir).replace(/\/+$/u, ""); -} - -function gitopsPathForProfile(args, profile) { - return `${gitopsRootForArgs(args)}/${runtimePathForProfile(profile)}`; -} - -function gitopsPathFor(args, relativePath) { - return `${gitopsRootForArgs(args)}/${String(relativePath).replace(/^\/+/, "")}`; -} - -function profileEndpoint(args, profile) { - if (isRuntimeLane(profile)) return { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; - return profile === "prod" - ? { runtimeEndpoint: args.prodRuntimeEndpoint, webEndpoint: args.prodWebEndpoint } - : { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; -} - -function laneRuntimeProfiles(args) { - return isRuntimeLane(args.lane) ? [args.lane] : ["dev", "prod"]; -} - -function laneNames(args) { - if (isRuntimeLane(args.lane)) { - const namespace = namespaceNameForProfile(args.lane); - return { - gitopsTarget: args.lane, - pipeline: `${namespace}-ci-image-publish`, - poller: `${namespace}-branch-poller`, - reconciler: `${namespace}-control-plane-reconciler`, - serviceAccount: `${namespace}-tekton-runner`, - pipelineRunPrefix: `${namespace}-ci-poll-`, - runtimeNamespace: namespace, - runtimePath: gitopsPathForProfile(args, args.lane), - argoApplications: [`argocd/hwlab-node-${args.lane}`] - }; - } - return { - gitopsTarget: "node", - pipeline: "hwlab-node-ci-image-publish", - poller: "hwlab-node-branch-poller", - reconciler: "hwlab-node-control-plane-reconciler", - serviceAccount: "hwlab-tekton-runner", - pipelineRunPrefix: "hwlab-node-ci-poll-", - runtimeNamespace: "hwlab-dev", - runtimePath: gitopsPathForProfile(args, "dev"), - argoApplications: ["argocd/hwlab-node-dev", "argocd/hwlab-node-prod"] - }; -} - -function argoApplicationName(profile) { - return `hwlab-node-${profile}`; -} - -function imageTagForSource(args, source) { - return args.imageTagMode === "full" ? source.full : source.short; -} - -function deepSeekBaseUrl(namespace) { - return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`; -} - -function codeAgentNoProxy(namespace) { - return [ - `${namespace}.svc.cluster.local`, - ".svc", - ".cluster.local", - "hyueapi.com", - ".hyueapi.com", - "hyui.com", - ".hyui.com", - "127.0.0.1", - "localhost", - "::1", - "10.0.0.0/8", - "10.42.0.0/16", - "10.43.0.0/16", - "api.minimaxi.com", - ".minimaxi.com" - ].join(","); -} - -function namespaceScopedHost(value, namespace) { - if (typeof value !== "string") return value; - return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`); -} - -function secretNameForProfile(secretName, profile) { - if (!isRuntimeLane(profile)) return secretName; - if (secretName === "hwlab-cloud-api-dev-db") return `hwlab-cloud-api-${profile}-db`; - if (secretName === "hwlab-code-agent-provider") return `hwlab-${profile}-code-agent-provider`; - if (secretName === "hwlab-code-agent-codex-auth") return `hwlab-${profile}-code-agent-codex-auth`; - if (secretName === "hwlab-opencode-server-auth") return `hwlab-${profile}-opencode-server-auth`; - if (secretName === "hwlab-bootstrap-admin") return `hwlab-${profile}-bootstrap-admin`; - return secretName; -} - -function secretRefObjectForProfile(secretRef, profile) { - const ref = configString(secretRef); - const slashIndex = ref.indexOf("/"); - assert.ok(slashIndex > 0 && slashIndex < ref.length - 1, `secretRef ${JSON.stringify(secretRef)} must be name/key`); - return { name: secretNameForProfile(ref.slice(0, slashIndex), profile), key: ref.slice(slashIndex + 1), optional: true }; -} - -function rewritePodSecretRefs(podSpec, profile) { - if (!podSpec || !isRuntimeLane(profile)) return; - for (const volume of podSpec.volumes ?? []) { - if (volume?.secret?.secretName) volume.secret.secretName = secretNameForProfile(volume.secret.secretName, profile); - } -} - -function deployEnvEntry(name, value, namespace, profile = "dev") { - if (typeof value === "string" && value.startsWith("secretRef:")) { - return { name, valueFrom: { secretKeyRef: secretRefObjectForProfile(value.slice("secretRef:".length), profile) } }; - } - return { name, value: namespaceScopedHost(value, namespace) }; -} - -function applyDeployEnv(envList, deployService, namespace, profile = "dev") { - if (!deployService?.env || typeof deployService.env !== "object" || Array.isArray(deployService.env)) return; - for (const [name, value] of Object.entries(deployService.env)) { - upsertEnvEntry(envList, deployEnvEntry(name, value, namespace, profile)); - } -} - -function applyDeployConfigMapMounts(podSpec, container, deployService) { - const mounts = Array.isArray(deployService?.configMapMounts) ? deployService.configMapMounts : []; - if (mounts.length === 0 || !podSpec || !container) return; - podSpec.volumes ??= []; - container.volumeMounts ??= []; - for (const mount of mounts) { - const name = String(mount?.name ?? "").trim(); - const configMapName = String(mount?.configMapName ?? "").trim(); - const mountPath = String(mount?.mountPath ?? "").trim(); - if (!name || !configMapName || !mountPath) continue; - const items = Array.isArray(mount.items) - ? mount.items - .map((item) => ({ key: String(item?.key ?? "").trim(), path: String(item?.path ?? "").trim() })) - .filter((item) => item.key && item.path) - : []; - const volume = { name, configMap: { name: configMapName } }; - if (items.length > 0) volume.configMap.items = items; - const existingVolume = podSpec.volumes.find((entry) => entry?.name === name); - if (existingVolume) Object.assign(existingVolume, volume); - else podSpec.volumes.push(volume); - const volumeMount = { name, mountPath, readOnly: mount.readOnly !== false }; - const existingMount = container.volumeMounts.find((entry) => entry?.name === name); - if (existingMount) Object.assign(existingMount, volumeMount); - else container.volumeMounts.push(volumeMount); - } -} - -function removeV02LegacySimulatorEnv(envList) { - if (!Array.isArray(envList)) return envList; - return envList.filter((entry) => !v02RemovedServiceEnvNames.has(entry?.name)); -} - -function removeV02RepoOwnedCodeAgentPodConfig(deploymentSpec, podSpec) { - if (!podSpec) return; - podSpec.initContainers = (podSpec.initContainers ?? []).filter((entry) => entry?.name !== "hwlab-code-agent-workspace-init"); - const removedVolumeNames = new Set([ - "hwlab-code-agent-workspace", - "hwlab-code-agent-codex-home", - "hwlab-code-agent-codex-config", - "hwlab-code-agent-codex-auth" - ]); - podSpec.volumes = (podSpec.volumes ?? []).filter((volume) => !removedVolumeNames.has(volume?.name)); - for (const container of podSpec.containers ?? []) { - container.volumeMounts = (container.volumeMounts ?? []).filter((mount) => !removedVolumeNames.has(mount?.name)); - } - if (deploymentSpec?.strategy?.type === "Recreate") delete deploymentSpec.strategy; -} - -function applyDeployServiceReplicas(item, deployService) { - if (!(item?.kind === "Deployment" || item?.kind === "StatefulSet")) return; - const replicas = deployService?.replicas; - if (!Number.isInteger(replicas) || replicas < 0) return; - item.spec ??= {}; - item.spec.replicas = replicas; -} - -function deployServicesForProfile(deploy, profile) { - const allowed = new Set(serviceIdsForProfile(profile, deploy)); - const services = new Map((deploy.services ?? []) - .filter((service) => allowed.has(service.serviceId)) - .map((service) => { - const copy = cloneJson(service); - copy.env = mergeEnvMaps( - copy.env, - serviceDeclarationEnvForProfile(deploy, profile, service.serviceId), - accessControlEnvForProfile(deploy, profile, service.serviceId), - runtimeStoreEnvForProfile(deploy, profile, service.serviceId), - workbenchRuntimeClientEnvForProfile(deploy, profile, service.serviceId), - workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, service.serviceId), - workbenchRuntimeSessionsSummaryEnvForProfile(deploy, profile, service.serviceId), - workbenchRuntimePostgresEnvForProfile(deploy, profile, service.serviceId), - workbenchRuntimeRedisEnvForProfile(deploy, profile, service.serviceId) - ); - return [service.serviceId, copy]; - })); - const laneServices = isRuntimeLane(profile) && Array.isArray(deploy.lanes?.[profile]?.services) - ? deploy.lanes[profile].services - : []; - for (const override of laneServices) { - const existing = services.get(override.serviceId) ?? { - serviceId: override.serviceId, - env: mergeEnvMaps( - serviceDeclarationEnvForProfile(deploy, profile, override.serviceId), - accessControlEnvForProfile(deploy, profile, override.serviceId), - runtimeStoreEnvForProfile(deploy, profile, override.serviceId), - workbenchRuntimeClientEnvForProfile(deploy, profile, override.serviceId), - workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, override.serviceId), - workbenchRuntimeSessionsSummaryEnvForProfile(deploy, profile, override.serviceId), - workbenchRuntimePostgresEnvForProfile(deploy, profile, override.serviceId), - workbenchRuntimeRedisEnvForProfile(deploy, profile, override.serviceId) - ) - }; - services.set(override.serviceId, { - ...existing, - ...cloneJson(override), - env: mergeEnvMaps(existing.env, override.env) - }); - } - return services; -} - -function accessControlEnvForProfile(deploy, profile, serviceId) { - return deploy?.__accessControlEnvByProfile?.[profile]?.[serviceId] ?? {}; -} - -function serviceDeclarationEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile)) return {}; - const env = deploy?.lanes?.[profile]?.serviceDeclarations?.[serviceId]?.env; - if (!env || typeof env !== "object" || Array.isArray(env)) return {}; - return cloneJson(env); -} - -function runtimeWorkbenchTraceTimelinePolicy(deploy, profile) { - if (!isRuntimeLane(profile)) return null; - const traceTimeline = runtimeLaneConfig(deploy, profile)?.workbench?.traceTimeline; - if (!traceTimeline || typeof traceTimeline !== "object" || Array.isArray(traceTimeline)) return null; - const result = {}; - if (typeof traceTimeline.autoExpandRunning === "boolean") result.autoExpandRunning = traceTimeline.autoExpandRunning; - if (typeof traceTimeline.autoCollapseTerminal === "boolean") result.autoCollapseTerminal = traceTimeline.autoCollapseTerminal; - return Object.keys(result).length > 0 ? result : null; -} - -function runtimeTraceExplorerUrlTemplate(deploy, profile) { - if (!isRuntimeLane(profile)) return null; - const template = runtimeLaneConfig(deploy, profile)?.observability?.traceExplorerUrlTemplate; - return typeof template === "string" && template.trim().length > 0 ? template.trim() : null; -} - -function runtimePrometheusOperatorResourcesEnabled(deploy, profile) { - if (!isRuntimeLane(profile)) return true; - const enabled = runtimeLaneConfig(deploy, profile)?.observability?.prometheusOperatorResources; - return enabled !== false; -} - -function runtimeFrpConfigForProfile(deploy, profile, args = {}) { - const fallbackWebRemotePort = isRuntimeLane(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; - const nodeId = effectiveSecretPlaneNodeId(args); - const nodeFrp = nodeId ? baseFrp?.nodes?.[nodeId] : null; - const frp = nodeFrp && typeof nodeFrp === "object" && !Array.isArray(nodeFrp) ? { ...baseFrp, ...nodeFrp } : baseFrp; - const server = frp && typeof frp === "object" && !Array.isArray(frp) ? frp.server : null; - const proxies = Array.isArray(frp?.proxies) ? frp.proxies : []; - const proxyByEndpoint = new Map(proxies - .filter((proxy) => proxy && typeof proxy === "object" && !Array.isArray(proxy)) - .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; - }; - const proxyPort = (endpointId, fallback) => { - const value = proxyByEndpoint.get(endpointId)?.remotePort; - return Number.isInteger(value) ? value : fallback; - }; - return { - 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), - 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, - authSecretKey: typeof frp?.auth?.secretKey === "string" && frp.auth.secretKey.trim().length > 0 ? frp.auth.secretKey.trim() : null - }; -} - -function runtimeStoreEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; - const postgres = runtimeLaneConfig(deploy, profile)?.runtimeStore?.postgres; - if (!postgres || typeof postgres !== "object" || Array.isArray(postgres)) return {}; - const env = {}; - if (postgres.sslMode === "disable" || postgres.sslMode === "require") env.HWLAB_CLOUD_DB_SSL_MODE = postgres.sslMode; - if (Number.isInteger(postgres.poolMax)) env.HWLAB_CLOUD_DB_POOL_MAX = String(postgres.poolMax); - if (Number.isInteger(postgres.connectionTimeoutMs)) { - env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS = String(postgres.connectionTimeoutMs); - } - if (Number.isInteger(postgres.queryTimeoutMs)) { - env.HWLAB_CLOUD_DB_QUERY_TIMEOUT_MS = String(postgres.queryTimeoutMs); - } - if (Number.isInteger(postgres.queryRetryMaxAttempts)) { - env.HWLAB_CLOUD_DB_QUERY_RETRY_MAX_ATTEMPTS = String(postgres.queryRetryMaxAttempts); - } - if (Number.isInteger(postgres.queryRetryInitialDelayMs)) { - env.HWLAB_CLOUD_DB_QUERY_RETRY_INITIAL_DELAY_MS = String(postgres.queryRetryInitialDelayMs); - } - if (Number.isInteger(postgres.queryRetryMaxDelayMs)) { - env.HWLAB_CLOUD_DB_QUERY_RETRY_MAX_DELAY_MS = String(postgres.queryRetryMaxDelayMs); - } - return env; -} - -function workbenchRuntimeRedisEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; - const redis = workbenchRuntimeRedisConfigForProfile(deploy, profile); - if (!redis || typeof redis !== "object" || Array.isArray(redis)) return {}; - const env = { - HWLAB_WORKBENCH_CACHE_REDIS_ENABLED: booleanEnv(Boolean(redis.enabled)) - }; - const namespace = typeof redis.namespace === "string" ? redis.namespace.trim() : ""; - const serviceName = typeof redis.serviceName === "string" ? redis.serviceName.trim() : ""; - const port = Number.isInteger(redis.port) ? redis.port : null; - if (namespace) env.HWLAB_WORKBENCH_CACHE_REDIS_NAMESPACE = namespace; - if (serviceName) env.HWLAB_WORKBENCH_CACHE_REDIS_SERVICE_NAME = serviceName; - if (port !== null) env.HWLAB_WORKBENCH_CACHE_REDIS_PORT = String(port); - if (namespace && serviceName && port !== null) { - env.HWLAB_WORKBENCH_CACHE_REDIS_URL = `redis://${serviceName}.${namespace}.svc.cluster.local:${port}/0`; - } - const ttl = redis.ttl ?? {}; - if (Number.isInteger(ttl.sessionsSummaryMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_SESSIONS_SUMMARY_MS = String(ttl.sessionsSummaryMs); - if (Number.isInteger(ttl.terminalTurnMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_TERMINAL_TURN_MS = String(ttl.terminalTurnMs); - if (Number.isInteger(ttl.terminalTracePageMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_TERMINAL_TRACE_PAGE_MS = String(ttl.terminalTracePageMs); - if (Number.isInteger(ttl.runningPageMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_RUNNING_PAGE_MS = String(ttl.runningPageMs); - const limits = redis.limits ?? {}; - if (Number.isInteger(limits.maxKeyBytes)) env.HWLAB_WORKBENCH_CACHE_REDIS_MAX_KEY_BYTES = String(limits.maxKeyBytes); - if (Number.isInteger(limits.maxPayloadBytes)) env.HWLAB_WORKBENCH_CACHE_REDIS_MAX_PAYLOAD_BYTES = String(limits.maxPayloadBytes); - const timeouts = redis.timeouts ?? {}; - if (Number.isInteger(timeouts.connectMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_CONNECT_TIMEOUT_MS = String(timeouts.connectMs); - if (Number.isInteger(timeouts.operationMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_OPERATION_TIMEOUT_MS = String(timeouts.operationMs); - const labels = redis.metrics?.labels; - if (labels && typeof labels === "object" && !Array.isArray(labels)) { - env.HWLAB_WORKBENCH_CACHE_REDIS_METRICS_LABELS = JSON.stringify(labels); - } - const behavior = redis.unavailableBehavior ?? {}; - if (typeof behavior.livenessFailure === "boolean") env.HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_LIVENESS_FAILURE = booleanEnv(behavior.livenessFailure); - if (typeof behavior.readMode === "string") env.HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_READ_MODE = behavior.readMode; - if (typeof behavior.fallbackStateSource === "boolean") env.HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_FALLBACK_STATE_SOURCE = booleanEnv(behavior.fallbackStateSource); - if (typeof redis.disableSwitchEnv === "string" && redis.disableSwitchEnv.trim()) { - env[redis.disableSwitchEnv.trim()] = booleanEnv(Boolean(redis.enabled)); - } - return env; -} - -function workbenchRuntimeClientEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; - const client = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.client; - if (!client || typeof client !== "object" || Array.isArray(client)) return {}; - const env = {}; - if (Number.isInteger(client.timeoutMs)) env.HWLAB_WORKBENCH_RUNTIME_TIMEOUT_MS = String(client.timeoutMs); - return env; -} - -function workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; - const factsQuery = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.factsQuery; - if (!factsQuery || typeof factsQuery !== "object" || Array.isArray(factsQuery)) return {}; - const env = {}; - if (Number.isInteger(factsQuery.maxAttempts)) env.HWLAB_WORKBENCH_FACTS_QUERY_MAX_ATTEMPTS = String(factsQuery.maxAttempts); - if (Number.isInteger(factsQuery.backoffBaseMs)) env.HWLAB_WORKBENCH_FACTS_QUERY_BACKOFF_BASE_MS = String(factsQuery.backoffBaseMs); - if (Number.isInteger(factsQuery.backoffMaxMs)) env.HWLAB_WORKBENCH_FACTS_QUERY_BACKOFF_MAX_MS = String(factsQuery.backoffMaxMs); - return env; -} - -function workbenchRuntimeSessionsSummaryEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; - const sessionsSummary = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.sessionsSummary; - if (!sessionsSummary || typeof sessionsSummary !== "object" || Array.isArray(sessionsSummary)) return {}; - const env = {}; - if (Number.isInteger(sessionsSummary.maxAttempts)) env.HWLAB_WORKBENCH_SESSIONS_SUMMARY_QUERY_MAX_ATTEMPTS = String(sessionsSummary.maxAttempts); - if (Number.isInteger(sessionsSummary.attemptTimeoutMs)) env.HWLAB_WORKBENCH_SESSIONS_SUMMARY_QUERY_ATTEMPT_TIMEOUT_MS = String(sessionsSummary.attemptTimeoutMs); - if (Number.isInteger(sessionsSummary.backoffMs)) env.HWLAB_WORKBENCH_SESSIONS_SUMMARY_QUERY_BACKOFF_MS = String(sessionsSummary.backoffMs); - return env; -} - -function workbenchRuntimePostgresEnvForProfile(deploy, profile, serviceId) { - if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; - const postgres = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.postgres; - if (!postgres || typeof postgres !== "object" || Array.isArray(postgres)) return {}; - const env = {}; - if (Number.isInteger(postgres.poolMax)) env.HWLAB_WORKBENCH_RUNTIME_DB_POOL_MAX = String(postgres.poolMax); - if (Number.isInteger(postgres.queryTimeoutMs)) env.HWLAB_WORKBENCH_RUNTIME_QUERY_TIMEOUT_MS = String(postgres.queryTimeoutMs); - if (Number.isInteger(postgres.readyTimeoutMs)) env.HWLAB_WORKBENCH_RUNTIME_READY_TIMEOUT_MS = String(postgres.readyTimeoutMs); - return env; -} - -function workbenchRuntimeRedisConfigForProfile(deploy, profile) { - if (!isRuntimeLane(profile)) return null; - const label = `deploy.lanes.${profile}.workbenchRuntime.cache.redis`; - const redis = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.cache?.redis; - 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)}`); - const serviceName = requiredConfigString(redis, "serviceName", label); - const image = requiredConfigString(redis, "image", label); - const port = requiredConfigPositiveInteger(redis, "port", label); - assert.ok(port <= 65535, `${label}.port must be a valid TCP port`); - const resources = configObject(redis.resources, `${label}.resources`); - configObject(resources.requests, `${label}.resources.requests`); - configObject(resources.limits, `${label}.resources.limits`); - const memoryPolicy = configObject(redis.memoryPolicy, `${label}.memoryPolicy`); - assert.equal(memoryPolicy.persistence, "disabled", `${label}.memoryPolicy.persistence must be disabled`); - requiredConfigString(memoryPolicy, "maxMemory", `${label}.memoryPolicy`); - requiredConfigString(memoryPolicy, "eviction", `${label}.memoryPolicy`); - return redis; -} - -function booleanEnv(value) { - return value ? "1" : "0"; -} - -function mergeEnvMaps(...values) { - const result = {}; - for (const value of values) { - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - Object.assign(result, value); - } - return result; -} - -function runtimeNodeIdForProfile(deploy, profile, fallback = "node") { - if (!isRuntimeLane(profile)) return fallback; - if (typeof fallback === "string" && fallback.trim() && fallback !== "node") return fallback; - const nodeId = deploy?.lanes?.[profile]?.node; - return typeof nodeId === "string" && nodeId.length > 0 ? nodeId : fallback; -} - -function v02MetricsLabels(serviceId) { - return { - "hwlab.pikastech.local/monitoring": "enabled", - "hwlab.pikastech.local/service-id": serviceId - }; -} - -function v02MetricsSidecarVolume(profile = "v02") { - return { name: "hwlab-metrics-sidecar", configMap: { name: `hwlab-${profile}-metrics-sidecar` } }; -} - -function v02MetricsSidecarAnnotations(metricsSidecarSha256) { - return metricsSidecarSha256 ? { "hwlab.pikastech.local/metrics-sidecar-sha256": metricsSidecarSha256 } : {}; -} - -function v02MetricsSidecarContainer({ deploy, serviceId, namespace, gitopsTarget, profile = "v02" }) { - const service = runtimeLaneObservableService(deploy, profile, serviceId); - assert.ok(service, `unknown ${profile} observable service ${serviceId}`); - const extraMetricsEnv = serviceId === "hwlab-cloud-api" - ? [ - { name: "HWLAB_METRICS_EXTRA_URL", value: `http://127.0.0.1:${service.port}/v1/web-performance/metrics` }, - { name: "HWLAB_METRICS_EXTRA_TIMEOUT_MS", value: "2000" } - ] - : []; - return { - name: "hwlab-metrics", - image: ciToolsRunnerImage, - imagePullPolicy: "IfNotPresent", - command: ["node", "/metrics/metrics-sidecar.mjs"], - env: [ - { name: "HWLAB_METRICS_SERVICE_ID", value: serviceId }, - { name: "HWLAB_METRICS_NAMESPACE", value: namespace }, - { name: "HWLAB_METRICS_GITOPS_TARGET", value: gitopsTarget }, - { name: "HWLAB_METRICS_PORT", value: "9100" }, - { name: "HWLAB_METRICS_TARGET_URL", value: `http://127.0.0.1:${service.port}${service.healthPath}` }, - { name: "HWLAB_METRICS_TARGET_TIMEOUT_MS", value: "2000" }, - ...extraMetricsEnv - ], - ports: [{ name: "metrics", containerPort: 9100 }], - readinessProbe: { httpGet: { path: "/health/live", port: "metrics" }, initialDelaySeconds: 3, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/health/live", port: "metrics" }, initialDelaySeconds: 10, periodSeconds: 20 }, - volumeMounts: [{ name: "hwlab-metrics-sidecar", mountPath: "/metrics", readOnly: true }] - }; -} - -function upsertV02MetricsSidecar(podSpec, options) { - if (!podSpec || !Array.isArray(podSpec.containers)) return; - podSpec.containers = podSpec.containers.filter((container) => container?.name !== "hwlab-metrics"); - podSpec.containers.push(v02MetricsSidecarContainer(options)); - podSpec.volumes ??= []; - podSpec.volumes = podSpec.volumes.filter((volume) => volume?.name !== "hwlab-metrics-sidecar"); - podSpec.volumes.push(v02MetricsSidecarVolume(options.profile)); -} - -function upsertV02MetricsPort(service) { - service.spec ??= {}; - service.spec.ports ??= []; - service.spec.ports = service.spec.ports.filter((port) => port?.name !== "metrics"); - service.spec.ports.push({ name: "metrics", port: 9100, targetPort: "metrics" }); -} - -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 profileLabel = runtimeLabelForProfile(profile); - const gitopsTarget = gitopsTargetForProfile(profile); - const runtimeLane = isRuntimeLane(profile); - const digestPin = runtimeLane; - const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile); - const runtimeObservableServiceIds = runtimeLane ? runtimeLaneObservableServiceIdsForDeploy(deploy, profile) : new Set(); - const stableRuntimeLabels = { - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": profileLabel, - "hwlab.pikastech.local/profile": profileLabel - }; - result.items = asItems(result, "workloads").filter((item) => !(runtimeLane && isV02RemovedServiceId(serviceIdForWorkload(item, null)))); - for (const item of asItems(result, "workloads")) { - item.metadata.namespace = namespace; - label(item.metadata, { - ...stableRuntimeLabels, - "hwlab.pikastech.local/gitops-target": gitopsTarget, - "hwlab.pikastech.local/gitops-render-source-commit": source.full, - "hwlab.pikastech.local/source-commit": source.full, - "unidesk.ai/node-staging": profile === "dev" ? "true" : "false" - }); - annotate(item.metadata, { - "hwlab.pikastech.local/gitops-note": profile === "v02" ? "HWLAB v0.2 GitOps target." : profile === "prod" ? "node PROD GitOps target." : "node DEV GitOps target.", - "hwlab.pikastech.local/gitops-render-source-commit": source.full, - "hwlab.pikastech.local/source-commit": source.full - }); - if (item.kind === "Job") { - annotate(item.metadata, { - "argocd.argoproj.io/sync-options": "Force=true,Replace=true" - }); - if (item.spec?.suspend === true) { - annotate(item.metadata, { - "argocd.argoproj.io/ignore-healthcheck": "true" - }); - } - } - const podTemplate = item?.spec?.template; - if (!podTemplate?.spec?.containers) continue; - const templateServiceId = serviceIdForWorkload(item, podTemplate.spec.containers[0]); - const templateArtifact = runtimeArtifactForService({ catalog, deploy, serviceId: templateServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); - const templateSourceCommit = runtimeLane ? (templateArtifact.commit ?? source.full) : source.full; - const templateArtifactCommit = templateArtifact.commit; - label(podTemplate.metadata ??= {}, { - ...stableRuntimeLabels, - "hwlab.pikastech.local/gitops-target": gitopsTarget, - "hwlab.pikastech.local/gitops-render-source-commit": source.full, - "hwlab.pikastech.local/source-commit": templateSourceCommit, - "hwlab.pikastech.local/artifact-source-commit": templateArtifactCommit - }); - if (runtimeLane && runtimeObservableServiceIds.has(templateServiceId)) { - label(item.metadata, v02MetricsLabels(templateServiceId)); - label(podTemplate.metadata, v02MetricsLabels(templateServiceId)); - annotate(podTemplate.metadata, v02MetricsSidecarAnnotations(metricsSidecarSha256)); - upsertV02MetricsSidecar(podTemplate.spec, { deploy, serviceId: templateServiceId, namespace, gitopsTarget, profile }); - } - 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/gitops-render-source-commit": source.full, - "hwlab.pikastech.local/source-commit": templateSourceCommit, - "hwlab.pikastech.local/artifact-source-commit": templateArtifactCommit, - "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" - }); - for (const container of podTemplate.spec.containers) { - if (container.name === "hwlab-metrics") continue; - const serviceId = serviceIdForWorkload(item, container); - const deployService = deployServices.get(serviceId); - if (!deployService) continue; - applyDeployServiceReplicas(item, deployService); - const artifact = runtimeArtifactForService({ catalog, deploy, serviceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); - const envReuse = runtimeLane && v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds); - const bootDeployService = envReuse ? deployServiceForBoot(deploy, serviceId, profile) : null; - const bootMetadata = envReuse ? bootMetadataForService({ args: { sourceRepo: deploy?.lanes?.[profile]?.sourceRepo || defaultSourceRepo, registryPrefix }, catalog, deployService: bootDeployService, serviceId, source }) : null; - const image = artifact.image; - const runtimeCommit = artifact.commit; - const runtimeImageTag = artifact.imageTag; - container.image = image; - container.imagePullPolicy = "IfNotPresent"; - container.env ??= []; - for (const entry of container.env) { - if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace); - } - applyDeployEnv(container.env, deployService, namespace, profile); - applyDeployConfigMapMounts(podTemplate.spec, container, deployService); - if (runtimeLane) container.env = removeV02LegacySimulatorEnv(container.env); - upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel); - upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit); - upsertEnv(container.env, "HWLAB_IMAGE", image); - upsertEnv(container.env, "HWLAB_IMAGE_TAG", runtimeImageTag); - if (serviceId === "hwlab-agent-skills") { - upsertEnv(container.env, "HWLAB_SKILLS_COMMIT_ID", runtimeCommit); - } - upsertEnv(container.env, "HWLAB_GITOPS_TARGET", gitopsTarget); - upsertEnv(container.env, "HWLAB_GITOPS_PROFILE", profileLabel); - upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", runtimeLane ? runtimeCommit : source.full); - if (runtimeLane && serviceId === "hwlab-cloud-api") { - upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT", runtimeCommit); - } - if (bootMetadata) { - applyEnvReuseBootEnv(container, bootMetadata, { - sourceBranch, - gitReadUrl, - bootSh: envReuseBootShForContainer({ serviceId, container, defaultBootSh: bootMetadata.bootSh }) - }); - if (container.name === serviceId) annotateEnvReusePodTemplate(podTemplate.metadata, bootMetadata); - } - if (runtimeLane && container.name === serviceId) { - applyServiceHealthProbe(container, deploy?.lanes?.[profile]?.serviceDeclarations?.[serviceId]?.healthProbe); - } - if (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-edge-proxy") { - upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint); - } - if (runtimeLane && serviceId === "hwlab-edge-proxy") { - useLocalHealthProbe(container, "/health"); - } - if (serviceId === "hwlab-cloud-api") { - if (runtimeLane) { - removeV02RepoOwnedCodeAgentPodConfig(item.spec, podTemplate.spec); - applyServiceShutdownDrainLifecycle(container, deployService.shutdownDrain); - upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false"); - upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", gitReadUrl ?? defaultRuntimeLaneGitReadUrl); - upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID", runtimeNodeIdForProfile(deploy, profile, nodeId)); - upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "enforce"); - upsertEnv(container.env, "HWLAB_OPENFGA_API_URL", `http://hwlab-openfga.${namespace}.svc.cluster.local:8080`); - upsertEnvEntry(container.env, deployEnvEntry("HWLAB_OPENFGA_AUTHN_TOKEN", `secretRef:hwlab-${profile}-openfga/authn-preshared-key`, namespace, profile)); - upsertEnv(container.env, "UNIDESK_MAIN_SERVER_IP", "http://74.48.78.17:18081"); - } else { - syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag }); - } - upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", runtimeLane ? (configString(runtimeLaneConfig(deploy, profile)?.opencode?.providerProfile) || "dsflash-go") : "deepseek"); - if (runtimeLane) upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "repo-owned"); - upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel); - upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace)); - upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_MODEL", codexApiProfileModel); - upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", codexApiProfileBaseUrl); - upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", codexApiForwarderUpstreamBaseUrl); - upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", codexApiForwarderPort); - upsertEnv(container.env, "HWLAB_PREINSTALLED_SKILLS_DIR", "/app/skills"); - upsertEnv(container.env, "HWLAB_USER_SKILLS_DIR", "/data/user-skills"); - upsertEnv(container.env, "HWLAB_CODE_AGENT_SKILLS_DIRS", "/app/skills:/data/user-skills"); - upsertEnv(container.env, "NO_PROXY", codeAgentNoProxy(namespace)); - upsertEnv(container.env, "no_proxy", codeAgentNoProxy(namespace)); - } - if (serviceId === "hwlab-cloud-web") { - upsertEnv(container.env, "HWLAB_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`); - upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); - const traceTimelinePolicy = runtimeWorkbenchTraceTimelinePolicy(deploy, profile); - if (typeof traceTimelinePolicy?.autoExpandRunning === "boolean") { - upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING", booleanEnv(traceTimelinePolicy.autoExpandRunning)); - } - if (typeof traceTimelinePolicy?.autoCollapseTerminal === "boolean") { - upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL", booleanEnv(traceTimelinePolicy.autoCollapseTerminal)); - } - const traceExplorerUrlTemplate = runtimeTraceExplorerUrlTemplate(deploy, profile); - if (traceExplorerUrlTemplate) { - upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE", traceExplorerUrlTemplate); - } - } - } - rewritePodSecretRefs(podTemplate.spec, profile); - } - return result; -} - -function transformHealthContract(value, namespace, labels, annotations, runtimeEndpoint, webEndpoint, profile = "dev") { - const result = transformListNamespace(value, namespace, labels, annotations); - if (result.metadata?.name === "hwlab-dev-health-contract") { - result.metadata.name = `hwlab-${profile}-health-contract`; - } - if (result.metadata?.labels && Object.hasOwn(result.metadata.labels, "app.kubernetes.io/name")) { - result.metadata.labels["app.kubernetes.io/name"] = `hwlab-${profile}-health-contract`; - } - 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 node 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 transformServices({ services, namespace, labels, annotations, profile = "dev", deploy = null }) { - const observableServiceIds = isRuntimeLane(profile) ? runtimeLaneObservableServiceIdsForDeploy(deploy, profile) : new Set(); - const result = transformListNamespace(services, namespace, labels, annotations); - if (result.kind === "List") { - result.items = (result.items ?? []).filter((item) => !(isRuntimeLane(profile) && isV02RemovedServiceId(serviceIdForWorkload(item, null)))); - for (const item of result.items) { - const serviceId = serviceIdForWorkload(item, null); - if (!isRuntimeLane(profile) || !observableServiceIds.has(serviceId)) continue; - label(item.metadata ??= {}, v02MetricsLabels(serviceId)); - upsertV02MetricsPort(item); - } - } - return result; -} - -function observabilityManifest({ deploy, profile, namespace, labels, annotations, metricsSidecarScript }) { - assert.ok(isRuntimeLane(profile), `observability profile must be a runtime lane, got ${profile}`); - const includePrometheusOperatorResources = runtimePrometheusOperatorResourcesEnabled(deploy, profile); - const baseLabels = { - ...labels, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/monitoring": "enabled" - }; - const serviceMonitors = runtimeLaneObservableServicesForDeploy(deploy, profile).map((service) => ({ - apiVersion: "monitoring.coreos.com/v1", - kind: "ServiceMonitor", - metadata: { - name: `hwlab-${profile}-${service.serviceId}`, - namespace, - labels: { ...baseLabels, "hwlab.pikastech.local/service-id": service.serviceId }, - annotations - }, - spec: { - namespaceSelector: { matchNames: [namespace] }, - selector: { - matchLabels: { - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/monitoring": "enabled", - "hwlab.pikastech.local/service-id": service.serviceId - } - }, - targetLabels: [ - "hwlab.pikastech.local/gitops-target", - "hwlab.pikastech.local/service-id" - ], - endpoints: [{ port: "metrics", path: "/metrics", interval: "30s", scrapeTimeout: "10s" }] - } - })); - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { name: `hwlab-${profile}-metrics-sidecar`, namespace, labels: baseLabels, annotations }, - data: { "metrics-sidecar.mjs": metricsSidecarScript } - }, - ...(includePrometheusOperatorResources ? serviceMonitors : []), - ...(includePrometheusOperatorResources ? [{ - apiVersion: "monitoring.coreos.com/v1", - kind: "PrometheusRule", - metadata: { name: `hwlab-${profile}-observability`, namespace, labels: baseLabels, annotations }, - spec: { - groups: [{ - name: `hwlab-${profile}-observability`, - rules: [ - { - alert: `HWLAB${profile.toUpperCase()}MetricsTargetDown`, - 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.` } - }, - { - alert: `HWLAB${profile.toUpperCase()}ServiceHealthProbeFailed`, - expr: `hwlab_service_health_probe_success{namespace="${namespace}"} == 0`, - for: "5m", - labels: { severity: "warning", lane: profile }, - annotations: { summary: "HWLAB v0.2 service health probe failed", description: "The metrics sidecar cannot reach the service health endpoint inside the pod network." } - } - ] - }] - } - }] : []) - ] - }; -} - -function shellSingleQuote(value) { - return `'${String(value).replace(/'/g, `'"'"'`)}'`; -} - -function ciTimingShellFunction() { - return `ci_now_ms() { - node -e 'console.log(Date.now())' -} - -ci_timing_emit() { - stage="$1" - status="$2" - started_ms="$3" - finished_ms="$(ci_now_ms)" - duration_ms="$((finished_ms - started_ms))" - service_id="\${HWLAB_TIMING_SERVICE_ID:-}" - 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" -} -`; -} - -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-node-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 gitSshShellFunction() { - return `git_ssh_setup() { - mkdir -p /root/.ssh - cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa - chmod 600 /root/.ssh/id_rsa - timeout 10 ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true - timeout 10 ssh-keyscan -p 443 ssh.github.com >> /root/.ssh/known_hosts 2>/dev/null || true - write_github_proxy_command - 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'" - git config --global url."ssh://git@ssh.github.com:443/".insteadOf "git@github.com:" - git config --global url."ssh://git@ssh.github.com:443/".insteadOf "ssh://git@github.com/" -} - -write_github_proxy_command() { - cat > /tmp/hwlab-github-proxy-connect.mjs <<'NODE_PROXY' -#!/usr/bin/env node -import net from "node:net"; - -const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2); -const proxyPort = Number.parseInt(proxyPortRaw || "", 10); -const targetPort = Number.parseInt(targetPortRaw || "", 10); -if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) { - console.error("usage: hwlab-github-proxy-connect "); - process.exit(64); -} - -const socket = net.createConnection({ host: proxyHost, port: proxyPort }); -let buffer = Buffer.alloc(0); - -socket.setTimeout(10000, () => { - console.error("proxy connect timeout " + proxyHost + ":" + proxyPort + " -> " + targetHost + ":" + targetPort); - socket.destroy(); - process.exit(65); -}); - -socket.on("connect", () => { - socket.write("CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1\\r\\nHost: " + targetHost + ":" + targetPort + "\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n"); -}); - -socket.on("error", (error) => { - console.error("proxy connect failed: " + error.message); - process.exit(66); -}); - -function onData(chunk) { - buffer = Buffer.concat([buffer, chunk]); - const headerEnd = buffer.indexOf("\\r\\n\\r\\n"); - if (headerEnd === -1 && buffer.length < 8192) return; - const head = buffer.slice(0, headerEnd + 4).toString("latin1"); - const statusLine = head.split("\\r\\n", 1)[0] || ""; - const statusCode = Number.parseInt(statusLine.split(" ")[1] || "", 10); - if (!statusLine.startsWith("HTTP/1.") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) { - console.error("proxy CONNECT rejected: " + (statusLine || "missing-status")); - socket.destroy(); - process.exit(67); - } - socket.off("data", onData); - socket.setTimeout(0); - const rest = buffer.slice(headerEnd + 4); - if (rest.length) process.stdout.write(rest); - process.stdin.pipe(socket); - socket.pipe(process.stdout); -} - -socket.on("data", onData); -socket.on("close", () => process.exit(0)); -NODE_PROXY - chmod 0700 /tmp/hwlab-github-proxy-connect.mjs -} - -git_now_ms() { - node -e 'console.log(Date.now())' 2>/dev/null || date +%s000 -} - -git_timed() { - phase="$1" - timeout_seconds="$2" - shift 2 - started_ms="$(git_now_ms)" - echo '{"event":"git-operation","phase":"'"$phase"'","status":"started","timeoutSeconds":'"$timeout_seconds"'}' >&2 - set +e - timeout "$timeout_seconds" "$@" - status="$?" - set -e - finished_ms="$(git_now_ms)" - duration_ms="$((finished_ms - started_ms))" - if [ "$status" -eq 0 ]; then - echo '{"event":"git-operation","phase":"'"$phase"'","status":"succeeded","durationMs":'"$duration_ms"'}' >&2 - return 0 - fi - if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then - echo '{"event":"git-operation","phase":"'"$phase"'","status":"failed","reason":"timeout","durationMs":'"$duration_ms"',"timeoutSeconds":'"$timeout_seconds"'}' >&2 - else - echo '{"event":"git-operation","phase":"'"$phase"'","status":"failed","exitCode":'"$status"',"durationMs":'"$duration_ms"'}' >&2 - fi - return "$status" -} -`; -} - -function prepareSourceScript() { - const lines = [ - "#!/bin/sh", - "set -eu", - ciTimingShellFunction(), - "export HWLAB_TEKTON_PIPELINERUN=\"$(context.pipelineRun.name)\"", - "export HWLAB_TEKTON_TASKRUN=\"$(context.taskRun.name)\"", - "export HWLAB_TEKTON_TASK=\"prepare-source\"", - "export HWLAB_SOURCE_REVISION=\"$(params.revision)\"", - `echo '{"event":"ci-base-image","phase":"prepare-source","image":"${ciToolsRunnerImage}","policy":"no-runtime-apk"}'`, - "for 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", - "node -e 'console.log(JSON.stringify({event:\"ci-base-image\",phase:\"prepare-source\",ok:true,node:process.version}))'", - gitSshShellFunction(), - "git_url_requires_ssh() { case \"$1\" in git@*|ssh://*) return 0 ;; *) return 1 ;; esac; }", - "git_read_url=\"$(params.git-read-url)\"", - "if git_url_requires_ssh \"$git_read_url\"; then", - " 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", - " git_ssh_setup", - "else", - " echo '{\"event\":\"git-ssh-setup\",\"phase\":\"prepare-source\",\"status\":\"skipped\",\"reason\":\"non-ssh-git-read-url\"}'", - "fi", - "rm -rf /workspace/source/repo", - "source_clone_started_ms=\"$(ci_now_ms)\"", - "git_timed source-clone 180 git clone --branch \"$(params.source-branch)\" \"$git_read_url\" /workspace/source/repo", - "cd /workspace/source/repo", - "git config --global --add safe.directory /workspace/source/repo", - "git remote set-url origin \"$git_read_url\"", - "git checkout \"$(params.revision)\"", - "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", - "git 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; }", - "ci_timing_emit source-clone succeeded \"$source_clone_started_ms\"", - "prepare_source_dependencies_started_ms=\"$(ci_now_ms)\"", - "echo '{\"event\":\"prepare-source-dependencies\",\"status\":\"skipped\",\"reason\":\"renderer-dependency-install-disabled\",\"dependency\":\"yaml\"}'", - "ci_timing_emit prepare-source-dependencies succeeded \"$prepare_source_dependencies_started_ms\"", - "catalog_path=\"$(params.catalog-path)\"", - "mkdir -p \"$(dirname \"$catalog_path\")\"", - "catalog_fetch_started_ms=\"$(ci_now_ms)\"", - "if git_timed catalog-ls-remote 45 git ls-remote --exit-code --heads \"$git_read_url\" \"$(params.gitops-branch)\" >/dev/null; then", - " git remote set-url gitops-catalog \"$git_read_url\" 2>/dev/null || git remote add gitops-catalog \"$git_read_url\"", - " git_timed catalog-fetch 90 git fetch --depth 1 gitops-catalog \"$(params.gitops-branch)\" >/dev/null || true", - " if git cat-file -e FETCH_HEAD:\"$catalog_path\" 2>/dev/null; then", - " git show FETCH_HEAD:\"$catalog_path\" > /tmp/hwlab-gitops-artifact-catalog.json", - " if node --input-type=module - /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.yaml \"$(params.services)\" <<'NODE'", - "import fs from \"node:fs\";", - "const [catalogPath, deployPath, selectedServices] = process.argv.slice(2);", - "const ids = (doc) => (doc.services || []).map((service) => service.serviceId).filter(Boolean);", - "const topLevelServiceIds = (text) => {", - " const values = [];", - " let inServices = false;", - " for (const line of text.split(/\\r?\\n/u)) {", - " if (/^services:\\s*$/u.test(line)) { inServices = true; continue; }", - " if (!inServices) continue;", - " if (/^\\S/u.test(line)) break;", - " const match = line.match(/^\\s*-\\s+serviceId:\\s*['\"]?([^'\"#\\s]+)['\"]?/u);", - " if (match) values.push(match[1]);", - " }", - " return values;", - "};", - "const selected = (selectedServices || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);", - "const uniqueSorted = (items) => [...new Set(items)].sort();", - "const gitops = JSON.parse(fs.readFileSync(catalogPath, \"utf8\"));", - "const expected = selected.length ? selected : topLevelServiceIds(fs.readFileSync(deployPath, \"utf8\"));", - "const actual = ids(gitops);", - "const expectedSet = uniqueSorted(expected);", - "const actualSet = uniqueSorted(actual);", - "const sameServices = expectedSet.length === actualSet.length && expectedSet.every((item, index) => item === actualSet[index]);", - "if (!sameServices) {", - " const missing = expectedSet.filter((item) => !actualSet.includes(item));", - " const extra = actualSet.filter((item) => !expectedSet.includes(item));", - " console.error(JSON.stringify({ event: \"gitops-artifact-catalog\", phase: \"prepare-source\", status: \"ignored-stale\", reason: \"service-ids-mismatch\", expected, actual, missing, extra }));", - " process.exit(42);", - "}", - "NODE", - " then", - " cp /tmp/hwlab-gitops-artifact-catalog.json \"$catalog_path\"", - " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"loaded\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'", - " else", - " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"ignored-stale\",\"reason\":\"service-ids-mismatch\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'", - " fi", - " else", - " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"missing-on-gitops-branch\"}'", - " fi", - "else", - " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"gitops-branch-missing\"}'", - "fi", - "if [ ! -s \"$catalog_path\" ] && [ \"$(params.lane)\" = \"v02\" ]; then", - " node --input-type=module - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" \"$(params.services)\" <<'NODE'", - "import fs from 'node:fs';", - "import path from 'node:path';", - "const [catalogPath, revision, registryPrefix, imageTagMode, selectedServices] = process.argv.slice(2);", - "const topLevelServiceIds = (text) => {", - " const values = [];", - " let inServices = false;", - " for (const line of text.split(/\\r?\\n/u)) {", - " if (/^services:\\s*$/u.test(line)) { inServices = true; continue; }", - " if (!inServices) continue;", - " if (/^\\S/u.test(line)) break;", - " const match = line.match(/^\\s*-\\s+serviceId:\\s*['\"]?([^'\"#\\s]+)['\"]?/u);", - " if (match) values.push(match[1]);", - " }", - " return values;", - "};", - "const tag = imageTagMode === 'full' ? revision : revision.slice(0, 7);", - "const namespace = 'hwlab-v02';", - "const selected = (selectedServices || '').split(',').filter(Boolean);", - "const serviceIds = selected.length ? selected : topLevelServiceIds(fs.readFileSync('deploy/deploy.yaml', 'utf8'));", - "const services = serviceIds.map((serviceId) => {", - " const service = { serviceId, profile: 'v02', namespace, commitId: tag, sourceCommitId: revision, image: `${registryPrefix}/${serviceId}:${tag}`, imageTag: tag, digest: null, buildBackend: 'contract-skeleton' };", - " return service;", - "});", - "fs.mkdirSync(path.dirname(catalogPath), { recursive: true });", - `fs.writeFileSync(catalogPath, JSON.stringify({ catalogVersion: 'v1', kind: 'hwlab-artifact-catalog', environment: 'v02', profile: 'v02', namespace, endpoint: ${JSON.stringify(defaultV02RuntimeEndpoint)}, commitId: tag, artifactState: 'contract-skeleton', publish: { registryPrefix, sourceCommitId: revision, imageTag: tag, publishedAt: null }, allowedProfiles: ['v02'], forbiddenProfiles: ['dev', 'prod'], services }, null, 2) + '\\n');`, - "NODE", - " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seeded-v02-skeleton\"}'", - "fi", - "ci_timing_emit catalog-fetch succeeded \"$catalog_fetch_started_ms\"", - `echo ${shellSingleQuote(`node prepare-source complete; validation task count=${primitiveValidationTasks.length}`)}` - ]; - return `${lines.join("\n")}\n`; -} - -function sourceValidationScript(commands) { - return [ - "#!/bin/sh", - "set -eu", - "git config --global --add safe.directory /workspace/source/repo", - "cd /workspace/source/repo", - "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", - ...commands - ].join("\n") + "\n"; -} - -function primitiveValidationTaskNames() { - return primitiveValidationTasks.map((task) => task.name); -} - -function primitiveValidationTask(task) { - return { - name: task.name, - 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: task.name, image: ciToolsRunnerImage, env: proxyEnv(), script: sourceValidationScript(task.commands) }] - }, - 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)" } - ] - }; -} - -function planArtifactsScript() { - return `#!/bin/sh -set -eu -echo '{"event":"ci-base-image","phase":"plan-artifacts","image":"${ciToolsRunnerImage}","policy":"no-runtime-apk"}' -for 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 -cd /workspace/source/repo -ci_node_deps="\${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}" -if [ -d "$ci_node_deps/yaml" ] && [ ! -e node_modules/yaml ]; then - mkdir -p node_modules - ln -s "$ci_node_deps/yaml" node_modules/yaml -fi -test "$(git rev-parse HEAD)" = "$(params.revision)" -HWLAB_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)" \ -node scripts/ci/restore-artifact-catalog.mjs -node 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 -node - <<'NODE' -const fs = require("node:fs"); -const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8")); -const selected = String(process.env.HWLAB_SELECTED_SERVICES || "").split(",").map((item) => item.trim()).filter(Boolean); -const allServices = selected.length > 0 ? selected : ${JSON.stringify(defaultServiceIds)}; -const affected = new Set(plan.affectedServices || []); -const plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null; -const selectedSet = new Set(selected); -const byService = new Map((plan.services || []).map((service) => [service.serviceId, service])); -const entries = allServices.map((serviceId) => { - const service = byService.get(serviceId) || {}; - const serviceSelected = selectedSet.has(serviceId); - const rolloutAffected = serviceSelected && affected.has(serviceId); - const envReuse = service.runtimeMode === "env-reuse-git-mirror-checkout" || service.envReuse === true; - const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected)); - return { - serviceId, - selected: serviceSelected, - affected: rolloutAffected, - buildRequired, - rolloutAffected, - runtimeMode: service.runtimeMode || "service-image", - envChanged: service.envChanged ?? null, - codeChanged: service.codeChanged ?? null - }; -}); -for (const entry of entries) { - fs.writeFileSync("/tekton/results/affected-" + entry.serviceId, entry.affected ? "true" : "false"); - fs.writeFileSync("/tekton/results/build-" + entry.serviceId, entry.buildRequired ? "true" : "false"); -} -fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({ - sourceCommitId: plan.sourceCommitId, - affectedServices: plan.affectedServices || [], - rolloutServices: plan.rolloutServices || plan.affectedServices || [], - buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId), - reusedServices: plan.reusedServices || [], - buildSkippedCount: plan.buildSkippedCount || 0, - envArtifactGroups: plan.envArtifactGroups || [], - changedPathSummary: plan.changedPathSummary || null, - ciCdPlan: plan.ciCdPlan || null, - services: plan.services || [], - entries -}, null, 2) + String.fromCharCode(10)); -console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], reusedServices: plan.reusedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, envArtifactGroups: plan.envArtifactGroups || [] })); -NODE -`; -} - -function perServicePublishScript() { - return `#!/bin/sh -set -eu -${ciTimingShellFunction()} -export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" -export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" -export HWLAB_TEKTON_TASK="build-$(params.service-id)" -export HWLAB_SOURCE_REVISION="$(params.revision)" -export HWLAB_TIMING_SERVICE_ID="$(params.service-id)" -${dependencyProxyProbeScript("service-image-publish-proxy-preflight", "http://deb.debian.org/debian/dists/bookworm/InRelease")} -echo '{"event":"ci-base-image","phase":"service-image-publish","serviceId":"'"$(params.service-id)"'","image":"${ciToolsRunnerImage}","policy":"no-runtime-apk"}' -for 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 -mkdir -p /workspace/service-work/home -export HOME=/workspace/service-work/home -git config --global --add safe.directory /workspace/source/repo -cd /workspace/source/repo -test "$(git rev-parse HEAD)" = "$(params.revision)" -export HWLAB_ARTIFACT_LANE="$(params.lane)" -export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" -export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml" -export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" -mkdir -p /workspace/source/service-results -if [ -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 - node - "$(params.service-id)" <<'NODE' -const fs = require("node:fs"); -const serviceId = process.argv[2]; -const catalog = JSON.parse(fs.readFileSync(process.env.HWLAB_ARTIFACT_CATALOG_PATH, "utf8")); -const plan = fs.existsSync("/workspace/source/affected-services.json") ? JSON.parse(fs.readFileSync("/workspace/source/affected-services.json", "utf8")) : {}; -const service = (catalog.services || []).find((item) => item.serviceId === serviceId) || {}; -const planned = (plan.services || []).find((item) => item.serviceId === serviceId) || {}; -const envReuse = planned.runtimeMode === "env-reuse-git-mirror-checkout" || service.runtimeMode === "env-reuse-git-mirror-checkout" || planned.envReuse === true || service.envReuse === true; -const image = envReuse ? (service.environmentImage || service.image || "") : (service.image || ""); -const digest = envReuse ? (service.environmentDigest || service.digest || "not_published") : (service.digest || "not_published"); -const imageTag = service.imageTag || (image.includes(":") ? image.slice(image.lastIndexOf(":") + 1) : ""); -const repository = image.includes(":") ? image.slice(0, image.lastIndexOf(":")) : image; -const revision = process.env.HWLAB_SOURCE_REVISION || planned.bootCommit || service.bootCommit || service.sourceCommitId || service.commitId || imageTag; -const componentInputHash = envReuse ? (planned.componentInputHash || service.componentInputHash || "") : (service.componentInputHash || ""); -const environmentInputHash = envReuse ? (service.environmentInputHash || planned.environmentInputHash || "") : (service.environmentInputHash || ""); -const codeInputHash = envReuse ? (planned.codeInputHash || service.codeInputHash || "") : (service.codeInputHash || ""); -const values = { - "service-id": serviceId, - status: /^sha256:[a-f0-9]{64}$/.test(digest) ? "reused" : "blocked_reuse_unavailable", - image, - "image-tag": imageTag, - digest, - "repository-digest": /^sha256:[a-f0-9]{64}$/.test(digest) && repository ? repository + "@" + digest : "", - "source-commit-id": envReuse ? revision : (service.sourceCommitId || service.commitId || imageTag), - "component-input-hash": componentInputHash, - "environment-input-hash": environmentInputHash, - "code-input-hash": codeInputHash, - "runtime-mode": envReuse ? "env-reuse-git-mirror-checkout" : "service-image", - "boot-repo": planned.bootRepo || service.bootRepo || "", - "boot-commit": envReuse ? revision : (service.bootCommit || ""), - "boot-sh": planned.bootSh || service.bootSh || "", - "build-created-at": service.buildCreatedAt || "", - "build-backend": envReuse ? "reused-env-catalog" : "reused-catalog", - "reused-from": (envReuse ? service.environmentInputHash : service.componentInputHash) || service.commitId || imageTag || "catalog" -}; -for (const [name, value] of Object.entries(values)) fs.writeFileSync("/tekton/results/" + name, String(value || "")); -NODE - echo '{"event":"service-build-skip","serviceId":"'"$(params.service-id)"'","reason":"component-inputs-unchanged"}' - exit 0 -fi -rm -rf /workspace/service-work/repo -mkdir -p /workspace/service-work/repo -tar -C /workspace/source/repo -cf - . | tar -C /workspace/service-work/repo -xo -f - -cd /workspace/service-work/repo -export HWLAB_CI_ARTIFACT_RUN_ID="$(context.pipelineRun.name)-$(params.service-id)" -export HWLAB_CI_ARTIFACT_RUN_OWNER="tekton" -export HWLAB_DEV_REGISTRY_PREFIX="$(params.registry-prefix)" -export HWLAB_DEV_REGISTRY_K8S_NATIVE=1 -export HWLAB_NODE_CICD_TIMING=1 -export HWLAB_TEKTON_PIPELINERUN -export HWLAB_TEKTON_TASKRUN -export HWLAB_TEKTON_TASK -export HWLAB_SOURCE_REVISION -export PATH="/workspace/buildkit-bin:$PATH" -export HWLAB_BUILDKIT_ADDR="unix:///workspace/buildkit-run/buildkitd.sock" -if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi -if [ -n "\${HWLAB_DEV_BASE_IMAGE:-}" ]; then - 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/"}' - registry_started_at="$(date +%s)" - curl -fsS --max-time 10 http://127.0.0.1:5000/v2/ >/dev/null - registry_finished_at="$(date +%s)" - 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))"'}' -fi -buildkit_ready=0 -for attempt in $(seq 1 60); do - if /workspace/buildkit-bin/buildctl --addr "$HWLAB_BUILDKIT_ADDR" debug workers >/dev/null 2>&1; then - buildkit_ready=1 - break - fi - sleep 1 -done -if [ "$buildkit_ready" != "1" ]; then - echo '{"event":"buildkit-sidecar-not-ready","serviceId":"'"$(params.service-id)"'","addr":"'"$HWLAB_BUILDKIT_ADDR"'"}' >&2 - exit 1 -fi -HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node 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)" -`; -} - -function collectArtifactsScript() { - return `#!/bin/sh -set -eu -echo '{"event":"ci-base-image","phase":"collect-artifacts","image":"${ciToolsRunnerImage}","policy":"no-runtime-apk"}' -for 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 -cd /workspace/source/repo -test "$(git rev-parse HEAD)" = "$(params.revision)" -export HWLAB_CI_ARTIFACT_RUN_ID="$(context.pipelineRun.name)-collect" -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 -export HWLAB_ARTIFACT_LANE="$(params.lane)" -export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" -export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml" -export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" -if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE' -const fs = require("node:fs"); -const plan = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : []; -const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : []; -const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true; -process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1); -NODE -then - rm -f /workspace/source/dev-artifacts.json - echo '{"event":"collect-artifacts","status":"skipped","reason":"no-build-no-rollout-plan"}' - exit 0 -fi -HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node 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 -node 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 -`; -} - -function gitopsPromoteScript() { - return `#!/bin/sh -set -eu -${ciTimingShellFunction()} -export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" -export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" -export HWLAB_TEKTON_TASK="gitops-promote" -export HWLAB_SOURCE_REVISION="$(params.revision)" -echo '{"event":"ci-base-image","phase":"gitops-promote","image":"${ciToolsRunnerImage}","policy":"no-runtime-apt"}' -for 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 -${gitSshShellFunction()} -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 ;; -esac -if [ "$runtime_lane" = "true" ]; then - printf 'false' > /tekton/results/runtime-ready-required -else - printf 'true' > /tekton/results/runtime-ready-required -fi -source_head_url_for_setup="$(params.git-url)" -gitops_read_url_for_setup="$(params.git-url)" -if [ "$runtime_lane" = "true" ]; then - source_head_url_for_setup="$(params.git-read-url)" - gitops_read_url_for_setup="$(params.git-read-url)" -fi -gitops_write_url_for_setup="$(params.git-write-url)" -if 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 - 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 - git_ssh_setup -else - echo '{"event":"git-ssh-setup","phase":"gitops-promote","status":"skipped","reason":"non-ssh-git-url"}' -fi -cd /workspace/source/repo -test "$(git rev-parse HEAD)" = "$(params.revision)" - -check_source_head() { - phase="$1" - expected="\${2:-$(params.revision)}" - source_head_url="$(params.git-url)" - if [ "$runtime_lane" = "true" ]; then - source_head_url="$(params.git-read-url)" - fi - latest_file="$(mktemp)" - if ! git_timed "source-head-$phase" 45 git ls-remote "$source_head_url" "refs/heads/$(params.source-branch)" > "$latest_file"; then - echo '{"status":"failed","reason":"source-head-check-failed","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'"}' - exit 1 - fi - latest="$(cut -f1 "$latest_file" | head -n 1)" - 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" != "$expected" ]; then - printf 'false' > /tekton/results/runtime-ready-required || true - echo '{"status":"skipped-stale-source","verdict":"superseded","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'","expectedRevision":"'"$expected"'","latestRevision":"'"$latest"'"}' - exit 0 - fi -} - -check_source_head before-render -if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE' -const fs = require("node:fs"); -const plan = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : []; -const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : []; -const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true; -process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1); -NODE -then - printf 'false' > /tekton/results/runtime-ready-required || true - echo '{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"}' - exit 0 -fi -git config --global user.name "HWLAB node GitOps Bot" -git config --global user.email "hwlab-node-gitops-bot@users.noreply.github.com" -catalog_path="$(params.catalog-path)" -runtime_path="$(params.runtime-path)" -gitops_root_from_runtime_path() { - path="$1" - case "$path" in - */*) printf '%s\n' "\${path%/*}" ;; - *) printf '.\n' ;; - esac -} -gitops_root="$(gitops_root_from_runtime_path "$runtime_path")" - -argo_hard_refresh_runtime_lane() { - if [ "$runtime_lane" != "true" ]; then return 0; fi - ARGO_APPLICATION="hwlab-node-$lane" ARGO_FIELD_MANAGER="hwlab-$lane-gitops-promote" node <<'NODE' -const fs = require("node:fs"); -const https = require("node:https"); - -const host = process.env.KUBERNETES_SERVICE_HOST; -const port = process.env.KUBERNETES_SERVICE_PORT || "443"; -const startedAt = Date.now(); -const application = process.env.ARGO_APPLICATION || "hwlab-node-v02"; -const fieldManager = process.env.ARGO_FIELD_MANAGER || "hwlab-v02-gitops-promote"; -const pipelineRun = process.env.HWLAB_TEKTON_PIPELINERUN || null; -const taskRun = process.env.HWLAB_TEKTON_TASKRUN || null; -const task = process.env.HWLAB_TEKTON_TASK || null; -const revision = process.env.HWLAB_SOURCE_REVISION || null; - -function emit(payload) { - console.log(JSON.stringify({ - event: "node-cicd-timing", - schemaVersion: "v1", - stage: "argo-hard-refresh", - pipelineRun, - taskRun, - task, - revision, - application, - source: "scripts/gitops-render.mjs", - at: new Date().toISOString(), - ...payload - })); -} - -function safeJson(text) { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function request(method, path, body, contentType = "application/merge-patch+json") { - 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 payload = body ? JSON.stringify(body) : ""; - const headers = { Authorization: "Bearer " + token }; - if (payload) { - headers["Content-Type"] = contentType; - 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, json: safeJson(data) })); - }); - req.on("error", reject); - if (payload) req.write(payload); - req.end(); - }); -} - -(async () => { - if (!host) { - emit({ status: "skipped", reason: "kubernetes-service-host-missing", durationMs: Date.now() - startedAt }); - return; - } - const response = await request( - "PATCH", - "/apis/argoproj.io/v1alpha1/namespaces/argocd/applications/" + encodeURIComponent(application) + "?fieldManager=" + encodeURIComponent(fieldManager), - { metadata: { annotations: { "argocd.argoproj.io/refresh": "hard" } } } - ); - if (response.statusCode >= 200 && response.statusCode < 300) { - emit({ status: "succeeded", durationMs: Date.now() - startedAt, statusCode: response.statusCode }); - return; - } - emit({ status: "degraded", reason: "argo-refresh-patch-failed", durationMs: Date.now() - startedAt, statusCode: response.statusCode, body: response.body.slice(0, 1000) }); -})().catch((error) => { - emit({ status: "degraded", reason: "argo-refresh-patch-error", durationMs: Date.now() - startedAt, error: error.message }); -}); -NODE -} - -if [ -s /workspace/source/dev-artifacts.json ]; then - 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 -fi -gitops_render_started_ms="$(ci_now_ms)" -node 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 "$gitops_root" --out "$gitops_root" --registry-prefix "$(params.registry-prefix)" --use-deploy-images -node 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 "$gitops_root" --out "$gitops_root" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check -ci_timing_emit gitops-render succeeded "$gitops_render_started_ms" -check_source_head before-push -workdir="$(mktemp -d)" -gitops_read_url="$(params.git-url)" -gitops_write_url="$(params.git-write-url)" -if [ "$runtime_lane" = "true" ]; then - gitops_read_url="$(params.git-read-url)" -fi -gitops_clone_started_ms="$(ci_now_ms)" -git_timed gitops-clone 180 git clone --no-checkout "$gitops_read_url" "$workdir/gitops" -cd "$workdir/gitops" -git remote set-url origin "$gitops_write_url" -old_runtime_snapshot="$workdir/old-runtime-snapshot" -if git_timed gitops-branch-ls 45 git ls-remote --exit-code --heads origin "$(params.gitops-branch)" >/dev/null; then - git_timed gitops-fetch 90 git fetch origin "$(params.gitops-branch)" - git checkout -B "$(params.gitops-branch)" "origin/$(params.gitops-branch)" - if [ "$runtime_lane" = "true" ] && [ -d "$runtime_path" ]; then - mkdir -p "$old_runtime_snapshot" - cp -a "$runtime_path"/. "$old_runtime_snapshot"/ - fi - if [ "$runtime_lane" = "true" ]; then rm -rf "$runtime_path" "$catalog_path"; else rm -rf deploy/gitops/node "$catalog_path"; fi -else - git checkout --orphan "$(params.gitops-branch)" - git rm -rf . >/dev/null 2>&1 || true -fi -ci_timing_emit gitops-clone succeeded "$gitops_clone_started_ms" -mkdir -p "$(dirname "$runtime_path")" "$(dirname "$catalog_path")" -if [ "$runtime_lane" = "true" ]; then - cp -a "/workspace/source/repo/$runtime_path" "$runtime_path" - cp "/workspace/source/repo/$catalog_path" "$catalog_path" - git add "$catalog_path" "$runtime_path" -else - mkdir -p deploy/gitops - cp -a /workspace/source/repo/deploy/gitops/node deploy/gitops/node - cp "/workspace/source/repo/$catalog_path" "$catalog_path" - git add "$catalog_path" deploy/gitops/node -fi -if [ "$runtime_lane" = "true" ] && [ -s /workspace/source/affected-services.json ]; then - runtime_noop_decision="$(node - "$runtime_path" "$old_runtime_snapshot" /workspace/source/affected-services.json <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const [runtimePath, oldRuntimePath, planPath] = process.argv.slice(2); - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function stable(value) { - if (Array.isArray(value)) return "[" + value.map(stable).join(",") + "]"; - if (value && typeof value === "object") { - return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stable(value[key])).join(",") + "}"; - } - return JSON.stringify(value); -} - -function scrub(value) { - if (Array.isArray(value)) return value.map(scrub); - if (!value || typeof value !== "object") return value; - const result = {}; - for (const [key, child] of Object.entries(value)) { - if (key === "hwlab.pikastech.local/source-commit" || - key === "hwlab.pikastech.local/artifact-source-commit" || - key === "hwlab.pikastech.local/boot-commit" || - key === "sourceCommitId" || - key === "bootCommit") { - result[key] = ""; - continue; - } - const envName = String(value.name || ""); - if (key === "value" && /^HWLAB_.*COMMIT/.test(envName)) { - result[key] = ""; - continue; - } - result[key] = scrub(child); - } - return result; -} - -function listFiles(rootDir) { - if (!rootDir || !fs.existsSync(rootDir)) return null; - const files = []; - function walk(current) { - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) walk(fullPath); - else if (entry.isFile()) files.push(path.relative(rootDir, fullPath).replaceAll(path.sep, "/")); - } - } - walk(rootDir); - return files.sort(); -} - -function normalizedTree(rootDir) { - const files = listFiles(rootDir); - if (!files) return null; - const entries = {}; - for (const relativePath of files) { - const filePath = path.join(rootDir, relativePath); - const text = fs.readFileSync(filePath, "utf8"); - try { - entries[relativePath] = stable(scrub(JSON.parse(text))); - } catch { - entries[relativePath] = text.replace(/[a-f0-9]{40}/gu, ""); - } - } - return stable(entries); -} - -const plan = readJson(planPath); -const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : []; -const rolloutServices = Array.isArray(plan.rolloutServices) ? plan.rolloutServices : []; -if (buildServices.length > 0 || rolloutServices.length > 0) { - process.stdout.write("runtime-required"); - process.exit(0); -} - -const oldTree = normalizedTree(oldRuntimePath); -const newTree = normalizedTree(runtimePath); -process.stdout.write(oldTree && newTree && oldTree === newTree ? "runtime-identity-only" : "runtime-required"); -NODE -)" - if [ "$runtime_noop_decision" = "runtime-identity-only" ]; then - printf 'false' > /tekton/results/runtime-ready-required - echo '{"status":"skipped-runtime-unchanged","reason":"runtime-identity-only","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' - ci_timing_emit gitops-commit skipped "$(ci_now_ms)" - ci_timing_emit gitops-push skipped "$(ci_now_ms)" - exit 0 - fi -fi -if git diff --cached --quiet; then - printf 'false' > /tekton/results/runtime-ready-required - echo '{"status":"unchanged","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' - ci_timing_emit gitops-commit unchanged "$(ci_now_ms)" - ci_timing_emit gitops-push unchanged "$(ci_now_ms)" - exit 0 -fi -short="$(printf '%.7s' "$(params.revision)")" -gitops_commit_started_ms="$(ci_now_ms)" -git commit -m "chore: promote node GitOps source $short" -ci_timing_emit gitops-commit succeeded "$gitops_commit_started_ms" -gitops_push_started_ms="$(ci_now_ms)" -git_timed gitops-push 120 git push origin "HEAD:$(params.gitops-branch)" -ci_timing_emit gitops-push succeeded "$gitops_push_started_ms" -if [ "$runtime_lane" = "true" ]; then - printf 'false' > /tekton/results/runtime-ready-required - echo '{"event":"runtime-ready","phase":"gitops-promote","status":"delegated-post-flush-closeout","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' -fi -argo_hard_refresh_runtime_lane -echo '{"status":"pushed","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'","gitopsWriteUrl":"'"$gitops_write_url"'"}' -`; -} - -function controlPlaneReconcileScript() { - return `#!/bin/sh -set -eu -${dependencyProxyProbeScript("control-plane-proxy-preflight", "http://deb.debian.org/debian/dists/bookworm/InRelease")} -echo '{"event":"ci-base-image","phase":"control-plane-reconciler","image":"${ciToolsRunnerImage}","policy":"no-runtime-apk"}' -for tool in node npm git python3 ssh curl timeout; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"control-plane-reconciler","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done -${gitSshShellFunction()} -git_ssh_setup -workdir="$(mktemp -d)" -git_timed control-plane-clone 180 git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_READ_URL" "$workdir/repo" -cd "$workdir/repo" -git remote set-url origin "$GIT_READ_URL" -revision="$(git rev-parse HEAD)" -: "\${LANE:?LANE is required}" -: "\${CATALOG_PATH:?CATALOG_PATH is required}" -: "\${IMAGE_TAG_MODE:?IMAGE_TAG_MODE is required}" -: "\${RUNTIME_PATH:?RUNTIME_PATH is required}" -gitops_root="\${RUNTIME_PATH%/*}" -if [ "$gitops_root" = "$RUNTIME_PATH" ]; then gitops_root="."; fi -node scripts/run-bun.mjs scripts/gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --gitops-root "$gitops_root" --out "$gitops_root" --registry-prefix "$REGISTRY_PREFIX" -node scripts/run-bun.mjs scripts/gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --gitops-root "$gitops_root" --out "$gitops_root" --registry-prefix "$REGISTRY_PREFIX" --check -node <<'NODE' -const fs = require("node:fs"); -const https = require("node:https"); - -function requiredEnv(name) { - const value = process.env[name]; - if (!value) throw new Error(name + " is required"); - return value; -} - -const namespace = requiredEnv("POD_NAMESPACE"); -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 tektonDir = requiredEnv("TEKTON_DIR"); -const argoFiles = String(process.env.ARGO_FILES || "").split(",").map((item) => item.trim()).filter(Boolean); -const fieldManager = requiredEnv("FIELD_MANAGER"); -const eventName = requiredEnv("RECONCILE_EVENT"); -const manifestFiles = [ - "deploy/gitops/node/" + tektonDir + "/rbac.yaml", - "deploy/gitops/node/" + tektonDir + "/pipeline.yaml", - ...(process.env.LANE === "v02" ? [] : [ - "deploy/gitops/node/" + tektonDir + "/poller.yaml", - "deploy/gitops/node/" + tektonDir + "/control-plane-reconciler.yaml" - ]), - ...argoFiles -]; -const plurals = new Map([ - ["ServiceAccount", "serviceaccounts"], - ["Role", "roles"], - ["RoleBinding", "rolebindings"], - ["Pipeline", "pipelines"], - ["CronJob", "cronjobs"], - ["Application", "applications"], - ["AppProject", "appprojects"] -]); - -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 reconcilePolicy(item) { - const ns = item.metadata?.namespace || namespace; - const crossNamespaceRbac = (item.kind === "Role" || item.kind === "RoleBinding") && ns !== namespace; - if (crossNamespaceRbac && process.env.HWLAB_RECONCILE_CROSS_NAMESPACE_RBAC !== "1") { - return { action: "skip", reason: "cross-namespace-rbac-bootstrap-managed", namespace: ns }; - } - return { action: "apply", namespace: ns }; -} - -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 policy = reconcilePolicy(item); - if (policy.action === "skip") { - results.push({ file, kind: item.kind, name: item.metadata?.name || null, namespace: policy.namespace, status: "skipped", reason: policy.reason }); - continue; - } - 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=" + encode(fieldManager) + "&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: eventName, 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-proxy-preflight", "http://deb.debian.org/debian/dists/bookworm/InRelease")} -echo '{"event":"ci-base-image","phase":"poller","image":"${ciToolsRunnerImage}","policy":"no-runtime-apk"}' -for tool in node git ssh curl timeout; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"poller","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done -${gitSshShellFunction()} -git_ssh_setup -workdir="$(mktemp -d)" -git_timed poller-clone 180 git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_READ_URL" "$workdir/repo" -cd "$workdir/repo" -git remote set-url origin "$GIT_READ_URL" -revision="$(git rev-parse HEAD)" -subject="$(git log -1 --pretty=%s)" -case "$subject" in - "chore: promote node GitOps source "*) - echo "Skipping generated GitOps promotion commit $revision" - exit 0 - ;; -esac -short="$(printf '%.12s' "$revision")" -: "\${PIPELINERUN_PREFIX:?PIPELINERUN_PREFIX is required}" -: "\${GITOPS_TARGET:?GITOPS_TARGET is required}" -prefix="$PIPELINERUN_PREFIX" -name="$prefix-$short" -export POLLER_REVISION="$revision" -export POLLER_PIPELINERUN="$name" -export POLLER_SOURCE_SUBJECT="$subject" -echo "Checking $GITOPS_TARGET source $revision with PipelineRun $name" -node <<'NODE' -const fs = require("node:fs"); -const https = require("node:https"); - -function requiredEnv(name) { - const value = process.env[name]; - if (!value) throw new Error(name + " is required"); - return value; -} - -function optionalEnv(name) { - return process.env[name] || ""; -} - -const namespace = requiredEnv("POD_NAMESPACE"); -const name = requiredEnv("POLLER_PIPELINERUN"); -const revision = requiredEnv("POLLER_REVISION"); -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"); - -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": requiredEnv("GITOPS_TARGET"), - "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": optionalEnv("POLLER_SOURCE_SUBJECT"), - "hwlab.pikastech.local/source-branch": requiredEnv("SOURCE_BRANCH"), - "hwlab.pikastech.local/gitops-branch": requiredEnv("GITOPS_BRANCH") - } - }, - spec: { - pipelineRef: { name: requiredEnv("PIPELINE_NAME") }, - taskRunTemplate: { - serviceAccountName: requiredEnv("SERVICE_ACCOUNT_NAME"), - podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", securityContext: { fsGroup: 1000 } } - }, - params: [ - { name: "git-url", value: requiredEnv("GIT_URL") }, - { name: "git-read-url", value: requiredEnv("GIT_READ_URL") }, - { name: "source-branch", value: requiredEnv("SOURCE_BRANCH") }, - { name: "gitops-branch", value: requiredEnv("GITOPS_BRANCH") }, - { name: "lane", value: requiredEnv("LANE") }, - { name: "catalog-path", value: requiredEnv("CATALOG_PATH") }, - { name: "image-tag-mode", value: requiredEnv("IMAGE_TAG_MODE") }, - { name: "runtime-path", value: requiredEnv("RUNTIME_PATH") }, - { name: "revision", value: revision }, - { name: "registry-prefix", value: requiredEnv("REGISTRY_PREFIX") }, - { name: "services", value: requiredEnv("SERVICES") }, - { name: "base-image", value: requiredEnv("BASE_IMAGE") } - ], - 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 ciLaneSettings(argsOrLane = "node") { - const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane; - if (isRuntimeLane(lane)) { - const namespace = namespaceNameForProfile(lane); - return { - lane, - profile: lane, - gitopsTarget: lane, - pipelineName: `${namespace}-ci-image-publish`, - pipelineRunPrefix: `${namespace}-ci-poll`, - serviceAccountName: `${namespace}-tekton-runner`, - pollerName: `${namespace}-branch-poller`, - controlPlaneReconcilerName: `${namespace}-control-plane-reconciler`, - tektonDir: `tekton-${lane}`, - catalogPath: typeof argsOrLane === "object" ? argsOrLane.catalogPath : `deploy/artifact-catalog.${lane}.json`, - imageTagMode: typeof argsOrLane === "object" ? argsOrLane.imageTagMode : "full", - runtimeNamespace: namespace - }; - } - return { - lane: "node", - profile: "dev", - gitopsTarget: "node", - pipelineName: "hwlab-node-ci-image-publish", - pipelineRunPrefix: "hwlab-node-ci-poll", - serviceAccountName: "hwlab-tekton-runner", - pollerName: "hwlab-node-branch-poller", - controlPlaneReconcilerName: "hwlab-node-control-plane-reconciler", - tektonDir: "tekton", - catalogPath: "deploy/artifact-catalog.dev.json", - imageTagMode: "short", - runtimeNamespace: "hwlab-dev" - }; -} - -function tektonRbac(args = { lane: "node" }) { - const settings = ciLaneSettings(args); - if (isRuntimeLane(settings.lane)) { - const runtimeObserverName = `${settings.runtimeNamespace}-runtime-observer`; - const pipelineRunWriterName = `${settings.runtimeNamespace}-pipelinerun-writer`; - const argoReconcilerName = `${settings.runtimeNamespace}-argocd-reconciler`; - return { - apiVersion: "v1", - kind: "List", - items: [ - { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, - { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: settings.serviceAccountName, namespace: "hwlab-ci" } }, - { - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "Role", - metadata: { name: runtimeObserverName, namespace: settings.runtimeNamespace }, - 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: runtimeObserverName, namespace: settings.runtimeNamespace }, - subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: runtimeObserverName } - }, - { - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "Role", - metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" }, - rules: [ - { apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "create"] }, - { apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "patch", "create"] }, - { apiGroups: ["batch"], resources: ["cronjobs"], verbs: ["get", "patch", "create"] }, - { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] }, - { apiGroups: [""], resources: ["serviceaccounts"], verbs: ["get", "patch", "create"] } - ] - }, - { - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "RoleBinding", - metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" }, - subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: pipelineRunWriterName } - }, - { - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "Role", - metadata: { name: argoReconcilerName, namespace: "argocd" }, - rules: [ - { apiGroups: ["argoproj.io"], resources: ["applications", "appprojects"], verbs: ["get", "patch", "create"] }, - { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] } - ] - }, - { - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "RoleBinding", - metadata: { name: argoReconcilerName, namespace: "argocd" }, - subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: argoReconcilerName } - } - ] - }; - } - 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-node-control-plane-reconciler", namespace: "hwlab-dev" }, - rules: [ - { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] } - ] - }, - { - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "RoleBinding", - metadata: { name: "hwlab-node-control-plane-reconciler", namespace: "hwlab-dev" }, - subjects: [{ kind: "ServiceAccount", name: "hwlab-tekton-runner", namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-node-control-plane-reconciler" } - }, - { - 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", "create"] }, - { apiGroups: ["batch"], resources: ["cronjobs"], verbs: ["get", "patch", "create"] }, - { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] }, - { apiGroups: [""], resources: ["serviceaccounts"], verbs: ["get", "patch", "create"] } - ] - }, - { - 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 buildTaskName(serviceId) { - return `build-${serviceId}`; -} - -function affectedResultName(serviceId) { - return `affected-${serviceId}`; -} - -function buildResultName(serviceId) { - return `build-${serviceId}`; -} - -const serviceResultFields = Object.freeze([ - "status", - "service-id", - "image", - "image-tag", - "digest", - "repository-digest", - "source-commit-id", - "component-input-hash", - "environment-input-hash", - "code-input-hash", - "runtime-mode", - "boot-repo", - "boot-commit", - "boot-sh", - "build-created-at", - "build-backend", - "reused-from", - "go-base-image", - "go-base-image-status", - "go-base-digest", - "go-base-build-duration-ms", - "go-base-buildkit-cache-ref" -]); - -function serviceResultParamName(serviceId, field) { - return `${serviceId}-${field}`; -} - -function serviceResultEnvName(serviceId, field) { - return `HWLAB_SERVICE_RESULT_${serviceId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_")}_${field.toUpperCase().replace(/[^A-Z0-9]+/gu, "_")}`; -} - -function serviceResultParams(serviceIds = defaultServiceIds) { - return serviceIds.flatMap((serviceId) => serviceResultFields.map((field) => ({ name: serviceResultParamName(serviceId, field), default: "" }))); -} - -function serviceResultParamValues(serviceIds = defaultServiceIds) { - return serviceIds.flatMap((serviceId) => serviceResultFields.map((field) => ({ - name: serviceResultParamName(serviceId, field), - value: `$(tasks.${buildTaskName(serviceId)}.results.${field})` - }))); -} - -function serviceResultEnv(serviceIds = defaultServiceIds) { - return serviceIds.flatMap((serviceId) => serviceResultFields.map((field) => ({ - name: serviceResultEnvName(serviceId, field), - value: `$(params.${serviceResultParamName(serviceId, field)})` - }))); -} - -function serviceWorkVolume() { - return { name: "service-work", emptyDir: {} }; -} - -function buildkitBinVolume() { - return { name: "buildkit-bin", emptyDir: {} }; -} - -function buildkitRunVolume() { - return { name: "buildkit-run", emptyDir: {} }; -} - -function prepareBuildkitClientStep() { - return { - name: "prepare-buildkit-client", - image: buildkitRunnerImage, - securityContext: { runAsUser: 0, runAsGroup: 0 }, - volumeMounts: [{ name: "buildkit-bin", mountPath: "/workspace/buildkit-bin" }], - script: `#!/bin/sh -set -eu -mkdir -p /workspace/buildkit-bin -cp /usr/bin/buildctl /workspace/buildkit-bin/ -chmod +x /workspace/buildkit-bin/* -` - }; -} - -function buildkitSidecar() { - return { - name: "buildkitd", - image: buildkitRunnerImage, - args: ["--addr", "unix:///workspace/buildkit-run/buildkitd.sock", "--oci-worker-no-process-sandbox"], - env: proxyEnv(), - volumeMounts: [{ name: "buildkit-run", mountPath: "/workspace/buildkit-run" }], - securityContext: { - runAsUser: 1000, - runAsGroup: 1000, - allowPrivilegeEscalation: true, - appArmorProfile: { type: "Unconfined" }, - seccompProfile: { type: "Unconfined" } - } - }; -} - -function planArtifactsTask({ serviceIds = defaultServiceIds } = {}) { - return { - name: "plan-artifacts", - runAfter: primitiveValidationTaskNames(), - 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: serviceIds.flatMap((serviceId) => [ - { name: affectedResultName(serviceId), description: `${serviceId} rollout affected according to ci-plan` }, - { name: buildResultName(serviceId), description: `${serviceId} image build required according to ci-plan` } - ]), - workspaces: [{ name: "source" }], - steps: [{ - name: "plan", - image: ciToolsRunnerImage, - env: [ - { name: "HWLAB_SELECTED_SERVICES", value: "$(params.services)" }, - { name: "HWLAB_DEV_REGISTRY_K8S_NATIVE", value: "1" }, - { name: "HWLAB_TEKTON_PIPELINERUN", value: "$(context.pipelineRun.name)" }, - ...proxyEnv() - ], - script: planArtifactsScript() - }] - }, - 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)" } - ] - }; -} - -function perServiceBuildTask(serviceId, { runAfter = ["plan-artifacts"] } = {}) { - return { - name: buildTaskName(serviceId), - runAfter, - workspaces: [{ name: "source", workspace: "source" }], - when: [{ input: `$(tasks.plan-artifacts.results.${buildResultName(serviceId)})`, operator: "in", values: ["true"] }], - 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: serviceResultFields.map((field) => ({ name: field, description: `${serviceId} ${field}` })), - workspaces: [{ name: "source" }], - volumes: [serviceWorkVolume(), buildkitBinVolume(), buildkitRunVolume()], - sidecars: [buildkitSidecar()], - steps: [ - prepareBuildkitClientStep(), - { - name: "publish", - image: ciToolsRunnerImage, - securityContext: { runAsUser: 1000, runAsGroup: 1000 }, - env: proxyEnv(), - volumeMounts: [{ name: "service-work", mountPath: "/workspace/service-work" }, { name: "buildkit-bin", mountPath: "/workspace/buildkit-bin" }, { name: "buildkit-run", mountPath: "/workspace/buildkit-run" }], - script: perServicePublishScript() - } - ] - }, - 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: "service-id", value: serviceId }, - { name: "base-image", value: "$(params.base-image)" }, - { name: "build-cache-mode", value: "$(params.build-cache-mode)" } - ] - }; -} - -function collectArtifactsTask({ serviceIds = defaultServiceIds } = {}) { - return { - name: "collect-artifacts", - runAfter: serviceIds.map(buildTaskName), - 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: ciToolsRunnerImage, env: proxyEnv(), script: collectArtifactsScript() }] - }, - 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)" } - ] - }; -} - -function runtimeReadyScript() { - return `#!/bin/sh -set -eu -${ciTimingShellFunction()} -export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" -export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" -export HWLAB_TEKTON_TASK="runtime-ready" -export HWLAB_SOURCE_REVISION="$(params.revision)" -export HWLAB_RUNTIME_READY_TIMEOUT_MS="$(params.timeout-ms)" -cd /workspace/source/repo -node scripts/ci/runtime-ready.mjs -`; -} - -function runtimeReadyTask({ profile = "dev" } = {}) { - return { - name: "runtime-ready", - runAfter: ["gitops-promote"], - workspaces: [{ name: "source", workspace: "source" }], - taskSpec: { - params: [{ name: "revision" }, { name: "runtime-namespace" }, { name: "gitops-target" }, { name: "argo-application" }, { name: "timeout-ms" }], - workspaces: [{ name: "source" }], - steps: [{ - name: "runtime-ready", - image: ciToolsRunnerImage, - env: [ - ...proxyEnv(), - { name: "HWLAB_RUNTIME_NAMESPACE", value: "$(params.runtime-namespace)" }, - { name: "HWLAB_GITOPS_TARGET", value: "$(params.gitops-target)" }, - { name: "HWLAB_ARGO_APPLICATION", value: "$(params.argo-application)" } - ], - script: runtimeReadyScript() - }] - }, - params: [ - { name: "revision", value: "$(params.revision)" }, - { name: "runtime-namespace", value: namespaceNameForProfile(profile) }, - { name: "gitops-target", value: gitopsTargetForProfile(profile) }, - { name: "argo-application", value: argoApplicationName(profile) }, - { name: "timeout-ms", value: "$(params.runtime-ready-timeout-ms)" } - ] - }; -} - -function imagePublishTaskSet(args = { lane: "node" }, deploy = null) { - const serviceIds = serviceIdsForLane(args.lane, deploy); - const buildTasks = serviceIds.map((serviceId) => perServiceBuildTask(serviceId, { - runAfter: ["plan-artifacts"] - })); - return [ - planArtifactsTask({ serviceIds }), - ...buildTasks, - collectArtifactsTask({ serviceIds }) - ]; -} - -function tektonPipeline(args = { lane: "node" }, deploy = null) { - const settings = ciLaneSettings(args); - const runtimePath = gitopsPathForProfile(args, settings.profile); - const preFlushRuntimeReadyTasks = isRuntimeLane(settings.lane) - ? [] - : [{ - ...runtimeReadyTask({ profile: settings.profile }), - when: [{ input: "$(tasks.gitops-promote.results.runtime-ready-required)", operator: "in", values: ["true"] }] - }]; - return { - apiVersion: "tekton.dev/v1", - kind: "Pipeline", - metadata: { - name: settings.pipelineName, - namespace: "hwlab-ci", - labels: { - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": settings.gitopsTarget - }, - 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" - } - }, - spec: { - params: [ - { name: "git-url", type: "string", default: defaultSourceRepo }, - { name: "git-read-url", type: "string", default: args.gitReadUrl }, - { name: "git-write-url", type: "string", default: args.gitWriteUrl }, - { name: "source-branch", type: "string", default: args.sourceBranch }, - { name: "gitops-branch", type: "string", default: args.gitopsBranch }, - { name: "lane", type: "string", default: settings.lane }, - { name: "catalog-path", type: "string", default: args.catalogPath || settings.catalogPath }, - { name: "image-tag-mode", type: "string", default: args.imageTagMode || settings.imageTagMode }, - { name: "runtime-path", type: "string", default: runtimePath }, - { 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: servicesParamForLane(args.lane, deploy) }, - { name: "base-image", type: "string", default: defaultDevBaseImage }, - { 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: ciToolsRunnerImage, env: proxyEnv(), script: prepareSourceScript() }] - }, - 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)" } - ] - }, - ...primitiveValidationTasks.map(primitiveValidationTask), - ...imagePublishTaskSet(args, deploy), - { - 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: ciToolsRunnerImage, env: proxyEnv(), script: gitopsPromoteScript() }] - }, - 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)" } - ] - }, - ...preFlushRuntimeReadyTasks - ] - } - }; -} - -function tektonPipelineRunTemplate({ source, args }) { - const settings = ciLaneSettings(args); - const runtimePath = gitopsPathForProfile(args, settings.profile); - return { - apiVersion: "tekton.dev/v1", - kind: "PipelineRun", - metadata: { - generateName: `${settings.pipelineRunPrefix}-manual-`, - namespace: "hwlab-ci", - labels: { - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": settings.gitopsTarget, - "hwlab.pikastech.local/source-commit": source.full - } - }, - spec: { - pipelineRef: { name: settings.pipelineName }, - taskRunTemplate: { - serviceAccountName: settings.serviceAccountName, - podTemplate: { - hostNetwork: true, - dnsPolicy: "ClusterFirstWithHostNet", - securityContext: { fsGroup: 1000 } - } - }, - params: [ - { name: "git-url", value: args.sourceRepo }, - { name: "git-read-url", value: args.gitReadUrl }, - { name: "git-write-url", value: args.gitWriteUrl }, - { name: "source-branch", value: args.sourceBranch }, - { name: "gitops-branch", value: args.gitopsBranch }, - { 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: runtimePath }, - { name: "revision", value: source.full }, - { name: "registry-prefix", value: args.registryPrefix }, - { name: "base-image", value: defaultDevBaseImage }, - { name: "build-cache-mode", value: "registry" } - ], - workspaces: [ - { name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } }, - { name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } } - ] - } - }; -} - -function tektonPollerCronJob(args, deploy) { - const settings = ciLaneSettings(args); - 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", - kind: "CronJob", - metadata: { - name: settings.pollerName, - namespace: "hwlab-ci", - labels: { - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": settings.gitopsTarget - }, - annotations: { - "hwlab.pikastech.local/source-branch": args.sourceBranch, - "hwlab.pikastech.local/gitops-branch": args.gitopsBranch - } - }, - spec: { - schedule: settings.lane === "v02" ? "*/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": settings.gitopsTarget - } - }, - spec: { - serviceAccountName: settings.serviceAccountName, - restartPolicy: "Never", - hostNetwork: true, - dnsPolicy: "ClusterFirstWithHostNet", - containers: [{ - name: "poll", - image: ciToolsRunnerImage, - imagePullPolicy: "IfNotPresent", - env: [ - ...proxyEnv(), - { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, - { name: "PIPELINE_NAME", value: settings.pipelineName }, - { name: "PIPELINERUN_PREFIX", value: settings.pipelineRunPrefix }, - { name: "SERVICE_ACCOUNT_NAME", value: settings.serviceAccountName }, - { name: "GITOPS_TARGET", value: settings.gitopsTarget }, - { 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: "GIT_URL", value: args.sourceRepo }, - { name: "GIT_READ_URL", value: args.gitReadUrl }, - { name: "SOURCE_BRANCH", value: args.sourceBranch }, - { name: "GITOPS_BRANCH", value: args.gitopsBranch }, - { name: "REGISTRY_PREFIX", value: args.registryPrefix }, - { name: "SERVICES", value: servicesParamForLane(args.lane, deploy) }, - { name: "BASE_IMAGE", value: defaultDevBaseImage } - ], - 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) { - const settings = ciLaneSettings(args); - if (settings.lane === "v02") throw new Error("v02 CI/CD is manually triggered by UniDesk CLI and must not render a control-plane reconciler CronJob"); - return { - apiVersion: "batch/v1", - kind: "CronJob", - metadata: { - name: settings.controlPlaneReconcilerName, - namespace: "hwlab-ci", - labels: { - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": settings.gitopsTarget - }, - annotations: { - "hwlab.pikastech.local/source-branch": args.sourceBranch, - "hwlab.pikastech.local/gitops-branch": args.gitopsBranch, - "hwlab.pikastech.local/purpose": "render-and-apply-node-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": settings.gitopsTarget - } - }, - spec: { - serviceAccountName: settings.serviceAccountName, - restartPolicy: "Never", - hostNetwork: true, - dnsPolicy: "ClusterFirstWithHostNet", - containers: [{ - name: "reconcile", - image: ciToolsRunnerImage, - imagePullPolicy: "IfNotPresent", - env: [ - ...proxyEnv(), - { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, - { name: "LANE", value: settings.lane }, - { name: "CATALOG_PATH", value: args.catalogPath || settings.catalogPath }, - { name: "IMAGE_TAG_MODE", value: args.imageTagMode || settings.imageTagMode }, - { name: "TEKTON_DIR", value: settings.tektonDir }, - { name: "ARGO_FILES", value: settings.lane === "v02" ? `${gitopsPathFor(args, "argocd/project.yaml")},${gitopsPathFor(args, "argocd/application-v02.yaml")}` : "" }, - { name: "FIELD_MANAGER", value: settings.controlPlaneReconcilerName }, - { name: "RECONCILE_EVENT", value: `${settings.gitopsTarget}-control-plane-reconciled` }, - { name: "GIT_URL", value: args.sourceRepo }, - { name: "GIT_READ_URL", value: args.gitReadUrl }, - { 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 uniqueStrings(values) { - return Array.from(new Set(values.filter((value) => typeof value === "string" && value.length > 0))); -} - -function configObject(value, label) { - assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); - return value; -} - -function requiredConfigString(record, key, label) { - const value = record[key]; - assert.ok(typeof value === "string" && value.length > 0, `${label}.${key} must be a non-empty string`); - return value; -} - -function optionalConfigString(record, key, label) { - const value = record[key]; - if (value === undefined || value === null || value === "") return null; - assert.ok(typeof value === "string", `${label}.${key} must be a string when set`); - return value; -} - -function requiredConfigPositiveInteger(record, key, label) { - const value = record[key]; - assert.ok(Number.isInteger(value) && value > 0, `${label}.${key} must be a positive integer`); - return value; -} - -function devopsInfraGitMirrorManifest(args, deploy) { - const mirrorSpecs = runtimeLaneMirrorSpecs(deploy, { defaultNodeId: args.nodeId, defaultGitopsRoot: defaultOutDir }); - assert.ok(mirrorSpecs.length > 0, "runtime lane git mirror requires at least one deploy.lanes entry"); - const sourceBranches = uniqueStrings(mirrorSpecs.map((spec) => spec.sourceBranch)); - const gitopsBranches = uniqueStrings(mirrorSpecs.map((spec) => spec.gitopsBranch)); - const mirrorBranches = uniqueStrings([...sourceBranches, ...gitopsBranches]); - const fetchConfigCommands = mirrorBranches - .map((branch) => `git -C "$repo_path" config --add remote.origin.fetch '+refs/heads/${branch}:refs/mirror-stage/heads/${branch}'`) - .join("\n"); - const requiredRefArgs = mirrorBranches.map((branch) => shellSingleQuote(`refs/mirror-stage/heads/${branch}`)).join(" "); - const sourceBranchArgs = sourceBranches.map((branch) => shellSingleQuote(branch)).join(" "); - const gitopsBranchArgs = gitopsBranches.map((branch) => shellSingleQuote(branch)).join(" "); - const mirrorBranchesJson = JSON.stringify(mirrorBranches); - const sourceBranchesJson = JSON.stringify(sourceBranches); - const gitopsBranchesJson = JSON.stringify(gitopsBranches); - const sourceSnapshotRefPrefix = "refs/unidesk/snapshots/hwlab-node-runtime"; - const laneConfig = configObject(configObject(deploy.lanes, "deploy.lanes")[args.lane], `deploy.lanes.${args.lane}`); - const gitMirrorConfig = configObject(laneConfig.gitMirror, `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorProxy = configObject(gitMirrorConfig.egressProxy, `deploy.lanes.${args.lane}.gitMirror.egressProxy`); - const gitMirrorProxyMode = requiredConfigString(gitMirrorProxy, "mode", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); - assert.ok(gitMirrorProxyMode === "node-global" || gitMirrorProxyMode === "host-proxy-client" || gitMirrorProxyMode === "direct", `deploy.lanes.${args.lane}.gitMirror.egressProxy.mode must be node-global, host-proxy-client, or direct`); - const useDirectGitMirrorProxy = gitMirrorProxyMode === "direct"; - const useHostGitMirrorProxy = gitMirrorProxyMode === "host-proxy-client"; - const gitMirrorNamespace = requiredConfigString(gitMirrorConfig, "namespace", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorReadName = requiredConfigString(gitMirrorConfig, "serviceReadName", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorWriteName = requiredConfigString(gitMirrorConfig, "serviceWriteName", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorCachePvcName = requiredConfigString(gitMirrorConfig, "cachePvcName", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorCachePvcStorage = requiredConfigString(gitMirrorConfig, "cachePvcStorage", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorCacheHostPath = optionalConfigString(gitMirrorConfig, "cacheHostPath", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorServicePort = requiredConfigPositiveInteger(gitMirrorConfig, "servicePort", `deploy.lanes.${args.lane}.gitMirror`); - const gitMirrorConfigMapName = requiredConfigString(gitMirrorConfig, "syncConfigMapName", `deploy.lanes.${args.lane}.gitMirror`); - const proxyNamespace = useDirectGitMirrorProxy || useHostGitMirrorProxy ? "" : requiredConfigString(gitMirrorProxy, "namespace", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); - const proxyServiceName = useDirectGitMirrorProxy || useHostGitMirrorProxy ? "" : requiredConfigString(gitMirrorProxy, "serviceName", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); - const proxyPort = useDirectGitMirrorProxy ? 0 : requiredConfigPositiveInteger(gitMirrorProxy, "port", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); - const proxyClientName = useDirectGitMirrorProxy ? "" : requiredConfigString(gitMirrorProxy, "clientName", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); - const proxyNoProxy = !useDirectGitMirrorProxy && Array.isArray(gitMirrorProxy.noProxy) ? gitMirrorProxy.noProxy.filter((value) => typeof value === "string" && value.length > 0).join(",") : ""; - const proxyHost = useHostGitMirrorProxy - ? requiredConfigString(gitMirrorProxy, "host", `deploy.lanes.${args.lane}.gitMirror.egressProxy`) - : `${proxyServiceName}.${proxyNamespace}.svc.cluster.local`; - const proxyUrl = `http://${proxyHost}:${proxyPort}`; - const proxySummary = useDirectGitMirrorProxy - ? "git-mirror-egress-proxy mode=direct required=false transport=ssh source=yaml" - : `git-mirror-egress-proxy client=${proxyClientName} mode=${gitMirrorProxyMode} required=true host=${proxyHost} port=${proxyPort} ssh=GIT_SSH-wrapper source=yaml`; - const proxyCommand = useDirectGitMirrorProxy ? "" : `ProxyCommand=node /tmp/hwlab-github-proxy-connect.mjs ${proxyHost} ${proxyPort} %h %p`; - const directGitMirrorSshPrelude = `printf '%s\\n' ${shellSingleQuote(proxySummary)} >&2 -cat > /tmp/hwlab-git-ssh-proxy.sh <<'SH_PROXY' -#!/bin/sh -exec ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -o ConnectTimeout=15 -o ServerAliveInterval=5 -o ServerAliveCountMax=1 "$@" -SH_PROXY -chmod 0700 /tmp/hwlab-git-ssh-proxy.sh -unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy -export NO_PROXY='*' -export no_proxy='*' -export GIT_SSH=/tmp/hwlab-git-ssh-proxy.sh -unset GIT_SSH_COMMAND`; - const proxiedGitMirrorSshPrelude = `printf '%s\\n' ${shellSingleQuote(proxySummary)} >&2 -cat > /tmp/hwlab-github-proxy-connect.mjs <<'NODE_PROXY' -#!/usr/bin/env node -import net from "node:net"; -const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2); -const proxyPort = Number.parseInt(proxyPortRaw || "", 10); -const targetPort = Number.parseInt(targetPortRaw || "", 10); -if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) { - console.error("hwlab git-mirror proxy-connect: invalid ProxyCommand arguments"); - process.exit(64); -} -let settled = false; -let tunnelEstablished = false; -function finish(code, message) { - if (settled) return; - settled = true; - if (message) console.error("hwlab git-mirror proxy-connect: " + message); - process.exit(code); -} -const socket = net.createConnection({ host: proxyHost, port: proxyPort }); -let buffer = Buffer.alloc(0); -socket.setTimeout(15000, () => { socket.destroy(); finish(65, "timeout connecting via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort); }); -socket.on("connect", () => socket.write("CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1\\r\\nHost: " + targetHost + ":" + targetPort + "\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n")); -socket.on("error", (error) => finish(tunnelEstablished ? 69 : 66, (tunnelEstablished ? "tunnel socket error: " : "tcp error connecting to proxy: ") + (error && error.message ? error.message : String(error)))); -socket.on("close", () => { if (!tunnelEstablished) finish(68, "proxy closed before CONNECT completed via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort); else finish(0); }); -function onData(chunk) { - buffer = Buffer.concat([buffer, chunk]); - const headerEnd = buffer.indexOf("\\r\\n\\r\\n"); - if (headerEnd === -1 && buffer.length < 8192) return; - if (headerEnd === -1) { socket.destroy(); finish(68, "proxy response header exceeded 8192 bytes before CONNECT status via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort); return; } - const head = buffer.slice(0, headerEnd + 4).toString("latin1"); - const statusLine = head.split("\\r\\n", 1)[0] || ""; - const statusCode = Number.parseInt(statusLine.split(" ")[1] || "", 10); - if (!statusLine.startsWith("HTTP/1.") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) { - const safeStatus = statusLine.replace(/[^\\x20-\\x7e]/g, "?").slice(0, 160); - socket.destroy(); - finish(67, "proxy CONNECT failed via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort + ": " + safeStatus); - return; - } - socket.off("data", onData); - socket.setTimeout(0); - tunnelEstablished = true; - const rest = buffer.slice(headerEnd + 4); - if (rest.length) process.stdout.write(rest); - process.stdin.on("error", () => {}); - process.stdout.on("error", () => {}); - process.stdin.pipe(socket); - socket.pipe(process.stdout); -} -socket.on("data", onData); -NODE_PROXY -chmod 0700 /tmp/hwlab-github-proxy-connect.mjs -cat > /tmp/hwlab-git-ssh-proxy.sh <<'SH_PROXY' -#!/bin/sh -exec ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -o ConnectTimeout=15 -o ServerAliveInterval=5 -o ServerAliveCountMax=1 -o ${shellSingleQuote(proxyCommand)} "$@" -SH_PROXY -chmod 0700 /tmp/hwlab-git-ssh-proxy.sh -export HTTP_PROXY=${shellSingleQuote(proxyUrl)} -export HTTPS_PROXY=${shellSingleQuote(proxyUrl)} -export ALL_PROXY=${shellSingleQuote(proxyUrl)} -export http_proxy=${shellSingleQuote(proxyUrl)} -export https_proxy=${shellSingleQuote(proxyUrl)} -export all_proxy=${shellSingleQuote(proxyUrl)} -export NO_PROXY=${shellSingleQuote(proxyNoProxy)} -export no_proxy=${shellSingleQuote(proxyNoProxy)} -export GIT_SSH=/tmp/hwlab-git-ssh-proxy.sh -unset GIT_SSH_COMMAND`; - const gitMirrorSshPrelude = useDirectGitMirrorProxy ? directGitMirrorSshPrelude : proxiedGitMirrorSshPrelude; - const cacheVolume = gitMirrorCacheHostPath === null - ? { name: "cache", persistentVolumeClaim: { claimName: gitMirrorCachePvcName } } - : { name: "cache", hostPath: { path: gitMirrorCacheHostPath, type: "DirectoryOrCreate" } }; - const labels = { - "app.kubernetes.io/part-of": "hwlab-node-control-plane", - "hwlab.pikastech.local/node": args.nodeId, - "hwlab.pikastech.local/lane": args.lane - }; - const configLabels = { ...labels, "app.kubernetes.io/name": "git-mirror" }; - const readLabels = { ...labels, "app.kubernetes.io/name": gitMirrorReadName, "hwlab.pikastech.local/git-mirror-mode": "read" }; - const writeLabels = { ...labels, "app.kubernetes.io/name": gitMirrorWriteName, "hwlab.pikastech.local/git-mirror-mode": "write" }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { apiVersion: "v1", kind: "Namespace", metadata: { name: gitMirrorNamespace } }, - ...(gitMirrorCacheHostPath === null ? [{ - apiVersion: "v1", - kind: "PersistentVolumeClaim", - metadata: { name: gitMirrorCachePvcName, namespace: gitMirrorNamespace, labels: configLabels }, - spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: gitMirrorCachePvcStorage } } } - }] : []), - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { name: gitMirrorConfigMapName, namespace: gitMirrorNamespace, labels: configLabels }, - data: { - "sync.sh": `#!/bin/sh -set -eu -repo_url="ssh://git@ssh.github.com:443/pikasTech/HWLAB.git" -repo_path="/cache/pikasTech/HWLAB.git" -source_snapshot_ref_prefix=${shellSingleQuote(sourceSnapshotRefPrefix)} -lock_dir="/cache/.hwlab-sync.lock" -sync_started_ms=$(node -e 'console.log(Date.now())') -now_ms() { node -e 'console.log(Date.now())'; } -emit_timing() { - phase="$1" - status="$2" - started_ms="$3" - finished_ms=$(now_ms) - duration_ms=$((finished_ms - started_ms)) - printf '{"event":"git-mirror-sync-timing","phase":"%s","status":"%s","durationMs":%s,"repository":"pikasTech/HWLAB"}\n' "$phase" "$status" "$duration_ms" -} -mkdir -p /cache/pikasTech /root/.ssh -if ! mkdir "$lock_dir" 2>/dev/null; then - echo "git mirror sync already running" - exit 0 -fi -trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT INT TERM -cp /git-ssh/ssh-privatekey /root/.ssh/id_rsa -chmod 0400 /root/.ssh/id_rsa -${gitMirrorSshPrelude} -if [ -d "$repo_path/objects" ] && [ -f "$repo_path/HEAD" ]; then - git -C "$repo_path" remote set-url origin "$repo_url" || git -C "$repo_path" remote add origin "$repo_url" -else - rm -rf "$repo_path" - git init --bare "$repo_path" - git -C "$repo_path" remote add origin "$repo_url" -fi -/script/install-hooks.sh -git -C "$repo_path" config uploadpack.hideRefs refs/mirror-stage -git -C "$repo_path" config uploadpack.allowReachableSHA1InWant true -git -C "$repo_path" config uploadpack.allowAnySHA1InWant true -git -C "$repo_path" config --unset-all remote.origin.fetch 2>/dev/null || true -${fetchConfigCommands} -fetch_started_ms=$(now_ms) -timeout 180 git -C "$repo_path" fetch --quiet --prune origin -emit_timing fetch succeeded "$fetch_started_ms" -git -C "$repo_path" for-each-ref --format='%(objectname) %(refname)' refs/mirror-stage/heads > /tmp/hwlab-stage-heads -git -C "$repo_path" for-each-ref --format='%(refname)' refs/mirror-stage/heads > /tmp/hwlab-stage-head-refs -git -C "$repo_path" for-each-ref --format='%(objectname) %(refname)' refs/mirror-stage/tags > /tmp/hwlab-stage-tags -git -C "$repo_path" for-each-ref --format='%(refname)' refs/mirror-stage/tags > /tmp/hwlab-stage-tag-refs -if [ ! -s /tmp/hwlab-stage-heads ]; then - echo "git mirror sync fetched no branch refs" >&2 - exit 41 -fi -for required_ref in ${requiredRefArgs}; do - git -C "$repo_path" show-ref --verify --quiet "$required_ref" || { echo "git mirror sync missing required ref $required_ref" >&2; exit 43; } -done -validate_started_ms=$(now_ms) -while read -r sha ref; do - [ -n "$sha" ] || continue - git -C "$repo_path" cat-file -e "$sha^{commit}" - git -C "$repo_path" cat-file -e "$sha^{tree}" - if git -C "$repo_path" rev-list --objects --missing=print "$sha" | grep -q '^?'; then - echo "git mirror sync found missing objects for $ref $sha" >&2 - exit 42 - fi -done < /tmp/hwlab-stage-heads -emit_timing validate succeeded "$validate_started_ms" -publish_started_ms=$(now_ms) -while read -r sha ref; do - [ -n "$sha" ] || continue - name=$(printf '%s\n' "$ref" | sed 's#^refs/mirror-stage/heads/##') - is_gitops_branch=false - for gitops_branch in ${gitopsBranchArgs}; do - if [ "$name" = "$gitops_branch" ]; then is_gitops_branch=true; break; fi - done - if [ "$is_gitops_branch" = true ]; then - local_sha=$(git -C "$repo_path" show-ref --verify --hash "refs/heads/$name" 2>/dev/null || true) - if [ -n "$local_sha" ] && [ "$local_sha" != "$sha" ]; then - if git -C "$repo_path" merge-base --is-ancestor "$sha" "$local_sha"; then - echo "git mirror sync keeps local $name ahead of GitHub" - continue - fi - if ! git -C "$repo_path" merge-base --is-ancestor "$local_sha" "$sha"; then - echo "git mirror sync found divergent $name local=$local_sha github=$sha" >&2 - exit 44 - fi - fi - fi - git -C "$repo_path" update-ref "refs/heads/$name" "$sha" -done < /tmp/hwlab-stage-heads -for source_branch in ${sourceBranchArgs}; do - source_sha=$(git -C "$repo_path" show-ref --verify --hash "refs/heads/$source_branch" 2>/dev/null || true) - if [ -n "$source_sha" ]; then - source_stage_ref="\${source_snapshot_ref_prefix%/}/$source_branch/$source_sha" - git -C "$repo_path" update-ref "$source_stage_ref" "$source_sha" - fi -done -while read -r sha ref; do - [ -n "$sha" ] || continue - name=$(printf '%s\n' "$ref" | sed 's#^refs/mirror-stage/tags/##') - git -C "$repo_path" update-ref "refs/tags/$name" "$sha" -done < /tmp/hwlab-stage-tags -git -C "$repo_path" for-each-ref --format='%(refname)' refs/heads > /tmp/hwlab-public-heads -while read -r ref; do - [ -n "$ref" ] || continue - name=$(printf '%s\n' "$ref" | sed 's#^refs/heads/##') - if ! grep -Fxq "refs/mirror-stage/heads/$name" /tmp/hwlab-stage-head-refs; then - git -C "$repo_path" update-ref -d "$ref" - fi -done < /tmp/hwlab-public-heads -git -C "$repo_path" for-each-ref --format='%(refname)' refs/tags > /tmp/hwlab-public-tags -while read -r ref; do - [ -n "$ref" ] || continue - name=$(printf '%s\n' "$ref" | sed 's#^refs/tags/##') - if ! grep -Fxq "refs/mirror-stage/tags/$name" /tmp/hwlab-stage-tag-refs; then - git -C "$repo_path" update-ref -d "$ref" - fi -done < /tmp/hwlab-public-tags -emit_timing publish succeeded "$publish_started_ms" -for head_branch in ${sourceBranchArgs}; do - if git -C "$repo_path" show-ref --verify --quiet "refs/heads/$head_branch"; then - git -C "$repo_path" symbolic-ref HEAD "refs/heads/$head_branch" - break - fi -done -fsck_started_ms=$(now_ms) -git -C "$repo_path" fsck --connectivity-only --no-dangling >/tmp/hwlab-git-fsck.out -emit_timing fsck succeeded "$fsck_started_ms" -git -C "$repo_path" update-server-info -published_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) -for source_branch in ${sourceBranchArgs}; do - git -C "$repo_path" rev-parse "refs/heads/$source_branch" 2>/dev/null | tee "/cache/HWLAB-$source_branch.head" >/dev/null || true -done -printf '%s\n' "$published_at" > /cache/HWLAB.last-sync -export published_at -export source_snapshot_ref_prefix -export mirror_branches_json=${shellSingleQuote(mirrorBranchesJson)} -export source_branches_json=${shellSingleQuote(sourceBranchesJson)} -export gitops_branches_json=${shellSingleQuote(gitopsBranchesJson)} -node - <<'NODE' | tee /cache/HWLAB.last-sync.json -const { execFileSync } = require("node:child_process"); -const repo = "/cache/pikasTech/HWLAB.git"; -const mirrorBranches = JSON.parse(process.env.mirror_branches_json || "[]"); -const sourceBranches = JSON.parse(process.env.source_branches_json || "[]"); -const gitopsBranches = JSON.parse(process.env.gitops_branches_json || "[]"); -const sourceSnapshotRefPrefix = (process.env.source_snapshot_ref_prefix || "refs/unidesk/snapshots/hwlab-node-runtime").replace(/\/+$/u, ""); -function rev(ref) { - try { return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim() || null; } catch { return null; } -} -const refs = {}; -for (const branch of mirrorBranches) { - refs["refs/heads/" + branch] = rev("refs/heads/" + branch); - refs["refs/mirror-stage/heads/" + branch] = rev("refs/mirror-stage/heads/" + branch); -} -const sourceSnapshots = {}; -for (const branch of sourceBranches) { - const source = refs["refs/heads/" + branch] || null; - const stageRef = source ? sourceSnapshotRefPrefix + "/" + branch + "/" + source : null; - const sourceSnapshot = stageRef ? rev(stageRef) : null; - if (stageRef) refs[stageRef] = sourceSnapshot; - sourceSnapshots[branch] = { stageRef, sourceSnapshot }; -} -const sourceInSync = sourceBranches.every((branch) => refs["refs/heads/" + branch] && refs["refs/heads/" + branch] === refs["refs/mirror-stage/heads/" + branch] && sourceSnapshots[branch]?.sourceSnapshot === refs["refs/mirror-stage/heads/" + branch]); -const gitopsInSync = gitopsBranches.every((branch) => refs["refs/heads/" + branch] && refs["refs/heads/" + branch] === refs["refs/mirror-stage/heads/" + branch]); -const pendingFlush = gitopsBranches.some((branch) => refs["refs/heads/" + branch] && refs["refs/mirror-stage/heads/" + branch] && refs["refs/heads/" + branch] !== refs["refs/mirror-stage/heads/" + branch]); -const payload = { - event: "git-mirror-sync", - status: "published", - repository: "pikasTech/HWLAB", - publishedAt: process.env.published_at, - pendingFlush, - sourceInSync, - gitopsInSync, - sourceSnapshots, - refs -}; -console.log(JSON.stringify(payload)); -NODE -emit_timing total succeeded "$sync_started_ms" -`, - "install-hooks.sh": `#!/bin/sh -set -eu -repo_path="/cache/pikasTech/HWLAB.git" -mkdir -p "$repo_path/hooks" -cat > "$repo_path/hooks/pre-receive" <<'HOOK' -#!/bin/sh -set -eu -zero="0000000000000000000000000000000000000000" -status=0 -while read -r old new ref; do - if [ "$new" = "$zero" ]; then - echo "git mirror write rejected: deleting $ref is forbidden" >&2 - status=1 - continue - fi - git cat-file -e "$new^{commit}" || status=1 - git cat-file -e "$new^{tree}" || status=1 - if git rev-list --objects --missing=print "$new" | grep -q '^?'; then - echo "git mirror write rejected: missing objects for $new" >&2 - status=1 - continue - fi - if [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new"; then - echo "git mirror write rejected: non-fast-forward $ref" >&2 - status=1 - continue - fi -done -exit "$status" -HOOK -cat > "$repo_path/hooks/post-receive" <<'HOOK' -#!/bin/sh -set -eu -repo_path=$(git rev-parse --git-dir) -git -C "$repo_path" update-server-info -written_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) -export repo_path written_at -export gitops_branches_json=${shellSingleQuote(gitopsBranchesJson)} -node - <<'NODE' > /cache/HWLAB.last-write.json -const { execFileSync } = require("node:child_process"); -const repo = process.env.repo_path || "/cache/pikasTech/HWLAB.git"; -const gitopsBranches = JSON.parse(process.env.gitops_branches_json || "[]"); -function rev(ref) { - try { return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim() || null; } catch { return null; } -} -const refs = {}; -for (const branch of gitopsBranches) { - refs["refs/heads/" + branch] = rev("refs/heads/" + branch); - refs["refs/mirror-stage/heads/" + branch] = rev("refs/mirror-stage/heads/" + branch); -} -const pendingFlush = gitopsBranches.some((branch) => refs["refs/heads/" + branch] && refs["refs/heads/" + branch] !== refs["refs/mirror-stage/heads/" + branch]); -console.log(JSON.stringify({ - event: "git-mirror-write", - status: "received", - repository: "pikasTech/HWLAB", - writtenAt: process.env.written_at, - pendingFlush, - refs -})); -NODE -HOOK -chmod 0755 "$repo_path/hooks/pre-receive" "$repo_path/hooks/post-receive" -git -C "$repo_path" config http.receivepack true -git -C "$repo_path" config receive.denyDeletes true -git -C "$repo_path" config receive.denyNonFastForwards true -`, - "flush.sh": `#!/bin/sh -set -eu -repo_url="ssh://git@ssh.github.com:443/pikasTech/HWLAB.git" -repo_path="/cache/pikasTech/HWLAB.git" -lock_dir="/cache/.hwlab-flush.lock" -started_ms=$(node -e 'console.log(Date.now())') -now_ms() { node -e 'console.log(Date.now())'; } -emit_timing() { - phase="$1" - status="$2" - started="$3" - finished=$(now_ms) - duration=$((finished - started)) - printf '{"event":"git-mirror-flush-timing","phase":"%s","status":"%s","durationMs":%s,"repository":"pikasTech/HWLAB"}\n' "$phase" "$status" "$duration" -} -if ! mkdir "$lock_dir" 2>/dev/null; then - echo "git mirror flush already running" - exit 0 -fi -trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT INT TERM -mkdir -p /root/.ssh -cp /git-ssh/ssh-privatekey /root/.ssh/id_rsa -chmod 0400 /root/.ssh/id_rsa -${gitMirrorSshPrelude} -/script/install-hooks.sh -for gitops_branch in ${gitopsBranchArgs}; do - local_head=$(git -C "$repo_path" show-ref --verify --hash "refs/heads/$gitops_branch" 2>/dev/null || true) - if [ -z "$local_head" ]; then - echo "git mirror flush skips missing local refs/heads/$gitops_branch" - continue - fi - fetch_started=$(now_ms) - timeout 90 git -C "$repo_path" fetch --quiet "$repo_url" "refs/heads/$gitops_branch:refs/mirror-stage/heads/$gitops_branch" - emit_timing "fetch-$gitops_branch" succeeded "$fetch_started" - github_head=$(git -C "$repo_path" show-ref --verify --hash "refs/mirror-stage/heads/$gitops_branch" 2>/dev/null || true) - if [ -n "$github_head" ] && [ "$github_head" != "$local_head" ] && ! git -C "$repo_path" merge-base --is-ancestor "$github_head" "$local_head"; then - echo "git mirror flush rejected divergent $gitops_branch local=$local_head github=$github_head" >&2 - exit 52 - fi - if [ "$local_head" != "$github_head" ]; then - push_started=$(now_ms) - timeout 120 git -C "$repo_path" push "$repo_url" "$local_head:refs/heads/$gitops_branch" - emit_timing "push-$gitops_branch" succeeded "$push_started" - else - echo "git mirror flush $gitops_branch already matches GitHub" - fi - git -C "$repo_path" update-ref "refs/mirror-stage/heads/$gitops_branch" "$local_head" -done -git -C "$repo_path" update-server-info -flushed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) -export flushed_at -export gitops_branches_json=${shellSingleQuote(gitopsBranchesJson)} -node - <<'NODE' | tee /cache/HWLAB.last-flush.json -const { execFileSync } = require("node:child_process"); -const repo = "/cache/pikasTech/HWLAB.git"; -const gitopsBranches = JSON.parse(process.env.gitops_branches_json || "[]"); -function rev(ref) { - try { return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim() || null; } catch { return null; } -} -const refs = {}; -for (const branch of gitopsBranches) { - refs["refs/heads/" + branch] = rev("refs/heads/" + branch); - refs["refs/mirror-stage/heads/" + branch] = rev("refs/mirror-stage/heads/" + branch); -} -const pendingFlush = gitopsBranches.some((branch) => refs["refs/heads/" + branch] && refs["refs/mirror-stage/heads/" + branch] && refs["refs/heads/" + branch] !== refs["refs/mirror-stage/heads/" + branch]); -console.log(JSON.stringify({ - event: "git-mirror-flush", - status: "flushed", - repository: "pikasTech/HWLAB", - flushedAt: process.env.flushed_at, - pendingFlush, - refs -})); -NODE -emit_timing total succeeded "$started_ms" -`, - "http.mjs": `import { createServer } from "node:http"; -import { spawn } from "node:child_process"; -import { createReadStream, statSync } from "node:fs"; -import path from "node:path"; -import { createGunzip, createInflate } from "node:zlib"; - -const cacheRoot = process.env.GIT_MIRROR_CACHE_ROOT || "/cache"; -const port = Number(process.env.PORT || 8080); - -createServer((req, res) => { - handle(req, res).catch((error) => { - if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: false, error: error.message })); - }); -}).listen(port, "0.0.0.0", () => { - console.log(JSON.stringify({ event: "git-mirror-smart-http-listening", port, cacheRoot })); -}); - -async function handle(req, res) { - const url = new URL(req.url || "/", "http://git-mirror-http"); - const writeHost = isWriteHost(req.headers.host || ""); - if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) { - res.writeHead(200, { "content-type": "application/json", "cache-control": "no-cache" }); - res.end(JSON.stringify({ ok: true, service: "git-mirror-smart-http", writeHost })); - return; - } - if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-upload-pack") { - await runUploadPackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res); - return; - } - if (req.method === "POST" && url.pathname.endsWith("/git-upload-pack")) { - await runUploadPackRpc(repoPathFromGitServicePath(url.pathname, "/git-upload-pack"), req, res); - return; - } - if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-receive-pack") { - if (!writeHost) return rejectReadOnly(res); - await runReceivePackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res); - return; - } - if (req.method === "POST" && url.pathname.endsWith("/git-receive-pack")) { - if (!writeHost) return rejectReadOnly(res); - await runReceivePackRpc(repoPathFromGitServicePath(url.pathname, "/git-receive-pack"), req, res); - return; - } - if (req.method === "GET" || req.method === "HEAD") { - serveStaticFile(url.pathname, req, res); - return; - } - res.writeHead(405, { "content-type": "text/plain" }); - res.end("method not allowed\\n"); -} - -function isWriteHost(hostHeader) { - const host = String(hostHeader || "").split(":", 1)[0].toLowerCase(); - return host === "git-mirror-write" || host.startsWith("git-mirror-write."); -} - -function rejectReadOnly(res) { - res.writeHead(403, { "content-type": "application/json", "cache-control": "no-cache" }); - res.end(JSON.stringify({ ok: false, error: "git mirror receive-pack is only available through git-mirror-write" })); -} - -function repoPathFromGitServicePath(urlPath, suffix) { - const repoUrlPath = urlPath.slice(0, -suffix.length); - if (!repoUrlPath.endsWith(".git")) throw new Error(` + "`" + `unsupported git repo path: \${urlPath}` + "`" + `); - return safePath(repoUrlPath); -} - -function safePath(urlPath) { - const decoded = decodeURIComponent(urlPath.replace(/^[/]+/u, "")); - const fullPath = path.resolve(cacheRoot, decoded); - const normalizedRoot = path.resolve(cacheRoot); - if (fullPath !== normalizedRoot && !fullPath.startsWith(` + "`" + `\${normalizedRoot}\${path.sep}` + "`" + `)) throw new Error("path escapes cache root"); - return fullPath; -} - -function smartHeaders(contentType) { - return { - "content-type": contentType, - "cache-control": "no-cache, max-age=0, must-revalidate", - expires: "Fri, 01 Jan 1980 00:00:00 GMT", - pragma: "no-cache" - }; -} - -function pktLine(text) { - const length = Buffer.byteLength(text) + 4; - return ` + "`" + `\${length.toString(16).padStart(4, "0")}\${text}` + "`" + `; -} - -function requestBodyStream(req) { - const encoding = String(req.headers["content-encoding"] || "identity").toLowerCase(); - if (encoding === "identity" || encoding === "") return req; - if (encoding === "gzip" || encoding === "x-gzip") return req.pipe(createGunzip()); - if (encoding === "deflate") return req.pipe(createInflate()); - throw new Error(` + "`" + `unsupported git upload-pack content-encoding: \${encoding}` + "`" + `); -} - -function runUploadPackAdvertisement(repoPath, res) { - return new Promise((resolve, reject) => { - const child = spawn("git-upload-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0 && !res.headersSent) reject(new Error(` + "`" + `git-upload-pack advertise failed: \${stderr.trim()}` + "`" + `)); - else resolve(); - }); - res.writeHead(200, smartHeaders("application/x-git-upload-pack-advertisement")); - res.write(pktLine("# service=git-upload-pack\\n")); - res.write("0000"); - child.stdout.pipe(res, { end: false }); - child.stdout.on("end", () => res.end()); - }); -} - -function runUploadPackRpc(repoPath, req, res) { - return new Promise((resolve, reject) => { - const child = spawn("git-upload-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0 && !res.headersSent) reject(new Error(` + "`" + `git-upload-pack rpc failed: \${stderr.trim()}` + "`" + `)); - else resolve(); - }); - res.writeHead(200, smartHeaders("application/x-git-upload-pack-result")); - const body = requestBodyStream(req); - body.on("error", (error) => child.stdin.destroy(error)); - body.pipe(child.stdin); - child.stdout.pipe(res); - }); -} - -function runReceivePackAdvertisement(repoPath, res) { - return new Promise((resolve, reject) => { - const child = spawn("git-receive-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0 && !res.headersSent) reject(new Error(` + "`" + `git-receive-pack advertise failed: \${stderr.trim()}` + "`" + `)); - else resolve(); - }); - res.writeHead(200, smartHeaders("application/x-git-receive-pack-advertisement")); - res.write(pktLine("# service=git-receive-pack\\n")); - res.write("0000"); - child.stdout.pipe(res, { end: false }); - child.stdout.on("end", () => res.end()); - }); -} - -function runReceivePackRpc(repoPath, req, res) { - return new Promise((resolve, reject) => { - const child = spawn("git-receive-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0 && !res.headersSent) reject(new Error(` + "`" + `git-receive-pack rpc failed: \${stderr.trim()}` + "`" + `)); - else resolve(); - }); - res.writeHead(200, smartHeaders("application/x-git-receive-pack-result")); - const body = requestBodyStream(req); - body.on("error", (error) => child.stdin.destroy(error)); - body.pipe(child.stdin); - child.stdout.pipe(res); - }); -} - -function serveStaticFile(urlPath, req, res) { - const fullPath = safePath(urlPath); - let stat; - try { stat = statSync(fullPath); } catch { - res.writeHead(404, { "content-type": "text/plain" }); - res.end("not found\\n"); - return; - } - if (!stat.isFile()) { - res.writeHead(404, { "content-type": "text/plain" }); - res.end("not found\\n"); - return; - } - res.writeHead(200, { "content-length": stat.size, "content-type": contentType(fullPath), "cache-control": "no-cache" }); - if (req.method === "HEAD") { - res.end(); - return; - } - createReadStream(fullPath).pipe(res); -} - -function contentType(filePath) { - if (filePath.endsWith(".pack")) return "application/x-git-packed-objects"; - if (filePath.endsWith(".idx")) return "application/x-git-packed-objects-toc"; - return "text/plain"; -} -` - } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { name: gitMirrorReadName, namespace: gitMirrorNamespace, labels: readLabels }, - spec: { - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": gitMirrorReadName } }, - template: { - metadata: { labels: readLabels }, - spec: { - containers: [{ - name: "git-mirror", - image: ciToolsRunnerImage, - imagePullPolicy: "IfNotPresent", - command: ["node"], - args: ["/etc/git-mirror/http.mjs"], - ports: [{ name: "http", containerPort: 8080 }], - readinessProbe: { httpGet: { path: "/pikasTech/HWLAB.git/HEAD", port: "http" }, initialDelaySeconds: 5, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/", port: "http" }, initialDelaySeconds: 10, periodSeconds: 30 }, - volumeMounts: [ - { name: "cache", mountPath: "/cache" }, - { name: "config", mountPath: "/etc/git-mirror", readOnly: true } - ] - }], - volumes: [ - cacheVolume, - { name: "config", configMap: { name: gitMirrorConfigMapName, defaultMode: 0o555 } } - ] - } - } - } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name: gitMirrorReadName, namespace: gitMirrorNamespace, labels: readLabels }, - spec: { selector: { "app.kubernetes.io/name": gitMirrorReadName }, ports: [{ name: "http", port: gitMirrorServicePort, targetPort: "http" }] } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name: gitMirrorWriteName, namespace: gitMirrorNamespace, labels: writeLabels }, - spec: { selector: { "app.kubernetes.io/name": gitMirrorReadName }, ports: [{ name: "http", port: gitMirrorServicePort, targetPort: "http" }] } - } - ] - }; -} - -function argoProject(args = { lane: "node" }) { - if (isRuntimeLane(args.lane)) { - const namespace = namespaceNameForProfile(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.`, - sourceRepos: [args.gitReadUrl], - destinations: [{ server: "https://kubernetes.default.svc", namespace }], - clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], - namespaceResourceWhitelist: [{ group: "*", kind: "*" }] - } - }; - } - return { - apiVersion: "argoproj.io/v1alpha1", - kind: "AppProject", - metadata: { name: "hwlab-node", namespace: "argocd" }, - spec: { - description: "HWLAB node GitOps project; D601 remains outside this project.", - sourceRepos: [defaultSourceRepo], - destinations: [ - { server: "https://kubernetes.default.svc", namespace: "hwlab-dev" }, - { server: "https://kubernetes.default.svc", namespace: "hwlab-prod" } - ], - clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], - namespaceResourceWhitelist: [{ group: "*", kind: "*" }] - } - }; -} - -function argoApplication(args, profile = "dev") { - const namespace = namespaceNameForProfile(profile); - const runtimePath = runtimePathForProfile(profile); - const gitopsTarget = gitopsTargetForProfile(profile); - const project = isRuntimeLane(profile) ? namespace : "hwlab-node"; - const repoURL = isRuntimeLane(profile) ? args.gitReadUrl : args.sourceRepo; - return { - apiVersion: "argoproj.io/v1alpha1", - kind: "Application", - metadata: { - name: argoApplicationName(profile), - namespace: "argocd", - labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": gitopsTarget } - }, - spec: { - project, - source: { repoURL, targetRevision: args.gitopsBranch, path: gitopsPathForProfile(args, profile) }, - destination: { server: "https://kubernetes.default.svc", namespace }, - syncPolicy: { automated: { prune: isRuntimeLane(profile), selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } - } - }; -} - -function nodeFrpcManifest({ profile = "dev", source = null, deploy = null, args = {} } = {}) { - const namespace = namespaceNameForProfile(profile); - 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); - const webProxyName = frpConfig.webProxyName || `${proxyPrefix}-cloud-web`; - const edgeProxyName = frpConfig.edgeProxyName || `${proxyPrefix}-edge-proxy`; - const labels = { - "app.kubernetes.io/name": deploymentName, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": profileLabel, - "hwlab.pikastech.local/gitops-target": gitopsTargetForProfile(profile), - "hwlab.pikastech.local/profile": profileLabel - }; - const sourceCommitLabel = source?.full ? { "hwlab.pikastech.local/source-commit": source.full } : {}; - const renderedLabels = { ...labels, ...sourceCommitLabel }; - const podNetwork = isRuntimeLane(profile) ? { - hostNetwork: true, - dnsPolicy: "ClusterFirstWithHostNet" - } : {}; - const authConfigText = frpConfig.authSecretName && frpConfig.authSecretKey ? `auth.token = "{{ .Envs.FRPC_AUTH_TOKEN }}"\n` : ""; - const configText = `serverAddr = "${frpConfig.serverAddr}" -serverPort = ${frpConfig.serverPort} -loginFailExit = true -${authConfigText} - -[[proxies]] -name = "${webProxyName}" -type = "tcp" -localIP = "hwlab-cloud-web.${namespace}.svc.cluster.local" -localPort = 8080 -remotePort = ${frpConfig.webRemotePort} - -[[proxies]] -name = "${edgeProxyName}" -type = "tcp" -localIP = "hwlab-edge-proxy.${namespace}.svc.cluster.local" -localPort = 6667 -remotePort = ${frpConfig.edgeRemotePort} -`; - const configSha256 = createHash("sha256").update(configText).digest("hex"); - const templateLabels = { ...labels, "hwlab.pikastech.local/config-sha256": k8sLabelHash(configSha256) }; - const templateAnnotations = { "hwlab.pikastech.local/config-sha256": configSha256 }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name: configName, - namespace, - labels: renderedLabels - }, - data: { - "frpc.toml": configText - } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { - name: deploymentName, - namespace, - labels: renderedLabels - }, - spec: { - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": deploymentName } }, - template: { - metadata: { labels: templateLabels, annotations: templateAnnotations }, - spec: { - ...podNetwork, - containers: [{ - name: "frpc", - image: "fatedier/frpc:v0.68.1", - imagePullPolicy: "IfNotPresent", - ...(frpConfig.authSecretName && frpConfig.authSecretKey ? { env: [{ name: "FRPC_AUTH_TOKEN", valueFrom: { secretKeyRef: { name: frpConfig.authSecretName, key: frpConfig.authSecretKey } } }] } : {}), - args: ["-c", "/etc/frp/frpc.toml"], - volumeMounts: [{ name: "config", mountPath: "/etc/frp", readOnly: true }] - }], - volumes: [{ name: "config", configMap: { name: configName } }] - } - } - } - } - ] - }; -} - -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 bridgeServiceId = "hwlab-cloud-api"; - const runtimeLane = isRuntimeLane(profile); - const digestPin = runtimeLane; - const envReuseServiceIds = runtimeLane ? envReuseServiceIdsForLane(deploy, profile) : null; - const bridgeImage = runtimeImageForService({ catalog, deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); - const bridgeCommit = runtimeCommitForService({ catalog, deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); - const bridgeImageTag = runtimeImageTagForService({ catalog, deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); - const bridgeBootMetadata = runtimeLane && v02EnvReuseEnabled(catalog, bridgeServiceId, envReuseServiceIds) - ? bootMetadataForService({ args: { sourceRepo: deploy?.lanes?.[profile]?.sourceRepo || sourceRepo, registryPrefix }, catalog, deployService: deployServiceForBoot(deploy, bridgeServiceId, profile), serviceId: bridgeServiceId, source }) - : null; - const labels = { - "app.kubernetes.io/name": "hwlab-deepseek-proxy", - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), - "hwlab.pikastech.local/gitops-target": gitopsTargetForProfile(profile), - "hwlab.pikastech.local/service-id": "hwlab-deepseek-proxy", - "hwlab.pikastech.local/source-commit": source.full - }; - if (runtimeLane) Object.assign(labels, v02MetricsLabels("hwlab-deepseek-proxy")); - const templateLabels = { - ...labels, - "hwlab.pikastech.local/source-commit": bridgeCommit, - "hwlab.pikastech.local/bridge-source-commit": bridgeCommit - }; - const templateAnnotations = { - "hwlab.pikastech.local/bridge-service-id": bridgeServiceId, - "hwlab.pikastech.local/source-commit": bridgeCommit, - "hwlab.pikastech.local/bridge-source-commit": bridgeCommit, - "hwlab.pikastech.local/bridge-image-tag": bridgeImageTag, - "hwlab.pikastech.local/bridge-image": bridgeImage, - ...v02MetricsSidecarAnnotations(runtimeLane ? metricsSidecarSha256 : null) - }; - const responsesBridgeContainer = { - name: "responses-bridge", - image: bridgeImage, - imagePullPolicy: "IfNotPresent", - command: ["/usr/local/bin/bun", "run", "/app/cmd/hwlab-deepseek-responses-bridge/main.ts"], - env: [ - { name: "PORT", value: "4000" }, - { name: "HWLAB_COMMIT_ID", value: bridgeCommit }, - { name: "HWLAB_IMAGE", value: bridgeImage }, - { name: "HWLAB_IMAGE_TAG", value: bridgeImageTag }, - { name: "HWLAB_DEEPSEEK_BRIDGE_UPSTREAM", value: "http://127.0.0.1:4001" }, - { name: "HWLAB_DEEPSEEK_BRIDGE_MODEL", value: deepSeekProfileModel } - ], - ports: [{ name: "http", containerPort: 4000 }], - readinessProbe: { httpGet: { path: "/health/readiness", port: "http" }, initialDelaySeconds: 10, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/health/liveliness", port: "http" }, initialDelaySeconds: 20, periodSeconds: 20 } - }; - if (bridgeBootMetadata) { - applyEnvReuseBootEnv(responsesBridgeContainer, bridgeBootMetadata, { sourceBranch, gitReadUrl, bootSh: "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh" }); - Object.assign(templateAnnotations, { - "hwlab.pikastech.local/bridge-runtime-mode": bridgeBootMetadata.runtimeMode, - "hwlab.pikastech.local/bridge-boot-repo": bridgeBootMetadata.bootRepo, - "hwlab.pikastech.local/bridge-boot-commit": bridgeBootMetadata.bootCommit, - "hwlab.pikastech.local/bridge-boot-sh": "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh", - "hwlab.pikastech.local/bridge-environment-digest": bridgeBootMetadata.environmentDigest ?? "not_published" - }); - Object.assign(templateLabels, { - "hwlab.pikastech.local/bridge-runtime-mode": bridgeBootMetadata.runtimeMode, - "hwlab.pikastech.local/bridge-boot-commit": bridgeBootMetadata.bootCommit - }); - } - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { name: "hwlab-deepseek-proxy-config", namespace, labels }, - data: { - "render-config.sh": moonBridgeConfigRenderScript() - } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { name: "hwlab-deepseek-proxy", namespace, labels, annotations: { "hwlab.pikastech.local/moonbridge-source-ref": moonBridgeSourceRef, ...templateAnnotations } }, - spec: { - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": "hwlab-deepseek-proxy" } }, - template: { - metadata: { labels: templateLabels, annotations: templateAnnotations }, - spec: { - initContainers: [{ - name: "moonbridge-config", - image: bridgeImage, - imagePullPolicy: "IfNotPresent", - command: ["/bin/sh", "-eu", "/scripts/render-config.sh"], - env: [ - { name: "DEEPSEEK_API_KEY", valueFrom: { secretKeyRef: { name: secretNameForProfile("hwlab-code-agent-provider", profile), key: "openai-api-key", optional: true } } }, - { name: "OPENCODE_API_KEY", valueFrom: { secretKeyRef: { name: secretNameForProfile("hwlab-code-agent-provider", profile), key: "opencode-api-key", optional: true } } } - ], - volumeMounts: [ - { name: "moonbridge-scripts", mountPath: "/scripts", readOnly: true }, - { name: "moonbridge-config", mountPath: "/config" } - ] - }], - containers: [responsesBridgeContainer, { - name: "moonbridge", - image: moonBridgeImage, - imagePullPolicy: "IfNotPresent", - args: ["-config", "/config/config.yml"], - ports: [{ name: "moonbridge-http", containerPort: 4001 }], - readinessProbe: { httpGet: { path: "/v1/models", port: "moonbridge-http" }, initialDelaySeconds: 10, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/v1/models", port: "moonbridge-http" }, initialDelaySeconds: 20, periodSeconds: 20 }, - volumeMounts: [ - { name: "moonbridge-config", mountPath: "/config", readOnly: true }, - { name: "moonbridge-data", mountPath: "/data" } - ] - }, ...(runtimeLane ? [v02MetricsSidecarContainer({ deploy, serviceId: "hwlab-deepseek-proxy", namespace, gitopsTarget: gitopsTargetForProfile(profile) })] : [])], - volumes: [ - { name: "moonbridge-scripts", configMap: { name: "hwlab-deepseek-proxy-config" } }, - { name: "moonbridge-config", emptyDir: {} }, - { name: "moonbridge-data", emptyDir: {} }, - ...(runtimeLane ? [v02MetricsSidecarVolume(profile)] : []) - ] - } - } - } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name: "hwlab-deepseek-proxy", namespace, labels }, - spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": "hwlab-deepseek-proxy" }, ports: [{ name: "http", port: 4000, targetPort: "http" }, ...(runtimeLane ? [{ name: "metrics", port: 9100, targetPort: "metrics" }] : [])] } - } - ] - }; -} - -function moonBridgeConfigRenderScript() { - return `#!/bin/sh -if [ -z "\${DEEPSEEK_API_KEY:-}" ] && [ -z "\${OPENCODE_API_KEY:-}" ]; then - echo "at least one of DEEPSEEK_API_KEY or OPENCODE_API_KEY is required" >&2 - exit 1 -fi -cat > /config/config.yml < {", - " let body = \"\";", - " req.setEncoding(\"utf8\");", - " req.on(\"data\", (chunk) => {", - " body += chunk;", - " if (body.length > 1024 * 1024) req.destroy(new Error(\"request body too large\"));", - " });", - " req.on(\"end\", () => resolve(body ? JSON.parse(body) : {}));", - " req.on(\"error\", reject);", - " });", - "}", - "", - "function postJson(url, payload, timeoutMs) {", - " return new Promise((resolve, reject) => {", - " const parsed = new URL(url);", - " const body = JSON.stringify(payload);", - " const request = http.request({", - " hostname: parsed.hostname,", - " port: parsed.port || 80,", - " path: parsed.pathname + parsed.search,", - " method: \"POST\",", - " headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) },", - " timeout: timeoutMs", - " }, (response) => {", - " let responseBody = \"\";", - " response.setEncoding(\"utf8\");", - " response.on(\"data\", (chunk) => { responseBody += chunk; });", - " response.on(\"end\", () => {", - " try {", - " const parsedBody = responseBody ? JSON.parse(responseBody) : null;", - " resolve({ statusCode: response.statusCode, body: parsedBody });", - " } catch (error) {", - " reject(error);", - " }", - " });", - " });", - " request.on(\"timeout\", () => request.destroy(new Error(\"request timeout after \" + timeoutMs + \"ms\")));", - " request.on(\"error\", reject);", - " request.end(body);", - " });", - "}", - "", - "function gatewayPayload(input) {", - " const traceId = input.traceId || \"trc_device_agent_71_freq_\" + Date.now();", - " return {", - " jsonrpc: \"2.0\",", - " id: input.id || \"req_device_agent_71_freq_\" + Date.now(),", - " method: \"hardware.invoke.shell\",", - " meta: { serviceId: \"hwlab-cloud-api\", actorId: \"device-agent-71-freq\", environment: \"dev\", traceId, deviceId, workspaceRoot },", - " params: {", - " gatewaySessionId,", - " resourceId,", - " capabilityId,", - " input: {", - " command: input.command,", - " cwd: input.cwd || workspaceRoot,", - " timeoutMs: input.timeoutMs || 30000", - " }", - " }", - " };", - "}", - "", - "const server = http.createServer(async (req, res) => {", - " try {", - " const url = new URL(req.url || \"/\", \"http://device-agent-71-freq\");", - " if (req.method === \"GET\" && url.pathname === \"/health\") {", - " return sendJson(res, 200, { ok: true, deviceId, workspaceRoot, gatewaySessionId, resourceId, capabilityId, cloudApiUrl });", - " }", - " if (req.method === \"GET\" && url.pathname === \"/skills\") {", - " return sendJson(res, 200, { ok: true, deviceId, skills: [{ name: \"cmd\", description: \"Run a bounded Windows cmd command through hwlab-gateway.\" }] });", - " }", - " if (req.method === \"POST\" && url.pathname === \"/run\") {", - " const input = await readBody(req);", - " if (!input.command || typeof input.command !== \"string\") return sendJson(res, 400, { ok: false, error: \"command is required\" });", - " const timeoutMs = Number(input.timeoutMs || 30000);", - " const upstream = await postJson(cloudApiUrl + \"/json-rpc\", gatewayPayload({ ...input, timeoutMs }), timeoutMs + 5000);", - " return sendJson(res, upstream.statusCode || 502, { ok: upstream.statusCode >= 200 && upstream.statusCode < 300, deviceId, gatewaySessionId, upstream: upstream.body });", - " }", - " return sendJson(res, 404, { ok: false, error: \"not found\" });", - " } catch (error) {", - " return sendJson(res, 500, { ok: false, error: error.message });", - " }", - "});", - "", - "server.listen(port, \"0.0.0.0\", () => {", - " console.log(JSON.stringify({ ok: true, service: \"device-agent-71-freq\", port, deviceId, workspaceRoot, cloudApiUrl }));", - "});" - ].join("\n") + "\n"; -} - -function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, catalog = null, useDeployImages = false }) { - assert.equal(profile, "dev", "71-freq device-agent is dev-only"); - const namespace = namespaceNameForProfile(profile); - const name = "device-agent-71-freq"; - const bridgeServiceId = "hwlab-cloud-api"; - const bridgeImage = runtimeImageForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const bridgeCommit = runtimeCommitForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const bridgeImageTag = runtimeImageTagForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const labels = { - "app.kubernetes.io/name": name, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/component": "device-agent", - "hwlab.pikastech.local/device-id": "71-freq", - "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), - "hwlab.pikastech.local/gitops-target": "node", - "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile), - "hwlab.pikastech.local/service-id": name, - "hwlab.pikastech.local/source-commit": source.full - }; - const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; - const selector = { "app.kubernetes.io/name": name }; - const script = deviceAgent71FreqServerScript(); - const scriptSha256 = createHash("sha256").update(script).digest("hex"); - const templateLabels = { ...labels, ...selector, "hwlab.pikastech.local/source-commit": bridgeCommit }; - const templateAnnotations = { - ...annotations, - "hwlab.pikastech.local/script-sha256": scriptSha256, - "hwlab.pikastech.local/bridge-service-id": bridgeServiceId, - "hwlab.pikastech.local/bridge-source-commit": bridgeCommit, - "hwlab.pikastech.local/bridge-image-tag": bridgeImageTag, - "hwlab.pikastech.local/bridge-image": bridgeImage - }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { name: `${name}-script`, namespace, labels, annotations: templateAnnotations }, - data: { "server.mjs": script } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { name, namespace, labels, annotations }, - spec: { - replicas: 1, - selector: { matchLabels: selector }, - template: { - metadata: { labels: templateLabels, annotations: templateAnnotations }, - spec: { - containers: [{ - name: "device-agent", - image: bridgeImage, - imagePullPolicy: "IfNotPresent", - command: ["node", "/opt/device-agent/server.mjs"], - env: [ - { name: "PORT", value: "7601" }, - { name: "HWLAB_COMMIT_ID", value: bridgeCommit }, - { name: "HWLAB_IMAGE", value: bridgeImage }, - { name: "HWLAB_IMAGE_TAG", value: bridgeImageTag }, - { name: "DEVICE_ID", value: "71-freq" }, - { name: "DEVICE_WORKSPACE_ROOT", value: "F:\\Work\\ConStart" }, - { name: "HWLAB_CLOUD_API_URL", value: `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667` }, - { name: "HWLAB_GATEWAY_SESSION_ID", value: "gws_d601_win_71_freq" }, - { name: "HWLAB_GATEWAY_RESOURCE_ID", value: "res_d601_windows_host" }, - { name: "HWLAB_GATEWAY_CAPABILITY_ID", value: "cap_d601_windows_cmd_exec" } - ], - ports: [{ name: "http", containerPort: 7601 }], - readinessProbe: { httpGet: { path: "/health", port: "http" }, initialDelaySeconds: 3, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/health", port: "http" }, initialDelaySeconds: 10, periodSeconds: 20 }, - resources: { requests: { cpu: "10m", memory: "64Mi" }, limits: { cpu: "200m", memory: "256Mi" } }, - volumeMounts: [ - { name: "script", mountPath: "/opt/device-agent", readOnly: true }, - { name: "hwlab-code-agent-workspace", mountPath: "/workspace" } - ] - }], - volumes: [ - { name: "script", configMap: { name: `${name}-script` } }, - { name: "hwlab-code-agent-workspace", persistentVolumeClaim: { claimName: "hwlab-code-agent-workspace" } } - ] - } - } - } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name, namespace, labels, annotations }, - spec: { type: "ClusterIP", selector, ports: [{ name: "http", port: 7601, targetPort: "http" }] } - } - ] - }; -} - -function runtimePostgresImageForProfile(deploy, profile) { - const postgres = runtimeLaneConfig(deploy, profile)?.runtimeStore?.postgres; - const image = postgres && typeof postgres === "object" && !Array.isArray(postgres) ? postgres.image : null; - 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); - const name = `${namespace}-postgres`; - const dbName = `hwlab_${profile}`; - const labels = { - "app.kubernetes.io/name": name, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": profile, - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/profile": profile, - "hwlab.pikastech.local/source-commit": source.full - }; - assert.ok(Array.isArray(migrationSources) && migrationSources.length > 0, "runtime Postgres migration chain is required"); - const migrationSql = migrationSources.map((migration) => migration.sql).join("\n"); - const migrationData = Object.fromEntries(migrationSources.map((migration) => [path.basename(migration.path), migration.sql])); - const migrationSha256 = createHash("sha256").update(migrationSql).digest("hex"); - const templateLabels = { - "app.kubernetes.io/name": name, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": profile, - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/profile": profile, - "hwlab.pikastech.local/migration-sha256": k8sLabelHash(migrationSha256) - }; - const templateAnnotations = { "hwlab.pikastech.local/migration-sha256": migrationSha256 }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { name: `${name}-init`, namespace, labels }, - data: migrationData - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name, namespace, labels }, - spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": name }, ports: [{ name: "postgres", port: 5432, targetPort: "postgres" }] } - }, - { - apiVersion: "apps/v1", - kind: "StatefulSet", - metadata: { name, namespace, labels }, - spec: { - serviceName: name, - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": name } }, - template: { - metadata: { labels: templateLabels, annotations: templateAnnotations }, - spec: { - containers: [{ - name: "postgres", - image, - imagePullPolicy: "IfNotPresent", - env: [ - { name: "POSTGRES_DB", value: dbName }, - { name: "POSTGRES_USER", value: dbName }, - { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name, key: "POSTGRES_PASSWORD" } } } - ], - ports: [{ name: "postgres", containerPort: 5432 }], - readinessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 5, periodSeconds: 10 }, - livenessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 30, periodSeconds: 20 }, - volumeMounts: [ - { name: "data", mountPath: "/var/lib/postgresql/data" }, - { name: "init", mountPath: "/docker-entrypoint-initdb.d", readOnly: true } - ] - }], - volumes: [{ name: "init", configMap: { name: `${name}-init` } }] - } - }, - volumeClaimTemplates: [{ - metadata: { name: "data" }, - spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } - }] - } - } - ] - }; -} - -function externalPostgresConfigForLane(deploy, profile, args = {}) { - if (!isRuntimeLane(profile)) return null; - const config = deploy?.lanes?.[profile]?.externalPostgres; - if (!config || config.enabled !== true) return null; - const nodeId = effectiveSecretPlaneNodeId(args); - const nodeConfig = nodeId ? config?.nodes?.[nodeId] : null; - const effective = nodeConfig && typeof nodeConfig === "object" && !Array.isArray(nodeConfig) ? { ...config, ...nodeConfig } : config; - assert.ok(typeof effective.serviceName === "string" && effective.serviceName.length > 0, `deploy.lanes.${profile}.externalPostgres.serviceName is required`); - 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 }; -} - -function externalPostgresManifest({ profile = "v03", config, source }) { - const namespace = namespaceNameForProfile(profile); - const labels = { - "app.kubernetes.io/name": config.serviceName, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/component": "platform-db-bridge", - "hwlab.pikastech.local/environment": profile, - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/profile": profile, - "hwlab.pikastech.local/source-commit": source.full - }; - const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "Service", - metadata: { name: config.serviceName, namespace, labels, annotations }, - spec: { type: "ClusterIP", ports: [{ name: "postgres", port: config.port, targetPort: config.port, protocol: "TCP" }] } - }, - { - apiVersion: "discovery.k8s.io/v1", - kind: "EndpointSlice", - metadata: { name: `${config.serviceName}-host`, namespace, labels: { ...labels, "kubernetes.io/service-name": config.serviceName }, annotations }, - addressType: "IPv4", - ports: [{ name: "postgres", port: config.port, protocol: "TCP" }], - endpoints: [{ addresses: [config.endpointAddress], conditions: { ready: true } }] - } - ] - }; -} - -function v02OpenFgaManifest({ profile = "v02", source }) { - const namespace = namespaceNameForProfile(profile); - const name = "hwlab-openfga"; - const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0"; - const labels = { - "app.kubernetes.io/name": name, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/component": "authorization", - "hwlab.pikastech.local/environment": profile, - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/profile": profile, - "hwlab.pikastech.local/service-id": name, - "hwlab.pikastech.local/source-commit": source.full - }; - const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; - const selector = { "app.kubernetes.io/name": name }; - const env = [ - { name: "OPENFGA_DATASTORE_ENGINE", value: "postgres" }, - { name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "datastore-uri" } } }, - { name: "OPENFGA_AUTHN_METHOD", value: "preshared" }, - { name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "authn-preshared-key" } } }, - { name: "OPENFGA_PLAYGROUND_ENABLED", value: "false" }, - { name: "OPENFGA_HTTP_ADDR", value: "0.0.0.0:8080" }, - { name: "OPENFGA_GRPC_ADDR", value: "0.0.0.0:8081" } - ]; - const templateLabels = { ...labels, ...selector }; - const runtimeAnnotations = { ...annotations, "argocd.argoproj.io/sync-wave": "2" }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "batch/v1", - kind: "Job", - metadata: { - name: `${name}-migrate`, - namespace, - labels, - annotations: { - ...annotations, - "argocd.argoproj.io/hook": "Sync", - "argocd.argoproj.io/sync-wave": "1", - "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded" - } - }, - spec: { - backoffLimit: 3, - template: { - metadata: { labels: templateLabels, annotations }, - spec: { - restartPolicy: "OnFailure", - containers: [{ name: "openfga-migrate", image, imagePullPolicy: "IfNotPresent", args: ["migrate"], env }] - } - } - } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { name, namespace, labels, annotations: runtimeAnnotations }, - spec: { - replicas: 1, - selector: { matchLabels: selector }, - template: { - metadata: { labels: templateLabels, annotations }, - spec: { - containers: [{ - name: "openfga", - image, - imagePullPolicy: "IfNotPresent", - args: ["run"], - env, - ports: [{ name: "http", containerPort: 8080 }, { name: "grpc", containerPort: 8081 }], - readinessProbe: { httpGet: { path: "/healthz", port: "http" }, initialDelaySeconds: 3, periodSeconds: 10 }, - livenessProbe: { httpGet: { path: "/healthz", port: "http" }, initialDelaySeconds: 15, periodSeconds: 20 }, - resources: { requests: { cpu: "50m", memory: "128Mi" }, limits: { cpu: "500m", memory: "512Mi" } } - }] - } - } - } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name, namespace, labels, annotations: runtimeAnnotations }, - spec: { type: "ClusterIP", selector, ports: [{ name: "http", port: 8080, targetPort: "http" }, { name: "grpc", port: 8081, targetPort: "grpc" }] } - } - ] - }; -} - -function workbenchRuntimeRedisConf(config) { - return [ - "save \"\"", - "appendonly no", - `maxmemory ${config.memoryPolicy.maxMemory}`, - `maxmemory-policy ${config.memoryPolicy.eviction}`, - "" - ].join("\n"); -} - -function workbenchRuntimeRedisManifest({ profile = "v03", config, source }) { - const namespace = config.namespace; - const name = config.serviceName; - const port = config.port; - const selector = { "app.kubernetes.io/name": name }; - const labels = { - ...selector, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/cache-role": "workbench-derived-read", - "hwlab.pikastech.local/component": "workbench-runtime-cache", - "hwlab.pikastech.local/environment": profile, - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/profile": profile, - "hwlab.pikastech.local/service-id": name, - "hwlab.pikastech.local/source-commit": source.full - }; - const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; - const runtimeAnnotations = { ...annotations, "argocd.argoproj.io/sync-wave": "2" }; - const templateLabels = { ...labels, ...selector }; - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { name: `${name}-config`, namespace, labels, annotations: runtimeAnnotations }, - data: { "redis.conf": workbenchRuntimeRedisConf(config) } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { name, namespace, labels, annotations: runtimeAnnotations }, - spec: { - replicas: 1, - selector: { matchLabels: selector }, - template: { - metadata: { labels: templateLabels, annotations: runtimeAnnotations }, - spec: { - containers: [{ - name: "redis", - image: config.image, - imagePullPolicy: "IfNotPresent", - args: ["redis-server", "/usr/local/etc/redis/redis.conf"], - ports: [{ name: "redis", containerPort: port }], - readinessProbe: { exec: { command: ["redis-cli", "-p", String(port), "ping"] }, initialDelaySeconds: 3, periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 3 }, - livenessProbe: { exec: { command: ["redis-cli", "-p", String(port), "ping"] }, initialDelaySeconds: 15, periodSeconds: 20, timeoutSeconds: 3, failureThreshold: 3 }, - resources: cloneJson(config.resources), - volumeMounts: [{ name: "config", mountPath: "/usr/local/etc/redis", readOnly: true }] - }], - volumes: [{ name: "config", configMap: { name: `${name}-config` } }] - } - } - } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name, namespace, labels, annotations: runtimeAnnotations }, - spec: { type: "ClusterIP", selector, ports: [{ name: "redis", port, targetPort: "redis", protocol: "TCP" }] } - }, - { - apiVersion: "networking.k8s.io/v1", - kind: "NetworkPolicy", - metadata: { name: `${name}-ingress`, namespace, labels, annotations: runtimeAnnotations }, - spec: { - podSelector: { matchLabels: selector }, - policyTypes: ["Ingress"], - ingress: [{ - from: [{ podSelector: { matchLabels: { "app.kubernetes.io/name": "hwlab-workbench-runtime" } } }], - ports: [{ protocol: "TCP", port }] - }] - } - } - ] - }; -} - -function runtimeConfigMapsForProfile(deploy, profile) { - if (!isRuntimeLane(profile)) return []; - const configMaps = deploy?.lanes?.[profile]?.configMaps; - if (!Array.isArray(configMaps)) return []; - return configMaps - .map((configMap) => cloneJson(configMap)) - .filter((configMap) => typeof configMap?.name === "string" && configMap.name.trim() && configMap.data && typeof configMap.data === "object" && !Array.isArray(configMap.data)); -} - -function runtimeConfigMapsManifest({ configMaps, namespace, labels, annotations }) { - return { - apiVersion: "v1", - kind: "List", - items: configMaps.map((configMap) => ({ - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name: configMap.name, - namespace, - labels: { ...labels, ...(configMap.labels ?? {}) }, - annotations: { ...annotations, ...(configMap.annotations ?? {}) } - }, - data: Object.fromEntries(Object.entries(configMap.data).map(([key, value]) => [key, String(value)])) - })) - }; -} - -async function runtimeSecretPlaneConfigForProfile(deploy, profile, args = {}) { - if (!isRuntimeLane(profile)) return null; - const laneConfig = deploy?.lanes?.[profile]; - const secretPlane = laneConfig?.secretPlaneRef - ? await readConfigRef(laneConfig.secretPlaneRef, `${profile}.secretPlaneRef`) - : laneConfig?.secretPlane; - if (!secretPlane || typeof secretPlane !== "object" || Array.isArray(secretPlane)) return null; - if (secretPlane.enabled !== true) return null; - if (!runtimeSecretPlaneEnabledForNode(secretPlane, args)) return null; - return cloneJson(secretPlane); -} - -function runtimeSecretPlaneEnabledForNode(secretPlane, args = {}) { - const enabledOnNodes = normalizeSecretPlaneNodeList(secretPlane.enabledOnNodes, "secretPlane.enabledOnNodes"); - if (enabledOnNodes.length === 0) return true; - const nodeId = effectiveSecretPlaneNodeId(args); - assert.ok(nodeId, "secretPlane.enabledOnNodes requires --node or a node-scoped --gitops-root"); - return enabledOnNodes.includes(nodeId); -} - -function normalizeSecretPlaneNodeList(value, label) { - if (value === undefined) return []; - assert.ok(Array.isArray(value), `${label} must be an array when set`); - return value.map((item, index) => { - assert.equal(typeof item, "string", `${label}[${index}] must be a string`); - const nodeId = item.trim().toUpperCase(); - assert.ok(/^[A-Z0-9][A-Z0-9-]*$/u.test(nodeId), `${label}[${index}] must be a node id`); - return nodeId; - }); -} - -function effectiveSecretPlaneNodeId(args = {}) { - return gitopsRootNodeId(args.gitopsRoot) ?? String(args.nodeId ?? "").trim().toUpperCase(); -} - -function gitopsRootNodeId(gitopsRoot) { - const normalized = String(gitopsRoot ?? "").replaceAll("\\", "/").replace(/\/+$/u, ""); - const match = normalized.match(/(?:^|\/)deploy\/gitops\/node\/([^/]+)$/u); - return match?.[1] ? match[1].trim().toUpperCase() : null; -} - -function runtimeSecretPlaneManifest({ config, namespace, labels, annotations }) { - const store = config?.store; - assert.ok(store && typeof store === "object" && !Array.isArray(store), "secretPlane.store must be an object"); - assert.equal(store.kind, "ClusterSecretStore", "secretPlane.store.kind must be ClusterSecretStore for node-scoped v0.3"); - assert.ok(typeof store.name === "string" && store.name.trim(), "secretPlane.store.name must be set"); - const secrets = Array.isArray(config.secrets) ? config.secrets : []; - assert.ok(secrets.length > 0, "secretPlane.secrets must not be empty"); - return { - apiVersion: "v1", - kind: "List", - items: secrets.map((secret, index) => runtimeSecretPlaneExternalSecret({ config, secret, index, namespace, labels, annotations, store })) - }; -} - -function runtimeSecretPlaneExternalSecret({ config, secret, index, namespace, labels, annotations, store }) { - const externalSecretName = requiredSecretPlaneString(secret?.externalSecretName, `secretPlane.secrets[${index}].externalSecretName`); - const targetSecretName = requiredSecretPlaneString(secret?.targetSecretName, `secretPlane.secrets[${index}].targetSecretName`); - const data = Array.isArray(secret?.data) ? secret.data : []; - assert.ok(data.length > 0, `secretPlane.secrets[${index}].data must not be empty`); - const sourceRefs = data.map((item) => requiredSecretPlaneString(item?.remoteRef, `secretPlane.secrets[${index}].data.remoteRef`)); - const issueRef = String(config.issue ?? "pikasTech/HWLAB#2234"); - return { - apiVersion: "external-secrets.io/v1", - kind: "ExternalSecret", - metadata: { - name: externalSecretName, - namespace, - labels: { - ...labels, - "app.kubernetes.io/name": externalSecretName, - "app.kubernetes.io/component": "external-secret", - "hwlab.pikastech.local/secret-plane": secretPlaneIssueLabelValue(issueRef) - }, - annotations: { - ...annotations, - "hwlab.pikastech.local/secret-plane-issue": issueRef, - "hwlab.pikastech.local/source-ref": sourceRefs.join(","), - "hwlab.pikastech.local/values-printed": "false" - } - }, - spec: { - refreshInterval: requiredSecretPlaneString(config.refreshInterval, "secretPlane.refreshInterval"), - secretStoreRef: { - name: requiredSecretPlaneString(store.name, "secretPlane.store.name"), - kind: store.kind - }, - target: { - name: targetSecretName, - creationPolicy: "Owner" - }, - data: data.map((item, itemIndex) => ({ - secretKey: requiredSecretPlaneString(item?.targetKey, `secretPlane.secrets[${index}].data[${itemIndex}].targetKey`), - remoteRef: { - key: requiredSecretPlaneString(item?.remoteRef, `secretPlane.secrets[${index}].data[${itemIndex}].remoteRef`), - property: requiredSecretPlaneString(item?.property, `secretPlane.secrets[${index}].data[${itemIndex}].property`) - } - })) - } - }; -} - -function secretPlaneIssueLabelValue(issueRef) { - const text = String(issueRef ?? "").trim(); - const issueNumber = text.match(/#([0-9]+)$/u)?.[1]; - const raw = issueNumber ? `issue-${issueNumber}` : text; - const normalized = raw.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/gu, ""); - const label = normalized.slice(0, 63).replace(/[^A-Za-z0-9]+$/u, ""); - assert.ok(label.length > 0, "secretPlane.issue must produce a Kubernetes label-safe value"); - return label; -} - -function opencodeRuntimeConfigForProfile(deploy, profile) { - const config = optionalObject(deploy?.lanes?.[profile]?.opencode); - const providerProfile = configString(config.providerProfile) || "dsflash-go"; - const providerId = configString(config.providerId) || providerProfile; - const model = configString(config.model) || "deepseek-v4-flash"; - const smallModel = configString(config.smallModel) || model; - const apiKeyEnv = configString(config.apiKeyEnv) || opencodeApiKeyEnvName(providerId); - const contextLimit = opencodePositiveInteger(config.contextLimit, 1000000, `deploy.lanes.${profile}.opencode.contextLimit`); - const outputLimit = opencodePositiveInteger(config.outputLimit, 384000, `deploy.lanes.${profile}.opencode.outputLimit`); - const reasoning = opencodeConfigBoolean(config.reasoning, false, `deploy.lanes.${profile}.opencode.reasoning`); - const toolCall = opencodeConfigBoolean(config.toolCall, true, `deploy.lanes.${profile}.opencode.toolCall`); - const upstreamBaseURL = configString(config.upstreamBaseURL) || configString(config.baseURL) || "https://opencode.ai/zen/go/v1"; - const providerProxyPort = opencodePositiveInteger(config.providerProxyPort, 4097, `deploy.lanes.${profile}.opencode.providerProxyPort`); - const providerProxyBasePath = configString(config.providerProxyBasePath) || "/v1"; - const providerProxyBaseURL = configString(config.providerProxyBaseURL) || `http://127.0.0.1:${providerProxyPort}${providerProxyBasePath}`; - return { - image: configString(config.image) || "ghcr.io/anomalyco/opencode:1.17.7", - providerProfile, - providerId, - displayName: configString(config.displayName) || `AgentRun ${providerProfile}`, - model, - smallModel, - baseURL: providerProxyBaseURL, - upstreamBaseURL, - apkProxyURL: configString(config.apkProxyURL) || "", - providerProxyPort, - providerProxyBasePath, - npm: configString(config.npm) || "@ai-sdk/openai-compatible", - apiKeyEnv, - apiKeySecretRef: configString(config.apiKeySecretRef) || "hwlab-code-agent-provider/opencode-api-key", - contextLimit, - outputLimit, - reasoning, - toolCall, - agentrunSecretNamespace: configString(config.agentrunSecretNamespace) || "agentrun-v02", - agentrunSecretName: configString(config.agentrunSecretName) || `agentrun-v01-provider-${providerProfile}`, - agentrunSecretKeys: Array.isArray(config.agentrunSecretKeys) && config.agentrunSecretKeys.length > 0 - ? config.agentrunSecretKeys.map(configString).filter(Boolean) - : ["auth.json", "config.toml", "model-catalog.json"] - }; -} - -function opencodePositiveInteger(value, fallback, label) { - if (value === undefined || value === null) return fallback; - assert.ok(Number.isInteger(value) && value > 0, `${label} must be a positive integer`); - return value; -} - -function opencodeConfigBoolean(value, fallback, label) { - if (value === undefined || value === null) return fallback; - assert.equal(typeof value, "boolean", `${label} must be a boolean`); - return value; -} - -function opencodeEgressProxyUrlForProfile(deploy, profile) { - const proxy = deploy?.lanes?.[profile]?.gitMirror?.egressProxy; - if (!proxy || proxy.required === false) return ""; - const proxyUrl = configString(proxy.proxyUrl); - if (proxyUrl) return proxyUrl; - const serviceName = configString(proxy.serviceName); - const namespace = configString(proxy.namespace) || "platform-infra"; - const port = Number(proxy.port); - if (!serviceName || !Number.isInteger(port) || port <= 0) return ""; - return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`; -} - -function opencodeEgressNoProxyForProfile(deploy, profile) { - const proxy = deploy?.lanes?.[profile]?.gitMirror?.egressProxy; - const entries = Array.isArray(proxy?.noProxy) ? proxy.noProxy.map(configString).filter(Boolean) : []; - return entries.length > 0 ? entries.join(",") : "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,.cluster.local"; -} - -function opencodeApiKeyEnvName(providerId) { - const normalized = String(providerId || "provider").toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, ""); - return `OPENCODE_${normalized || "PROVIDER"}_API_KEY`; -} - -function opencodeConfigContent(config) { - return JSON.stringify({ - $schema: "https://opencode.ai/config.json", - autoupdate: false, - share: "disabled", - model: `${config.providerId}/${config.model}`, - small_model: `${config.providerId}/${config.smallModel}`, - provider: { - [config.providerId]: { - npm: config.npm, - name: config.displayName, - options: { - baseURL: config.baseURL, - apiKey: `{env:${config.apiKeyEnv}}`, - timeout: 600000, - headerTimeout: 600000, - chunkTimeout: 300000 - }, - models: { - [config.model]: { - name: config.model, - reasoning: config.reasoning, - tool_call: config.toolCall, - limit: { - context: config.contextLimit, - output: config.outputLimit - } - } - } - } - } - }); -} - -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 name = "opencode-server"; - const helperServiceId = "hwlab-cloud-web"; - const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile); - const helperImage = runtimeImageForService({ catalog, deploy, serviceId: helperServiceId, source, registryPrefix, useDeployImages, digestPin: true, envReuseServiceIds }); - const helperCommit = runtimeCommitForService({ catalog, deploy, serviceId: helperServiceId, source, registryPrefix, useDeployImages, digestPin: true, envReuseServiceIds }); - const helperImageTag = runtimeImageTagForService({ catalog, deploy, serviceId: helperServiceId, source, registryPrefix, useDeployImages, digestPin: true, envReuseServiceIds }); - const providerProxyBootSh = "deploy/runtime/boot/opencode-provider-proxy.sh"; - const providerProxyBootMetadata = v02EnvReuseEnabled(catalog, helperServiceId, envReuseServiceIds) - ? bootMetadataForService({ args: { sourceRepo: deploy?.lanes?.[profile]?.sourceRepo || sourceRepo, registryPrefix }, catalog, deployService: deployServiceForBoot(deploy, helperServiceId, profile), serviceId: helperServiceId, source }) - : null; - const selector = { - "app.kubernetes.io/name": name, - "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": profile, - "hwlab.pikastech.local/profile": profile - }; - const labels = { - ...selector, - "hwlab.pikastech.local/component": "opencode", - "hwlab.pikastech.local/gitops-target": profile, - "hwlab.pikastech.local/service-id": name, - "hwlab.pikastech.local/source-commit": source.full - }; - const opencodeConfig = opencodeRuntimeConfigForProfile(deploy, profile); - const configContent = opencodeConfigContent(opencodeConfig); - const configSha256 = createHash("sha256").update(configContent).digest("hex"); - const apiKeySecretRef = secretRefObjectForProfile(opencodeConfig.apiKeySecretRef, profile); - const opencodeApkProxyUrl = opencodeConfig.apkProxyURL || opencodeEgressProxyUrlForProfile(deploy, profile); - const opencodeApkNoProxy = opencodeEgressNoProxyForProfile(deploy, profile); - const annotations = { - "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs", - "hwlab.pikastech.local/opencode-provider-profile": opencodeConfig.providerProfile, - "hwlab.pikastech.local/opencode-provider-id": opencodeConfig.providerId, - "hwlab.pikastech.local/opencode-model": opencodeConfig.model, - "hwlab.pikastech.local/opencode-provider-base-url": opencodeConfig.baseURL, - "hwlab.pikastech.local/opencode-provider-upstream-base-url": opencodeConfig.upstreamBaseURL, - "hwlab.pikastech.local/opencode-image": opencodeConfig.image, - "hwlab.pikastech.local/opencode-config-sha256": configSha256, - "hwlab.pikastech.local/opencode-api-key-secret-ref": `${apiKeySecretRef.name}/${apiKeySecretRef.key}`, - "hwlab.pikastech.local/agentrun-provider-secret-ref": `${opencodeConfig.agentrunSecretNamespace}/${opencodeConfig.agentrunSecretName}`, - "hwlab.pikastech.local/agentrun-provider-secret-keys": opencodeConfig.agentrunSecretKeys.join(","), - "hwlab.pikastech.local/values-printed": "false" - }; - if (providerProxyBootMetadata) { - Object.assign(annotations, { - "hwlab.pikastech.local/opencode-provider-proxy-runtime-mode": providerProxyBootMetadata.runtimeMode, - "hwlab.pikastech.local/opencode-provider-proxy-boot-repo": providerProxyBootMetadata.bootRepo, - "hwlab.pikastech.local/opencode-provider-proxy-boot-commit": providerProxyBootMetadata.bootCommit, - "hwlab.pikastech.local/opencode-provider-proxy-boot-sh": providerProxyBootSh, - "hwlab.pikastech.local/opencode-provider-proxy-environment-digest": providerProxyBootMetadata.environmentDigest ?? "not_published" - }); - } - const authSecretName = secretNameForProfile("hwlab-opencode-server-auth", profile); - return { - apiVersion: "v1", - kind: "List", - items: [ - { - apiVersion: "v1", - kind: "ServiceAccount", - metadata: { name, namespace, labels, annotations } - }, - { - apiVersion: "v1", - kind: "PersistentVolumeClaim", - metadata: { name: `${name}-data`, namespace, labels, annotations }, - spec: { - accessModes: ["ReadWriteOnce"], - resources: { requests: { storage: "8Gi" } } - } - }, - { - apiVersion: "v1", - kind: "Service", - metadata: { name, namespace, labels, annotations }, - spec: { - type: "ClusterIP", - selector, - ports: [{ name: "http", port: 4096, targetPort: "http" }] - } - }, - { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { name, namespace, labels, annotations }, - spec: { - replicas: 1, - strategy: { type: "Recreate" }, - selector: { matchLabels: selector }, - template: { - metadata: { labels, annotations }, - spec: { - serviceAccountName: name, - securityContext: { fsGroup: 1000, fsGroupChangePolicy: "OnRootMismatch" }, - initContainers: [{ - name: "opencode-workspace-git-init", - image: helperImage, - imagePullPolicy: "IfNotPresent", - command: ["/bin/sh", "-ec"], - args: [ - [ - "set -eu", - "mkdir -p /workspace", - "if [ ! -d /workspace/.git ]; then", - " git -C /workspace init", - "fi", - "git -C /workspace config user.name 'HWLAB OpenCode' || true", - "git -C /workspace config user.email 'opencode@hwlab.local' || true", - "chgrp -R 1000 /workspace/.git 2>/dev/null || true", - "chmod -R g+rwX /workspace/.git 2>/dev/null || true" - ].join("\n") - ], - resources: { requests: { cpu: "25m", memory: "64Mi" }, limits: { cpu: "250m", memory: "256Mi" } }, - volumeMounts: [{ name: "workspace", mountPath: "/workspace" }] - }], - containers: [{ - name, - image: opencodeConfig.image, - imagePullPolicy: "IfNotPresent", - command: ["/bin/sh", "-ec"], - args: ["exec opencode serve --hostname 0.0.0.0 --port 4096"], - workingDir: "/workspace", - env: [ - { name: "HOME", value: "/workspace" }, - { name: "XDG_CONFIG_HOME", value: "/workspace/.config" }, - { name: "XDG_DATA_HOME", value: "/workspace/.local/share" }, - ...(opencodeApkProxyUrl ? [ - { name: "HTTP_PROXY", value: opencodeApkProxyUrl }, - { name: "HTTPS_PROXY", value: opencodeApkProxyUrl }, - { name: "http_proxy", value: opencodeApkProxyUrl }, - { name: "https_proxy", value: opencodeApkProxyUrl }, - { name: "NO_PROXY", value: opencodeApkNoProxy }, - { name: "no_proxy", value: opencodeApkNoProxy } - ] : []), - { name: "OPENCODE_CONFIG_CONTENT", value: configContent }, - { name: opencodeConfig.apiKeyEnv, valueFrom: { secretKeyRef: apiKeySecretRef } }, - { name: "OPENCODE_SERVER_USERNAME", valueFrom: { secretKeyRef: { name: authSecretName, key: "username" } } }, - { name: "OPENCODE_SERVER_PASSWORD", valueFrom: { secretKeyRef: { name: authSecretName, key: "password" } } } - ], - ports: [{ name: "http", containerPort: 4096 }], - startupProbe: { tcpSocket: { port: "http" }, periodSeconds: 5, failureThreshold: 60 }, - readinessProbe: { tcpSocket: { port: "http" }, periodSeconds: 10, failureThreshold: 3 }, - livenessProbe: { tcpSocket: { port: "http" }, periodSeconds: 20, failureThreshold: 3 }, - resources: { requests: { cpu: "100m", memory: "256Mi" }, limits: { cpu: "1", memory: "1Gi" } }, - volumeMounts: [{ name: "workspace", mountPath: "/workspace" }] - }, (() => { - const container = { - name: "opencode-provider-proxy", - image: helperImage, - imagePullPolicy: "IfNotPresent", - command: ["node", "/app/internal/dev-entrypoint/opencode-provider-proxy.mjs"], - env: [ - { name: "HWLAB_SERVICE_ID", value: "opencode-provider-proxy" }, - { name: "HWLAB_ENVIRONMENT", value: profile }, - { name: "HWLAB_COMMIT_ID", value: helperCommit }, - { name: "HWLAB_IMAGE", value: helperImage }, - { name: "HWLAB_IMAGE_TAG", value: helperImageTag }, - { name: "PORT", value: String(opencodeConfig.providerProxyPort) }, - { name: "HWLAB_OPENCODE_PROVIDER_PROXY_UPSTREAM_BASE_URL", value: opencodeConfig.upstreamBaseURL }, - { name: "HWLAB_OPENCODE_PROVIDER_PROXY_PUBLIC_BASE_PATH", value: opencodeConfig.providerProxyBasePath }, - { name: "HWLAB_OPENCODE_PROVIDER_PROXY_TIMEOUT_MS", value: "600000" }, - { name: "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", value: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces" }, - { name: "OTEL_SERVICE_NAME", value: "opencode-provider-proxy" } - ], - ports: [{ name: "provider", containerPort: opencodeConfig.providerProxyPort }], - readinessProbe: { httpGet: { path: "/health/readiness", port: "provider" }, periodSeconds: 10, failureThreshold: 3 }, - livenessProbe: { httpGet: { path: "/health/live", port: "provider" }, periodSeconds: 20, failureThreshold: 3 }, - resources: { requests: { cpu: "25m", memory: "64Mi" }, limits: { cpu: "250m", memory: "256Mi" } } - }; - if (providerProxyBootMetadata) { - applyEnvReuseBootEnv(container, providerProxyBootMetadata, { sourceBranch, gitReadUrl, bootSh: providerProxyBootSh }); - } - return container; - })()], - volumes: [{ name: "workspace", persistentVolumeClaim: { claimName: `${name}-data` } }] - } - } - } - } - ] - }; -} - -function requiredSecretPlaneString(value, label) { - assert.equal(typeof value, "string", `${label} must be a string`); - const trimmed = value.trim(); - assert.ok(trimmed, `${label} must not be empty`); - return trimmed; -} - function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false, externalSecrets = false } = {}) { 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"); diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index fd2c45b9..fd07f93f 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -5,7 +5,9 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis import os from "node:os"; import path from "node:path"; import test from "node:test"; -import vm from "node:vm"; + +import { configString } from "./src/gitops-render/core.mjs"; +import { opencodeEgressProxyUrlForProfile } from "./src/gitops-render/runtime-manifests.mjs"; import { CLOUD_CORE_MIGRATIONS, CLOUD_TRANSACTIONAL_REALTIME_MIGRATION_ID } from "../internal/db/schema.ts"; @@ -101,36 +103,11 @@ async function runtimeNoopDecision(gitopsPromoteScript, { oldRuntime, newRuntime } } -function extractNamedFunction(source, name) { - const start = source.indexOf(`function ${name}`); - assert.notEqual(start, -1, `missing function ${name}`); - const bodyStart = source.indexOf("{", start); - assert.notEqual(bodyStart, -1, `missing function body for ${name}`); - let depth = 0; - for (let index = bodyStart; index < source.length; index += 1) { - const char = source[index]; - if (char === "{") depth += 1; - else if (char === "}") { - depth -= 1; - if (depth === 0) return source.slice(start, index + 1); - } - } - assert.fail(`unterminated function ${name}`); -} - test("opencode APK proxy prefers explicit gitMirror proxyUrl", async () => { - const source = await readFile("scripts/gitops-render.mjs", "utf8"); - const context = {}; - vm.runInNewContext(`${extractNamedFunction(source, "configString")} -${extractNamedFunction(source, "opencodeEgressProxyUrlForProfile")} -globalThis.result = { - hostRoute: opencodeEgressProxyUrlForProfile({ lanes: { v03: { gitMirror: { egressProxy: { required: true, proxyUrl: "http://10.42.0.1:10808", namespace: "platform-infra", serviceName: "jd01-host-proxy", port: 10808 } } } } }, "v03"), - serviceFallback: opencodeEgressProxyUrlForProfile({ lanes: { v03: { gitMirror: { egressProxy: { required: true, namespace: "platform-infra", serviceName: "sub2api-egress-proxy", port: 10808 } } } } }, "v03"), - disabled: opencodeEgressProxyUrlForProfile({ lanes: { v03: { gitMirror: { egressProxy: { required: false, proxyUrl: "http://10.42.0.1:10808" } } } } }, "v03") -};`, context, { filename: "gitops-render-opencode-proxy.test.mjs" }); - assert.equal(context.result.hostRoute, "http://10.42.0.1:10808"); - assert.equal(context.result.serviceFallback, "http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808"); - assert.equal(context.result.disabled, ""); + assert.equal(configString(" value "), "value"); + assert.equal(opencodeEgressProxyUrlForProfile({ lanes: { v03: { gitMirror: { egressProxy: { required: true, proxyUrl: "http://10.42.0.1:10808", namespace: "platform-infra", serviceName: "jd01-host-proxy", port: 10808 } } } } }, "v03"), "http://10.42.0.1:10808"); + assert.equal(opencodeEgressProxyUrlForProfile({ lanes: { v03: { gitMirror: { egressProxy: { required: true, namespace: "platform-infra", serviceName: "sub2api-egress-proxy", port: 10808 } } } } }, "v03"), "http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808"); + assert.equal(opencodeEgressProxyUrlForProfile({ lanes: { v03: { gitMirror: { egressProxy: { required: false, proxyUrl: "http://10.42.0.1:10808" } } } } }, "v03"), ""); }); test("v02 render follows TypeScript runtime checks and does not self-patch bootstrap RBAC", async () => { diff --git a/scripts/src/gitops-render/core.mjs b/scripts/src/gitops-render/core.mjs new file mode 100644 index 00000000..eccbf06a --- /dev/null +++ b/scripts/src/gitops-render/core.mjs @@ -0,0 +1,1782 @@ +#!/usr/bin/env node +// SPEC: PJ2026-01060505 Workbench Performance; PJ2026-0106 平台运维 +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { + applyRuntimeLaneDeployConfig, + defaultEndpointForRuntimeLane, + defaultPortForRuntimeLane, + isRuntimeLane, + runtimeLaneMirrorSpecs, + runtimeLaneConfig, + versionNameForRuntimeLane +} from "../runtime-lane.ts"; +import { readStructuredFile } from "../structured-config.mjs"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const defaultOutDir = "deploy/gitops/node"; +const defaultRegistryPrefix = process.env.HWLAB_NODE_REGISTRY_PREFIX || "127.0.0.1:5000/hwlab"; +const defaultSourceRepo = process.env.HWLAB_NODE_GIT_URL || "git@github.com:pikasTech/HWLAB.git"; +const defaultGitReadUrl = process.env.HWLAB_NODE_GIT_READ_URL || null; +const defaultV02GitReadUrl = process.env.HWLAB_V02_GIT_READ_URL || "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; +const defaultV02GitWriteUrl = process.env.HWLAB_V02_GIT_WRITE_URL || "http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; +const defaultRuntimeLaneGitReadUrl = process.env.HWLAB_RUNTIME_LANE_GIT_READ_URL || "http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git"; +const defaultRuntimeLaneGitWriteUrl = process.env.HWLAB_RUNTIME_LANE_GIT_WRITE_URL || defaultV02GitWriteUrl; +const defaultBranch = process.env.HWLAB_NODE_BRANCH || "node"; +const defaultGitopsBranch = process.env.HWLAB_NODE_GITOPS_BRANCH || "node-gitops"; +const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; +const defaultRuntimeEndpoint = process.env.HWLAB_NODE_RUNTIME_ENDPOINT || "http://74.48.78.17:17667"; +const defaultWebEndpoint = process.env.HWLAB_NODE_WEB_ENDPOINT || "http://74.48.78.17:17666"; +const defaultProdRuntimeEndpoint = process.env.HWLAB_NODE_PROD_RUNTIME_ENDPOINT || "http://74.48.78.17:18667"; +const defaultProdWebEndpoint = process.env.HWLAB_NODE_PROD_WEB_ENDPOINT || "http://74.48.78.17:18666"; +const defaultV02RuntimeEndpoint = process.env.HWLAB_V02_RUNTIME_ENDPOINT || "https://hwlab.74-48-78-17.nip.io"; +const defaultV02WebEndpoint = process.env.HWLAB_V02_WEB_ENDPOINT || "https://hwlab.74-48-78-17.nip.io"; +const defaultProxyUrl = process.env.HWLAB_NODE_PROXY_URL || "http://127.0.0.1:10808"; +const defaultAllProxyUrl = process.env.HWLAB_NODE_ALL_PROXY_URL || "socks5h://127.0.0.1:10808"; +const defaultNoProxy = process.env.HWLAB_NODE_NO_PROXY || "hyueapi.com,.hyueapi.com,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,git-mirror-http,git-mirror-http.devops-infra,git-mirror-http.devops-infra.svc,git-mirror-http.devops-infra.svc.cluster.local,git-mirror-write,git-mirror-write.devops-infra,git-mirror-write.devops-infra.svc,git-mirror-write.devops-infra.svc.cluster.local"; +const ciToolsRunnerImage = process.env.HWLAB_NODE_CI_TOOLS_IMAGE || "127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1"; +const buildkitRunnerImage = process.env.HWLAB_NODE_BUILDKIT_IMAGE || "moby/buildkit:rootless"; +const defaultDevBaseImage = process.env.HWLAB_NODE_DEV_BASE_IMAGE || "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim"; +const defaultServiceIds = Object.freeze([ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-gateway", + "hwlab-edge-proxy", + "hwlab-agent-skills" +]); +const v02ExtraObservableServices = Object.freeze([ + { serviceId: "hwlab-deepseek-proxy", port: 4000, healthPath: "/health/live" } +]); +const v02RemovedServiceIds = new Set([ + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-agent-worker-template", + "hwlab-code-agent-workspace", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel" +]); +const v02RemovedServiceEnvNames = new Set([ + "CODEX_HOME", + "OPENAI_API_KEY", + "HWLAB_CODE_AGENT_PROVIDER", + "HWLAB_CODE_AGENT_MODEL", + "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "HWLAB_CODE_AGENT_WORKSPACE", + "HWLAB_CODE_AGENT_CODEX_HOME", + "HWLAB_CODE_AGENT_CODEX_WORKSPACE", + "HWLAB_CODE_AGENT_CODEX_SANDBOX", + "HWLAB_CODE_AGENT_CODEX_COMMAND", + "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", + "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", + "HWLAB_M3_GATEWAY_SIMU_1_URL", + "HWLAB_M3_GATEWAY_SIMU_2_URL", + "HWLAB_M3_PATCH_PANEL_URL", + "HWLAB_GATEWAY_SIMU_URL", + "HWLAB_BOX_SIMU_URL", + "HWLAB_PATCH_PANEL_URL", + "HWLAB_ROUTER_URL", + "HWLAB_TUNNEL_CLIENT_URL" +]); +const moonBridgeSourceRef = process.env.HWLAB_NODE_MOONBRIDGE_REF || "1b99888d3dae889b79ee602cb875c7907f7e76f2"; +const moonBridgeImage = process.env.HWLAB_NODE_MOONBRIDGE_IMAGE || `${defaultRegistryPrefix}/moonbridge:${moonBridgeSourceRef.slice(0, 12)}`; +const deepSeekProfileModel = process.env.HWLAB_NODE_DEEPSEEK_PROFILE_MODEL || "deepseek-chat"; +const codexApiProfileModel = process.env.HWLAB_NODE_CODEX_API_PROFILE_MODEL || "gpt-5.5"; +const codexApiProfileBaseUrl = process.env.HWLAB_NODE_CODEX_API_PROFILE_BASE_URL || "http://127.0.0.1:49280/responses"; +const codexApiForwarderPort = process.env.HWLAB_NODE_CODEX_API_FORWARDER_PORT || "49280"; +const codexApiForwarderUpstreamBaseUrl = process.env.HWLAB_NODE_CODEX_API_UPSTREAM_BASE_URL || "https://hyueapi.com"; +const sourceCommitPattern = /^[a-f0-9]{7,40}$/u; +const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout"; + +function k8sLabelHash(value) { + return String(value || "").slice(0, 16); +} + +const primitiveValidationTasks = Object.freeze([ + { + name: "repo-reports-guard", + commands: ["node scripts/repo-reports-guard.mjs"] + }, + { + name: "node-contract-check", + commands: [ + "node --check scripts/gitops-render.mjs", + `node --input-type=module <<'NODE' +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const ciFileName = "CI" + ".json"; +const forbiddenCiFragments = [ + "ci" + "-json", + "HWLAB_NODE_TEKTON" + "_SINGLE_IMAGE_PUBLISH", + "single" + "-dind", + "docker" + ":29", + "DOCKER" + "_HOST", + "Docker" + "-in-Docker", + "D" + "IND", + "docker" + " push", + "--build" + "-backend", + "HWLAB_ARTIFACT" + "_BUILD_BACKEND", + "--legacy" + "-source-images", + "HWLAB_NODE_USE_DEPLOY_IMAGES" + "=0" +]; +const scanTargets = [ + "scripts/gitops-render.mjs", + "scripts/src/runtime-lane.ts", + "scripts/artifact-publish.mjs", + "scripts/src/ci-plan-lib.mjs", + "deploy/gitops/node/tekton" +]; +const skipDirs = new Set([".git", "node_modules", ".worktree"]); + +function walkFiles(relativePath) { + const absolutePath = path.join(root, relativePath); + let stat; + try { + stat = statSync(absolutePath); + } catch { + return []; + } + if (stat.isFile()) return [relativePath]; + if (!stat.isDirectory()) return []; + const files = []; + for (const entry of readdirSync(absolutePath, { withFileTypes: true })) { + if (entry.isDirectory() && skipDirs.has(entry.name)) continue; + files.push(...walkFiles(path.join(relativePath, entry.name))); + } + return files; +} + +const ciFiles = walkFiles(".").filter((filePath) => path.basename(filePath) === ciFileName); +const violations = []; +for (const filePath of scanTargets.flatMap(walkFiles)) { + const text = readFileSync(path.join(root, filePath), "utf8"); + for (const fragment of forbiddenCiFragments) { + if (text.includes(fragment)) violations.push({ filePath, fragment }); + } +} +if (ciFiles.length || violations.length) { + console.error(JSON.stringify({ status: "failed", ciFiles, violations }, null, 2)); + process.exit(1); +} +NODE`, + "node --check scripts/artifact-publish.mjs" + ] + }, + { + name: "codex-api-forwarder-check", + commands: [ + "node scripts/run-bun.mjs test cmd/hwlab-codex-api-responses-forwarder/main.test.ts" + ] + } +]); + +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 = { + lane: process.env.HWLAB_GITOPS_LANE || "node", + nodeId: process.env.HWLAB_NODE_ID || "node", + outDir: defaultOutDir, + gitopsRoot: defaultOutDir, + catalogPath: defaultCatalogPath, + imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short", + registryPrefix: defaultRegistryPrefix, + sourceRevision: null, + sourceBranch: defaultBranch, + gitopsBranch: defaultGitopsBranch, + sourceRepo: defaultSourceRepo, + gitReadUrl: defaultGitReadUrl, + gitWriteUrl: null, + runtimeEndpoint: defaultRuntimeEndpoint, + webEndpoint: defaultWebEndpoint, + prodRuntimeEndpoint: defaultProdRuntimeEndpoint, + prodWebEndpoint: defaultProdWebEndpoint, + useDeployImages: true, + check: false, + write: true, + help: false + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--lane") args.lane = readOption(argv, ++index, arg); + else if (arg === "--node") args.nodeId = readOption(argv, ++index, arg); + else if (arg === "--out") args.outDir = readOption(argv, ++index, arg); + else if (arg === "--gitops-root") args.gitopsRoot = readOption(argv, ++index, arg); + else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg); + else if (arg === "--image-tag-mode") args.imageTagMode = 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 === "--git-read-url") args.gitReadUrl = readOption(argv, ++index, arg); + else if (arg === "--git-write-url") args.gitWriteUrl = 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 === "--prod-runtime-endpoint") args.prodRuntimeEndpoint = readOption(argv, ++index, arg); + else if (arg === "--prod-web-endpoint") args.prodWebEndpoint = readOption(argv, ++index, arg); + else if (arg === "--use-deploy-images") args.useDeployImages = true; + 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}`); + } + if (isRuntimeLane(args.lane)) { + const versionName = versionNameForRuntimeLane(args.lane); + const defaultLaneEndpoint = defaultEndpointForRuntimeLane(args.lane); + if (args.sourceBranch === defaultBranch) args.sourceBranch = versionName; + if (args.gitopsBranch === defaultGitopsBranch) args.gitopsBranch = `${versionName}-gitops`; + if (args.catalogPath === defaultCatalogPath) args.catalogPath = `deploy/artifact-catalog.${args.lane}.json`; + if (args.runtimeEndpoint === defaultRuntimeEndpoint) args.runtimeEndpoint = defaultLaneEndpoint; + if (args.webEndpoint === defaultWebEndpoint) args.webEndpoint = defaultLaneEndpoint; + if (args.imageTagMode === "short") args.imageTagMode = "full"; + if (!args.gitReadUrl) args.gitReadUrl = defaultRuntimeLaneGitReadUrl; + if (!args.gitWriteUrl) args.gitWriteUrl = defaultRuntimeLaneGitWriteUrl; + } + if (!args.gitReadUrl) args.gitReadUrl = args.sourceRepo; + 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)) { + 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`); + } + 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/run-bun.mjs scripts/gitops-render.mjs [options]", + "", + "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", + ` --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`, + ` --catalog-path PATH default: ${defaultCatalogPath}`, + " --image-tag-mode MODE short or full; v02 uses full source commit IDs", + ` --source-repo URL default: ${defaultSourceRepo}`, + " --git-read-url URL read-only source URL; runtime lanes default to devops-infra git mirror", + " --git-write-url URL GitOps write URL; runtime lanes default to devops-infra git mirror write relay", + ` --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}; runtime lane default is derived from deploy.yaml/public URL`, + ` --web-endpoint URL default: ${defaultWebEndpoint}; runtime lane default is derived from deploy.yaml/public URL`, + ` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`, + ` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`, + " --use-deploy-images default and only supported mode: render workload images from deploy/artifact-catalog.dev.json per service", + " --check verify generated files are current without writing", + " --no-write plan and print generated file list without writing" + ].join("\n"); +} + +function generatedPath(filePath) { + return path.isAbsolute(filePath) ? filePath : path.join(repoRoot, filePath); +} + +async function readJson(relativePath) { + return readStructuredFile(repoRoot, relativePath); +} + +async function readJsonIfPresent(relativePath, fallback = null) { + try { + return await readJson(relativePath); + } catch (error) { + if (error && error.code === "ENOENT") return fallback; + throw error; + } +} + +async function attachAccessControlEnv(deploy) { + const byProfile = {}; + for (const [profile, laneConfig] of Object.entries(deploy?.lanes ?? {})) { + if (!isRuntimeLane(profile)) continue; + const accessControl = laneConfig?.accessControl; + if (!accessControl || typeof accessControl !== "object" || Array.isArray(accessControl)) continue; + const users = await readConfigRef(accessControl.usersRef, `${profile}.accessControl.usersRef`); + const navProfiles = await readConfigRef(accessControl.navProfilesRef, `${profile}.accessControl.navProfilesRef`); + const env = accessControlEnvFromConfig({ users, navProfiles }); + if (Object.keys(env).length > 0) byProfile[profile] = { "hwlab-cloud-api": env }; + } + Object.defineProperty(deploy, "__accessControlEnvByProfile", { value: byProfile, enumerable: false, configurable: true }); +} + +async function readConfigRef(ref, label) { + assert.equal(typeof ref, "string", `${label} must be a file reference`); + const [filePath, fragment = ""] = ref.split("#"); + assert.ok(filePath, `${label} must include a file path`); + const root = await readJson(filePath); + return configFragment(root, fragment || "", label); +} + +function configFragment(value, fragment, label) { + const pathText = String(fragment ?? "").replace(/^\//u, ""); + if (!pathText) return value; + return pathText.split(/[./]/u).filter(Boolean).reduce((current, segment) => { + assert.ok(current && typeof current === "object" && Object.hasOwn(current, segment), `${label} fragment is missing ${segment}`); + return current[segment]; + }, value); +} + +function accessControlEnvFromConfig({ users, navProfiles }) { + assert.ok(Array.isArray(users), "accessControl usersRef must resolve to an array"); + assert.ok(Array.isArray(navProfiles), "accessControl navProfilesRef must resolve to an array"); + const env = {}; + const renderedUsers = users.map((user, index) => normalizeAccessControlUser(user, index, env)); + const renderedProfiles = navProfiles.map(normalizeNavProfile); + env.HWLAB_ACCESS_CONTROL_USERS_JSON = JSON.stringify(renderedUsers); + env.HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON = JSON.stringify(renderedProfiles); + return env; +} + +function normalizeAccessControlUser(user, index, env) { + assert.ok(user && typeof user === "object" && !Array.isArray(user), `accessControl user ${index} must be an object`); + const passwordHashRef = user.passwordHashRef && typeof user.passwordHashRef === "object" && !Array.isArray(user.passwordHashRef) ? user.passwordHashRef : null; + const passwordHashEnv = configString(user.passwordHashEnv) || configString(passwordHashRef?.env); + const secretRef = configString(passwordHashRef?.secretRef) || secretRefFromParts(passwordHashRef); + if (passwordHashEnv && secretRef) env[passwordHashEnv] = `secretRef:${secretRef}`; + return { + id: requiredAccessConfigString(user.id, `accessControl user ${index}.id`), + username: requiredAccessConfigString(user.username, `accessControl user ${index}.username`), + displayName: configString(user.displayName) || configString(user.username), + email: configString(user.email) || null, + role: configString(user.role) === "admin" ? "admin" : "user", + status: configString(user.status) === "disabled" ? "disabled" : "active", + navProfile: requiredAccessConfigString(user.navProfile, `accessControl user ${index}.navProfile`), + passwordHashEnv: passwordHashEnv || null + }; +} + +function normalizeNavProfile(profile, index) { + assert.ok(profile && typeof profile === "object" && !Array.isArray(profile), `accessControl nav profile ${index} must be an object`); + const allowedIds = Array.isArray(profile.allowedIds) ? profile.allowedIds.map(configString).filter(Boolean) : []; + assert.ok(allowedIds.length > 0, `accessControl nav profile ${index}.allowedIds must not be empty`); + return { + id: requiredAccessConfigString(profile.id, `accessControl nav profile ${index}.id`), + displayName: configString(profile.displayName) || configString(profile.id), + allowedIds + }; +} + +function secretRefFromParts(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + const sourceRef = configString(value.sourceRef ?? value.secretName); + const targetKey = configString(value.targetKey ?? value.key); + return sourceRef && targetKey ? `${sourceRef}/${targetKey}` : ""; +} + +function configString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function requiredAccessConfigString(value, label) { + const text = configString(value); + assert.ok(text, `${label} is required`); + return text; +} + +function optionalObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +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) }; +} + +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 upsertEnvEntry(envList, entry) { + if (!Array.isArray(envList) || !entry?.name) return; + const existing = envList.find((item) => item?.name === entry.name); + if (existing) { + delete existing.value; + delete existing.valueFrom; + Object.assign(existing, cloneJson(entry)); + } else { + envList.push(cloneJson(entry)); + } +} + +function syncCodeAgentWorkspaceInitContainer(initContainers, { image, runtimeCommit, runtimeImageTag }) { + const initContainer = Array.isArray(initContainers) + ? initContainers.find((entry) => entry?.name === "hwlab-code-agent-workspace-init") + : null; + if (!initContainer) return; + initContainer.image = image; + initContainer.imagePullPolicy = "IfNotPresent"; + initContainer.env ??= []; + upsertEnv(initContainer.env, "HWLAB_WORKSPACE_SOURCE_COMMIT", runtimeCommit); + upsertEnv(initContainer.env, "HWLAB_IMAGE", image); + upsertEnv(initContainer.env, "HWLAB_IMAGE_TAG", runtimeImageTag); +} + +function annotate(metadata, annotations) { + metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations }; +} + +function label(metadata, labels) { + metadata.labels = { ...(metadata.labels ?? {}), ...labels }; +} + +function imageFor(registryPrefix, serviceId, imageTag) { + return `${registryPrefix}/${serviceId}:${imageTag}`; +} + +function imageTagFromReference(image) { + const value = String(image ?? ""); + const slashIndex = value.lastIndexOf("/"); + const colonIndex = value.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return value.slice(colonIndex + 1).split("@", 1)[0] || null; +} + +function repositoryFromImage(image) { + const value = String(image ?? "").split("@", 1)[0]; + const slashIndex = value.lastIndexOf("/"); + const colonIndex = value.lastIndexOf(":"); + return colonIndex > slashIndex ? value.slice(0, colonIndex) : value; +} + +function digestPinnedImage(image, digest) { + if (!/^sha256:[a-f0-9]{64}$/u.test(String(digest ?? ""))) return image; + const repository = repositoryFromImage(image); + return repository ? `${repository}@${digest}` : image; +} + +function catalogServiceById(catalog, serviceId) { + return (catalog?.services ?? []).find((service) => service?.serviceId === serviceId) ?? null; +} + +function v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds = null) { + if (envReuseServiceIds?.has(serviceId)) return true; + const service = catalogServiceById(catalog, serviceId); + return service?.runtimeMode === v02EnvReuseRuntimeMode || service?.envReuse === true; +} + +function bootShForService(deployService, serviceId) { + assert.ok(deployService?.bootSh, `deploy lane bootScripts.${serviceId} is required`); + const value = deployService.bootSh; + const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, ""); + assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`); + return text; +} + +function envReuseServiceIdsForLane(deploy, lane) { + if (!isRuntimeLane(lane)) return new Set(); + const configured = deploy?.lanes?.[lane]?.envReuseServices; + const allowed = new Set(serviceIdsForLane(lane, deploy)); + return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId))); +} + +function deployServiceForBoot(deploy, serviceId, lane = "v02") { + return { + serviceId, + bootSh: deploy?.lanes?.[lane]?.bootScripts?.[serviceId] + }; +} + +function bootMetadataForService({ args, catalog, deployService, serviceId, source }) { + const catalogService = catalogServiceById(catalog, serviceId); + const environmentImage = catalogService?.environmentImage ?? imageFor(args.registryPrefix, `${serviceId}-env`, `env-${String(catalogService?.environmentInputHash ?? source.full).slice(0, 12)}`); + const environmentDigest = catalogService?.environmentDigest ?? catalogService?.digest ?? null; + return { + runtimeMode: v02EnvReuseRuntimeMode, + environmentImage, + environmentDigest, + environmentInputHash: catalogService?.environmentInputHash ?? null, + codeInputHash: catalogService?.codeInputHash ?? null, + bootRepo: catalogService?.bootRepo ?? args.sourceRepo, + bootCommit: source.full, + bootSh: catalogService?.bootSh ?? bootShForService(deployService, serviceId) + }; +} + +function envReuseBootShForContainer({ serviceId, container, defaultBootSh }) { + if (serviceId === "hwlab-cloud-api" && container?.name === "hwlab-codex-api-forwarder") { + return "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh"; + } + return defaultBootSh; +} + +function applyEnvReuseBootEnv(container, bootMetadata, { sourceBranch, gitReadUrl, bootSh = bootMetadata?.bootSh } = {}) { + if (!container || !bootMetadata) return; + delete container.command; + delete container.args; + container.env ??= []; + upsertEnv(container.env, "HWLAB_RUNTIME_MODE", bootMetadata.runtimeMode); + upsertEnv(container.env, "HWLAB_BOOT_REPO", bootMetadata.bootRepo); + upsertEnv(container.env, "HWLAB_BOOT_COMMIT", bootMetadata.bootCommit); + upsertEnv(container.env, "HWLAB_BOOT_REF", sourceBranch); + upsertEnv(container.env, "HWLAB_BOOT_SH", bootSh); + upsertEnv(container.env, "HWLAB_BOOT_READ_URL", gitReadUrl ?? defaultV02GitReadUrl); + upsertEnv(container.env, "HWLAB_ENVIRONMENT_IMAGE", bootMetadata.environmentImage ?? ""); + upsertEnv(container.env, "HWLAB_ENVIRONMENT_DIGEST", bootMetadata.environmentDigest ?? ""); + upsertEnv(container.env, "HWLAB_ENVIRONMENT_INPUT_HASH", bootMetadata.environmentInputHash ?? ""); + upsertEnv(container.env, "HWLAB_CODE_INPUT_HASH", bootMetadata.codeInputHash ?? ""); + upsertEnv(container.env, "HWLAB_IMAGE_DIGEST", bootMetadata.environmentDigest ?? "unknown"); + applyEnvReuseStartupProbe(container); +} + +function applyEnvReuseStartupProbe(container) { + const probe = container.readinessProbe ?? container.livenessProbe; + if (!probe?.httpGet && !probe?.tcpSocket && !probe?.exec) return; + container.startupProbe ??= { + ...cloneJson(probe), + periodSeconds: 10, + timeoutSeconds: Math.max(Number(probe.timeoutSeconds ?? 1), 2), + failureThreshold: 30, + successThreshold: 1 + }; +} + +function applyServiceHealthProbe(container, healthProbe) { + if (!healthProbe || typeof healthProbe !== "object" || Array.isArray(healthProbe)) return; + for (const [probeName, key] of [["readinessProbe", "readiness"], ["livenessProbe", "liveness"], ["startupProbe", "startup"]]) { + const probe = container?.[probeName]; + const timing = normalizeProbeTiming(healthProbe[key]); + if (!probe || Object.keys(timing).length === 0) continue; + Object.assign(probe, timing); + } +} + +function applyServiceShutdownDrainLifecycle(container, shutdownDrain) { + if (!container || !shutdownDrain || shutdownDrain.enabled !== true) return; + const pathValue = typeof shutdownDrain.path === "string" && /^\/[A-Za-z0-9/_:.-]+$/u.test(shutdownDrain.path) ? shutdownDrain.path : "/internal/workbench/drain"; + const sleepSeconds = boundedInteger(shutdownDrain.sleepSeconds, 3, 0, 30); + const timeoutMs = boundedInteger(shutdownDrain.timeoutMs, 2500, 100, 30000); + container.env ??= []; + upsertEnv(container.env, "HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS", String(timeoutMs)); + const command = [ + 'port="${PORT:-${HWLAB_PORT:-${HWLAB_CLOUD_API_PORT:-6667}}}"', + `url="http://127.0.0.1:\${port}${pathValue}"`, + '/usr/local/bin/bun -e \'const [url, hostname] = process.argv.slice(2); await fetch(url, { method: "POST", headers: { "x-hwlab-drain-hostname": hostname || "" } }).catch(() => {});\' "$url" "${HOSTNAME:-}" >/dev/null 2>&1 || true', + `sleep ${sleepSeconds}` + ].join("; "); + container.lifecycle ??= {}; + container.lifecycle.preStop = { exec: { command: ["sh", "-c", command] } }; +} + +function boundedInteger(value, fallback, minimum, maximum) { + const parsed = Number(value); + if (!Number.isInteger(parsed)) return fallback; + return Math.max(minimum, Math.min(maximum, parsed)); +} + +function normalizeProbeTiming(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result = {}; + for (const field of ["initialDelaySeconds", "periodSeconds", "timeoutSeconds", "failureThreshold", "successThreshold"]) { + const raw = value[field]; + const minimum = field === "initialDelaySeconds" ? 0 : 1; + if (Number.isInteger(raw) && raw >= minimum) result[field] = raw; + } + return result; +} + +function useLocalHealthProbe(container, pathValue = "/health") { + if (!container) return; + for (const probeName of ["readinessProbe", "livenessProbe"]) { + const probe = container[probeName]; + if (!probe?.httpGet) continue; + probe.httpGet.path = pathValue; + } +} + +function annotateEnvReusePodTemplate(podMetadata, bootMetadata) { + if (!podMetadata || !bootMetadata) return; + annotate(podMetadata, { + "hwlab.pikastech.local/runtime-mode": bootMetadata.runtimeMode, + "hwlab.pikastech.local/boot-repo": bootMetadata.bootRepo, + "hwlab.pikastech.local/boot-commit": bootMetadata.bootCommit, + "hwlab.pikastech.local/boot-sh": bootMetadata.bootSh, + "hwlab.pikastech.local/environment-digest": bootMetadata.environmentDigest ?? "not_published", + "hwlab.pikastech.local/environment-input-hash": bootMetadata.environmentInputHash ?? "unknown", + "hwlab.pikastech.local/code-input-hash": bootMetadata.codeInputHash ?? "unknown" + }); + label(podMetadata, { + "hwlab.pikastech.local/runtime-mode": bootMetadata.runtimeMode, + "hwlab.pikastech.local/boot-commit": bootMetadata.bootCommit + }); +} + +function runtimeArtifactForService({ catalog, deploy, serviceId, source, registryPrefix, useDeployImages = false, digestPin = false, envReuseServiceIds = null }) { + const catalogService = catalogServiceById(catalog, serviceId); + if (v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds)) { + const environmentImage = catalogService?.environmentImage ?? imageFor(registryPrefix, `${serviceId}-env`, `env-${String(catalogService?.environmentInputHash ?? source.full).slice(0, 12)}`); + const runtimeImage = digestPin && useDeployImages ? digestPinnedImage(environmentImage, catalogService?.environmentDigest ?? catalogService?.digest) : environmentImage; + return { + image: runtimeImage, + imageTag: imageTagFromReference(environmentImage) ?? source.imageTag ?? source.short, + commit: source.full, + runtimeMode: v02EnvReuseRuntimeMode, + environmentDigest: catalogService?.environmentDigest ?? catalogService?.digest ?? null + }; + } + const fallbackImageTag = source.imageTag ?? source.short; + const image = useDeployImages && catalogService?.image + ? catalogService.image + : imageFor(registryPrefix, serviceId, fallbackImageTag); + const runtimeImage = digestPin && useDeployImages ? digestPinnedImage(image, catalogService?.digest) : image; + const imageTag = useDeployImages && catalogService?.imageTag + ? catalogService.imageTag + : imageTagFromReference(image) ?? fallbackImageTag; + const commit = useDeployImages + ? catalogService?.sourceCommitId ?? catalogService?.commitId ?? imageTag ?? source.full + : source.full; + return { image: runtimeImage, imageTag, commit }; +} + +function runtimeImageForService(options) { + return runtimeArtifactForService(options).image; +} + +function runtimeCommitForService(options) { + return runtimeArtifactForService(options).commit; +} + +function runtimeImageTagForService(options) { + return runtimeArtifactForService(options).imageTag; +} + +function artifactEnvironment(args) { + return isRuntimeLane(args.lane) ? args.lane : "dev"; +} + +function artifactEndpoint(args) { + return isRuntimeLane(args.lane) ? args.runtimeEndpoint : defaultRuntimeEndpoint; +} + +function serviceIdsForLane(lane, deploy = null) { + return isRuntimeLane(lane) ? runtimeLaneServiceIdsFromDeploy(deploy, lane) : defaultServiceIds; +} + +function serviceIdsForProfile(profile, deploy = null) { + return isRuntimeLane(profile) ? runtimeLaneServiceIdsFromDeploy(deploy, profile) : defaultServiceIds; +} + +function runtimeLaneServiceIdsFromDeploy(deploy, lane) { + const laneConfig = runtimeLaneConfig(deploy, lane); + const serviceIds = uniquePreserveOrder((Array.isArray(laneConfig.envReuseServices) ? laneConfig.envReuseServices : []) + .map((item) => String(item ?? "").trim()) + .filter(Boolean)); + assert.ok(serviceIds.length > 0, `deploy.lanes.${lane}.envReuseServices must not be empty`); + return serviceIds; +} + +function v02ServiceIdsFromDeploy(deploy) { + return runtimeLaneServiceIdsFromDeploy(deploy, "v02"); +} + +function runtimeLaneObservableServicesForDeploy(deploy, lane) { + const laneConfig = runtimeLaneConfig(deploy, lane); + const declarations = laneConfig.serviceDeclarations ?? {}; + const serviceIds = new Set(runtimeLaneServiceIdsFromDeploy(deploy, lane)); + const declared = Object.entries(declarations) + .filter(([serviceId, declaration]) => serviceIds.has(serviceId) && declaration?.observable !== false && Number.isInteger(declaration?.healthPort)) + .map(([serviceId, declaration]) => ({ + serviceId, + port: declaration.healthPort, + healthPath: declaration.healthPath || "/health/live" + })); + return [...declared, ...v02ExtraObservableServices]; +} + +function v02ObservableServicesForDeploy(deploy) { + return runtimeLaneObservableServicesForDeploy(deploy, "v02"); +} + +function v02ObservableService(deploy, serviceId) { + return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null; +} + +function runtimeLaneObservableService(deploy, lane, serviceId) { + return runtimeLaneObservableServicesForDeploy(deploy, lane).find((item) => item.serviceId === serviceId) ?? null; +} + +function runtimeLaneObservableServiceIdsForDeploy(deploy, lane) { + return new Set(runtimeLaneObservableServicesForDeploy(deploy, lane).map((item) => item.serviceId)); +} + +function v02ObservableServiceIdsForDeploy(deploy) { + return runtimeLaneObservableServiceIdsForDeploy(deploy, "v02"); +} + +function servicesParamForLane(lane, deploy = null) { + return serviceIdsForLane(lane, deploy).join(","); +} + +function uniquePreserveOrder(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +function isV02RemovedServiceId(serviceId) { + return v02RemovedServiceIds.has(serviceId); +} + +function artifactCatalogSkeleton({ args, deploy, source }) { + const environment = artifactEnvironment(args); + const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane) : "hwlab-dev"; + const tag = imageTagForSource(args, source); + return { + catalogVersion: "v1", + kind: "hwlab-artifact-catalog", + environment, + profile: environment, + namespace, + endpoint: artifactEndpoint(args), + commitId: tag, + artifactState: "contract-skeleton", + publish: { + registryPrefix: args.registryPrefix, + sourceCommitId: source.full, + imageTag: tag, + publishedAt: null + }, + allowedProfiles: [environment], + forbiddenProfiles: isRuntimeLane(args.lane) ? ["dev", "prod"] : ["prod"], + services: serviceIdsForLane(args.lane, deploy).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag })) + }; +} + +function artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }) { + const base = { + serviceId, + profile: environment, + namespace, + commitId: tag, + sourceCommitId: source.full, + image: imageFor(args.registryPrefix, serviceId, tag), + imageTag: tag, + digest: null, + buildBackend: "contract-skeleton" + }; + const envReuseServiceIds = envReuseServiceIdsForLane(deploy, args.lane); + if (!envReuseServiceIds.has(serviceId)) return base; + const bootSh = bootShForService(deployServiceForBoot(deploy, serviceId, args.lane), serviceId); + const environmentImage = imageFor(args.registryPrefix, `${serviceId}-env`, `env-${String(source.full).slice(0, 12)}`); + return { + ...base, + image: environmentImage, + imageTag: imageTagFromReference(environmentImage), + runtimeMode: v02EnvReuseRuntimeMode, + envReuse: true, + environmentImage, + environmentDigest: null, + environmentInputHash: null, + bootRepo: args.sourceRepo, + bootCommit: source.full, + bootSh, + codeInputHash: null, + bootEnvEvidence: { + env: { + HWLAB_BOOT_REPO: args.sourceRepo, + HWLAB_BOOT_COMMIT: source.full, + HWLAB_BOOT_SH: bootSh + } + } + }; +} + +function namespaceNameForProfile(profile) { + if (isRuntimeLane(profile)) return `hwlab-${profile}`; + return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; +} + +function runtimePathForProfile(profile) { + if (isRuntimeLane(profile)) return `runtime-${profile}`; + return profile === "prod" ? "runtime-prod" : "runtime-dev"; +} + +function runtimeLabelForProfile(profile) { + if (isRuntimeLane(profile)) return profile; + return profile === "prod" ? "prod" : "dev"; +} + +function gitopsTargetForProfile(profile) { + return isRuntimeLane(profile) ? profile : "node"; +} + +function gitopsRootForArgs(args) { + return String(args.gitopsRoot || defaultOutDir).replace(/\/+$/u, ""); +} + +function gitopsPathForProfile(args, profile) { + return `${gitopsRootForArgs(args)}/${runtimePathForProfile(profile)}`; +} + +function gitopsPathFor(args, relativePath) { + return `${gitopsRootForArgs(args)}/${String(relativePath).replace(/^\/+/, "")}`; +} + +function profileEndpoint(args, profile) { + if (isRuntimeLane(profile)) return { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; + return profile === "prod" + ? { runtimeEndpoint: args.prodRuntimeEndpoint, webEndpoint: args.prodWebEndpoint } + : { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; +} + +function laneRuntimeProfiles(args) { + return isRuntimeLane(args.lane) ? [args.lane] : ["dev", "prod"]; +} + +function laneNames(args) { + if (isRuntimeLane(args.lane)) { + const namespace = namespaceNameForProfile(args.lane); + return { + gitopsTarget: args.lane, + pipeline: `${namespace}-ci-image-publish`, + poller: `${namespace}-branch-poller`, + reconciler: `${namespace}-control-plane-reconciler`, + serviceAccount: `${namespace}-tekton-runner`, + pipelineRunPrefix: `${namespace}-ci-poll-`, + runtimeNamespace: namespace, + runtimePath: gitopsPathForProfile(args, args.lane), + argoApplications: [`argocd/hwlab-node-${args.lane}`] + }; + } + return { + gitopsTarget: "node", + pipeline: "hwlab-node-ci-image-publish", + poller: "hwlab-node-branch-poller", + reconciler: "hwlab-node-control-plane-reconciler", + serviceAccount: "hwlab-tekton-runner", + pipelineRunPrefix: "hwlab-node-ci-poll-", + runtimeNamespace: "hwlab-dev", + runtimePath: gitopsPathForProfile(args, "dev"), + argoApplications: ["argocd/hwlab-node-dev", "argocd/hwlab-node-prod"] + }; +} + +function argoApplicationName(profile) { + return `hwlab-node-${profile}`; +} + +function imageTagForSource(args, source) { + return args.imageTagMode === "full" ? source.full : source.short; +} + +function deepSeekBaseUrl(namespace) { + return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`; +} + +function codeAgentNoProxy(namespace) { + return [ + `${namespace}.svc.cluster.local`, + ".svc", + ".cluster.local", + "hyueapi.com", + ".hyueapi.com", + "hyui.com", + ".hyui.com", + "127.0.0.1", + "localhost", + "::1", + "10.0.0.0/8", + "10.42.0.0/16", + "10.43.0.0/16", + "api.minimaxi.com", + ".minimaxi.com" + ].join(","); +} + +function namespaceScopedHost(value, namespace) { + if (typeof value !== "string") return value; + return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`); +} + +function secretNameForProfile(secretName, profile) { + if (!isRuntimeLane(profile)) return secretName; + if (secretName === "hwlab-cloud-api-dev-db") return `hwlab-cloud-api-${profile}-db`; + if (secretName === "hwlab-code-agent-provider") return `hwlab-${profile}-code-agent-provider`; + if (secretName === "hwlab-code-agent-codex-auth") return `hwlab-${profile}-code-agent-codex-auth`; + if (secretName === "hwlab-opencode-server-auth") return `hwlab-${profile}-opencode-server-auth`; + if (secretName === "hwlab-bootstrap-admin") return `hwlab-${profile}-bootstrap-admin`; + return secretName; +} + +function secretRefObjectForProfile(secretRef, profile) { + const ref = configString(secretRef); + const slashIndex = ref.indexOf("/"); + assert.ok(slashIndex > 0 && slashIndex < ref.length - 1, `secretRef ${JSON.stringify(secretRef)} must be name/key`); + return { name: secretNameForProfile(ref.slice(0, slashIndex), profile), key: ref.slice(slashIndex + 1), optional: true }; +} + +function rewritePodSecretRefs(podSpec, profile) { + if (!podSpec || !isRuntimeLane(profile)) return; + for (const volume of podSpec.volumes ?? []) { + if (volume?.secret?.secretName) volume.secret.secretName = secretNameForProfile(volume.secret.secretName, profile); + } +} + +function deployEnvEntry(name, value, namespace, profile = "dev") { + if (typeof value === "string" && value.startsWith("secretRef:")) { + return { name, valueFrom: { secretKeyRef: secretRefObjectForProfile(value.slice("secretRef:".length), profile) } }; + } + return { name, value: namespaceScopedHost(value, namespace) }; +} + +function applyDeployEnv(envList, deployService, namespace, profile = "dev") { + if (!deployService?.env || typeof deployService.env !== "object" || Array.isArray(deployService.env)) return; + for (const [name, value] of Object.entries(deployService.env)) { + upsertEnvEntry(envList, deployEnvEntry(name, value, namespace, profile)); + } +} + +function applyDeployConfigMapMounts(podSpec, container, deployService) { + const mounts = Array.isArray(deployService?.configMapMounts) ? deployService.configMapMounts : []; + if (mounts.length === 0 || !podSpec || !container) return; + podSpec.volumes ??= []; + container.volumeMounts ??= []; + for (const mount of mounts) { + const name = String(mount?.name ?? "").trim(); + const configMapName = String(mount?.configMapName ?? "").trim(); + const mountPath = String(mount?.mountPath ?? "").trim(); + if (!name || !configMapName || !mountPath) continue; + const items = Array.isArray(mount.items) + ? mount.items + .map((item) => ({ key: String(item?.key ?? "").trim(), path: String(item?.path ?? "").trim() })) + .filter((item) => item.key && item.path) + : []; + const volume = { name, configMap: { name: configMapName } }; + if (items.length > 0) volume.configMap.items = items; + const existingVolume = podSpec.volumes.find((entry) => entry?.name === name); + if (existingVolume) Object.assign(existingVolume, volume); + else podSpec.volumes.push(volume); + const volumeMount = { name, mountPath, readOnly: mount.readOnly !== false }; + const existingMount = container.volumeMounts.find((entry) => entry?.name === name); + if (existingMount) Object.assign(existingMount, volumeMount); + else container.volumeMounts.push(volumeMount); + } +} + +function removeV02LegacySimulatorEnv(envList) { + if (!Array.isArray(envList)) return envList; + return envList.filter((entry) => !v02RemovedServiceEnvNames.has(entry?.name)); +} + +function removeV02RepoOwnedCodeAgentPodConfig(deploymentSpec, podSpec) { + if (!podSpec) return; + podSpec.initContainers = (podSpec.initContainers ?? []).filter((entry) => entry?.name !== "hwlab-code-agent-workspace-init"); + const removedVolumeNames = new Set([ + "hwlab-code-agent-workspace", + "hwlab-code-agent-codex-home", + "hwlab-code-agent-codex-config", + "hwlab-code-agent-codex-auth" + ]); + podSpec.volumes = (podSpec.volumes ?? []).filter((volume) => !removedVolumeNames.has(volume?.name)); + for (const container of podSpec.containers ?? []) { + container.volumeMounts = (container.volumeMounts ?? []).filter((mount) => !removedVolumeNames.has(mount?.name)); + } + if (deploymentSpec?.strategy?.type === "Recreate") delete deploymentSpec.strategy; +} + +function applyDeployServiceReplicas(item, deployService) { + if (!(item?.kind === "Deployment" || item?.kind === "StatefulSet")) return; + const replicas = deployService?.replicas; + if (!Number.isInteger(replicas) || replicas < 0) return; + item.spec ??= {}; + item.spec.replicas = replicas; +} + +function deployServicesForProfile(deploy, profile) { + const allowed = new Set(serviceIdsForProfile(profile, deploy)); + const services = new Map((deploy.services ?? []) + .filter((service) => allowed.has(service.serviceId)) + .map((service) => { + const copy = cloneJson(service); + copy.env = mergeEnvMaps( + copy.env, + serviceDeclarationEnvForProfile(deploy, profile, service.serviceId), + accessControlEnvForProfile(deploy, profile, service.serviceId), + runtimeStoreEnvForProfile(deploy, profile, service.serviceId), + workbenchRuntimeClientEnvForProfile(deploy, profile, service.serviceId), + workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, service.serviceId), + workbenchRuntimeSessionsSummaryEnvForProfile(deploy, profile, service.serviceId), + workbenchRuntimePostgresEnvForProfile(deploy, profile, service.serviceId), + workbenchRuntimeRedisEnvForProfile(deploy, profile, service.serviceId) + ); + return [service.serviceId, copy]; + })); + const laneServices = isRuntimeLane(profile) && Array.isArray(deploy.lanes?.[profile]?.services) + ? deploy.lanes[profile].services + : []; + for (const override of laneServices) { + const existing = services.get(override.serviceId) ?? { + serviceId: override.serviceId, + env: mergeEnvMaps( + serviceDeclarationEnvForProfile(deploy, profile, override.serviceId), + accessControlEnvForProfile(deploy, profile, override.serviceId), + runtimeStoreEnvForProfile(deploy, profile, override.serviceId), + workbenchRuntimeClientEnvForProfile(deploy, profile, override.serviceId), + workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, override.serviceId), + workbenchRuntimeSessionsSummaryEnvForProfile(deploy, profile, override.serviceId), + workbenchRuntimePostgresEnvForProfile(deploy, profile, override.serviceId), + workbenchRuntimeRedisEnvForProfile(deploy, profile, override.serviceId) + ) + }; + services.set(override.serviceId, { + ...existing, + ...cloneJson(override), + env: mergeEnvMaps(existing.env, override.env) + }); + } + return services; +} + +function accessControlEnvForProfile(deploy, profile, serviceId) { + return deploy?.__accessControlEnvByProfile?.[profile]?.[serviceId] ?? {}; +} + +function serviceDeclarationEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile)) return {}; + const env = deploy?.lanes?.[profile]?.serviceDeclarations?.[serviceId]?.env; + if (!env || typeof env !== "object" || Array.isArray(env)) return {}; + return cloneJson(env); +} + +function runtimeWorkbenchTraceTimelinePolicy(deploy, profile) { + if (!isRuntimeLane(profile)) return null; + const traceTimeline = runtimeLaneConfig(deploy, profile)?.workbench?.traceTimeline; + if (!traceTimeline || typeof traceTimeline !== "object" || Array.isArray(traceTimeline)) return null; + const result = {}; + if (typeof traceTimeline.autoExpandRunning === "boolean") result.autoExpandRunning = traceTimeline.autoExpandRunning; + if (typeof traceTimeline.autoCollapseTerminal === "boolean") result.autoCollapseTerminal = traceTimeline.autoCollapseTerminal; + return Object.keys(result).length > 0 ? result : null; +} + +function runtimeTraceExplorerUrlTemplate(deploy, profile) { + if (!isRuntimeLane(profile)) return null; + const template = runtimeLaneConfig(deploy, profile)?.observability?.traceExplorerUrlTemplate; + return typeof template === "string" && template.trim().length > 0 ? template.trim() : null; +} + +function runtimePrometheusOperatorResourcesEnabled(deploy, profile) { + if (!isRuntimeLane(profile)) return true; + const enabled = runtimeLaneConfig(deploy, profile)?.observability?.prometheusOperatorResources; + return enabled !== false; +} + +function effectiveSecretPlaneNodeId(args = {}) { + return gitopsRootNodeId(args.gitopsRoot) ?? String(args.nodeId ?? "").trim().toUpperCase(); +} + +function gitopsRootNodeId(gitopsRoot) { + const normalized = String(gitopsRoot ?? "").replaceAll("\\", "/").replace(/\/+$/u, ""); + const match = normalized.match(/(?:^|\/)deploy\/gitops\/node\/([^/]+)$/u); + return match?.[1] ? match[1].trim().toUpperCase() : null; +} + +function runtimeFrpConfigForProfile(deploy, profile, args = {}) { + const fallbackWebRemotePort = isRuntimeLane(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; + const nodeId = effectiveSecretPlaneNodeId(args); + const nodeFrp = nodeId ? baseFrp?.nodes?.[nodeId] : null; + const frp = nodeFrp && typeof nodeFrp === "object" && !Array.isArray(nodeFrp) ? { ...baseFrp, ...nodeFrp } : baseFrp; + const server = frp && typeof frp === "object" && !Array.isArray(frp) ? frp.server : null; + const proxies = Array.isArray(frp?.proxies) ? frp.proxies : []; + const proxyByEndpoint = new Map(proxies + .filter((proxy) => proxy && typeof proxy === "object" && !Array.isArray(proxy)) + .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; + }; + const proxyPort = (endpointId, fallback) => { + const value = proxyByEndpoint.get(endpointId)?.remotePort; + return Number.isInteger(value) ? value : fallback; + }; + return { + 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), + 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, + authSecretKey: typeof frp?.auth?.secretKey === "string" && frp.auth.secretKey.trim().length > 0 ? frp.auth.secretKey.trim() : null + }; +} + +function runtimeStoreEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; + const postgres = runtimeLaneConfig(deploy, profile)?.runtimeStore?.postgres; + if (!postgres || typeof postgres !== "object" || Array.isArray(postgres)) return {}; + const env = {}; + if (postgres.sslMode === "disable" || postgres.sslMode === "require") env.HWLAB_CLOUD_DB_SSL_MODE = postgres.sslMode; + if (Number.isInteger(postgres.poolMax)) env.HWLAB_CLOUD_DB_POOL_MAX = String(postgres.poolMax); + if (Number.isInteger(postgres.connectionTimeoutMs)) { + env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS = String(postgres.connectionTimeoutMs); + } + if (Number.isInteger(postgres.queryTimeoutMs)) { + env.HWLAB_CLOUD_DB_QUERY_TIMEOUT_MS = String(postgres.queryTimeoutMs); + } + if (Number.isInteger(postgres.queryRetryMaxAttempts)) { + env.HWLAB_CLOUD_DB_QUERY_RETRY_MAX_ATTEMPTS = String(postgres.queryRetryMaxAttempts); + } + if (Number.isInteger(postgres.queryRetryInitialDelayMs)) { + env.HWLAB_CLOUD_DB_QUERY_RETRY_INITIAL_DELAY_MS = String(postgres.queryRetryInitialDelayMs); + } + if (Number.isInteger(postgres.queryRetryMaxDelayMs)) { + env.HWLAB_CLOUD_DB_QUERY_RETRY_MAX_DELAY_MS = String(postgres.queryRetryMaxDelayMs); + } + return env; +} + +function workbenchRuntimeRedisEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; + const redis = workbenchRuntimeRedisConfigForProfile(deploy, profile); + if (!redis || typeof redis !== "object" || Array.isArray(redis)) return {}; + const env = { + HWLAB_WORKBENCH_CACHE_REDIS_ENABLED: booleanEnv(Boolean(redis.enabled)) + }; + const namespace = typeof redis.namespace === "string" ? redis.namespace.trim() : ""; + const serviceName = typeof redis.serviceName === "string" ? redis.serviceName.trim() : ""; + const port = Number.isInteger(redis.port) ? redis.port : null; + if (namespace) env.HWLAB_WORKBENCH_CACHE_REDIS_NAMESPACE = namespace; + if (serviceName) env.HWLAB_WORKBENCH_CACHE_REDIS_SERVICE_NAME = serviceName; + if (port !== null) env.HWLAB_WORKBENCH_CACHE_REDIS_PORT = String(port); + if (namespace && serviceName && port !== null) { + env.HWLAB_WORKBENCH_CACHE_REDIS_URL = `redis://${serviceName}.${namespace}.svc.cluster.local:${port}/0`; + } + const ttl = redis.ttl ?? {}; + if (Number.isInteger(ttl.sessionsSummaryMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_SESSIONS_SUMMARY_MS = String(ttl.sessionsSummaryMs); + if (Number.isInteger(ttl.terminalTurnMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_TERMINAL_TURN_MS = String(ttl.terminalTurnMs); + if (Number.isInteger(ttl.terminalTracePageMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_TERMINAL_TRACE_PAGE_MS = String(ttl.terminalTracePageMs); + if (Number.isInteger(ttl.runningPageMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_TTL_RUNNING_PAGE_MS = String(ttl.runningPageMs); + const limits = redis.limits ?? {}; + if (Number.isInteger(limits.maxKeyBytes)) env.HWLAB_WORKBENCH_CACHE_REDIS_MAX_KEY_BYTES = String(limits.maxKeyBytes); + if (Number.isInteger(limits.maxPayloadBytes)) env.HWLAB_WORKBENCH_CACHE_REDIS_MAX_PAYLOAD_BYTES = String(limits.maxPayloadBytes); + const timeouts = redis.timeouts ?? {}; + if (Number.isInteger(timeouts.connectMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_CONNECT_TIMEOUT_MS = String(timeouts.connectMs); + if (Number.isInteger(timeouts.operationMs)) env.HWLAB_WORKBENCH_CACHE_REDIS_OPERATION_TIMEOUT_MS = String(timeouts.operationMs); + const labels = redis.metrics?.labels; + if (labels && typeof labels === "object" && !Array.isArray(labels)) { + env.HWLAB_WORKBENCH_CACHE_REDIS_METRICS_LABELS = JSON.stringify(labels); + } + const behavior = redis.unavailableBehavior ?? {}; + if (typeof behavior.livenessFailure === "boolean") env.HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_LIVENESS_FAILURE = booleanEnv(behavior.livenessFailure); + if (typeof behavior.readMode === "string") env.HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_READ_MODE = behavior.readMode; + if (typeof behavior.fallbackStateSource === "boolean") env.HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_FALLBACK_STATE_SOURCE = booleanEnv(behavior.fallbackStateSource); + if (typeof redis.disableSwitchEnv === "string" && redis.disableSwitchEnv.trim()) { + env[redis.disableSwitchEnv.trim()] = booleanEnv(Boolean(redis.enabled)); + } + return env; +} + +function workbenchRuntimeClientEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; + const client = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.client; + if (!client || typeof client !== "object" || Array.isArray(client)) return {}; + const env = {}; + if (Number.isInteger(client.timeoutMs)) env.HWLAB_WORKBENCH_RUNTIME_TIMEOUT_MS = String(client.timeoutMs); + return env; +} + +function workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; + const factsQuery = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.factsQuery; + if (!factsQuery || typeof factsQuery !== "object" || Array.isArray(factsQuery)) return {}; + const env = {}; + if (Number.isInteger(factsQuery.maxAttempts)) env.HWLAB_WORKBENCH_FACTS_QUERY_MAX_ATTEMPTS = String(factsQuery.maxAttempts); + if (Number.isInteger(factsQuery.backoffBaseMs)) env.HWLAB_WORKBENCH_FACTS_QUERY_BACKOFF_BASE_MS = String(factsQuery.backoffBaseMs); + if (Number.isInteger(factsQuery.backoffMaxMs)) env.HWLAB_WORKBENCH_FACTS_QUERY_BACKOFF_MAX_MS = String(factsQuery.backoffMaxMs); + return env; +} + +function workbenchRuntimeSessionsSummaryEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; + const sessionsSummary = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.sessionsSummary; + if (!sessionsSummary || typeof sessionsSummary !== "object" || Array.isArray(sessionsSummary)) return {}; + const env = {}; + if (Number.isInteger(sessionsSummary.maxAttempts)) env.HWLAB_WORKBENCH_SESSIONS_SUMMARY_QUERY_MAX_ATTEMPTS = String(sessionsSummary.maxAttempts); + if (Number.isInteger(sessionsSummary.attemptTimeoutMs)) env.HWLAB_WORKBENCH_SESSIONS_SUMMARY_QUERY_ATTEMPT_TIMEOUT_MS = String(sessionsSummary.attemptTimeoutMs); + if (Number.isInteger(sessionsSummary.backoffMs)) env.HWLAB_WORKBENCH_SESSIONS_SUMMARY_QUERY_BACKOFF_MS = String(sessionsSummary.backoffMs); + return env; +} + +function workbenchRuntimePostgresEnvForProfile(deploy, profile, serviceId) { + if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; + const postgres = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.postgres; + if (!postgres || typeof postgres !== "object" || Array.isArray(postgres)) return {}; + const env = {}; + if (Number.isInteger(postgres.poolMax)) env.HWLAB_WORKBENCH_RUNTIME_DB_POOL_MAX = String(postgres.poolMax); + if (Number.isInteger(postgres.queryTimeoutMs)) env.HWLAB_WORKBENCH_RUNTIME_QUERY_TIMEOUT_MS = String(postgres.queryTimeoutMs); + if (Number.isInteger(postgres.readyTimeoutMs)) env.HWLAB_WORKBENCH_RUNTIME_READY_TIMEOUT_MS = String(postgres.readyTimeoutMs); + return env; +} + +function configObject(value, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); + return value; +} + +function requiredConfigString(record, key, label) { + const value = record[key]; + assert.ok(typeof value === "string" && value.length > 0, `${label}.${key} must be a non-empty string`); + return value; +} + +function optionalConfigString(record, key, label) { + const value = record[key]; + if (value === undefined || value === null || value === "") return null; + assert.ok(typeof value === "string", `${label}.${key} must be a string when set`); + return value; +} + +function requiredConfigPositiveInteger(record, key, label) { + const value = record[key]; + assert.ok(Number.isInteger(value) && value > 0, `${label}.${key} must be a positive integer`); + return value; +} + +function workbenchRuntimeRedisConfigForProfile(deploy, profile) { + if (!isRuntimeLane(profile)) return null; + const label = `deploy.lanes.${profile}.workbenchRuntime.cache.redis`; + const redis = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.cache?.redis; + 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)}`); + const serviceName = requiredConfigString(redis, "serviceName", label); + const image = requiredConfigString(redis, "image", label); + const port = requiredConfigPositiveInteger(redis, "port", label); + assert.ok(port <= 65535, `${label}.port must be a valid TCP port`); + const resources = configObject(redis.resources, `${label}.resources`); + configObject(resources.requests, `${label}.resources.requests`); + configObject(resources.limits, `${label}.resources.limits`); + const memoryPolicy = configObject(redis.memoryPolicy, `${label}.memoryPolicy`); + assert.equal(memoryPolicy.persistence, "disabled", `${label}.memoryPolicy.persistence must be disabled`); + requiredConfigString(memoryPolicy, "maxMemory", `${label}.memoryPolicy`); + requiredConfigString(memoryPolicy, "eviction", `${label}.memoryPolicy`); + return redis; +} + +function booleanEnv(value) { + return value ? "1" : "0"; +} + +function mergeEnvMaps(...values) { + const result = {}; + for (const value of values) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + Object.assign(result, value); + } + return result; +} + +function runtimeNodeIdForProfile(deploy, profile, fallback = "node") { + if (!isRuntimeLane(profile)) return fallback; + if (typeof fallback === "string" && fallback.trim() && fallback !== "node") return fallback; + const nodeId = deploy?.lanes?.[profile]?.node; + return typeof nodeId === "string" && nodeId.length > 0 ? nodeId : fallback; +} + +function v02MetricsLabels(serviceId) { + return { + "hwlab.pikastech.local/monitoring": "enabled", + "hwlab.pikastech.local/service-id": serviceId + }; +} + +function v02MetricsSidecarVolume(profile = "v02") { + return { name: "hwlab-metrics-sidecar", configMap: { name: `hwlab-${profile}-metrics-sidecar` } }; +} + +function v02MetricsSidecarAnnotations(metricsSidecarSha256) { + return metricsSidecarSha256 ? { "hwlab.pikastech.local/metrics-sidecar-sha256": metricsSidecarSha256 } : {}; +} + +function v02MetricsSidecarContainer({ deploy, serviceId, namespace, gitopsTarget, profile = "v02" }) { + const service = runtimeLaneObservableService(deploy, profile, serviceId); + assert.ok(service, `unknown ${profile} observable service ${serviceId}`); + const extraMetricsEnv = serviceId === "hwlab-cloud-api" + ? [ + { name: "HWLAB_METRICS_EXTRA_URL", value: `http://127.0.0.1:${service.port}/v1/web-performance/metrics` }, + { name: "HWLAB_METRICS_EXTRA_TIMEOUT_MS", value: "2000" } + ] + : []; + return { + name: "hwlab-metrics", + image: ciToolsRunnerImage, + imagePullPolicy: "IfNotPresent", + command: ["node", "/metrics/metrics-sidecar.mjs"], + env: [ + { name: "HWLAB_METRICS_SERVICE_ID", value: serviceId }, + { name: "HWLAB_METRICS_NAMESPACE", value: namespace }, + { name: "HWLAB_METRICS_GITOPS_TARGET", value: gitopsTarget }, + { name: "HWLAB_METRICS_PORT", value: "9100" }, + { name: "HWLAB_METRICS_TARGET_URL", value: `http://127.0.0.1:${service.port}${service.healthPath}` }, + { name: "HWLAB_METRICS_TARGET_TIMEOUT_MS", value: "2000" }, + ...extraMetricsEnv + ], + ports: [{ name: "metrics", containerPort: 9100 }], + readinessProbe: { httpGet: { path: "/health/live", port: "metrics" }, initialDelaySeconds: 3, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/health/live", port: "metrics" }, initialDelaySeconds: 10, periodSeconds: 20 }, + volumeMounts: [{ name: "hwlab-metrics-sidecar", mountPath: "/metrics", readOnly: true }] + }; +} + +function upsertV02MetricsSidecar(podSpec, options) { + if (!podSpec || !Array.isArray(podSpec.containers)) return; + podSpec.containers = podSpec.containers.filter((container) => container?.name !== "hwlab-metrics"); + podSpec.containers.push(v02MetricsSidecarContainer(options)); + podSpec.volumes ??= []; + podSpec.volumes = podSpec.volumes.filter((volume) => volume?.name !== "hwlab-metrics-sidecar"); + podSpec.volumes.push(v02MetricsSidecarVolume(options.profile)); +} + +function upsertV02MetricsPort(service) { + service.spec ??= {}; + service.spec.ports ??= []; + service.spec.ports = service.spec.ports.filter((port) => port?.name !== "metrics"); + service.spec.ports.push({ name: "metrics", port: 9100, targetPort: "metrics" }); +} + +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 profileLabel = runtimeLabelForProfile(profile); + const gitopsTarget = gitopsTargetForProfile(profile); + const runtimeLane = isRuntimeLane(profile); + const digestPin = runtimeLane; + const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile); + const runtimeObservableServiceIds = runtimeLane ? runtimeLaneObservableServiceIdsForDeploy(deploy, profile) : new Set(); + const stableRuntimeLabels = { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": profileLabel, + "hwlab.pikastech.local/profile": profileLabel + }; + result.items = asItems(result, "workloads").filter((item) => !(runtimeLane && isV02RemovedServiceId(serviceIdForWorkload(item, null)))); + for (const item of asItems(result, "workloads")) { + item.metadata.namespace = namespace; + label(item.metadata, { + ...stableRuntimeLabels, + "hwlab.pikastech.local/gitops-target": gitopsTarget, + "hwlab.pikastech.local/gitops-render-source-commit": source.full, + "hwlab.pikastech.local/source-commit": source.full, + "unidesk.ai/node-staging": profile === "dev" ? "true" : "false" + }); + annotate(item.metadata, { + "hwlab.pikastech.local/gitops-note": profile === "v02" ? "HWLAB v0.2 GitOps target." : profile === "prod" ? "node PROD GitOps target." : "node DEV GitOps target.", + "hwlab.pikastech.local/gitops-render-source-commit": source.full, + "hwlab.pikastech.local/source-commit": source.full + }); + if (item.kind === "Job") { + annotate(item.metadata, { + "argocd.argoproj.io/sync-options": "Force=true,Replace=true" + }); + if (item.spec?.suspend === true) { + annotate(item.metadata, { + "argocd.argoproj.io/ignore-healthcheck": "true" + }); + } + } + const podTemplate = item?.spec?.template; + if (!podTemplate?.spec?.containers) continue; + const templateServiceId = serviceIdForWorkload(item, podTemplate.spec.containers[0]); + const templateArtifact = runtimeArtifactForService({ catalog, deploy, serviceId: templateServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); + const templateSourceCommit = runtimeLane ? (templateArtifact.commit ?? source.full) : source.full; + const templateArtifactCommit = templateArtifact.commit; + label(podTemplate.metadata ??= {}, { + ...stableRuntimeLabels, + "hwlab.pikastech.local/gitops-target": gitopsTarget, + "hwlab.pikastech.local/gitops-render-source-commit": source.full, + "hwlab.pikastech.local/source-commit": templateSourceCommit, + "hwlab.pikastech.local/artifact-source-commit": templateArtifactCommit + }); + if (runtimeLane && runtimeObservableServiceIds.has(templateServiceId)) { + label(item.metadata, v02MetricsLabels(templateServiceId)); + label(podTemplate.metadata, v02MetricsLabels(templateServiceId)); + annotate(podTemplate.metadata, v02MetricsSidecarAnnotations(metricsSidecarSha256)); + upsertV02MetricsSidecar(podTemplate.spec, { deploy, serviceId: templateServiceId, namespace, gitopsTarget, profile }); + } + 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/gitops-render-source-commit": source.full, + "hwlab.pikastech.local/source-commit": templateSourceCommit, + "hwlab.pikastech.local/artifact-source-commit": templateArtifactCommit, + "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" + }); + for (const container of podTemplate.spec.containers) { + if (container.name === "hwlab-metrics") continue; + const serviceId = serviceIdForWorkload(item, container); + const deployService = deployServices.get(serviceId); + if (!deployService) continue; + applyDeployServiceReplicas(item, deployService); + const artifact = runtimeArtifactForService({ catalog, deploy, serviceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); + const envReuse = runtimeLane && v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds); + const bootDeployService = envReuse ? deployServiceForBoot(deploy, serviceId, profile) : null; + const bootMetadata = envReuse ? bootMetadataForService({ args: { sourceRepo: deploy?.lanes?.[profile]?.sourceRepo || defaultSourceRepo, registryPrefix }, catalog, deployService: bootDeployService, serviceId, source }) : null; + const image = artifact.image; + const runtimeCommit = artifact.commit; + const runtimeImageTag = artifact.imageTag; + container.image = image; + container.imagePullPolicy = "IfNotPresent"; + container.env ??= []; + for (const entry of container.env) { + if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace); + } + applyDeployEnv(container.env, deployService, namespace, profile); + applyDeployConfigMapMounts(podTemplate.spec, container, deployService); + if (runtimeLane) container.env = removeV02LegacySimulatorEnv(container.env); + upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel); + upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit); + upsertEnv(container.env, "HWLAB_IMAGE", image); + upsertEnv(container.env, "HWLAB_IMAGE_TAG", runtimeImageTag); + if (serviceId === "hwlab-agent-skills") { + upsertEnv(container.env, "HWLAB_SKILLS_COMMIT_ID", runtimeCommit); + } + upsertEnv(container.env, "HWLAB_GITOPS_TARGET", gitopsTarget); + upsertEnv(container.env, "HWLAB_GITOPS_PROFILE", profileLabel); + upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", runtimeLane ? runtimeCommit : source.full); + if (runtimeLane && serviceId === "hwlab-cloud-api") { + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT", runtimeCommit); + } + if (bootMetadata) { + applyEnvReuseBootEnv(container, bootMetadata, { + sourceBranch, + gitReadUrl, + bootSh: envReuseBootShForContainer({ serviceId, container, defaultBootSh: bootMetadata.bootSh }) + }); + if (container.name === serviceId) annotateEnvReusePodTemplate(podTemplate.metadata, bootMetadata); + } + if (runtimeLane && container.name === serviceId) { + applyServiceHealthProbe(container, deploy?.lanes?.[profile]?.serviceDeclarations?.[serviceId]?.healthProbe); + } + if (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-edge-proxy") { + upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint); + } + if (runtimeLane && serviceId === "hwlab-edge-proxy") { + useLocalHealthProbe(container, "/health"); + } + if (serviceId === "hwlab-cloud-api") { + if (runtimeLane) { + removeV02RepoOwnedCodeAgentPodConfig(item.spec, podTemplate.spec); + applyServiceShutdownDrainLifecycle(container, deployService.shutdownDrain); + upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", gitReadUrl ?? defaultRuntimeLaneGitReadUrl); + upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID", runtimeNodeIdForProfile(deploy, profile, nodeId)); + upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "enforce"); + upsertEnv(container.env, "HWLAB_OPENFGA_API_URL", `http://hwlab-openfga.${namespace}.svc.cluster.local:8080`); + upsertEnvEntry(container.env, deployEnvEntry("HWLAB_OPENFGA_AUTHN_TOKEN", `secretRef:hwlab-${profile}-openfga/authn-preshared-key`, namespace, profile)); + upsertEnv(container.env, "UNIDESK_MAIN_SERVER_IP", "http://74.48.78.17:18081"); + } else { + syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag }); + } + upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", runtimeLane ? (configString(runtimeLaneConfig(deploy, profile)?.opencode?.providerProfile) || "dsflash-go") : "deepseek"); + if (runtimeLane) upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "repo-owned"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel); + upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace)); + upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_MODEL", codexApiProfileModel); + upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", codexApiProfileBaseUrl); + upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", codexApiForwarderUpstreamBaseUrl); + upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", codexApiForwarderPort); + upsertEnv(container.env, "HWLAB_PREINSTALLED_SKILLS_DIR", "/app/skills"); + upsertEnv(container.env, "HWLAB_USER_SKILLS_DIR", "/data/user-skills"); + upsertEnv(container.env, "HWLAB_CODE_AGENT_SKILLS_DIRS", "/app/skills:/data/user-skills"); + upsertEnv(container.env, "NO_PROXY", codeAgentNoProxy(namespace)); + upsertEnv(container.env, "no_proxy", codeAgentNoProxy(namespace)); + } + if (serviceId === "hwlab-cloud-web") { + upsertEnv(container.env, "HWLAB_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`); + upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); + const traceTimelinePolicy = runtimeWorkbenchTraceTimelinePolicy(deploy, profile); + if (typeof traceTimelinePolicy?.autoExpandRunning === "boolean") { + upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING", booleanEnv(traceTimelinePolicy.autoExpandRunning)); + } + if (typeof traceTimelinePolicy?.autoCollapseTerminal === "boolean") { + upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL", booleanEnv(traceTimelinePolicy.autoCollapseTerminal)); + } + const traceExplorerUrlTemplate = runtimeTraceExplorerUrlTemplate(deploy, profile); + if (traceExplorerUrlTemplate) { + upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE", traceExplorerUrlTemplate); + } + } + } + rewritePodSecretRefs(podTemplate.spec, profile); + } + return result; +} + +function transformHealthContract(value, namespace, labels, annotations, runtimeEndpoint, webEndpoint, profile = "dev") { + const result = transformListNamespace(value, namespace, labels, annotations); + if (result.metadata?.name === "hwlab-dev-health-contract") { + result.metadata.name = `hwlab-${profile}-health-contract`; + } + if (result.metadata?.labels && Object.hasOwn(result.metadata.labels, "app.kubernetes.io/name")) { + result.metadata.labels["app.kubernetes.io/name"] = `hwlab-${profile}-health-contract`; + } + 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 node 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 transformServices({ services, namespace, labels, annotations, profile = "dev", deploy = null }) { + const observableServiceIds = isRuntimeLane(profile) ? runtimeLaneObservableServiceIdsForDeploy(deploy, profile) : new Set(); + const result = transformListNamespace(services, namespace, labels, annotations); + if (result.kind === "List") { + result.items = (result.items ?? []).filter((item) => !(isRuntimeLane(profile) && isV02RemovedServiceId(serviceIdForWorkload(item, null)))); + for (const item of result.items) { + const serviceId = serviceIdForWorkload(item, null); + if (!isRuntimeLane(profile) || !observableServiceIds.has(serviceId)) continue; + label(item.metadata ??= {}, v02MetricsLabels(serviceId)); + upsertV02MetricsPort(item); + } + } + return result; +} + +function observabilityManifest({ deploy, profile, namespace, labels, annotations, metricsSidecarScript }) { + assert.ok(isRuntimeLane(profile), `observability profile must be a runtime lane, got ${profile}`); + const includePrometheusOperatorResources = runtimePrometheusOperatorResourcesEnabled(deploy, profile); + const baseLabels = { + ...labels, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/monitoring": "enabled" + }; + const serviceMonitors = runtimeLaneObservableServicesForDeploy(deploy, profile).map((service) => ({ + apiVersion: "monitoring.coreos.com/v1", + kind: "ServiceMonitor", + metadata: { + name: `hwlab-${profile}-${service.serviceId}`, + namespace, + labels: { ...baseLabels, "hwlab.pikastech.local/service-id": service.serviceId }, + annotations + }, + spec: { + namespaceSelector: { matchNames: [namespace] }, + selector: { + matchLabels: { + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/monitoring": "enabled", + "hwlab.pikastech.local/service-id": service.serviceId + } + }, + targetLabels: [ + "hwlab.pikastech.local/gitops-target", + "hwlab.pikastech.local/service-id" + ], + endpoints: [{ port: "metrics", path: "/metrics", interval: "30s", scrapeTimeout: "10s" }] + } + })); + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `hwlab-${profile}-metrics-sidecar`, namespace, labels: baseLabels, annotations }, + data: { "metrics-sidecar.mjs": metricsSidecarScript } + }, + ...(includePrometheusOperatorResources ? serviceMonitors : []), + ...(includePrometheusOperatorResources ? [{ + apiVersion: "monitoring.coreos.com/v1", + kind: "PrometheusRule", + metadata: { name: `hwlab-${profile}-observability`, namespace, labels: baseLabels, annotations }, + spec: { + groups: [{ + name: `hwlab-${profile}-observability`, + rules: [ + { + alert: `HWLAB${profile.toUpperCase()}MetricsTargetDown`, + 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.` } + }, + { + alert: `HWLAB${profile.toUpperCase()}ServiceHealthProbeFailed`, + expr: `hwlab_service_health_probe_success{namespace="${namespace}"} == 0`, + for: "5m", + labels: { severity: "warning", lane: profile }, + annotations: { summary: "HWLAB v0.2 service health probe failed", description: "The metrics sidecar cannot reach the service health endpoint inside the pod network." } + } + ] + }] + } + }] : []) + ] + }; +} + +export { + applyDeployConfigMapMounts, applyDeployEnv, applyDeployServiceReplicas, + applyEnvReuseBootEnv, applyEnvReuseStartupProbe, applyRuntimeLaneDeployConfig, + argoApplicationName, artifactCatalogSkeleton, artifactEndpoint, artifactEnvironment, + asItems, attachAccessControlEnv, annotate, booleanEnv, bootMetadataForService, + bootShForService, boundedInteger, buildkitRunnerImage, catalogServiceById, + ciToolsRunnerImage, cloneJson, codeAgentNoProxy, codexApiForwarderPort, + codexApiForwarderUpstreamBaseUrl, codexApiProfileBaseUrl, codexApiProfileModel, + configObject, configString, deepSeekBaseUrl, deepSeekProfileModel, defaultAllProxyUrl, + defaultBranch, defaultCatalogPath, defaultDevBaseImage, defaultEndpointForRuntimeLane, + defaultGitopsBranch, defaultGitReadUrl, defaultNoProxy, defaultOutDir, + defaultPortForRuntimeLane, defaultProdRuntimeEndpoint, defaultProdWebEndpoint, + defaultProxyUrl, defaultRegistryPrefix, defaultRuntimeEndpoint, + defaultRuntimeLaneGitReadUrl, defaultRuntimeLaneGitWriteUrl, defaultServiceIds, + defaultSourceRepo, defaultV02GitReadUrl, defaultV02GitWriteUrl, + defaultV02RuntimeEndpoint, defaultV02WebEndpoint, defaultWebEndpoint, + deployEnvEntry, deployServiceForBoot, deployServicesForProfile, digestPinnedImage, + effectiveSecretPlaneNodeId, ensureObject, envReuseBootShForContainer, envReuseServiceIdsForLane, + generatedPath, gitopsPathFor, gitopsPathForProfile, gitopsRootForArgs, gitopsRootNodeId, + gitopsTargetForProfile, gitValue, imageFor, imageTagForSource, + imageTagFromReference, isRuntimeLane, isV02RemovedServiceId, jsonManifest, + k8sLabelHash, label, laneNames, laneRuntimeProfiles, mergeEnvMaps, + moonBridgeImage, moonBridgeSourceRef, namespaceNameForProfile, namespaceScopedHost, + normalizeAccessControlUser, normalizeNavProfile, normalizeProbeTiming, + observabilityManifest, optionalConfigString, optionalObject, parseArgs, primitiveValidationTasks, + profileEndpoint, proxyEnv, readConfigRef, readJson, readJsonIfPresent, + readOption, readStructuredFile, removeV02LegacySimulatorEnv, repoRoot, + removeV02RepoOwnedCodeAgentPodConfig, repositoryFromImage, + requiredAccessConfigString, requiredConfigPositiveInteger, requiredConfigString, + resolveSourceRevision, rewritePodSecretRefs, + runtimeArtifactForService, runtimeCommitForService, runtimeFrpConfigForProfile, + runtimeImageForService, runtimeImageTagForService, runtimeLabelForProfile, + runtimeLaneConfig, runtimeLaneMirrorSpecs, runtimeLaneObservableService, + runtimeLaneObservableServiceIdsForDeploy, runtimeLaneObservableServicesForDeploy, + runtimeLaneServiceIdsFromDeploy, runtimeNodeIdForProfile, + runtimePathForProfile, runtimePrometheusOperatorResourcesEnabled, + runtimeStoreEnvForProfile, runtimeTraceExplorerUrlTemplate, + runtimeWorkbenchTraceTimelinePolicy, secretNameForProfile, + secretRefFromParts, secretRefObjectForProfile, serviceDeclarationEnvForProfile, + serviceIdForWorkload, serviceIdsForLane, serviceIdsForProfile, + servicesParamForLane, sourceCommitPattern, syncCodeAgentWorkspaceInitContainer, + textFile, transformHealthContract, transformListNamespace, transformServices, + transformWorkloads, uniquePreserveOrder, upsertEnv, upsertEnvEntry, + upsertV02MetricsPort, upsertV02MetricsSidecar, usage, useLocalHealthProbe, + v02EnvReuseEnabled, v02EnvReuseRuntimeMode, v02ExtraObservableServices, + v02MetricsLabels, v02MetricsSidecarAnnotations, v02MetricsSidecarContainer, + v02MetricsSidecarVolume, v02ObservableService, v02ObservableServiceIdsForDeploy, + v02ObservableServicesForDeploy, v02RemovedServiceEnvNames, v02RemovedServiceIds, + v02ServiceIdsFromDeploy, versionNameForRuntimeLane, workbenchRuntimeClientEnvForProfile, + workbenchRuntimeFactsQueryEnvForProfile, workbenchRuntimePostgresEnvForProfile, + workbenchRuntimeRedisConfigForProfile, workbenchRuntimeRedisEnvForProfile, + workbenchRuntimeSessionsSummaryEnvForProfile +}; diff --git a/scripts/src/gitops-render/infra-manifests.mjs b/scripts/src/gitops-render/infra-manifests.mjs new file mode 100644 index 00000000..ab9fd877 --- /dev/null +++ b/scripts/src/gitops-render/infra-manifests.mjs @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; + +import { + ciToolsRunnerImage, + configObject, + defaultOutDir, + label, + optionalConfigString, + requiredConfigPositiveInteger, + requiredConfigString, + runtimeLaneMirrorSpecs +} from "./core.mjs"; +import { renderTemplate, shellSingleQuote } from "./tekton-scripts.mjs"; + +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 uniqueStrings(values) { + return Array.from(new Set(values.filter((value) => typeof value === "string" && value.length > 0))); +} + +function renderGitMirrorSshPrelude({ direct, proxySummary, proxyCommand, proxyUrl, proxyNoProxy }) { + if (direct) { + return renderTemplate("git-mirror-ssh-direct.sh", { + "__HWLAB_PROXY_SUMMARY_SHELL__": shellSingleQuote(proxySummary) + }); + } + return renderTemplate("git-mirror-ssh-proxied.sh", { + "// __HWLAB_GIT_MIRROR_PROXY_CONNECT_MJS__": renderTemplate("git-mirror-proxy-connect.mjs"), + "__HWLAB_PROXY_SUMMARY_SHELL__": shellSingleQuote(proxySummary), + "__HWLAB_PROXY_COMMAND_SHELL__": shellSingleQuote(proxyCommand), + "__HWLAB_PROXY_URL_SHELL__": shellSingleQuote(proxyUrl), + "__HWLAB_PROXY_NO_PROXY_SHELL__": shellSingleQuote(proxyNoProxy) + }); +} + +function gitMirrorConfigScripts({ + fetchConfigCommands, + gitMirrorSshPrelude, + gitopsBranchArgs, + gitopsBranchesJson, + mirrorBranchesJson, + requiredRefArgs, + sourceBranchArgs, + sourceBranchesJson, + sourceSnapshotRefPrefix +}) { + const gitopsBranchesJsonShell = shellSingleQuote(gitopsBranchesJson); + return { + "sync.sh": renderTemplate("git-mirror-sync.sh", { + "# __HWLAB_GIT_MIRROR_SSH_PRELUDE__": gitMirrorSshPrelude, + "__HWLAB_SOURCE_SNAPSHOT_REF_PREFIX_SHELL__": shellSingleQuote(sourceSnapshotRefPrefix), + "# __HWLAB_FETCH_CONFIG_COMMANDS__": fetchConfigCommands, + "__HWLAB_REQUIRED_REF_ARGS__": requiredRefArgs, + "__HWLAB_SOURCE_BRANCH_ARGS__": sourceBranchArgs, + "__HWLAB_GITOPS_BRANCH_ARGS__": gitopsBranchArgs, + "__HWLAB_MIRROR_BRANCHES_JSON_SHELL__": shellSingleQuote(mirrorBranchesJson), + "__HWLAB_SOURCE_BRANCHES_JSON_SHELL__": shellSingleQuote(sourceBranchesJson), + "__HWLAB_GITOPS_BRANCHES_JSON_SHELL__": gitopsBranchesJsonShell + }), + "install-hooks.sh": renderTemplate("git-mirror-install-hooks.sh", { + "__HWLAB_GITOPS_BRANCHES_JSON_SHELL__": gitopsBranchesJsonShell + }), + "flush.sh": renderTemplate("git-mirror-flush.sh", { + "# __HWLAB_GIT_MIRROR_SSH_PRELUDE__": gitMirrorSshPrelude, + "__HWLAB_GITOPS_BRANCH_ARGS__": gitopsBranchArgs, + "__HWLAB_GITOPS_BRANCHES_JSON_SHELL__": gitopsBranchesJsonShell + }), + "http.mjs": renderTemplate("git-mirror-http.mjs") + }; +} + +function devopsInfraGitMirrorManifest(args, deploy) { + const mirrorSpecs = runtimeLaneMirrorSpecs(deploy, { defaultNodeId: args.nodeId, defaultGitopsRoot: defaultOutDir }); + assert.ok(mirrorSpecs.length > 0, "runtime lane git mirror requires at least one deploy.lanes entry"); + const sourceBranches = uniqueStrings(mirrorSpecs.map((spec) => spec.sourceBranch)); + const gitopsBranches = uniqueStrings(mirrorSpecs.map((spec) => spec.gitopsBranch)); + const mirrorBranches = uniqueStrings([...sourceBranches, ...gitopsBranches]); + const fetchConfigCommands = mirrorBranches + .map((branch) => `git -C "$repo_path" config --add remote.origin.fetch '+refs/heads/${branch}:refs/mirror-stage/heads/${branch}'`) + .join("\n"); + const requiredRefArgs = mirrorBranches.map((branch) => shellSingleQuote(`refs/mirror-stage/heads/${branch}`)).join(" "); + const sourceBranchArgs = sourceBranches.map((branch) => shellSingleQuote(branch)).join(" "); + const gitopsBranchArgs = gitopsBranches.map((branch) => shellSingleQuote(branch)).join(" "); + const mirrorBranchesJson = JSON.stringify(mirrorBranches); + const sourceBranchesJson = JSON.stringify(sourceBranches); + const gitopsBranchesJson = JSON.stringify(gitopsBranches); + const sourceSnapshotRefPrefix = "refs/unidesk/snapshots/hwlab-node-runtime"; + const laneConfig = configObject(configObject(deploy.lanes, "deploy.lanes")[args.lane], `deploy.lanes.${args.lane}`); + const gitMirrorConfig = configObject(laneConfig.gitMirror, `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorProxy = configObject(gitMirrorConfig.egressProxy, `deploy.lanes.${args.lane}.gitMirror.egressProxy`); + const gitMirrorProxyMode = requiredConfigString(gitMirrorProxy, "mode", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); + assert.ok(gitMirrorProxyMode === "node-global" || gitMirrorProxyMode === "host-proxy-client" || gitMirrorProxyMode === "direct", `deploy.lanes.${args.lane}.gitMirror.egressProxy.mode must be node-global, host-proxy-client, or direct`); + const useDirectGitMirrorProxy = gitMirrorProxyMode === "direct"; + const useHostGitMirrorProxy = gitMirrorProxyMode === "host-proxy-client"; + const gitMirrorNamespace = requiredConfigString(gitMirrorConfig, "namespace", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorReadName = requiredConfigString(gitMirrorConfig, "serviceReadName", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorWriteName = requiredConfigString(gitMirrorConfig, "serviceWriteName", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorCachePvcName = requiredConfigString(gitMirrorConfig, "cachePvcName", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorCachePvcStorage = requiredConfigString(gitMirrorConfig, "cachePvcStorage", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorCacheHostPath = optionalConfigString(gitMirrorConfig, "cacheHostPath", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorServicePort = requiredConfigPositiveInteger(gitMirrorConfig, "servicePort", `deploy.lanes.${args.lane}.gitMirror`); + const gitMirrorConfigMapName = requiredConfigString(gitMirrorConfig, "syncConfigMapName", `deploy.lanes.${args.lane}.gitMirror`); + const proxyNamespace = useDirectGitMirrorProxy || useHostGitMirrorProxy ? "" : requiredConfigString(gitMirrorProxy, "namespace", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); + const proxyServiceName = useDirectGitMirrorProxy || useHostGitMirrorProxy ? "" : requiredConfigString(gitMirrorProxy, "serviceName", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); + const proxyPort = useDirectGitMirrorProxy ? 0 : requiredConfigPositiveInteger(gitMirrorProxy, "port", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); + const proxyClientName = useDirectGitMirrorProxy ? "" : requiredConfigString(gitMirrorProxy, "clientName", `deploy.lanes.${args.lane}.gitMirror.egressProxy`); + const proxyNoProxy = !useDirectGitMirrorProxy && Array.isArray(gitMirrorProxy.noProxy) ? gitMirrorProxy.noProxy.filter((value) => typeof value === "string" && value.length > 0).join(",") : ""; + const proxyHost = useHostGitMirrorProxy + ? requiredConfigString(gitMirrorProxy, "host", `deploy.lanes.${args.lane}.gitMirror.egressProxy`) + : `${proxyServiceName}.${proxyNamespace}.svc.cluster.local`; + const proxyUrl = `http://${proxyHost}:${proxyPort}`; + const proxySummary = useDirectGitMirrorProxy + ? "git-mirror-egress-proxy mode=direct required=false transport=ssh source=yaml" + : `git-mirror-egress-proxy client=${proxyClientName} mode=${gitMirrorProxyMode} required=true host=${proxyHost} port=${proxyPort} ssh=GIT_SSH-wrapper source=yaml`; + const proxyCommand = useDirectGitMirrorProxy ? "" : `ProxyCommand=node /tmp/hwlab-github-proxy-connect.mjs ${proxyHost} ${proxyPort} %h %p`; + const gitMirrorSshPrelude = renderGitMirrorSshPrelude({ + direct: useDirectGitMirrorProxy, + proxySummary, + proxyCommand, + proxyUrl, + proxyNoProxy + }); + const cacheVolume = gitMirrorCacheHostPath === null + ? { name: "cache", persistentVolumeClaim: { claimName: gitMirrorCachePvcName } } + : { name: "cache", hostPath: { path: gitMirrorCacheHostPath, type: "DirectoryOrCreate" } }; + const labels = { + "app.kubernetes.io/part-of": "hwlab-node-control-plane", + "hwlab.pikastech.local/node": args.nodeId, + "hwlab.pikastech.local/lane": args.lane + }; + const configLabels = { ...labels, "app.kubernetes.io/name": "git-mirror" }; + const readLabels = { ...labels, "app.kubernetes.io/name": gitMirrorReadName, "hwlab.pikastech.local/git-mirror-mode": "read" }; + const writeLabels = { ...labels, "app.kubernetes.io/name": gitMirrorWriteName, "hwlab.pikastech.local/git-mirror-mode": "write" }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: gitMirrorNamespace } }, + ...(gitMirrorCacheHostPath === null ? [{ + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: gitMirrorCachePvcName, namespace: gitMirrorNamespace, labels: configLabels }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: gitMirrorCachePvcStorage } } } + }] : []), + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: gitMirrorConfigMapName, namespace: gitMirrorNamespace, labels: configLabels }, + data: gitMirrorConfigScripts({ + fetchConfigCommands, + gitMirrorSshPrelude, + gitopsBranchArgs, + gitopsBranchesJson, + mirrorBranchesJson, + requiredRefArgs, + sourceBranchArgs, + sourceBranchesJson, + sourceSnapshotRefPrefix + }) + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: gitMirrorReadName, namespace: gitMirrorNamespace, labels: readLabels }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": gitMirrorReadName } }, + template: { + metadata: { labels: readLabels }, + spec: { + containers: [{ + name: "git-mirror", + image: ciToolsRunnerImage, + imagePullPolicy: "IfNotPresent", + command: ["node"], + args: ["/etc/git-mirror/http.mjs"], + ports: [{ name: "http", containerPort: 8080 }], + readinessProbe: { httpGet: { path: "/pikasTech/HWLAB.git/HEAD", port: "http" }, initialDelaySeconds: 5, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/", port: "http" }, initialDelaySeconds: 10, periodSeconds: 30 }, + volumeMounts: [ + { name: "cache", mountPath: "/cache" }, + { name: "config", mountPath: "/etc/git-mirror", readOnly: true } + ] + }], + volumes: [ + cacheVolume, + { name: "config", configMap: { name: gitMirrorConfigMapName, defaultMode: 0o555 } } + ] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name: gitMirrorReadName, namespace: gitMirrorNamespace, labels: readLabels }, + spec: { selector: { "app.kubernetes.io/name": gitMirrorReadName }, ports: [{ name: "http", port: gitMirrorServicePort, targetPort: "http" }] } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name: gitMirrorWriteName, namespace: gitMirrorNamespace, labels: writeLabels }, + spec: { selector: { "app.kubernetes.io/name": gitMirrorReadName }, ports: [{ name: "http", port: gitMirrorServicePort, targetPort: "http" }] } + } + ] + }; +} + +export { + devopsInfraGitMirrorManifest, + registryManifest, + uniqueStrings +}; diff --git a/scripts/src/gitops-render/runtime-manifests.mjs b/scripts/src/gitops-render/runtime-manifests.mjs new file mode 100644 index 00000000..13685771 --- /dev/null +++ b/scripts/src/gitops-render/runtime-manifests.mjs @@ -0,0 +1,1418 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + applyEnvReuseBootEnv, + argoApplicationName, + bootMetadataForService, + cloneJson, + configString, + deepSeekProfileModel, + defaultBranch, + defaultRegistryPrefix, + defaultSourceRepo, + defaultV02GitReadUrl, + deployServiceForBoot, + effectiveSecretPlaneNodeId, + envReuseServiceIdsForLane, + gitopsPathForProfile, + gitopsRootNodeId, + gitopsTargetForProfile, + isRuntimeLane, + k8sLabelHash, + label, + moonBridgeImage, + moonBridgeSourceRef, + namespaceNameForProfile, + optionalObject, + readConfigRef, + runtimeCommitForService, + runtimeFrpConfigForProfile, + runtimeImageForService, + runtimeImageTagForService, + runtimeLabelForProfile, + runtimeLaneConfig, + runtimePathForProfile, + secretNameForProfile, + secretRefObjectForProfile, + v02EnvReuseEnabled, + v02MetricsLabels, + v02MetricsSidecarAnnotations, + v02MetricsSidecarContainer, + v02MetricsSidecarVolume, + versionNameForRuntimeLane +} from "./core.mjs"; + +function argoProject(args = { lane: "node" }) { + if (isRuntimeLane(args.lane)) { + const namespace = namespaceNameForProfile(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.`, + sourceRepos: [args.gitReadUrl], + destinations: [{ server: "https://kubernetes.default.svc", namespace }], + clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], + namespaceResourceWhitelist: [{ group: "*", kind: "*" }] + } + }; + } + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "AppProject", + metadata: { name: "hwlab-node", namespace: "argocd" }, + spec: { + description: "HWLAB node GitOps project; D601 remains outside this project.", + sourceRepos: [defaultSourceRepo], + destinations: [ + { server: "https://kubernetes.default.svc", namespace: "hwlab-dev" }, + { server: "https://kubernetes.default.svc", namespace: "hwlab-prod" } + ], + clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], + namespaceResourceWhitelist: [{ group: "*", kind: "*" }] + } + }; +} + +function argoApplication(args, profile = "dev") { + const namespace = namespaceNameForProfile(profile); + const runtimePath = runtimePathForProfile(profile); + const gitopsTarget = gitopsTargetForProfile(profile); + const project = isRuntimeLane(profile) ? namespace : "hwlab-node"; + const repoURL = isRuntimeLane(profile) ? args.gitReadUrl : args.sourceRepo; + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "Application", + metadata: { + name: argoApplicationName(profile), + namespace: "argocd", + labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": gitopsTarget } + }, + spec: { + project, + source: { repoURL, targetRevision: args.gitopsBranch, path: gitopsPathForProfile(args, profile) }, + destination: { server: "https://kubernetes.default.svc", namespace }, + syncPolicy: { automated: { prune: isRuntimeLane(profile), selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } + } + }; +} + +function nodeFrpcManifest({ profile = "dev", source = null, deploy = null, args = {} } = {}) { + const namespace = namespaceNameForProfile(profile); + 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); + const webProxyName = frpConfig.webProxyName || `${proxyPrefix}-cloud-web`; + const edgeProxyName = frpConfig.edgeProxyName || `${proxyPrefix}-edge-proxy`; + const labels = { + "app.kubernetes.io/name": deploymentName, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": profileLabel, + "hwlab.pikastech.local/gitops-target": gitopsTargetForProfile(profile), + "hwlab.pikastech.local/profile": profileLabel + }; + const sourceCommitLabel = source?.full ? { "hwlab.pikastech.local/source-commit": source.full } : {}; + const renderedLabels = { ...labels, ...sourceCommitLabel }; + const podNetwork = isRuntimeLane(profile) ? { + hostNetwork: true, + dnsPolicy: "ClusterFirstWithHostNet" + } : {}; + const authConfigText = frpConfig.authSecretName && frpConfig.authSecretKey ? `auth.token = "{{ .Envs.FRPC_AUTH_TOKEN }}"\n` : ""; + const configText = `serverAddr = "${frpConfig.serverAddr}" +serverPort = ${frpConfig.serverPort} +loginFailExit = true +${authConfigText} + +[[proxies]] +name = "${webProxyName}" +type = "tcp" +localIP = "hwlab-cloud-web.${namespace}.svc.cluster.local" +localPort = 8080 +remotePort = ${frpConfig.webRemotePort} + +[[proxies]] +name = "${edgeProxyName}" +type = "tcp" +localIP = "hwlab-edge-proxy.${namespace}.svc.cluster.local" +localPort = 6667 +remotePort = ${frpConfig.edgeRemotePort} +`; + const configSha256 = createHash("sha256").update(configText).digest("hex"); + const templateLabels = { ...labels, "hwlab.pikastech.local/config-sha256": k8sLabelHash(configSha256) }; + const templateAnnotations = { "hwlab.pikastech.local/config-sha256": configSha256 }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: configName, + namespace, + labels: renderedLabels + }, + data: { + "frpc.toml": configText + } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: deploymentName, + namespace, + labels: renderedLabels + }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": deploymentName } }, + template: { + metadata: { labels: templateLabels, annotations: templateAnnotations }, + spec: { + ...podNetwork, + containers: [{ + name: "frpc", + image: "fatedier/frpc:v0.68.1", + imagePullPolicy: "IfNotPresent", + ...(frpConfig.authSecretName && frpConfig.authSecretKey ? { env: [{ name: "FRPC_AUTH_TOKEN", valueFrom: { secretKeyRef: { name: frpConfig.authSecretName, key: frpConfig.authSecretKey } } }] } : {}), + args: ["-c", "/etc/frp/frpc.toml"], + volumeMounts: [{ name: "config", mountPath: "/etc/frp", readOnly: true }] + }], + volumes: [{ name: "config", configMap: { name: configName } }] + } + } + } + } + ] + }; +} + +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 bridgeServiceId = "hwlab-cloud-api"; + const runtimeLane = isRuntimeLane(profile); + const digestPin = runtimeLane; + const envReuseServiceIds = runtimeLane ? envReuseServiceIdsForLane(deploy, profile) : null; + const bridgeImage = runtimeImageForService({ catalog, deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); + const bridgeCommit = runtimeCommitForService({ catalog, deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); + const bridgeImageTag = runtimeImageTagForService({ catalog, deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin, envReuseServiceIds }); + const bridgeBootMetadata = runtimeLane && v02EnvReuseEnabled(catalog, bridgeServiceId, envReuseServiceIds) + ? bootMetadataForService({ args: { sourceRepo: deploy?.lanes?.[profile]?.sourceRepo || sourceRepo, registryPrefix }, catalog, deployService: deployServiceForBoot(deploy, bridgeServiceId, profile), serviceId: bridgeServiceId, source }) + : null; + const labels = { + "app.kubernetes.io/name": "hwlab-deepseek-proxy", + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), + "hwlab.pikastech.local/gitops-target": gitopsTargetForProfile(profile), + "hwlab.pikastech.local/service-id": "hwlab-deepseek-proxy", + "hwlab.pikastech.local/source-commit": source.full + }; + if (runtimeLane) Object.assign(labels, v02MetricsLabels("hwlab-deepseek-proxy")); + const templateLabels = { + ...labels, + "hwlab.pikastech.local/source-commit": bridgeCommit, + "hwlab.pikastech.local/bridge-source-commit": bridgeCommit + }; + const templateAnnotations = { + "hwlab.pikastech.local/bridge-service-id": bridgeServiceId, + "hwlab.pikastech.local/source-commit": bridgeCommit, + "hwlab.pikastech.local/bridge-source-commit": bridgeCommit, + "hwlab.pikastech.local/bridge-image-tag": bridgeImageTag, + "hwlab.pikastech.local/bridge-image": bridgeImage, + ...v02MetricsSidecarAnnotations(runtimeLane ? metricsSidecarSha256 : null) + }; + const responsesBridgeContainer = { + name: "responses-bridge", + image: bridgeImage, + imagePullPolicy: "IfNotPresent", + command: ["/usr/local/bin/bun", "run", "/app/cmd/hwlab-deepseek-responses-bridge/main.ts"], + env: [ + { name: "PORT", value: "4000" }, + { name: "HWLAB_COMMIT_ID", value: bridgeCommit }, + { name: "HWLAB_IMAGE", value: bridgeImage }, + { name: "HWLAB_IMAGE_TAG", value: bridgeImageTag }, + { name: "HWLAB_DEEPSEEK_BRIDGE_UPSTREAM", value: "http://127.0.0.1:4001" }, + { name: "HWLAB_DEEPSEEK_BRIDGE_MODEL", value: deepSeekProfileModel } + ], + ports: [{ name: "http", containerPort: 4000 }], + readinessProbe: { httpGet: { path: "/health/readiness", port: "http" }, initialDelaySeconds: 10, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/health/liveliness", port: "http" }, initialDelaySeconds: 20, periodSeconds: 20 } + }; + if (bridgeBootMetadata) { + applyEnvReuseBootEnv(responsesBridgeContainer, bridgeBootMetadata, { sourceBranch, gitReadUrl, bootSh: "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh" }); + Object.assign(templateAnnotations, { + "hwlab.pikastech.local/bridge-runtime-mode": bridgeBootMetadata.runtimeMode, + "hwlab.pikastech.local/bridge-boot-repo": bridgeBootMetadata.bootRepo, + "hwlab.pikastech.local/bridge-boot-commit": bridgeBootMetadata.bootCommit, + "hwlab.pikastech.local/bridge-boot-sh": "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh", + "hwlab.pikastech.local/bridge-environment-digest": bridgeBootMetadata.environmentDigest ?? "not_published" + }); + Object.assign(templateLabels, { + "hwlab.pikastech.local/bridge-runtime-mode": bridgeBootMetadata.runtimeMode, + "hwlab.pikastech.local/bridge-boot-commit": bridgeBootMetadata.bootCommit + }); + } + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: "hwlab-deepseek-proxy-config", namespace, labels }, + data: { + "render-config.sh": moonBridgeConfigRenderScript() + } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: "hwlab-deepseek-proxy", namespace, labels, annotations: { "hwlab.pikastech.local/moonbridge-source-ref": moonBridgeSourceRef, ...templateAnnotations } }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": "hwlab-deepseek-proxy" } }, + template: { + metadata: { labels: templateLabels, annotations: templateAnnotations }, + spec: { + initContainers: [{ + name: "moonbridge-config", + image: bridgeImage, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-eu", "/scripts/render-config.sh"], + env: [ + { name: "DEEPSEEK_API_KEY", valueFrom: { secretKeyRef: { name: secretNameForProfile("hwlab-code-agent-provider", profile), key: "openai-api-key", optional: true } } }, + { name: "OPENCODE_API_KEY", valueFrom: { secretKeyRef: { name: secretNameForProfile("hwlab-code-agent-provider", profile), key: "opencode-api-key", optional: true } } } + ], + volumeMounts: [ + { name: "moonbridge-scripts", mountPath: "/scripts", readOnly: true }, + { name: "moonbridge-config", mountPath: "/config" } + ] + }], + containers: [responsesBridgeContainer, { + name: "moonbridge", + image: moonBridgeImage, + imagePullPolicy: "IfNotPresent", + args: ["-config", "/config/config.yml"], + ports: [{ name: "moonbridge-http", containerPort: 4001 }], + readinessProbe: { httpGet: { path: "/v1/models", port: "moonbridge-http" }, initialDelaySeconds: 10, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/v1/models", port: "moonbridge-http" }, initialDelaySeconds: 20, periodSeconds: 20 }, + volumeMounts: [ + { name: "moonbridge-config", mountPath: "/config", readOnly: true }, + { name: "moonbridge-data", mountPath: "/data" } + ] + }, ...(runtimeLane ? [v02MetricsSidecarContainer({ deploy, serviceId: "hwlab-deepseek-proxy", namespace, gitopsTarget: gitopsTargetForProfile(profile) })] : [])], + volumes: [ + { name: "moonbridge-scripts", configMap: { name: "hwlab-deepseek-proxy-config" } }, + { name: "moonbridge-config", emptyDir: {} }, + { name: "moonbridge-data", emptyDir: {} }, + ...(runtimeLane ? [v02MetricsSidecarVolume(profile)] : []) + ] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name: "hwlab-deepseek-proxy", namespace, labels }, + spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": "hwlab-deepseek-proxy" }, ports: [{ name: "http", port: 4000, targetPort: "http" }, ...(runtimeLane ? [{ name: "metrics", port: 9100, targetPort: "metrics" }] : [])] } + } + ] + }; +} + +function moonBridgeConfigRenderScript() { + return `#!/bin/sh +if [ -z "\${DEEPSEEK_API_KEY:-}" ] && [ -z "\${OPENCODE_API_KEY:-}" ]; then + echo "at least one of DEEPSEEK_API_KEY or OPENCODE_API_KEY is required" >&2 + exit 1 +fi +cat > /config/config.yml < {", + " let body = \"\";", + " req.setEncoding(\"utf8\");", + " req.on(\"data\", (chunk) => {", + " body += chunk;", + " if (body.length > 1024 * 1024) req.destroy(new Error(\"request body too large\"));", + " });", + " req.on(\"end\", () => resolve(body ? JSON.parse(body) : {}));", + " req.on(\"error\", reject);", + " });", + "}", + "", + "function postJson(url, payload, timeoutMs) {", + " return new Promise((resolve, reject) => {", + " const parsed = new URL(url);", + " const body = JSON.stringify(payload);", + " const request = http.request({", + " hostname: parsed.hostname,", + " port: parsed.port || 80,", + " path: parsed.pathname + parsed.search,", + " method: \"POST\",", + " headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) },", + " timeout: timeoutMs", + " }, (response) => {", + " let responseBody = \"\";", + " response.setEncoding(\"utf8\");", + " response.on(\"data\", (chunk) => { responseBody += chunk; });", + " response.on(\"end\", () => {", + " try {", + " const parsedBody = responseBody ? JSON.parse(responseBody) : null;", + " resolve({ statusCode: response.statusCode, body: parsedBody });", + " } catch (error) {", + " reject(error);", + " }", + " });", + " });", + " request.on(\"timeout\", () => request.destroy(new Error(\"request timeout after \" + timeoutMs + \"ms\")));", + " request.on(\"error\", reject);", + " request.end(body);", + " });", + "}", + "", + "function gatewayPayload(input) {", + " const traceId = input.traceId || \"trc_device_agent_71_freq_\" + Date.now();", + " return {", + " jsonrpc: \"2.0\",", + " id: input.id || \"req_device_agent_71_freq_\" + Date.now(),", + " method: \"hardware.invoke.shell\",", + " meta: { serviceId: \"hwlab-cloud-api\", actorId: \"device-agent-71-freq\", environment: \"dev\", traceId, deviceId, workspaceRoot },", + " params: {", + " gatewaySessionId,", + " resourceId,", + " capabilityId,", + " input: {", + " command: input.command,", + " cwd: input.cwd || workspaceRoot,", + " timeoutMs: input.timeoutMs || 30000", + " }", + " }", + " };", + "}", + "", + "const server = http.createServer(async (req, res) => {", + " try {", + " const url = new URL(req.url || \"/\", \"http://device-agent-71-freq\");", + " if (req.method === \"GET\" && url.pathname === \"/health\") {", + " return sendJson(res, 200, { ok: true, deviceId, workspaceRoot, gatewaySessionId, resourceId, capabilityId, cloudApiUrl });", + " }", + " if (req.method === \"GET\" && url.pathname === \"/skills\") {", + " return sendJson(res, 200, { ok: true, deviceId, skills: [{ name: \"cmd\", description: \"Run a bounded Windows cmd command through hwlab-gateway.\" }] });", + " }", + " if (req.method === \"POST\" && url.pathname === \"/run\") {", + " const input = await readBody(req);", + " if (!input.command || typeof input.command !== \"string\") return sendJson(res, 400, { ok: false, error: \"command is required\" });", + " const timeoutMs = Number(input.timeoutMs || 30000);", + " const upstream = await postJson(cloudApiUrl + \"/json-rpc\", gatewayPayload({ ...input, timeoutMs }), timeoutMs + 5000);", + " return sendJson(res, upstream.statusCode || 502, { ok: upstream.statusCode >= 200 && upstream.statusCode < 300, deviceId, gatewaySessionId, upstream: upstream.body });", + " }", + " return sendJson(res, 404, { ok: false, error: \"not found\" });", + " } catch (error) {", + " return sendJson(res, 500, { ok: false, error: error.message });", + " }", + "});", + "", + "server.listen(port, \"0.0.0.0\", () => {", + " console.log(JSON.stringify({ ok: true, service: \"device-agent-71-freq\", port, deviceId, workspaceRoot, cloudApiUrl }));", + "});" + ].join("\n") + "\n"; +} + +function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, catalog = null, useDeployImages = false }) { + assert.equal(profile, "dev", "71-freq device-agent is dev-only"); + const namespace = namespaceNameForProfile(profile); + const name = "device-agent-71-freq"; + const bridgeServiceId = "hwlab-cloud-api"; + const bridgeImage = runtimeImageForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const bridgeCommit = runtimeCommitForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const bridgeImageTag = runtimeImageTagForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const labels = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/component": "device-agent", + "hwlab.pikastech.local/device-id": "71-freq", + "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), + "hwlab.pikastech.local/gitops-target": "node", + "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile), + "hwlab.pikastech.local/service-id": name, + "hwlab.pikastech.local/source-commit": source.full + }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + const selector = { "app.kubernetes.io/name": name }; + const script = deviceAgent71FreqServerScript(); + const scriptSha256 = createHash("sha256").update(script).digest("hex"); + const templateLabels = { ...labels, ...selector, "hwlab.pikastech.local/source-commit": bridgeCommit }; + const templateAnnotations = { + ...annotations, + "hwlab.pikastech.local/script-sha256": scriptSha256, + "hwlab.pikastech.local/bridge-service-id": bridgeServiceId, + "hwlab.pikastech.local/bridge-source-commit": bridgeCommit, + "hwlab.pikastech.local/bridge-image-tag": bridgeImageTag, + "hwlab.pikastech.local/bridge-image": bridgeImage + }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `${name}-script`, namespace, labels, annotations: templateAnnotations }, + data: { "server.mjs": script } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels, annotations }, + spec: { + replicas: 1, + selector: { matchLabels: selector }, + template: { + metadata: { labels: templateLabels, annotations: templateAnnotations }, + spec: { + containers: [{ + name: "device-agent", + image: bridgeImage, + imagePullPolicy: "IfNotPresent", + command: ["node", "/opt/device-agent/server.mjs"], + env: [ + { name: "PORT", value: "7601" }, + { name: "HWLAB_COMMIT_ID", value: bridgeCommit }, + { name: "HWLAB_IMAGE", value: bridgeImage }, + { name: "HWLAB_IMAGE_TAG", value: bridgeImageTag }, + { name: "DEVICE_ID", value: "71-freq" }, + { name: "DEVICE_WORKSPACE_ROOT", value: "F:\\Work\\ConStart" }, + { name: "HWLAB_CLOUD_API_URL", value: `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667` }, + { name: "HWLAB_GATEWAY_SESSION_ID", value: "gws_d601_win_71_freq" }, + { name: "HWLAB_GATEWAY_RESOURCE_ID", value: "res_d601_windows_host" }, + { name: "HWLAB_GATEWAY_CAPABILITY_ID", value: "cap_d601_windows_cmd_exec" } + ], + ports: [{ name: "http", containerPort: 7601 }], + readinessProbe: { httpGet: { path: "/health", port: "http" }, initialDelaySeconds: 3, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/health", port: "http" }, initialDelaySeconds: 10, periodSeconds: 20 }, + resources: { requests: { cpu: "10m", memory: "64Mi" }, limits: { cpu: "200m", memory: "256Mi" } }, + volumeMounts: [ + { name: "script", mountPath: "/opt/device-agent", readOnly: true }, + { name: "hwlab-code-agent-workspace", mountPath: "/workspace" } + ] + }], + volumes: [ + { name: "script", configMap: { name: `${name}-script` } }, + { name: "hwlab-code-agent-workspace", persistentVolumeClaim: { claimName: "hwlab-code-agent-workspace" } } + ] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels, annotations }, + spec: { type: "ClusterIP", selector, ports: [{ name: "http", port: 7601, targetPort: "http" }] } + } + ] + }; +} + +function runtimePostgresImageForProfile(deploy, profile) { + const postgres = runtimeLaneConfig(deploy, profile)?.runtimeStore?.postgres; + const image = postgres && typeof postgres === "object" && !Array.isArray(postgres) ? postgres.image : null; + 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); + const name = `${namespace}-postgres`; + const dbName = `hwlab_${profile}`; + const labels = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, + "hwlab.pikastech.local/source-commit": source.full + }; + assert.ok(Array.isArray(migrationSources) && migrationSources.length > 0, "runtime Postgres migration chain is required"); + const migrationSql = migrationSources.map((migration) => migration.sql).join("\n"); + const migrationData = Object.fromEntries(migrationSources.map((migration) => [path.basename(migration.path), migration.sql])); + const migrationSha256 = createHash("sha256").update(migrationSql).digest("hex"); + const templateLabels = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, + "hwlab.pikastech.local/migration-sha256": k8sLabelHash(migrationSha256) + }; + const templateAnnotations = { "hwlab.pikastech.local/migration-sha256": migrationSha256 }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `${name}-init`, namespace, labels }, + data: migrationData + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels }, + spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": name }, ports: [{ name: "postgres", port: 5432, targetPort: "postgres" }] } + }, + { + apiVersion: "apps/v1", + kind: "StatefulSet", + metadata: { name, namespace, labels }, + spec: { + serviceName: name, + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": name } }, + template: { + metadata: { labels: templateLabels, annotations: templateAnnotations }, + spec: { + containers: [{ + name: "postgres", + image, + imagePullPolicy: "IfNotPresent", + env: [ + { name: "POSTGRES_DB", value: dbName }, + { name: "POSTGRES_USER", value: dbName }, + { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name, key: "POSTGRES_PASSWORD" } } } + ], + ports: [{ name: "postgres", containerPort: 5432 }], + readinessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 5, periodSeconds: 10 }, + livenessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 30, periodSeconds: 20 }, + volumeMounts: [ + { name: "data", mountPath: "/var/lib/postgresql/data" }, + { name: "init", mountPath: "/docker-entrypoint-initdb.d", readOnly: true } + ] + }], + volumes: [{ name: "init", configMap: { name: `${name}-init` } }] + } + }, + volumeClaimTemplates: [{ + metadata: { name: "data" }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } + }] + } + } + ] + }; +} + +function externalPostgresConfigForLane(deploy, profile, args = {}) { + if (!isRuntimeLane(profile)) return null; + const config = deploy?.lanes?.[profile]?.externalPostgres; + if (!config || config.enabled !== true) return null; + const nodeId = effectiveSecretPlaneNodeId(args); + const nodeConfig = nodeId ? config?.nodes?.[nodeId] : null; + const effective = nodeConfig && typeof nodeConfig === "object" && !Array.isArray(nodeConfig) ? { ...config, ...nodeConfig } : config; + assert.ok(typeof effective.serviceName === "string" && effective.serviceName.length > 0, `deploy.lanes.${profile}.externalPostgres.serviceName is required`); + 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 }; +} + +function externalPostgresManifest({ profile = "v03", config, source }) { + const namespace = namespaceNameForProfile(profile); + const labels = { + "app.kubernetes.io/name": config.serviceName, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/component": "platform-db-bridge", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, + "hwlab.pikastech.local/source-commit": source.full + }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "Service", + metadata: { name: config.serviceName, namespace, labels, annotations }, + spec: { type: "ClusterIP", ports: [{ name: "postgres", port: config.port, targetPort: config.port, protocol: "TCP" }] } + }, + { + apiVersion: "discovery.k8s.io/v1", + kind: "EndpointSlice", + metadata: { name: `${config.serviceName}-host`, namespace, labels: { ...labels, "kubernetes.io/service-name": config.serviceName }, annotations }, + addressType: "IPv4", + ports: [{ name: "postgres", port: config.port, protocol: "TCP" }], + endpoints: [{ addresses: [config.endpointAddress], conditions: { ready: true } }] + } + ] + }; +} + +function v02OpenFgaManifest({ profile = "v02", source }) { + const namespace = namespaceNameForProfile(profile); + const name = "hwlab-openfga"; + const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0"; + const labels = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/component": "authorization", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, + "hwlab.pikastech.local/service-id": name, + "hwlab.pikastech.local/source-commit": source.full + }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + const selector = { "app.kubernetes.io/name": name }; + const env = [ + { name: "OPENFGA_DATASTORE_ENGINE", value: "postgres" }, + { name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "datastore-uri" } } }, + { name: "OPENFGA_AUTHN_METHOD", value: "preshared" }, + { name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "authn-preshared-key" } } }, + { name: "OPENFGA_PLAYGROUND_ENABLED", value: "false" }, + { name: "OPENFGA_HTTP_ADDR", value: "0.0.0.0:8080" }, + { name: "OPENFGA_GRPC_ADDR", value: "0.0.0.0:8081" } + ]; + const templateLabels = { ...labels, ...selector }; + const runtimeAnnotations = { ...annotations, "argocd.argoproj.io/sync-wave": "2" }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name: `${name}-migrate`, + namespace, + labels, + annotations: { + ...annotations, + "argocd.argoproj.io/hook": "Sync", + "argocd.argoproj.io/sync-wave": "1", + "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded" + } + }, + spec: { + backoffLimit: 3, + template: { + metadata: { labels: templateLabels, annotations }, + spec: { + restartPolicy: "OnFailure", + containers: [{ name: "openfga-migrate", image, imagePullPolicy: "IfNotPresent", args: ["migrate"], env }] + } + } + } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels, annotations: runtimeAnnotations }, + spec: { + replicas: 1, + selector: { matchLabels: selector }, + template: { + metadata: { labels: templateLabels, annotations }, + spec: { + containers: [{ + name: "openfga", + image, + imagePullPolicy: "IfNotPresent", + args: ["run"], + env, + ports: [{ name: "http", containerPort: 8080 }, { name: "grpc", containerPort: 8081 }], + readinessProbe: { httpGet: { path: "/healthz", port: "http" }, initialDelaySeconds: 3, periodSeconds: 10 }, + livenessProbe: { httpGet: { path: "/healthz", port: "http" }, initialDelaySeconds: 15, periodSeconds: 20 }, + resources: { requests: { cpu: "50m", memory: "128Mi" }, limits: { cpu: "500m", memory: "512Mi" } } + }] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels, annotations: runtimeAnnotations }, + spec: { type: "ClusterIP", selector, ports: [{ name: "http", port: 8080, targetPort: "http" }, { name: "grpc", port: 8081, targetPort: "grpc" }] } + } + ] + }; +} + +function workbenchRuntimeRedisConf(config) { + return [ + "save \"\"", + "appendonly no", + `maxmemory ${config.memoryPolicy.maxMemory}`, + `maxmemory-policy ${config.memoryPolicy.eviction}`, + "" + ].join("\n"); +} + +function workbenchRuntimeRedisManifest({ profile = "v03", config, source }) { + const namespace = config.namespace; + const name = config.serviceName; + const port = config.port; + const selector = { "app.kubernetes.io/name": name }; + const labels = { + ...selector, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/cache-role": "workbench-derived-read", + "hwlab.pikastech.local/component": "workbench-runtime-cache", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, + "hwlab.pikastech.local/service-id": name, + "hwlab.pikastech.local/source-commit": source.full + }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + const runtimeAnnotations = { ...annotations, "argocd.argoproj.io/sync-wave": "2" }; + const templateLabels = { ...labels, ...selector }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `${name}-config`, namespace, labels, annotations: runtimeAnnotations }, + data: { "redis.conf": workbenchRuntimeRedisConf(config) } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels, annotations: runtimeAnnotations }, + spec: { + replicas: 1, + selector: { matchLabels: selector }, + template: { + metadata: { labels: templateLabels, annotations: runtimeAnnotations }, + spec: { + containers: [{ + name: "redis", + image: config.image, + imagePullPolicy: "IfNotPresent", + args: ["redis-server", "/usr/local/etc/redis/redis.conf"], + ports: [{ name: "redis", containerPort: port }], + readinessProbe: { exec: { command: ["redis-cli", "-p", String(port), "ping"] }, initialDelaySeconds: 3, periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 3 }, + livenessProbe: { exec: { command: ["redis-cli", "-p", String(port), "ping"] }, initialDelaySeconds: 15, periodSeconds: 20, timeoutSeconds: 3, failureThreshold: 3 }, + resources: cloneJson(config.resources), + volumeMounts: [{ name: "config", mountPath: "/usr/local/etc/redis", readOnly: true }] + }], + volumes: [{ name: "config", configMap: { name: `${name}-config` } }] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels, annotations: runtimeAnnotations }, + spec: { type: "ClusterIP", selector, ports: [{ name: "redis", port, targetPort: "redis", protocol: "TCP" }] } + }, + { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { name: `${name}-ingress`, namespace, labels, annotations: runtimeAnnotations }, + spec: { + podSelector: { matchLabels: selector }, + policyTypes: ["Ingress"], + ingress: [{ + from: [{ podSelector: { matchLabels: { "app.kubernetes.io/name": "hwlab-workbench-runtime" } } }], + ports: [{ protocol: "TCP", port }] + }] + } + } + ] + }; +} + +function runtimeConfigMapsForProfile(deploy, profile) { + if (!isRuntimeLane(profile)) return []; + const configMaps = deploy?.lanes?.[profile]?.configMaps; + if (!Array.isArray(configMaps)) return []; + return configMaps + .map((configMap) => cloneJson(configMap)) + .filter((configMap) => typeof configMap?.name === "string" && configMap.name.trim() && configMap.data && typeof configMap.data === "object" && !Array.isArray(configMap.data)); +} + +function runtimeConfigMapsManifest({ configMaps, namespace, labels, annotations }) { + return { + apiVersion: "v1", + kind: "List", + items: configMaps.map((configMap) => ({ + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: configMap.name, + namespace, + labels: { ...labels, ...(configMap.labels ?? {}) }, + annotations: { ...annotations, ...(configMap.annotations ?? {}) } + }, + data: Object.fromEntries(Object.entries(configMap.data).map(([key, value]) => [key, String(value)])) + })) + }; +} + +async function runtimeSecretPlaneConfigForProfile(deploy, profile, args = {}) { + if (!isRuntimeLane(profile)) return null; + const laneConfig = deploy?.lanes?.[profile]; + const secretPlane = laneConfig?.secretPlaneRef + ? await readConfigRef(laneConfig.secretPlaneRef, `${profile}.secretPlaneRef`) + : laneConfig?.secretPlane; + if (!secretPlane || typeof secretPlane !== "object" || Array.isArray(secretPlane)) return null; + if (secretPlane.enabled !== true) return null; + if (!runtimeSecretPlaneEnabledForNode(secretPlane, args)) return null; + return cloneJson(secretPlane); +} + +function runtimeSecretPlaneEnabledForNode(secretPlane, args = {}) { + const enabledOnNodes = normalizeSecretPlaneNodeList(secretPlane.enabledOnNodes, "secretPlane.enabledOnNodes"); + if (enabledOnNodes.length === 0) return true; + const nodeId = effectiveSecretPlaneNodeId(args); + assert.ok(nodeId, "secretPlane.enabledOnNodes requires --node or a node-scoped --gitops-root"); + return enabledOnNodes.includes(nodeId); +} + +function normalizeSecretPlaneNodeList(value, label) { + if (value === undefined) return []; + assert.ok(Array.isArray(value), `${label} must be an array when set`); + return value.map((item, index) => { + assert.equal(typeof item, "string", `${label}[${index}] must be a string`); + const nodeId = item.trim().toUpperCase(); + assert.ok(/^[A-Z0-9][A-Z0-9-]*$/u.test(nodeId), `${label}[${index}] must be a node id`); + return nodeId; + }); +} + +function requiredSecretPlaneString(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + const trimmed = value.trim(); + assert.ok(trimmed, `${label} must not be empty`); + return trimmed; +} + +function runtimeSecretPlaneManifest({ config, namespace, labels, annotations }) { + const store = config?.store; + assert.ok(store && typeof store === "object" && !Array.isArray(store), "secretPlane.store must be an object"); + assert.equal(store.kind, "ClusterSecretStore", "secretPlane.store.kind must be ClusterSecretStore for node-scoped v0.3"); + assert.ok(typeof store.name === "string" && store.name.trim(), "secretPlane.store.name must be set"); + const secrets = Array.isArray(config.secrets) ? config.secrets : []; + assert.ok(secrets.length > 0, "secretPlane.secrets must not be empty"); + return { + apiVersion: "v1", + kind: "List", + items: secrets.map((secret, index) => runtimeSecretPlaneExternalSecret({ config, secret, index, namespace, labels, annotations, store })) + }; +} + +function runtimeSecretPlaneExternalSecret({ config, secret, index, namespace, labels, annotations, store }) { + const externalSecretName = requiredSecretPlaneString(secret?.externalSecretName, `secretPlane.secrets[${index}].externalSecretName`); + const targetSecretName = requiredSecretPlaneString(secret?.targetSecretName, `secretPlane.secrets[${index}].targetSecretName`); + const data = Array.isArray(secret?.data) ? secret.data : []; + assert.ok(data.length > 0, `secretPlane.secrets[${index}].data must not be empty`); + const sourceRefs = data.map((item) => requiredSecretPlaneString(item?.remoteRef, `secretPlane.secrets[${index}].data.remoteRef`)); + const issueRef = String(config.issue ?? "pikasTech/HWLAB#2234"); + return { + apiVersion: "external-secrets.io/v1", + kind: "ExternalSecret", + metadata: { + name: externalSecretName, + namespace, + labels: { + ...labels, + "app.kubernetes.io/name": externalSecretName, + "app.kubernetes.io/component": "external-secret", + "hwlab.pikastech.local/secret-plane": secretPlaneIssueLabelValue(issueRef) + }, + annotations: { + ...annotations, + "hwlab.pikastech.local/secret-plane-issue": issueRef, + "hwlab.pikastech.local/source-ref": sourceRefs.join(","), + "hwlab.pikastech.local/values-printed": "false" + } + }, + spec: { + refreshInterval: requiredSecretPlaneString(config.refreshInterval, "secretPlane.refreshInterval"), + secretStoreRef: { + name: requiredSecretPlaneString(store.name, "secretPlane.store.name"), + kind: store.kind + }, + target: { + name: targetSecretName, + creationPolicy: "Owner" + }, + data: data.map((item, itemIndex) => ({ + secretKey: requiredSecretPlaneString(item?.targetKey, `secretPlane.secrets[${index}].data[${itemIndex}].targetKey`), + remoteRef: { + key: requiredSecretPlaneString(item?.remoteRef, `secretPlane.secrets[${index}].data[${itemIndex}].remoteRef`), + property: requiredSecretPlaneString(item?.property, `secretPlane.secrets[${index}].data[${itemIndex}].property`) + } + })) + } + }; +} + +function secretPlaneIssueLabelValue(issueRef) { + const text = String(issueRef ?? "").trim(); + const issueNumber = text.match(/#([0-9]+)$/u)?.[1]; + const raw = issueNumber ? `issue-${issueNumber}` : text; + const normalized = raw.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/gu, ""); + const label = normalized.slice(0, 63).replace(/[^A-Za-z0-9]+$/u, ""); + assert.ok(label.length > 0, "secretPlane.issue must produce a Kubernetes label-safe value"); + return label; +} + +function opencodeRuntimeConfigForProfile(deploy, profile) { + const config = optionalObject(deploy?.lanes?.[profile]?.opencode); + const providerProfile = configString(config.providerProfile) || "dsflash-go"; + const providerId = configString(config.providerId) || providerProfile; + const model = configString(config.model) || "deepseek-v4-flash"; + const smallModel = configString(config.smallModel) || model; + const apiKeyEnv = configString(config.apiKeyEnv) || opencodeApiKeyEnvName(providerId); + const contextLimit = opencodePositiveInteger(config.contextLimit, 1000000, `deploy.lanes.${profile}.opencode.contextLimit`); + const outputLimit = opencodePositiveInteger(config.outputLimit, 384000, `deploy.lanes.${profile}.opencode.outputLimit`); + const reasoning = opencodeConfigBoolean(config.reasoning, false, `deploy.lanes.${profile}.opencode.reasoning`); + const toolCall = opencodeConfigBoolean(config.toolCall, true, `deploy.lanes.${profile}.opencode.toolCall`); + const upstreamBaseURL = configString(config.upstreamBaseURL) || configString(config.baseURL) || "https://opencode.ai/zen/go/v1"; + const providerProxyPort = opencodePositiveInteger(config.providerProxyPort, 4097, `deploy.lanes.${profile}.opencode.providerProxyPort`); + const providerProxyBasePath = configString(config.providerProxyBasePath) || "/v1"; + const providerProxyBaseURL = configString(config.providerProxyBaseURL) || `http://127.0.0.1:${providerProxyPort}${providerProxyBasePath}`; + return { + image: configString(config.image) || "ghcr.io/anomalyco/opencode:1.17.7", + providerProfile, + providerId, + displayName: configString(config.displayName) || `AgentRun ${providerProfile}`, + model, + smallModel, + baseURL: providerProxyBaseURL, + upstreamBaseURL, + apkProxyURL: configString(config.apkProxyURL) || "", + providerProxyPort, + providerProxyBasePath, + npm: configString(config.npm) || "@ai-sdk/openai-compatible", + apiKeyEnv, + apiKeySecretRef: configString(config.apiKeySecretRef) || "hwlab-code-agent-provider/opencode-api-key", + contextLimit, + outputLimit, + reasoning, + toolCall, + agentrunSecretNamespace: configString(config.agentrunSecretNamespace) || "agentrun-v02", + agentrunSecretName: configString(config.agentrunSecretName) || `agentrun-v01-provider-${providerProfile}`, + agentrunSecretKeys: Array.isArray(config.agentrunSecretKeys) && config.agentrunSecretKeys.length > 0 + ? config.agentrunSecretKeys.map(configString).filter(Boolean) + : ["auth.json", "config.toml", "model-catalog.json"] + }; +} + +function opencodePositiveInteger(value, fallback, label) { + if (value === undefined || value === null) return fallback; + assert.ok(Number.isInteger(value) && value > 0, `${label} must be a positive integer`); + return value; +} + +function opencodeConfigBoolean(value, fallback, label) { + if (value === undefined || value === null) return fallback; + assert.equal(typeof value, "boolean", `${label} must be a boolean`); + return value; +} + +function opencodeEgressProxyUrlForProfile(deploy, profile) { + const proxy = deploy?.lanes?.[profile]?.gitMirror?.egressProxy; + if (!proxy || proxy.required === false) return ""; + const proxyUrl = configString(proxy.proxyUrl); + if (proxyUrl) return proxyUrl; + const serviceName = configString(proxy.serviceName); + const namespace = configString(proxy.namespace) || "platform-infra"; + const port = Number(proxy.port); + if (!serviceName || !Number.isInteger(port) || port <= 0) return ""; + return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`; +} + +function opencodeEgressNoProxyForProfile(deploy, profile) { + const proxy = deploy?.lanes?.[profile]?.gitMirror?.egressProxy; + const entries = Array.isArray(proxy?.noProxy) ? proxy.noProxy.map(configString).filter(Boolean) : []; + return entries.length > 0 ? entries.join(",") : "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,.cluster.local"; +} + +function opencodeApiKeyEnvName(providerId) { + const normalized = String(providerId || "provider").toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, ""); + return `OPENCODE_${normalized || "PROVIDER"}_API_KEY`; +} + +function opencodeConfigContent(config) { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + share: "disabled", + model: `${config.providerId}/${config.model}`, + small_model: `${config.providerId}/${config.smallModel}`, + provider: { + [config.providerId]: { + npm: config.npm, + name: config.displayName, + options: { + baseURL: config.baseURL, + apiKey: `{env:${config.apiKeyEnv}}`, + timeout: 600000, + headerTimeout: 600000, + chunkTimeout: 300000 + }, + models: { + [config.model]: { + name: config.model, + reasoning: config.reasoning, + tool_call: config.toolCall, + limit: { + context: config.contextLimit, + output: config.outputLimit + } + } + } + } + } + }); +} + +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 name = "opencode-server"; + const helperServiceId = "hwlab-cloud-web"; + const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile); + const helperImage = runtimeImageForService({ catalog, deploy, serviceId: helperServiceId, source, registryPrefix, useDeployImages, digestPin: true, envReuseServiceIds }); + const helperCommit = runtimeCommitForService({ catalog, deploy, serviceId: helperServiceId, source, registryPrefix, useDeployImages, digestPin: true, envReuseServiceIds }); + const helperImageTag = runtimeImageTagForService({ catalog, deploy, serviceId: helperServiceId, source, registryPrefix, useDeployImages, digestPin: true, envReuseServiceIds }); + const providerProxyBootSh = "deploy/runtime/boot/opencode-provider-proxy.sh"; + const providerProxyBootMetadata = v02EnvReuseEnabled(catalog, helperServiceId, envReuseServiceIds) + ? bootMetadataForService({ args: { sourceRepo: deploy?.lanes?.[profile]?.sourceRepo || sourceRepo, registryPrefix }, catalog, deployService: deployServiceForBoot(deploy, helperServiceId, profile), serviceId: helperServiceId, source }) + : null; + const selector = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/profile": profile + }; + const labels = { + ...selector, + "hwlab.pikastech.local/component": "opencode", + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/service-id": name, + "hwlab.pikastech.local/source-commit": source.full + }; + const opencodeConfig = opencodeRuntimeConfigForProfile(deploy, profile); + const configContent = opencodeConfigContent(opencodeConfig); + const configSha256 = createHash("sha256").update(configContent).digest("hex"); + const apiKeySecretRef = secretRefObjectForProfile(opencodeConfig.apiKeySecretRef, profile); + const opencodeApkProxyUrl = opencodeConfig.apkProxyURL || opencodeEgressProxyUrlForProfile(deploy, profile); + const opencodeApkNoProxy = opencodeEgressNoProxyForProfile(deploy, profile); + const annotations = { + "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs", + "hwlab.pikastech.local/opencode-provider-profile": opencodeConfig.providerProfile, + "hwlab.pikastech.local/opencode-provider-id": opencodeConfig.providerId, + "hwlab.pikastech.local/opencode-model": opencodeConfig.model, + "hwlab.pikastech.local/opencode-provider-base-url": opencodeConfig.baseURL, + "hwlab.pikastech.local/opencode-provider-upstream-base-url": opencodeConfig.upstreamBaseURL, + "hwlab.pikastech.local/opencode-image": opencodeConfig.image, + "hwlab.pikastech.local/opencode-config-sha256": configSha256, + "hwlab.pikastech.local/opencode-api-key-secret-ref": `${apiKeySecretRef.name}/${apiKeySecretRef.key}`, + "hwlab.pikastech.local/agentrun-provider-secret-ref": `${opencodeConfig.agentrunSecretNamespace}/${opencodeConfig.agentrunSecretName}`, + "hwlab.pikastech.local/agentrun-provider-secret-keys": opencodeConfig.agentrunSecretKeys.join(","), + "hwlab.pikastech.local/values-printed": "false" + }; + if (providerProxyBootMetadata) { + Object.assign(annotations, { + "hwlab.pikastech.local/opencode-provider-proxy-runtime-mode": providerProxyBootMetadata.runtimeMode, + "hwlab.pikastech.local/opencode-provider-proxy-boot-repo": providerProxyBootMetadata.bootRepo, + "hwlab.pikastech.local/opencode-provider-proxy-boot-commit": providerProxyBootMetadata.bootCommit, + "hwlab.pikastech.local/opencode-provider-proxy-boot-sh": providerProxyBootSh, + "hwlab.pikastech.local/opencode-provider-proxy-environment-digest": providerProxyBootMetadata.environmentDigest ?? "not_published" + }); + } + const authSecretName = secretNameForProfile("hwlab-opencode-server-auth", profile); + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { name, namespace, labels, annotations } + }, + { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: `${name}-data`, namespace, labels, annotations }, + spec: { + accessModes: ["ReadWriteOnce"], + resources: { requests: { storage: "8Gi" } } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels, annotations }, + spec: { + type: "ClusterIP", + selector, + ports: [{ name: "http", port: 4096, targetPort: "http" }] + } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels, annotations }, + spec: { + replicas: 1, + strategy: { type: "Recreate" }, + selector: { matchLabels: selector }, + template: { + metadata: { labels, annotations }, + spec: { + serviceAccountName: name, + securityContext: { fsGroup: 1000, fsGroupChangePolicy: "OnRootMismatch" }, + initContainers: [{ + name: "opencode-workspace-git-init", + image: helperImage, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-ec"], + args: [ + [ + "set -eu", + "mkdir -p /workspace", + "if [ ! -d /workspace/.git ]; then", + " git -C /workspace init", + "fi", + "git -C /workspace config user.name 'HWLAB OpenCode' || true", + "git -C /workspace config user.email 'opencode@hwlab.local' || true", + "chgrp -R 1000 /workspace/.git 2>/dev/null || true", + "chmod -R g+rwX /workspace/.git 2>/dev/null || true" + ].join("\n") + ], + resources: { requests: { cpu: "25m", memory: "64Mi" }, limits: { cpu: "250m", memory: "256Mi" } }, + volumeMounts: [{ name: "workspace", mountPath: "/workspace" }] + }], + containers: [{ + name, + image: opencodeConfig.image, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-ec"], + args: ["exec opencode serve --hostname 0.0.0.0 --port 4096"], + workingDir: "/workspace", + env: [ + { name: "HOME", value: "/workspace" }, + { name: "XDG_CONFIG_HOME", value: "/workspace/.config" }, + { name: "XDG_DATA_HOME", value: "/workspace/.local/share" }, + ...(opencodeApkProxyUrl ? [ + { name: "HTTP_PROXY", value: opencodeApkProxyUrl }, + { name: "HTTPS_PROXY", value: opencodeApkProxyUrl }, + { name: "http_proxy", value: opencodeApkProxyUrl }, + { name: "https_proxy", value: opencodeApkProxyUrl }, + { name: "NO_PROXY", value: opencodeApkNoProxy }, + { name: "no_proxy", value: opencodeApkNoProxy } + ] : []), + { name: "OPENCODE_CONFIG_CONTENT", value: configContent }, + { name: opencodeConfig.apiKeyEnv, valueFrom: { secretKeyRef: apiKeySecretRef } }, + { name: "OPENCODE_SERVER_USERNAME", valueFrom: { secretKeyRef: { name: authSecretName, key: "username" } } }, + { name: "OPENCODE_SERVER_PASSWORD", valueFrom: { secretKeyRef: { name: authSecretName, key: "password" } } } + ], + ports: [{ name: "http", containerPort: 4096 }], + startupProbe: { tcpSocket: { port: "http" }, periodSeconds: 5, failureThreshold: 60 }, + readinessProbe: { tcpSocket: { port: "http" }, periodSeconds: 10, failureThreshold: 3 }, + livenessProbe: { tcpSocket: { port: "http" }, periodSeconds: 20, failureThreshold: 3 }, + resources: { requests: { cpu: "100m", memory: "256Mi" }, limits: { cpu: "1", memory: "1Gi" } }, + volumeMounts: [{ name: "workspace", mountPath: "/workspace" }] + }, (() => { + const container = { + name: "opencode-provider-proxy", + image: helperImage, + imagePullPolicy: "IfNotPresent", + command: ["node", "/app/internal/dev-entrypoint/opencode-provider-proxy.mjs"], + env: [ + { name: "HWLAB_SERVICE_ID", value: "opencode-provider-proxy" }, + { name: "HWLAB_ENVIRONMENT", value: profile }, + { name: "HWLAB_COMMIT_ID", value: helperCommit }, + { name: "HWLAB_IMAGE", value: helperImage }, + { name: "HWLAB_IMAGE_TAG", value: helperImageTag }, + { name: "PORT", value: String(opencodeConfig.providerProxyPort) }, + { name: "HWLAB_OPENCODE_PROVIDER_PROXY_UPSTREAM_BASE_URL", value: opencodeConfig.upstreamBaseURL }, + { name: "HWLAB_OPENCODE_PROVIDER_PROXY_PUBLIC_BASE_PATH", value: opencodeConfig.providerProxyBasePath }, + { name: "HWLAB_OPENCODE_PROVIDER_PROXY_TIMEOUT_MS", value: "600000" }, + { name: "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", value: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces" }, + { name: "OTEL_SERVICE_NAME", value: "opencode-provider-proxy" } + ], + ports: [{ name: "provider", containerPort: opencodeConfig.providerProxyPort }], + readinessProbe: { httpGet: { path: "/health/readiness", port: "provider" }, periodSeconds: 10, failureThreshold: 3 }, + livenessProbe: { httpGet: { path: "/health/live", port: "provider" }, periodSeconds: 20, failureThreshold: 3 }, + resources: { requests: { cpu: "25m", memory: "64Mi" }, limits: { cpu: "250m", memory: "256Mi" } } + }; + if (providerProxyBootMetadata) { + applyEnvReuseBootEnv(container, providerProxyBootMetadata, { sourceBranch, gitReadUrl, bootSh: providerProxyBootSh }); + } + return container; + })()], + volumes: [{ name: "workspace", persistentVolumeClaim: { claimName: `${name}-data` } }] + } + } + } + } + ] + }; +} + +export { + argoApplication, + argoProject, + deepSeekProxyManifest, + deviceAgent71FreqManifest, + deviceAgent71FreqServerScript, + externalPostgresConfigForLane, + externalPostgresManifest, + moonBridgeConfigRenderScript, + nodeFrpcManifest, + normalizeSecretPlaneNodeList, + opencodeApiKeyEnvName, + opencodeConfigBoolean, + opencodeConfigContent, + opencodeEgressNoProxyForProfile, + opencodeEgressProxyUrlForProfile, + opencodePositiveInteger, + opencodeRuntimeConfigForProfile, + opencodeServerManifest, + runtimeConfigMapsForProfile, + runtimeConfigMapsManifest, + runtimePostgresImageForProfile, + runtimeSecretPlaneConfigForProfile, + runtimeSecretPlaneEnabledForNode, + runtimeSecretPlaneExternalSecret, + runtimeSecretPlaneManifest, + secretPlaneIssueLabelValue, + v02OpenFgaManifest, + v02PostgresManifest, + workbenchRuntimeRedisConf, + workbenchRuntimeRedisManifest +}; diff --git a/scripts/src/gitops-render/tekton-manifests.mjs b/scripts/src/gitops-render/tekton-manifests.mjs new file mode 100644 index 00000000..5d555130 --- /dev/null +++ b/scripts/src/gitops-render/tekton-manifests.mjs @@ -0,0 +1,830 @@ +import { + argoApplicationName, + buildkitRunnerImage, + ciToolsRunnerImage, + defaultDevBaseImage, + defaultRegistryPrefix, + defaultServiceIds, + defaultSourceRepo, + gitopsPathFor, + gitopsPathForProfile, + gitopsTargetForProfile, + isRuntimeLane, + namespaceNameForProfile, + primitiveValidationTasks, + proxyEnv, + serviceIdsForLane, + servicesParamForLane +} from "./core.mjs"; +import { + ciTimingShellFunction, + collectArtifactsScript, + controlPlaneReconcileScript, + gitopsPromoteScript, + perServicePublishScript, + planArtifactsScript, + pollerScript, + prepareSourceScript, + primitiveValidationTask, + primitiveValidationTaskNames +} from "./tekton-scripts.mjs"; + +function ciLaneSettings(argsOrLane = "node") { + const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane; + if (isRuntimeLane(lane)) { + const namespace = namespaceNameForProfile(lane); + return { + lane, + profile: lane, + gitopsTarget: lane, + pipelineName: `${namespace}-ci-image-publish`, + pipelineRunPrefix: `${namespace}-ci-poll`, + serviceAccountName: `${namespace}-tekton-runner`, + pollerName: `${namespace}-branch-poller`, + controlPlaneReconcilerName: `${namespace}-control-plane-reconciler`, + tektonDir: `tekton-${lane}`, + catalogPath: typeof argsOrLane === "object" ? argsOrLane.catalogPath : `deploy/artifact-catalog.${lane}.json`, + imageTagMode: typeof argsOrLane === "object" ? argsOrLane.imageTagMode : "full", + runtimeNamespace: namespace + }; + } + return { + lane: "node", + profile: "dev", + gitopsTarget: "node", + pipelineName: "hwlab-node-ci-image-publish", + pipelineRunPrefix: "hwlab-node-ci-poll", + serviceAccountName: "hwlab-tekton-runner", + pollerName: "hwlab-node-branch-poller", + controlPlaneReconcilerName: "hwlab-node-control-plane-reconciler", + tektonDir: "tekton", + catalogPath: "deploy/artifact-catalog.dev.json", + imageTagMode: "short", + runtimeNamespace: "hwlab-dev" + }; +} + +function tektonRbac(args = { lane: "node" }) { + const settings = ciLaneSettings(args); + if (isRuntimeLane(settings.lane)) { + const runtimeObserverName = `${settings.runtimeNamespace}-runtime-observer`; + const pipelineRunWriterName = `${settings.runtimeNamespace}-pipelinerun-writer`; + const argoReconcilerName = `${settings.runtimeNamespace}-argocd-reconciler`; + return { + apiVersion: "v1", + kind: "List", + items: [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, + { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: settings.serviceAccountName, namespace: "hwlab-ci" } }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: runtimeObserverName, namespace: settings.runtimeNamespace }, + 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: runtimeObserverName, namespace: settings.runtimeNamespace }, + subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: runtimeObserverName } + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" }, + rules: [ + { apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "create"] }, + { apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["batch"], resources: ["cronjobs"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] }, + { apiGroups: [""], resources: ["serviceaccounts"], verbs: ["get", "patch", "create"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" }, + subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: pipelineRunWriterName } + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: argoReconcilerName, namespace: "argocd" }, + rules: [ + { apiGroups: ["argoproj.io"], resources: ["applications", "appprojects"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: argoReconcilerName, namespace: "argocd" }, + subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: argoReconcilerName } + } + ] + }; + } + 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-node-control-plane-reconciler", namespace: "hwlab-dev" }, + rules: [ + { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: "hwlab-node-control-plane-reconciler", namespace: "hwlab-dev" }, + subjects: [{ kind: "ServiceAccount", name: "hwlab-tekton-runner", namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-node-control-plane-reconciler" } + }, + { + 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", "create"] }, + { apiGroups: ["batch"], resources: ["cronjobs"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] }, + { apiGroups: [""], resources: ["serviceaccounts"], verbs: ["get", "patch", "create"] } + ] + }, + { + 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 buildTaskName(serviceId) { + return `build-${serviceId}`; +} + +function affectedResultName(serviceId) { + return `affected-${serviceId}`; +} + +function buildResultName(serviceId) { + return `build-${serviceId}`; +} + +const serviceResultFields = Object.freeze([ + "status", + "service-id", + "image", + "image-tag", + "digest", + "repository-digest", + "source-commit-id", + "component-input-hash", + "environment-input-hash", + "code-input-hash", + "runtime-mode", + "boot-repo", + "boot-commit", + "boot-sh", + "build-created-at", + "build-backend", + "reused-from", + "go-base-image", + "go-base-image-status", + "go-base-digest", + "go-base-build-duration-ms", + "go-base-buildkit-cache-ref" +]); + +function serviceResultParamName(serviceId, field) { + return `${serviceId}-${field}`; +} + +function serviceResultEnvName(serviceId, field) { + return `HWLAB_SERVICE_RESULT_${serviceId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_")}_${field.toUpperCase().replace(/[^A-Z0-9]+/gu, "_")}`; +} + +function serviceResultParams(serviceIds = defaultServiceIds) { + return serviceIds.flatMap((serviceId) => serviceResultFields.map((field) => ({ name: serviceResultParamName(serviceId, field), default: "" }))); +} + +function serviceResultParamValues(serviceIds = defaultServiceIds) { + return serviceIds.flatMap((serviceId) => serviceResultFields.map((field) => ({ + name: serviceResultParamName(serviceId, field), + value: `$(tasks.${buildTaskName(serviceId)}.results.${field})` + }))); +} + +function serviceResultEnv(serviceIds = defaultServiceIds) { + return serviceIds.flatMap((serviceId) => serviceResultFields.map((field) => ({ + name: serviceResultEnvName(serviceId, field), + value: `$(params.${serviceResultParamName(serviceId, field)})` + }))); +} + +function serviceWorkVolume() { + return { name: "service-work", emptyDir: {} }; +} + +function buildkitBinVolume() { + return { name: "buildkit-bin", emptyDir: {} }; +} + +function buildkitRunVolume() { + return { name: "buildkit-run", emptyDir: {} }; +} + +function prepareBuildkitClientStep() { + return { + name: "prepare-buildkit-client", + image: buildkitRunnerImage, + securityContext: { runAsUser: 0, runAsGroup: 0 }, + volumeMounts: [{ name: "buildkit-bin", mountPath: "/workspace/buildkit-bin" }], + script: `#!/bin/sh +set -eu +mkdir -p /workspace/buildkit-bin +cp /usr/bin/buildctl /workspace/buildkit-bin/ +chmod +x /workspace/buildkit-bin/* +` + }; +} + +function buildkitSidecar() { + return { + name: "buildkitd", + image: buildkitRunnerImage, + args: ["--addr", "unix:///workspace/buildkit-run/buildkitd.sock", "--oci-worker-no-process-sandbox"], + env: proxyEnv(), + volumeMounts: [{ name: "buildkit-run", mountPath: "/workspace/buildkit-run" }], + securityContext: { + runAsUser: 1000, + runAsGroup: 1000, + allowPrivilegeEscalation: true, + appArmorProfile: { type: "Unconfined" }, + seccompProfile: { type: "Unconfined" } + } + }; +} + +function planArtifactsTask({ serviceIds = defaultServiceIds } = {}) { + return { + name: "plan-artifacts", + runAfter: primitiveValidationTaskNames(), + 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: serviceIds.flatMap((serviceId) => [ + { name: affectedResultName(serviceId), description: `${serviceId} rollout affected according to ci-plan` }, + { name: buildResultName(serviceId), description: `${serviceId} image build required according to ci-plan` } + ]), + workspaces: [{ name: "source" }], + steps: [{ + name: "plan", + image: ciToolsRunnerImage, + env: [ + { name: "HWLAB_SELECTED_SERVICES", value: "$(params.services)" }, + { name: "HWLAB_DEV_REGISTRY_K8S_NATIVE", value: "1" }, + { name: "HWLAB_TEKTON_PIPELINERUN", value: "$(context.pipelineRun.name)" }, + ...proxyEnv() + ], + script: planArtifactsScript() + }] + }, + 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)" } + ] + }; +} + +function perServiceBuildTask(serviceId, { runAfter = ["plan-artifacts"] } = {}) { + return { + name: buildTaskName(serviceId), + runAfter, + workspaces: [{ name: "source", workspace: "source" }], + when: [{ input: `$(tasks.plan-artifacts.results.${buildResultName(serviceId)})`, operator: "in", values: ["true"] }], + 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: serviceResultFields.map((field) => ({ name: field, description: `${serviceId} ${field}` })), + workspaces: [{ name: "source" }], + volumes: [serviceWorkVolume(), buildkitBinVolume(), buildkitRunVolume()], + sidecars: [buildkitSidecar()], + steps: [ + prepareBuildkitClientStep(), + { + name: "publish", + image: ciToolsRunnerImage, + securityContext: { runAsUser: 1000, runAsGroup: 1000 }, + env: proxyEnv(), + volumeMounts: [{ name: "service-work", mountPath: "/workspace/service-work" }, { name: "buildkit-bin", mountPath: "/workspace/buildkit-bin" }, { name: "buildkit-run", mountPath: "/workspace/buildkit-run" }], + script: perServicePublishScript() + } + ] + }, + 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: "service-id", value: serviceId }, + { name: "base-image", value: "$(params.base-image)" }, + { name: "build-cache-mode", value: "$(params.build-cache-mode)" } + ] + }; +} + +function collectArtifactsTask({ serviceIds = defaultServiceIds } = {}) { + return { + name: "collect-artifacts", + runAfter: serviceIds.map(buildTaskName), + 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: ciToolsRunnerImage, env: proxyEnv(), script: collectArtifactsScript() }] + }, + 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)" } + ] + }; +} + +function runtimeReadyScript() { + return `#!/bin/sh +set -eu +${ciTimingShellFunction()} +export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" +export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" +export HWLAB_TEKTON_TASK="runtime-ready" +export HWLAB_SOURCE_REVISION="$(params.revision)" +export HWLAB_RUNTIME_READY_TIMEOUT_MS="$(params.timeout-ms)" +cd /workspace/source/repo +node scripts/ci/runtime-ready.mjs +`; +} + +function runtimeReadyTask({ profile = "dev" } = {}) { + return { + name: "runtime-ready", + runAfter: ["gitops-promote"], + workspaces: [{ name: "source", workspace: "source" }], + taskSpec: { + params: [{ name: "revision" }, { name: "runtime-namespace" }, { name: "gitops-target" }, { name: "argo-application" }, { name: "timeout-ms" }], + workspaces: [{ name: "source" }], + steps: [{ + name: "runtime-ready", + image: ciToolsRunnerImage, + env: [ + ...proxyEnv(), + { name: "HWLAB_RUNTIME_NAMESPACE", value: "$(params.runtime-namespace)" }, + { name: "HWLAB_GITOPS_TARGET", value: "$(params.gitops-target)" }, + { name: "HWLAB_ARGO_APPLICATION", value: "$(params.argo-application)" } + ], + script: runtimeReadyScript() + }] + }, + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "runtime-namespace", value: namespaceNameForProfile(profile) }, + { name: "gitops-target", value: gitopsTargetForProfile(profile) }, + { name: "argo-application", value: argoApplicationName(profile) }, + { name: "timeout-ms", value: "$(params.runtime-ready-timeout-ms)" } + ] + }; +} + +function imagePublishTaskSet(args = { lane: "node" }, deploy = null) { + const serviceIds = serviceIdsForLane(args.lane, deploy); + const buildTasks = serviceIds.map((serviceId) => perServiceBuildTask(serviceId, { + runAfter: ["plan-artifacts"] + })); + return [ + planArtifactsTask({ serviceIds }), + ...buildTasks, + collectArtifactsTask({ serviceIds }) + ]; +} + +function tektonPipeline(args = { lane: "node" }, deploy = null) { + const settings = ciLaneSettings(args); + const runtimePath = gitopsPathForProfile(args, settings.profile); + const preFlushRuntimeReadyTasks = isRuntimeLane(settings.lane) + ? [] + : [{ + ...runtimeReadyTask({ profile: settings.profile }), + when: [{ input: "$(tasks.gitops-promote.results.runtime-ready-required)", operator: "in", values: ["true"] }] + }]; + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { + name: settings.pipelineName, + namespace: "hwlab-ci", + labels: { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget + }, + 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" + } + }, + spec: { + params: [ + { name: "git-url", type: "string", default: defaultSourceRepo }, + { name: "git-read-url", type: "string", default: args.gitReadUrl }, + { name: "git-write-url", type: "string", default: args.gitWriteUrl }, + { name: "source-branch", type: "string", default: args.sourceBranch }, + { name: "gitops-branch", type: "string", default: args.gitopsBranch }, + { name: "lane", type: "string", default: settings.lane }, + { name: "catalog-path", type: "string", default: args.catalogPath || settings.catalogPath }, + { name: "image-tag-mode", type: "string", default: args.imageTagMode || settings.imageTagMode }, + { name: "runtime-path", type: "string", default: runtimePath }, + { 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: servicesParamForLane(args.lane, deploy) }, + { name: "base-image", type: "string", default: defaultDevBaseImage }, + { 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: ciToolsRunnerImage, env: proxyEnv(), script: prepareSourceScript() }] + }, + 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)" } + ] + }, + ...primitiveValidationTasks.map(primitiveValidationTask), + ...imagePublishTaskSet(args, deploy), + { + 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: ciToolsRunnerImage, env: proxyEnv(), script: gitopsPromoteScript() }] + }, + 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)" } + ] + }, + ...preFlushRuntimeReadyTasks + ] + } + }; +} + +function tektonPipelineRunTemplate({ source, args }) { + const settings = ciLaneSettings(args); + const runtimePath = gitopsPathForProfile(args, settings.profile); + return { + apiVersion: "tekton.dev/v1", + kind: "PipelineRun", + metadata: { + generateName: `${settings.pipelineRunPrefix}-manual-`, + namespace: "hwlab-ci", + labels: { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget, + "hwlab.pikastech.local/source-commit": source.full + } + }, + spec: { + pipelineRef: { name: settings.pipelineName }, + taskRunTemplate: { + serviceAccountName: settings.serviceAccountName, + podTemplate: { + hostNetwork: true, + dnsPolicy: "ClusterFirstWithHostNet", + securityContext: { fsGroup: 1000 } + } + }, + params: [ + { name: "git-url", value: args.sourceRepo }, + { name: "git-read-url", value: args.gitReadUrl }, + { name: "git-write-url", value: args.gitWriteUrl }, + { name: "source-branch", value: args.sourceBranch }, + { name: "gitops-branch", value: args.gitopsBranch }, + { 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: runtimePath }, + { name: "revision", value: source.full }, + { name: "registry-prefix", value: args.registryPrefix }, + { name: "base-image", value: defaultDevBaseImage }, + { name: "build-cache-mode", value: "registry" } + ], + workspaces: [ + { name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } }, + { name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } } + ] + } + }; +} + +function tektonPollerCronJob(args, deploy) { + const settings = ciLaneSettings(args); + 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", + kind: "CronJob", + metadata: { + name: settings.pollerName, + namespace: "hwlab-ci", + labels: { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget + }, + annotations: { + "hwlab.pikastech.local/source-branch": args.sourceBranch, + "hwlab.pikastech.local/gitops-branch": args.gitopsBranch + } + }, + spec: { + schedule: settings.lane === "v02" ? "*/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": settings.gitopsTarget + } + }, + spec: { + serviceAccountName: settings.serviceAccountName, + restartPolicy: "Never", + hostNetwork: true, + dnsPolicy: "ClusterFirstWithHostNet", + containers: [{ + name: "poll", + image: ciToolsRunnerImage, + imagePullPolicy: "IfNotPresent", + env: [ + ...proxyEnv(), + { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, + { name: "PIPELINE_NAME", value: settings.pipelineName }, + { name: "PIPELINERUN_PREFIX", value: settings.pipelineRunPrefix }, + { name: "SERVICE_ACCOUNT_NAME", value: settings.serviceAccountName }, + { name: "GITOPS_TARGET", value: settings.gitopsTarget }, + { 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: "GIT_URL", value: args.sourceRepo }, + { name: "GIT_READ_URL", value: args.gitReadUrl }, + { name: "SOURCE_BRANCH", value: args.sourceBranch }, + { name: "GITOPS_BRANCH", value: args.gitopsBranch }, + { name: "REGISTRY_PREFIX", value: args.registryPrefix }, + { name: "SERVICES", value: servicesParamForLane(args.lane, deploy) }, + { name: "BASE_IMAGE", value: defaultDevBaseImage } + ], + 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) { + const settings = ciLaneSettings(args); + if (settings.lane === "v02") throw new Error("v02 CI/CD is manually triggered by UniDesk CLI and must not render a control-plane reconciler CronJob"); + return { + apiVersion: "batch/v1", + kind: "CronJob", + metadata: { + name: settings.controlPlaneReconcilerName, + namespace: "hwlab-ci", + labels: { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget + }, + annotations: { + "hwlab.pikastech.local/source-branch": args.sourceBranch, + "hwlab.pikastech.local/gitops-branch": args.gitopsBranch, + "hwlab.pikastech.local/purpose": "render-and-apply-node-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": settings.gitopsTarget + } + }, + spec: { + serviceAccountName: settings.serviceAccountName, + restartPolicy: "Never", + hostNetwork: true, + dnsPolicy: "ClusterFirstWithHostNet", + containers: [{ + name: "reconcile", + image: ciToolsRunnerImage, + imagePullPolicy: "IfNotPresent", + env: [ + ...proxyEnv(), + { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, + { name: "LANE", value: settings.lane }, + { name: "CATALOG_PATH", value: args.catalogPath || settings.catalogPath }, + { name: "IMAGE_TAG_MODE", value: args.imageTagMode || settings.imageTagMode }, + { name: "TEKTON_DIR", value: settings.tektonDir }, + { name: "ARGO_FILES", value: settings.lane === "v02" ? `${gitopsPathFor(args, "argocd/project.yaml")},${gitopsPathFor(args, "argocd/application-v02.yaml")}` : "" }, + { name: "FIELD_MANAGER", value: settings.controlPlaneReconcilerName }, + { name: "RECONCILE_EVENT", value: `${settings.gitopsTarget}-control-plane-reconciled` }, + { name: "GIT_URL", value: args.sourceRepo }, + { name: "GIT_READ_URL", value: args.gitReadUrl }, + { 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" } }] + } + } + } + } + } + }; +} + +export { + affectedResultName, + buildResultName, + buildTaskName, + buildkitBinVolume, + buildkitRunVolume, + buildkitSidecar, + ciLaneSettings, + collectArtifactsTask, + imagePublishTaskSet, + perServiceBuildTask, + planArtifactsTask, + prepareBuildkitClientStep, + runtimeReadyScript, + runtimeReadyTask, + serviceResultEnv, + serviceResultEnvName, + serviceResultParamName, + serviceResultParams, + serviceResultParamValues, + serviceWorkVolume, + tektonControlPlaneReconcilerCronJob, + tektonPipeline, + tektonPipelineRunTemplate, + tektonPollerCronJob, + tektonRbac +}; diff --git a/scripts/src/gitops-render/tekton-scripts.mjs b/scripts/src/gitops-render/tekton-scripts.mjs new file mode 100644 index 00000000..1f207e33 --- /dev/null +++ b/scripts/src/gitops-render/tekton-scripts.mjs @@ -0,0 +1,187 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ciToolsRunnerImage, + defaultServiceIds, + defaultV02RuntimeEndpoint, + primitiveValidationTasks, + proxyEnv +} from "./core.mjs"; + +const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates"); +const templateCache = new Map(); + +function readTemplate(name) { + if (!templateCache.has(name)) { + templateCache.set(name, readFileSync(path.join(templatesDir, name), "utf8")); + } + return templateCache.get(name); +} + +function renderTemplate(name, replacements = {}) { + let rendered = readTemplate(name); + for (const [token, value] of Object.entries(replacements)) { + if (!rendered.includes(token)) throw new Error(`template ${name} is missing token ${token}`); + rendered = rendered.replaceAll(token, String(value)); + } + const unresolved = [...rendered.matchAll(/__HWLAB_[A-Z0-9_]+__/gu)].map((match) => match[0]); + if (unresolved.length > 0) { + throw new Error(`template ${name} has unresolved tokens: ${[...new Set(unresolved)].join(", ")}`); + } + return rendered; +} + +function shellSingleQuote(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +function ciTimingShellFunction() { + return readTemplate("ci-timing-functions.sh"); +} + +function dependencyProxyProbeScript(phase, targetUrl) { + if (!/^[a-z0-9-]+$/u.test(phase)) throw new Error(`invalid dependency proxy probe phase: ${phase}`); + return renderTemplate("dependency-proxy-probe.sh", { + "__HWLAB_PROXY_PHASE_SHELL__": shellSingleQuote(phase), + "__HWLAB_PROXY_TARGET_SHELL__": shellSingleQuote(targetUrl), + "__HWLAB_PROXY_PHASE__": phase + }); +} + +function curlDownloadProbeFunction() { + return readTemplate("curl-download-probe.sh"); +} + +function gitSshShellFunction() { + return renderTemplate("git-ssh-functions.sh", { + "// __HWLAB_GITHUB_PROXY_CONNECT_MJS__": readTemplate("github-proxy-connect.mjs") + }); +} + +function prepareSourceScript() { + return renderTemplate("prepare-source.sh", { + "# __HWLAB_CI_TIMING_SHELL__\n": ciTimingShellFunction(), + "# __HWLAB_GIT_SSH_SHELL__\n": gitSshShellFunction(), + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage, + "__HWLAB_V02_RUNTIME_ENDPOINT__": JSON.stringify(defaultV02RuntimeEndpoint).slice(1, -1), + "__HWLAB_VALIDATION_TASK_COUNT__": primitiveValidationTasks.length + }); +} + +function sourceValidationScript(commands) { + return [ + "#!/bin/sh", + "set -eu", + "git config --global --add safe.directory /workspace/source/repo", + "cd /workspace/source/repo", + "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", + ...commands + ].join("\n") + "\n"; +} + +function primitiveValidationTaskNames() { + return primitiveValidationTasks.map((task) => task.name); +} + +function primitiveValidationTask(task) { + return { + name: task.name, + 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: task.name, image: ciToolsRunnerImage, env: proxyEnv(), script: sourceValidationScript(task.commands) }] + }, + 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)" } + ] + }; +} + +function planArtifactsScript() { + return renderTemplate("plan-artifacts.sh", { + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage, + '["__HWLAB_DEFAULT_SERVICE_IDS__"]': JSON.stringify(defaultServiceIds) + }); +} + +function perServicePublishScript() { + return renderTemplate("per-service-publish.sh", { + "# __HWLAB_CI_TIMING_SHELL__\n": ciTimingShellFunction(), + "# __HWLAB_DEPENDENCY_PROXY_PROBE__": dependencyProxyProbeScript( + "service-image-publish-proxy-preflight", + "http://deb.debian.org/debian/dists/bookworm/InRelease" + ), + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage + }); +} + +function collectArtifactsScript() { + return renderTemplate("collect-artifacts.sh", { + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage + }); +} + +function gitopsPromoteScript() { + return renderTemplate("gitops-promote.sh", { + "# __HWLAB_CI_TIMING_SHELL__\n": ciTimingShellFunction(), + "# __HWLAB_GIT_SSH_SHELL__\n": gitSshShellFunction(), + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage + }); +} + +function controlPlaneReconcileScript() { + return renderTemplate("control-plane-reconcile.sh", { + "# __HWLAB_DEPENDENCY_PROXY_PROBE__": dependencyProxyProbeScript( + "control-plane-proxy-preflight", + "http://deb.debian.org/debian/dists/bookworm/InRelease" + ), + "# __HWLAB_GIT_SSH_SHELL__\n": gitSshShellFunction(), + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage + }); +} + +function pollerScript() { + return renderTemplate("poller.sh", { + "# __HWLAB_DEPENDENCY_PROXY_PROBE__": dependencyProxyProbeScript( + "poller-proxy-preflight", + "http://deb.debian.org/debian/dists/bookworm/InRelease" + ), + "# __HWLAB_GIT_SSH_SHELL__\n": gitSshShellFunction(), + "__HWLAB_CI_TOOLS_IMAGE__": ciToolsRunnerImage + }); +} + +export { + ciTimingShellFunction, + collectArtifactsScript, + controlPlaneReconcileScript, + curlDownloadProbeFunction, + dependencyProxyProbeScript, + gitopsPromoteScript, + gitSshShellFunction, + perServicePublishScript, + planArtifactsScript, + pollerScript, + prepareSourceScript, + primitiveValidationTask, + primitiveValidationTaskNames, + renderTemplate, + shellSingleQuote, + sourceValidationScript +}; diff --git a/scripts/src/gitops-render/templates/ci-timing-functions.sh b/scripts/src/gitops-render/templates/ci-timing-functions.sh new file mode 100644 index 00000000..62a0cd2d --- /dev/null +++ b/scripts/src/gitops-render/templates/ci-timing-functions.sh @@ -0,0 +1,13 @@ +ci_now_ms() { + node -e 'console.log(Date.now())' +} + +ci_timing_emit() { + stage="$1" + status="$2" + started_ms="$3" + finished_ms="$(ci_now_ms)" + duration_ms="$((finished_ms - started_ms))" + service_id="${HWLAB_TIMING_SERVICE_ID:-}" + 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" +} diff --git a/scripts/src/gitops-render/templates/collect-artifacts.sh b/scripts/src/gitops-render/templates/collect-artifacts.sh new file mode 100644 index 00000000..748b5280 --- /dev/null +++ b/scripts/src/gitops-render/templates/collect-artifacts.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu +echo '{"event":"ci-base-image","phase":"collect-artifacts","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apk"}' +for 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 +cd /workspace/source/repo +test "$(git rev-parse HEAD)" = "$(params.revision)" +export HWLAB_CI_ARTIFACT_RUN_ID="$(context.pipelineRun.name)-collect" +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 +export HWLAB_ARTIFACT_LANE="$(params.lane)" +export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" +export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml" +export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" +if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE' +const fs = require("node:fs"); +const plan = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : []; +const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : []; +const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true; +process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1); +NODE +then + rm -f /workspace/source/dev-artifacts.json + echo '{"event":"collect-artifacts","status":"skipped","reason":"no-build-no-rollout-plan"}' + exit 0 +fi +HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node 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 +node 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 diff --git a/scripts/src/gitops-render/templates/control-plane-reconcile.sh b/scripts/src/gitops-render/templates/control-plane-reconcile.sh new file mode 100644 index 00000000..1e404cf8 --- /dev/null +++ b/scripts/src/gitops-render/templates/control-plane-reconcile.sh @@ -0,0 +1,139 @@ +#!/bin/sh +set -eu +# __HWLAB_DEPENDENCY_PROXY_PROBE__ +echo '{"event":"ci-base-image","phase":"control-plane-reconciler","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apk"}' +for tool in node npm git python3 ssh curl timeout; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"control-plane-reconciler","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done +# __HWLAB_GIT_SSH_SHELL__ + +git_ssh_setup +workdir="$(mktemp -d)" +git_timed control-plane-clone 180 git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_READ_URL" "$workdir/repo" +cd "$workdir/repo" +git remote set-url origin "$GIT_READ_URL" +revision="$(git rev-parse HEAD)" +: "${LANE:?LANE is required}" +: "${CATALOG_PATH:?CATALOG_PATH is required}" +: "${IMAGE_TAG_MODE:?IMAGE_TAG_MODE is required}" +: "${RUNTIME_PATH:?RUNTIME_PATH is required}" +gitops_root="${RUNTIME_PATH%/*}" +if [ "$gitops_root" = "$RUNTIME_PATH" ]; then gitops_root="."; fi +node scripts/run-bun.mjs scripts/gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --gitops-root "$gitops_root" --out "$gitops_root" --registry-prefix "$REGISTRY_PREFIX" +node scripts/run-bun.mjs scripts/gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --gitops-root "$gitops_root" --out "$gitops_root" --registry-prefix "$REGISTRY_PREFIX" --check +node <<'NODE' +const fs = require("node:fs"); +const https = require("node:https"); + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +} + +const namespace = requiredEnv("POD_NAMESPACE"); +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 tektonDir = requiredEnv("TEKTON_DIR"); +const argoFiles = String(process.env.ARGO_FILES || "").split(",").map((item) => item.trim()).filter(Boolean); +const fieldManager = requiredEnv("FIELD_MANAGER"); +const eventName = requiredEnv("RECONCILE_EVENT"); +const manifestFiles = [ + "deploy/gitops/node/" + tektonDir + "/rbac.yaml", + "deploy/gitops/node/" + tektonDir + "/pipeline.yaml", + ...(process.env.LANE === "v02" ? [] : [ + "deploy/gitops/node/" + tektonDir + "/poller.yaml", + "deploy/gitops/node/" + tektonDir + "/control-plane-reconciler.yaml" + ]), + ...argoFiles +]; +const plurals = new Map([ + ["ServiceAccount", "serviceaccounts"], + ["Role", "roles"], + ["RoleBinding", "rolebindings"], + ["Pipeline", "pipelines"], + ["CronJob", "cronjobs"], + ["Application", "applications"], + ["AppProject", "appprojects"] +]); + +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 reconcilePolicy(item) { + const ns = item.metadata?.namespace || namespace; + const crossNamespaceRbac = (item.kind === "Role" || item.kind === "RoleBinding") && ns !== namespace; + if (crossNamespaceRbac && process.env.HWLAB_RECONCILE_CROSS_NAMESPACE_RBAC !== "1") { + return { action: "skip", reason: "cross-namespace-rbac-bootstrap-managed", namespace: ns }; + } + return { action: "apply", namespace: ns }; +} + +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 policy = reconcilePolicy(item); + if (policy.action === "skip") { + results.push({ file, kind: item.kind, name: item.metadata?.name || null, namespace: policy.namespace, status: "skipped", reason: policy.reason }); + continue; + } + 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=" + encode(fieldManager) + "&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: eventName, sourceRevision: process.env.RECONCILE_REVISION || null, results }, null, 2)); +})().catch((error) => { + console.error(error.stack || error.message); + process.exitCode = 1; +}); +NODE diff --git a/scripts/src/gitops-render/templates/curl-download-probe.sh b/scripts/src/gitops-render/templates/curl-download-probe.sh new file mode 100644 index 00000000..a3a44cc2 --- /dev/null +++ b/scripts/src/gitops-render/templates/curl-download-probe.sh @@ -0,0 +1,17 @@ +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}"} +' "$url" +} \ No newline at end of file diff --git a/scripts/src/gitops-render/templates/dependency-proxy-probe.sh b/scripts/src/gitops-render/templates/dependency-proxy-probe.sh new file mode 100644 index 00000000..7b2063ee --- /dev/null +++ b/scripts/src/gitops-render/templates/dependency-proxy-probe.sh @@ -0,0 +1,96 @@ +HWLAB_PROXY_PROBE_PHASE=__HWLAB_PROXY_PHASE_SHELL__ HWLAB_PROXY_PROBE_URL=__HWLAB_PROXY_TARGET_SHELL__ node <<'NODE' || echo '{"event":"dependency-proxy-probe-nonblocking","phase":"__HWLAB_PROXY_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-node-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 \ No newline at end of file diff --git a/scripts/src/gitops-render/templates/git-mirror-flush.sh b/scripts/src/gitops-render/templates/git-mirror-flush.sh new file mode 100644 index 00000000..cf0931b2 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-flush.sh @@ -0,0 +1,76 @@ +#!/bin/sh +set -eu +repo_url="ssh://git@ssh.github.com:443/pikasTech/HWLAB.git" +repo_path="/cache/pikasTech/HWLAB.git" +lock_dir="/cache/.hwlab-flush.lock" +started_ms=$(node -e 'console.log(Date.now())') +now_ms() { node -e 'console.log(Date.now())'; } +emit_timing() { + phase="$1" + status="$2" + started="$3" + finished=$(now_ms) + duration=$((finished - started)) + printf '{"event":"git-mirror-flush-timing","phase":"%s","status":"%s","durationMs":%s,"repository":"pikasTech/HWLAB"} +' "$phase" "$status" "$duration" +} +if ! mkdir "$lock_dir" 2>/dev/null; then + echo "git mirror flush already running" + exit 0 +fi +trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT INT TERM +mkdir -p /root/.ssh +cp /git-ssh/ssh-privatekey /root/.ssh/id_rsa +chmod 0400 /root/.ssh/id_rsa +# __HWLAB_GIT_MIRROR_SSH_PRELUDE__ +/script/install-hooks.sh +for gitops_branch in __HWLAB_GITOPS_BRANCH_ARGS__; do + local_head=$(git -C "$repo_path" show-ref --verify --hash "refs/heads/$gitops_branch" 2>/dev/null || true) + if [ -z "$local_head" ]; then + echo "git mirror flush skips missing local refs/heads/$gitops_branch" + continue + fi + fetch_started=$(now_ms) + timeout 90 git -C "$repo_path" fetch --quiet "$repo_url" "refs/heads/$gitops_branch:refs/mirror-stage/heads/$gitops_branch" + emit_timing "fetch-$gitops_branch" succeeded "$fetch_started" + github_head=$(git -C "$repo_path" show-ref --verify --hash "refs/mirror-stage/heads/$gitops_branch" 2>/dev/null || true) + if [ -n "$github_head" ] && [ "$github_head" != "$local_head" ] && ! git -C "$repo_path" merge-base --is-ancestor "$github_head" "$local_head"; then + echo "git mirror flush rejected divergent $gitops_branch local=$local_head github=$github_head" >&2 + exit 52 + fi + if [ "$local_head" != "$github_head" ]; then + push_started=$(now_ms) + timeout 120 git -C "$repo_path" push "$repo_url" "$local_head:refs/heads/$gitops_branch" + emit_timing "push-$gitops_branch" succeeded "$push_started" + else + echo "git mirror flush $gitops_branch already matches GitHub" + fi + git -C "$repo_path" update-ref "refs/mirror-stage/heads/$gitops_branch" "$local_head" +done +git -C "$repo_path" update-server-info +flushed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +export flushed_at +export gitops_branches_json=__HWLAB_GITOPS_BRANCHES_JSON_SHELL__ +node - <<'NODE' | tee /cache/HWLAB.last-flush.json +const { execFileSync } = require("node:child_process"); +const repo = "/cache/pikasTech/HWLAB.git"; +const gitopsBranches = JSON.parse(process.env.gitops_branches_json || "[]"); +function rev(ref) { + try { return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim() || null; } catch { return null; } +} +const refs = {}; +for (const branch of gitopsBranches) { + refs["refs/heads/" + branch] = rev("refs/heads/" + branch); + refs["refs/mirror-stage/heads/" + branch] = rev("refs/mirror-stage/heads/" + branch); +} +const pendingFlush = gitopsBranches.some((branch) => refs["refs/heads/" + branch] && refs["refs/mirror-stage/heads/" + branch] && refs["refs/heads/" + branch] !== refs["refs/mirror-stage/heads/" + branch]); +console.log(JSON.stringify({ + event: "git-mirror-flush", + status: "flushed", + repository: "pikasTech/HWLAB", + flushedAt: process.env.flushed_at, + pendingFlush, + refs +})); +NODE +emit_timing total succeeded "$started_ms" diff --git a/scripts/src/gitops-render/templates/git-mirror-http.mjs b/scripts/src/gitops-render/templates/git-mirror-http.mjs new file mode 100644 index 00000000..a69446d9 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-http.mjs @@ -0,0 +1,196 @@ +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createReadStream, statSync } from "node:fs"; +import path from "node:path"; +import { createGunzip, createInflate } from "node:zlib"; + +const cacheRoot = process.env.GIT_MIRROR_CACHE_ROOT || "/cache"; +const port = Number(process.env.PORT || 8080); + +createServer((req, res) => { + handle(req, res).catch((error) => { + if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: error.message })); + }); +}).listen(port, "0.0.0.0", () => { + console.log(JSON.stringify({ event: "git-mirror-smart-http-listening", port, cacheRoot })); +}); + +async function handle(req, res) { + const url = new URL(req.url || "/", "http://git-mirror-http"); + const writeHost = isWriteHost(req.headers.host || ""); + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) { + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-cache" }); + res.end(JSON.stringify({ ok: true, service: "git-mirror-smart-http", writeHost })); + return; + } + if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-upload-pack") { + await runUploadPackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res); + return; + } + if (req.method === "POST" && url.pathname.endsWith("/git-upload-pack")) { + await runUploadPackRpc(repoPathFromGitServicePath(url.pathname, "/git-upload-pack"), req, res); + return; + } + if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-receive-pack") { + if (!writeHost) return rejectReadOnly(res); + await runReceivePackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res); + return; + } + if (req.method === "POST" && url.pathname.endsWith("/git-receive-pack")) { + if (!writeHost) return rejectReadOnly(res); + await runReceivePackRpc(repoPathFromGitServicePath(url.pathname, "/git-receive-pack"), req, res); + return; + } + if (req.method === "GET" || req.method === "HEAD") { + serveStaticFile(url.pathname, req, res); + return; + } + res.writeHead(405, { "content-type": "text/plain" }); + res.end("method not allowed\n"); +} + +function isWriteHost(hostHeader) { + const host = String(hostHeader || "").split(":", 1)[0].toLowerCase(); + return host === "git-mirror-write" || host.startsWith("git-mirror-write."); +} + +function rejectReadOnly(res) { + res.writeHead(403, { "content-type": "application/json", "cache-control": "no-cache" }); + res.end(JSON.stringify({ ok: false, error: "git mirror receive-pack is only available through git-mirror-write" })); +} + +function repoPathFromGitServicePath(urlPath, suffix) { + const repoUrlPath = urlPath.slice(0, -suffix.length); + if (!repoUrlPath.endsWith(".git")) throw new Error(`unsupported git repo path: ${urlPath}`); + return safePath(repoUrlPath); +} + +function safePath(urlPath) { + const decoded = decodeURIComponent(urlPath.replace(/^[/]+/u, "")); + const fullPath = path.resolve(cacheRoot, decoded); + const normalizedRoot = path.resolve(cacheRoot); + if (fullPath !== normalizedRoot && !fullPath.startsWith(`${normalizedRoot}${path.sep}`)) throw new Error("path escapes cache root"); + return fullPath; +} + +function smartHeaders(contentType) { + return { + "content-type": contentType, + "cache-control": "no-cache, max-age=0, must-revalidate", + expires: "Fri, 01 Jan 1980 00:00:00 GMT", + pragma: "no-cache" + }; +} + +function pktLine(text) { + const length = Buffer.byteLength(text) + 4; + return `${length.toString(16).padStart(4, "0")}${text}`; +} + +function requestBodyStream(req) { + const encoding = String(req.headers["content-encoding"] || "identity").toLowerCase(); + if (encoding === "identity" || encoding === "") return req; + if (encoding === "gzip" || encoding === "x-gzip") return req.pipe(createGunzip()); + if (encoding === "deflate") return req.pipe(createInflate()); + throw new Error(`unsupported git upload-pack content-encoding: ${encoding}`); +} + +function runUploadPackAdvertisement(repoPath, res) { + return new Promise((resolve, reject) => { + const child = spawn("git-upload-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0 && !res.headersSent) reject(new Error(`git-upload-pack advertise failed: ${stderr.trim()}`)); + else resolve(); + }); + res.writeHead(200, smartHeaders("application/x-git-upload-pack-advertisement")); + res.write(pktLine("# service=git-upload-pack\n")); + res.write("0000"); + child.stdout.pipe(res, { end: false }); + child.stdout.on("end", () => res.end()); + }); +} + +function runUploadPackRpc(repoPath, req, res) { + return new Promise((resolve, reject) => { + const child = spawn("git-upload-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0 && !res.headersSent) reject(new Error(`git-upload-pack rpc failed: ${stderr.trim()}`)); + else resolve(); + }); + res.writeHead(200, smartHeaders("application/x-git-upload-pack-result")); + const body = requestBodyStream(req); + body.on("error", (error) => child.stdin.destroy(error)); + body.pipe(child.stdin); + child.stdout.pipe(res); + }); +} + +function runReceivePackAdvertisement(repoPath, res) { + return new Promise((resolve, reject) => { + const child = spawn("git-receive-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0 && !res.headersSent) reject(new Error(`git-receive-pack advertise failed: ${stderr.trim()}`)); + else resolve(); + }); + res.writeHead(200, smartHeaders("application/x-git-receive-pack-advertisement")); + res.write(pktLine("# service=git-receive-pack\n")); + res.write("0000"); + child.stdout.pipe(res, { end: false }); + child.stdout.on("end", () => res.end()); + }); +} + +function runReceivePackRpc(repoPath, req, res) { + return new Promise((resolve, reject) => { + const child = spawn("git-receive-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0 && !res.headersSent) reject(new Error(`git-receive-pack rpc failed: ${stderr.trim()}`)); + else resolve(); + }); + res.writeHead(200, smartHeaders("application/x-git-receive-pack-result")); + const body = requestBodyStream(req); + body.on("error", (error) => child.stdin.destroy(error)); + body.pipe(child.stdin); + child.stdout.pipe(res); + }); +} + +function serveStaticFile(urlPath, req, res) { + const fullPath = safePath(urlPath); + let stat; + try { stat = statSync(fullPath); } catch { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found\n"); + return; + } + if (!stat.isFile()) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found\n"); + return; + } + res.writeHead(200, { "content-length": stat.size, "content-type": contentType(fullPath), "cache-control": "no-cache" }); + if (req.method === "HEAD") { + res.end(); + return; + } + createReadStream(fullPath).pipe(res); +} + +function contentType(filePath) { + if (filePath.endsWith(".pack")) return "application/x-git-packed-objects"; + if (filePath.endsWith(".idx")) return "application/x-git-packed-objects-toc"; + return "text/plain"; +} diff --git a/scripts/src/gitops-render/templates/git-mirror-install-hooks.sh b/scripts/src/gitops-render/templates/git-mirror-install-hooks.sh new file mode 100644 index 00000000..b8f70d18 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-install-hooks.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -eu +repo_path="/cache/pikasTech/HWLAB.git" +mkdir -p "$repo_path/hooks" +cat > "$repo_path/hooks/pre-receive" <<'HOOK' +#!/bin/sh +set -eu +zero="0000000000000000000000000000000000000000" +status=0 +while read -r old new ref; do + if [ "$new" = "$zero" ]; then + echo "git mirror write rejected: deleting $ref is forbidden" >&2 + status=1 + continue + fi + git cat-file -e "$new^{commit}" || status=1 + git cat-file -e "$new^{tree}" || status=1 + if git rev-list --objects --missing=print "$new" | grep -q '^?'; then + echo "git mirror write rejected: missing objects for $new" >&2 + status=1 + continue + fi + if [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new"; then + echo "git mirror write rejected: non-fast-forward $ref" >&2 + status=1 + continue + fi +done +exit "$status" +HOOK +cat > "$repo_path/hooks/post-receive" <<'HOOK' +#!/bin/sh +set -eu +repo_path=$(git rev-parse --git-dir) +git -C "$repo_path" update-server-info +written_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +export repo_path written_at +export gitops_branches_json=__HWLAB_GITOPS_BRANCHES_JSON_SHELL__ +node - <<'NODE' > /cache/HWLAB.last-write.json +const { execFileSync } = require("node:child_process"); +const repo = process.env.repo_path || "/cache/pikasTech/HWLAB.git"; +const gitopsBranches = JSON.parse(process.env.gitops_branches_json || "[]"); +function rev(ref) { + try { return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim() || null; } catch { return null; } +} +const refs = {}; +for (const branch of gitopsBranches) { + refs["refs/heads/" + branch] = rev("refs/heads/" + branch); + refs["refs/mirror-stage/heads/" + branch] = rev("refs/mirror-stage/heads/" + branch); +} +const pendingFlush = gitopsBranches.some((branch) => refs["refs/heads/" + branch] && refs["refs/heads/" + branch] !== refs["refs/mirror-stage/heads/" + branch]); +console.log(JSON.stringify({ + event: "git-mirror-write", + status: "received", + repository: "pikasTech/HWLAB", + writtenAt: process.env.written_at, + pendingFlush, + refs +})); +NODE +HOOK +chmod 0755 "$repo_path/hooks/pre-receive" "$repo_path/hooks/post-receive" +git -C "$repo_path" config http.receivepack true +git -C "$repo_path" config receive.denyDeletes true +git -C "$repo_path" config receive.denyNonFastForwards true diff --git a/scripts/src/gitops-render/templates/git-mirror-proxy-connect.mjs b/scripts/src/gitops-render/templates/git-mirror-proxy-connect.mjs new file mode 100644 index 00000000..372bd7a4 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-proxy-connect.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import net from "node:net"; +const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2); +const proxyPort = Number.parseInt(proxyPortRaw || "", 10); +const targetPort = Number.parseInt(targetPortRaw || "", 10); +if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) { + console.error("hwlab git-mirror proxy-connect: invalid ProxyCommand arguments"); + process.exit(64); +} +let settled = false; +let tunnelEstablished = false; +function finish(code, message) { + if (settled) return; + settled = true; + if (message) console.error("hwlab git-mirror proxy-connect: " + message); + process.exit(code); +} +const socket = net.createConnection({ host: proxyHost, port: proxyPort }); +let buffer = Buffer.alloc(0); +socket.setTimeout(15000, () => { socket.destroy(); finish(65, "timeout connecting via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort); }); +socket.on("connect", () => socket.write("CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1\r\nHost: " + targetHost + ":" + targetPort + "\r\nProxy-Connection: Keep-Alive\r\n\r\n")); +socket.on("error", (error) => finish(tunnelEstablished ? 69 : 66, (tunnelEstablished ? "tunnel socket error: " : "tcp error connecting to proxy: ") + (error && error.message ? error.message : String(error)))); +socket.on("close", () => { if (!tunnelEstablished) finish(68, "proxy closed before CONNECT completed via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort); else finish(0); }); +function onData(chunk) { + buffer = Buffer.concat([buffer, chunk]); + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1 && buffer.length < 8192) return; + if (headerEnd === -1) { socket.destroy(); finish(68, "proxy response header exceeded 8192 bytes before CONNECT status via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort); return; } + const head = buffer.slice(0, headerEnd + 4).toString("latin1"); + const statusLine = head.split("\r\n", 1)[0] || ""; + const statusCode = Number.parseInt(statusLine.split(" ")[1] || "", 10); + if (!statusLine.startsWith("HTTP/1.") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) { + const safeStatus = statusLine.replace(/[^\x20-\x7e]/g, "?").slice(0, 160); + socket.destroy(); + finish(67, "proxy CONNECT failed via " + proxyHost + ":" + proxyPort + " to " + targetHost + ":" + targetPort + ": " + safeStatus); + return; + } + socket.off("data", onData); + socket.setTimeout(0); + tunnelEstablished = true; + const rest = buffer.slice(headerEnd + 4); + if (rest.length) process.stdout.write(rest); + process.stdin.on("error", () => {}); + process.stdout.on("error", () => {}); + process.stdin.pipe(socket); + socket.pipe(process.stdout); +} +socket.on("data", onData); \ No newline at end of file diff --git a/scripts/src/gitops-render/templates/git-mirror-ssh-direct.sh b/scripts/src/gitops-render/templates/git-mirror-ssh-direct.sh new file mode 100644 index 00000000..b2ce5451 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-ssh-direct.sh @@ -0,0 +1,11 @@ +printf '%s\n' __HWLAB_PROXY_SUMMARY_SHELL__ >&2 +cat > /tmp/hwlab-git-ssh-proxy.sh <<'SH_PROXY' +#!/bin/sh +exec ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -o ConnectTimeout=15 -o ServerAliveInterval=5 -o ServerAliveCountMax=1 "$@" +SH_PROXY +chmod 0700 /tmp/hwlab-git-ssh-proxy.sh +unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy +export NO_PROXY='*' +export no_proxy='*' +export GIT_SSH=/tmp/hwlab-git-ssh-proxy.sh +unset GIT_SSH_COMMAND diff --git a/scripts/src/gitops-render/templates/git-mirror-ssh-proxied.sh b/scripts/src/gitops-render/templates/git-mirror-ssh-proxied.sh new file mode 100644 index 00000000..354bb9a9 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-ssh-proxied.sh @@ -0,0 +1,20 @@ +printf '%s\n' __HWLAB_PROXY_SUMMARY_SHELL__ >&2 +cat > /tmp/hwlab-github-proxy-connect.mjs <<'NODE_PROXY' +// __HWLAB_GIT_MIRROR_PROXY_CONNECT_MJS__ +NODE_PROXY +chmod 0700 /tmp/hwlab-github-proxy-connect.mjs +cat > /tmp/hwlab-git-ssh-proxy.sh <<'SH_PROXY' +#!/bin/sh +exec ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -o ConnectTimeout=15 -o ServerAliveInterval=5 -o ServerAliveCountMax=1 -o __HWLAB_PROXY_COMMAND_SHELL__ "$@" +SH_PROXY +chmod 0700 /tmp/hwlab-git-ssh-proxy.sh +export HTTP_PROXY=__HWLAB_PROXY_URL_SHELL__ +export HTTPS_PROXY=__HWLAB_PROXY_URL_SHELL__ +export ALL_PROXY=__HWLAB_PROXY_URL_SHELL__ +export http_proxy=__HWLAB_PROXY_URL_SHELL__ +export https_proxy=__HWLAB_PROXY_URL_SHELL__ +export all_proxy=__HWLAB_PROXY_URL_SHELL__ +export NO_PROXY=__HWLAB_PROXY_NO_PROXY_SHELL__ +export no_proxy=__HWLAB_PROXY_NO_PROXY_SHELL__ +export GIT_SSH=/tmp/hwlab-git-ssh-proxy.sh +unset GIT_SSH_COMMAND \ No newline at end of file diff --git a/scripts/src/gitops-render/templates/git-mirror-sync.sh b/scripts/src/gitops-render/templates/git-mirror-sync.sh new file mode 100644 index 00000000..b007bc27 --- /dev/null +++ b/scripts/src/gitops-render/templates/git-mirror-sync.sh @@ -0,0 +1,181 @@ +#!/bin/sh +set -eu +repo_url="ssh://git@ssh.github.com:443/pikasTech/HWLAB.git" +repo_path="/cache/pikasTech/HWLAB.git" +source_snapshot_ref_prefix=__HWLAB_SOURCE_SNAPSHOT_REF_PREFIX_SHELL__ +lock_dir="/cache/.hwlab-sync.lock" +sync_started_ms=$(node -e 'console.log(Date.now())') +now_ms() { node -e 'console.log(Date.now())'; } +emit_timing() { + phase="$1" + status="$2" + started_ms="$3" + finished_ms=$(now_ms) + duration_ms=$((finished_ms - started_ms)) + printf '{"event":"git-mirror-sync-timing","phase":"%s","status":"%s","durationMs":%s,"repository":"pikasTech/HWLAB"} +' "$phase" "$status" "$duration_ms" +} +mkdir -p /cache/pikasTech /root/.ssh +if ! mkdir "$lock_dir" 2>/dev/null; then + echo "git mirror sync already running" + exit 0 +fi +trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT INT TERM +cp /git-ssh/ssh-privatekey /root/.ssh/id_rsa +chmod 0400 /root/.ssh/id_rsa +# __HWLAB_GIT_MIRROR_SSH_PRELUDE__ +if [ -d "$repo_path/objects" ] && [ -f "$repo_path/HEAD" ]; then + git -C "$repo_path" remote set-url origin "$repo_url" || git -C "$repo_path" remote add origin "$repo_url" +else + rm -rf "$repo_path" + git init --bare "$repo_path" + git -C "$repo_path" remote add origin "$repo_url" +fi +/script/install-hooks.sh +git -C "$repo_path" config uploadpack.hideRefs refs/mirror-stage +git -C "$repo_path" config uploadpack.allowReachableSHA1InWant true +git -C "$repo_path" config uploadpack.allowAnySHA1InWant true +git -C "$repo_path" config --unset-all remote.origin.fetch 2>/dev/null || true +# __HWLAB_FETCH_CONFIG_COMMANDS__ +fetch_started_ms=$(now_ms) +timeout 180 git -C "$repo_path" fetch --quiet --prune origin +emit_timing fetch succeeded "$fetch_started_ms" +git -C "$repo_path" for-each-ref --format='%(objectname) %(refname)' refs/mirror-stage/heads > /tmp/hwlab-stage-heads +git -C "$repo_path" for-each-ref --format='%(refname)' refs/mirror-stage/heads > /tmp/hwlab-stage-head-refs +git -C "$repo_path" for-each-ref --format='%(objectname) %(refname)' refs/mirror-stage/tags > /tmp/hwlab-stage-tags +git -C "$repo_path" for-each-ref --format='%(refname)' refs/mirror-stage/tags > /tmp/hwlab-stage-tag-refs +if [ ! -s /tmp/hwlab-stage-heads ]; then + echo "git mirror sync fetched no branch refs" >&2 + exit 41 +fi +for required_ref in __HWLAB_REQUIRED_REF_ARGS__; do + git -C "$repo_path" show-ref --verify --quiet "$required_ref" || { echo "git mirror sync missing required ref $required_ref" >&2; exit 43; } +done +validate_started_ms=$(now_ms) +while read -r sha ref; do + [ -n "$sha" ] || continue + git -C "$repo_path" cat-file -e "$sha^{commit}" + git -C "$repo_path" cat-file -e "$sha^{tree}" + if git -C "$repo_path" rev-list --objects --missing=print "$sha" | grep -q '^?'; then + echo "git mirror sync found missing objects for $ref $sha" >&2 + exit 42 + fi +done < /tmp/hwlab-stage-heads +emit_timing validate succeeded "$validate_started_ms" +publish_started_ms=$(now_ms) +while read -r sha ref; do + [ -n "$sha" ] || continue + name=$(printf '%s +' "$ref" | sed 's#^refs/mirror-stage/heads/##') + is_gitops_branch=false + for gitops_branch in __HWLAB_GITOPS_BRANCH_ARGS__; do + if [ "$name" = "$gitops_branch" ]; then is_gitops_branch=true; break; fi + done + if [ "$is_gitops_branch" = true ]; then + local_sha=$(git -C "$repo_path" show-ref --verify --hash "refs/heads/$name" 2>/dev/null || true) + if [ -n "$local_sha" ] && [ "$local_sha" != "$sha" ]; then + if git -C "$repo_path" merge-base --is-ancestor "$sha" "$local_sha"; then + echo "git mirror sync keeps local $name ahead of GitHub" + continue + fi + if ! git -C "$repo_path" merge-base --is-ancestor "$local_sha" "$sha"; then + echo "git mirror sync found divergent $name local=$local_sha github=$sha" >&2 + exit 44 + fi + fi + fi + git -C "$repo_path" update-ref "refs/heads/$name" "$sha" +done < /tmp/hwlab-stage-heads +for source_branch in __HWLAB_SOURCE_BRANCH_ARGS__; do + source_sha=$(git -C "$repo_path" show-ref --verify --hash "refs/heads/$source_branch" 2>/dev/null || true) + if [ -n "$source_sha" ]; then + source_stage_ref="${source_snapshot_ref_prefix%/}/$source_branch/$source_sha" + git -C "$repo_path" update-ref "$source_stage_ref" "$source_sha" + fi +done +while read -r sha ref; do + [ -n "$sha" ] || continue + name=$(printf '%s +' "$ref" | sed 's#^refs/mirror-stage/tags/##') + git -C "$repo_path" update-ref "refs/tags/$name" "$sha" +done < /tmp/hwlab-stage-tags +git -C "$repo_path" for-each-ref --format='%(refname)' refs/heads > /tmp/hwlab-public-heads +while read -r ref; do + [ -n "$ref" ] || continue + name=$(printf '%s +' "$ref" | sed 's#^refs/heads/##') + if ! grep -Fxq "refs/mirror-stage/heads/$name" /tmp/hwlab-stage-head-refs; then + git -C "$repo_path" update-ref -d "$ref" + fi +done < /tmp/hwlab-public-heads +git -C "$repo_path" for-each-ref --format='%(refname)' refs/tags > /tmp/hwlab-public-tags +while read -r ref; do + [ -n "$ref" ] || continue + name=$(printf '%s +' "$ref" | sed 's#^refs/tags/##') + if ! grep -Fxq "refs/mirror-stage/tags/$name" /tmp/hwlab-stage-tag-refs; then + git -C "$repo_path" update-ref -d "$ref" + fi +done < /tmp/hwlab-public-tags +emit_timing publish succeeded "$publish_started_ms" +for head_branch in __HWLAB_SOURCE_BRANCH_ARGS__; do + if git -C "$repo_path" show-ref --verify --quiet "refs/heads/$head_branch"; then + git -C "$repo_path" symbolic-ref HEAD "refs/heads/$head_branch" + break + fi +done +fsck_started_ms=$(now_ms) +git -C "$repo_path" fsck --connectivity-only --no-dangling >/tmp/hwlab-git-fsck.out +emit_timing fsck succeeded "$fsck_started_ms" +git -C "$repo_path" update-server-info +published_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +for source_branch in __HWLAB_SOURCE_BRANCH_ARGS__; do + git -C "$repo_path" rev-parse "refs/heads/$source_branch" 2>/dev/null | tee "/cache/HWLAB-$source_branch.head" >/dev/null || true +done +printf '%s +' "$published_at" > /cache/HWLAB.last-sync +export published_at +export source_snapshot_ref_prefix +export mirror_branches_json=__HWLAB_MIRROR_BRANCHES_JSON_SHELL__ +export source_branches_json=__HWLAB_SOURCE_BRANCHES_JSON_SHELL__ +export gitops_branches_json=__HWLAB_GITOPS_BRANCHES_JSON_SHELL__ +node - <<'NODE' | tee /cache/HWLAB.last-sync.json +const { execFileSync } = require("node:child_process"); +const repo = "/cache/pikasTech/HWLAB.git"; +const mirrorBranches = JSON.parse(process.env.mirror_branches_json || "[]"); +const sourceBranches = JSON.parse(process.env.source_branches_json || "[]"); +const gitopsBranches = JSON.parse(process.env.gitops_branches_json || "[]"); +const sourceSnapshotRefPrefix = (process.env.source_snapshot_ref_prefix || "refs/unidesk/snapshots/hwlab-node-runtime").replace(//+$/u, ""); +function rev(ref) { + try { return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim() || null; } catch { return null; } +} +const refs = {}; +for (const branch of mirrorBranches) { + refs["refs/heads/" + branch] = rev("refs/heads/" + branch); + refs["refs/mirror-stage/heads/" + branch] = rev("refs/mirror-stage/heads/" + branch); +} +const sourceSnapshots = {}; +for (const branch of sourceBranches) { + const source = refs["refs/heads/" + branch] || null; + const stageRef = source ? sourceSnapshotRefPrefix + "/" + branch + "/" + source : null; + const sourceSnapshot = stageRef ? rev(stageRef) : null; + if (stageRef) refs[stageRef] = sourceSnapshot; + sourceSnapshots[branch] = { stageRef, sourceSnapshot }; +} +const sourceInSync = sourceBranches.every((branch) => refs["refs/heads/" + branch] && refs["refs/heads/" + branch] === refs["refs/mirror-stage/heads/" + branch] && sourceSnapshots[branch]?.sourceSnapshot === refs["refs/mirror-stage/heads/" + branch]); +const gitopsInSync = gitopsBranches.every((branch) => refs["refs/heads/" + branch] && refs["refs/heads/" + branch] === refs["refs/mirror-stage/heads/" + branch]); +const pendingFlush = gitopsBranches.some((branch) => refs["refs/heads/" + branch] && refs["refs/mirror-stage/heads/" + branch] && refs["refs/heads/" + branch] !== refs["refs/mirror-stage/heads/" + branch]); +const payload = { + event: "git-mirror-sync", + status: "published", + repository: "pikasTech/HWLAB", + publishedAt: process.env.published_at, + pendingFlush, + sourceInSync, + gitopsInSync, + sourceSnapshots, + refs +}; +console.log(JSON.stringify(payload)); +NODE +emit_timing total succeeded "$sync_started_ms" diff --git a/scripts/src/gitops-render/templates/git-ssh-functions.sh b/scripts/src/gitops-render/templates/git-ssh-functions.sh new file mode 100644 index 00000000..d133a16b --- /dev/null +++ b/scripts/src/gitops-render/templates/git-ssh-functions.sh @@ -0,0 +1,46 @@ +git_ssh_setup() { + mkdir -p /root/.ssh + cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa + chmod 600 /root/.ssh/id_rsa + timeout 10 ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true + timeout 10 ssh-keyscan -p 443 ssh.github.com >> /root/.ssh/known_hosts 2>/dev/null || true + write_github_proxy_command + 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'" + git config --global url."ssh://git@ssh.github.com:443/".insteadOf "git@github.com:" + git config --global url."ssh://git@ssh.github.com:443/".insteadOf "ssh://git@github.com/" +} + +write_github_proxy_command() { + cat > /tmp/hwlab-github-proxy-connect.mjs <<'NODE_PROXY' +// __HWLAB_GITHUB_PROXY_CONNECT_MJS__ +NODE_PROXY + chmod 0700 /tmp/hwlab-github-proxy-connect.mjs +} + +git_now_ms() { + node -e 'console.log(Date.now())' 2>/dev/null || date +%s000 +} + +git_timed() { + phase="$1" + timeout_seconds="$2" + shift 2 + started_ms="$(git_now_ms)" + echo '{"event":"git-operation","phase":"'"$phase"'","status":"started","timeoutSeconds":'"$timeout_seconds"'}' >&2 + set +e + timeout "$timeout_seconds" "$@" + status="$?" + set -e + finished_ms="$(git_now_ms)" + duration_ms="$((finished_ms - started_ms))" + if [ "$status" -eq 0 ]; then + echo '{"event":"git-operation","phase":"'"$phase"'","status":"succeeded","durationMs":'"$duration_ms"'}' >&2 + return 0 + fi + if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then + echo '{"event":"git-operation","phase":"'"$phase"'","status":"failed","reason":"timeout","durationMs":'"$duration_ms"',"timeoutSeconds":'"$timeout_seconds"'}' >&2 + else + echo '{"event":"git-operation","phase":"'"$phase"'","status":"failed","exitCode":'"$status"',"durationMs":'"$duration_ms"'}' >&2 + fi + return "$status" +} diff --git a/scripts/src/gitops-render/templates/github-proxy-connect.mjs b/scripts/src/gitops-render/templates/github-proxy-connect.mjs new file mode 100644 index 00000000..06e8cdcb --- /dev/null +++ b/scripts/src/gitops-render/templates/github-proxy-connect.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import net from "node:net"; + +const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2); +const proxyPort = Number.parseInt(proxyPortRaw || "", 10); +const targetPort = Number.parseInt(targetPortRaw || "", 10); +if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) { + console.error("usage: hwlab-github-proxy-connect "); + process.exit(64); +} + +const socket = net.createConnection({ host: proxyHost, port: proxyPort }); +let buffer = Buffer.alloc(0); + +socket.setTimeout(10000, () => { + console.error("proxy connect timeout " + proxyHost + ":" + proxyPort + " -> " + targetHost + ":" + targetPort); + socket.destroy(); + process.exit(65); +}); + +socket.on("connect", () => { + socket.write("CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1\r\nHost: " + targetHost + ":" + targetPort + "\r\nProxy-Connection: Keep-Alive\r\n\r\n"); +}); + +socket.on("error", (error) => { + console.error("proxy connect failed: " + error.message); + process.exit(66); +}); + +function onData(chunk) { + buffer = Buffer.concat([buffer, chunk]); + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1 && buffer.length < 8192) return; + const head = buffer.slice(0, headerEnd + 4).toString("latin1"); + const statusLine = head.split("\r\n", 1)[0] || ""; + const statusCode = Number.parseInt(statusLine.split(" ")[1] || "", 10); + if (!statusLine.startsWith("HTTP/1.") || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) { + console.error("proxy CONNECT rejected: " + (statusLine || "missing-status")); + socket.destroy(); + process.exit(67); + } + socket.off("data", onData); + socket.setTimeout(0); + const rest = buffer.slice(headerEnd + 4); + if (rest.length) process.stdout.write(rest); + process.stdin.pipe(socket); + socket.pipe(process.stdout); +} + +socket.on("data", onData); +socket.on("close", () => process.exit(0)); \ No newline at end of file diff --git a/scripts/src/gitops-render/templates/gitops-promote.sh b/scripts/src/gitops-render/templates/gitops-promote.sh new file mode 100644 index 00000000..1c2431c4 --- /dev/null +++ b/scripts/src/gitops-render/templates/gitops-promote.sh @@ -0,0 +1,331 @@ +#!/bin/sh +set -eu +# __HWLAB_CI_TIMING_SHELL__ + +export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" +export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" +export HWLAB_TEKTON_TASK="gitops-promote" +export HWLAB_SOURCE_REVISION="$(params.revision)" +echo '{"event":"ci-base-image","phase":"gitops-promote","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apt"}' +for 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 +# __HWLAB_GIT_SSH_SHELL__ + +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 ;; +esac +if [ "$runtime_lane" = "true" ]; then + printf 'false' > /tekton/results/runtime-ready-required +else + printf 'true' > /tekton/results/runtime-ready-required +fi +source_head_url_for_setup="$(params.git-url)" +gitops_read_url_for_setup="$(params.git-url)" +if [ "$runtime_lane" = "true" ]; then + source_head_url_for_setup="$(params.git-read-url)" + gitops_read_url_for_setup="$(params.git-read-url)" +fi +gitops_write_url_for_setup="$(params.git-write-url)" +if 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 + 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 + git_ssh_setup +else + echo '{"event":"git-ssh-setup","phase":"gitops-promote","status":"skipped","reason":"non-ssh-git-url"}' +fi +cd /workspace/source/repo +test "$(git rev-parse HEAD)" = "$(params.revision)" + +check_source_head() { + phase="$1" + expected="${2:-$(params.revision)}" + source_head_url="$(params.git-url)" + if [ "$runtime_lane" = "true" ]; then + source_head_url="$(params.git-read-url)" + fi + latest_file="$(mktemp)" + if ! git_timed "source-head-$phase" 45 git ls-remote "$source_head_url" "refs/heads/$(params.source-branch)" > "$latest_file"; then + echo '{"status":"failed","reason":"source-head-check-failed","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'"}' + exit 1 + fi + latest="$(cut -f1 "$latest_file" | head -n 1)" + 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" != "$expected" ]; then + printf 'false' > /tekton/results/runtime-ready-required || true + echo '{"status":"skipped-stale-source","verdict":"superseded","phase":"'"$phase"'","sourceBranch":"'"$(params.source-branch)"'","sourceRevision":"'"$(params.revision)"'","expectedRevision":"'"$expected"'","latestRevision":"'"$latest"'"}' + exit 0 + fi +} + +check_source_head before-render +if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE' +const fs = require("node:fs"); +const plan = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : []; +const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : []; +const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true; +process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1); +NODE +then + printf 'false' > /tekton/results/runtime-ready-required || true + echo '{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"}' + exit 0 +fi +git config --global user.name "HWLAB node GitOps Bot" +git config --global user.email "hwlab-node-gitops-bot@users.noreply.github.com" +catalog_path="$(params.catalog-path)" +runtime_path="$(params.runtime-path)" +gitops_root_from_runtime_path() { + path="$1" + case "$path" in + */*) printf '%s +' "${path%/*}" ;; + *) printf '. +' ;; + esac +} +gitops_root="$(gitops_root_from_runtime_path "$runtime_path")" + +argo_hard_refresh_runtime_lane() { + if [ "$runtime_lane" != "true" ]; then return 0; fi + ARGO_APPLICATION="hwlab-node-$lane" ARGO_FIELD_MANAGER="hwlab-$lane-gitops-promote" node <<'NODE' +const fs = require("node:fs"); +const https = require("node:https"); + +const host = process.env.KUBERNETES_SERVICE_HOST; +const port = process.env.KUBERNETES_SERVICE_PORT || "443"; +const startedAt = Date.now(); +const application = process.env.ARGO_APPLICATION || "hwlab-node-v02"; +const fieldManager = process.env.ARGO_FIELD_MANAGER || "hwlab-v02-gitops-promote"; +const pipelineRun = process.env.HWLAB_TEKTON_PIPELINERUN || null; +const taskRun = process.env.HWLAB_TEKTON_TASKRUN || null; +const task = process.env.HWLAB_TEKTON_TASK || null; +const revision = process.env.HWLAB_SOURCE_REVISION || null; + +function emit(payload) { + console.log(JSON.stringify({ + event: "node-cicd-timing", + schemaVersion: "v1", + stage: "argo-hard-refresh", + pipelineRun, + taskRun, + task, + revision, + application, + source: "scripts/gitops-render.mjs", + at: new Date().toISOString(), + ...payload + })); +} + +function safeJson(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function request(method, path, body, contentType = "application/merge-patch+json") { + 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 payload = body ? JSON.stringify(body) : ""; + const headers = { Authorization: "Bearer " + token }; + if (payload) { + headers["Content-Type"] = contentType; + 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, json: safeJson(data) })); + }); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +(async () => { + if (!host) { + emit({ status: "skipped", reason: "kubernetes-service-host-missing", durationMs: Date.now() - startedAt }); + return; + } + const response = await request( + "PATCH", + "/apis/argoproj.io/v1alpha1/namespaces/argocd/applications/" + encodeURIComponent(application) + "?fieldManager=" + encodeURIComponent(fieldManager), + { metadata: { annotations: { "argocd.argoproj.io/refresh": "hard" } } } + ); + if (response.statusCode >= 200 && response.statusCode < 300) { + emit({ status: "succeeded", durationMs: Date.now() - startedAt, statusCode: response.statusCode }); + return; + } + emit({ status: "degraded", reason: "argo-refresh-patch-failed", durationMs: Date.now() - startedAt, statusCode: response.statusCode, body: response.body.slice(0, 1000) }); +})().catch((error) => { + emit({ status: "degraded", reason: "argo-refresh-patch-error", durationMs: Date.now() - startedAt, error: error.message }); +}); +NODE +} + +if [ -s /workspace/source/dev-artifacts.json ]; then + 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 +fi +gitops_render_started_ms="$(ci_now_ms)" +node 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 "$gitops_root" --out "$gitops_root" --registry-prefix "$(params.registry-prefix)" --use-deploy-images +node 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 "$gitops_root" --out "$gitops_root" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check +ci_timing_emit gitops-render succeeded "$gitops_render_started_ms" +check_source_head before-push +workdir="$(mktemp -d)" +gitops_read_url="$(params.git-url)" +gitops_write_url="$(params.git-write-url)" +if [ "$runtime_lane" = "true" ]; then + gitops_read_url="$(params.git-read-url)" +fi +gitops_clone_started_ms="$(ci_now_ms)" +git_timed gitops-clone 180 git clone --no-checkout "$gitops_read_url" "$workdir/gitops" +cd "$workdir/gitops" +git remote set-url origin "$gitops_write_url" +old_runtime_snapshot="$workdir/old-runtime-snapshot" +if git_timed gitops-branch-ls 45 git ls-remote --exit-code --heads origin "$(params.gitops-branch)" >/dev/null; then + git_timed gitops-fetch 90 git fetch origin "$(params.gitops-branch)" + git checkout -B "$(params.gitops-branch)" "origin/$(params.gitops-branch)" + if [ "$runtime_lane" = "true" ] && [ -d "$runtime_path" ]; then + mkdir -p "$old_runtime_snapshot" + cp -a "$runtime_path"/. "$old_runtime_snapshot"/ + fi + if [ "$runtime_lane" = "true" ]; then rm -rf "$runtime_path" "$catalog_path"; else rm -rf deploy/gitops/node "$catalog_path"; fi +else + git checkout --orphan "$(params.gitops-branch)" + git rm -rf . >/dev/null 2>&1 || true +fi +ci_timing_emit gitops-clone succeeded "$gitops_clone_started_ms" +mkdir -p "$(dirname "$runtime_path")" "$(dirname "$catalog_path")" +if [ "$runtime_lane" = "true" ]; then + cp -a "/workspace/source/repo/$runtime_path" "$runtime_path" + cp "/workspace/source/repo/$catalog_path" "$catalog_path" + git add "$catalog_path" "$runtime_path" +else + mkdir -p deploy/gitops + cp -a /workspace/source/repo/deploy/gitops/node deploy/gitops/node + cp "/workspace/source/repo/$catalog_path" "$catalog_path" + git add "$catalog_path" deploy/gitops/node +fi +if [ "$runtime_lane" = "true" ] && [ -s /workspace/source/affected-services.json ]; then + runtime_noop_decision="$(node - "$runtime_path" "$old_runtime_snapshot" /workspace/source/affected-services.json <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const [runtimePath, oldRuntimePath, planPath] = process.argv.slice(2); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function stable(value) { + if (Array.isArray(value)) return "[" + value.map(stable).join(",") + "]"; + if (value && typeof value === "object") { + return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stable(value[key])).join(",") + "}"; + } + return JSON.stringify(value); +} + +function scrub(value) { + if (Array.isArray(value)) return value.map(scrub); + if (!value || typeof value !== "object") return value; + const result = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "hwlab.pikastech.local/source-commit" || + key === "hwlab.pikastech.local/artifact-source-commit" || + key === "hwlab.pikastech.local/boot-commit" || + key === "sourceCommitId" || + key === "bootCommit") { + result[key] = ""; + continue; + } + const envName = String(value.name || ""); + if (key === "value" && /^HWLAB_.*COMMIT/.test(envName)) { + result[key] = ""; + continue; + } + result[key] = scrub(child); + } + return result; +} + +function listFiles(rootDir) { + if (!rootDir || !fs.existsSync(rootDir)) return null; + const files = []; + function walk(current) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) walk(fullPath); + else if (entry.isFile()) files.push(path.relative(rootDir, fullPath).replaceAll(path.sep, "/")); + } + } + walk(rootDir); + return files.sort(); +} + +function normalizedTree(rootDir) { + const files = listFiles(rootDir); + if (!files) return null; + const entries = {}; + for (const relativePath of files) { + const filePath = path.join(rootDir, relativePath); + const text = fs.readFileSync(filePath, "utf8"); + try { + entries[relativePath] = stable(scrub(JSON.parse(text))); + } catch { + entries[relativePath] = text.replace(/[a-f0-9]{40}/gu, ""); + } + } + return stable(entries); +} + +const plan = readJson(planPath); +const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : []; +const rolloutServices = Array.isArray(plan.rolloutServices) ? plan.rolloutServices : []; +if (buildServices.length > 0 || rolloutServices.length > 0) { + process.stdout.write("runtime-required"); + process.exit(0); +} + +const oldTree = normalizedTree(oldRuntimePath); +const newTree = normalizedTree(runtimePath); +process.stdout.write(oldTree && newTree && oldTree === newTree ? "runtime-identity-only" : "runtime-required"); +NODE +)" + if [ "$runtime_noop_decision" = "runtime-identity-only" ]; then + printf 'false' > /tekton/results/runtime-ready-required + echo '{"status":"skipped-runtime-unchanged","reason":"runtime-identity-only","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' + ci_timing_emit gitops-commit skipped "$(ci_now_ms)" + ci_timing_emit gitops-push skipped "$(ci_now_ms)" + exit 0 + fi +fi +if git diff --cached --quiet; then + printf 'false' > /tekton/results/runtime-ready-required + echo '{"status":"unchanged","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' + ci_timing_emit gitops-commit unchanged "$(ci_now_ms)" + ci_timing_emit gitops-push unchanged "$(ci_now_ms)" + exit 0 +fi +short="$(printf '%.7s' "$(params.revision)")" +gitops_commit_started_ms="$(ci_now_ms)" +git commit -m "chore: promote node GitOps source $short" +ci_timing_emit gitops-commit succeeded "$gitops_commit_started_ms" +gitops_push_started_ms="$(ci_now_ms)" +git_timed gitops-push 120 git push origin "HEAD:$(params.gitops-branch)" +ci_timing_emit gitops-push succeeded "$gitops_push_started_ms" +if [ "$runtime_lane" = "true" ]; then + printf 'false' > /tekton/results/runtime-ready-required + echo '{"event":"runtime-ready","phase":"gitops-promote","status":"delegated-post-flush-closeout","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' +fi +argo_hard_refresh_runtime_lane +echo '{"status":"pushed","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'","gitopsWriteUrl":"'"$gitops_write_url"'"}' diff --git a/scripts/src/gitops-render/templates/per-service-publish.sh b/scripts/src/gitops-render/templates/per-service-publish.sh new file mode 100644 index 00000000..70c4e0e5 --- /dev/null +++ b/scripts/src/gitops-render/templates/per-service-publish.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu +# __HWLAB_CI_TIMING_SHELL__ + +export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" +export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" +export HWLAB_TEKTON_TASK="build-$(params.service-id)" +export HWLAB_SOURCE_REVISION="$(params.revision)" +export HWLAB_TIMING_SERVICE_ID="$(params.service-id)" +# __HWLAB_DEPENDENCY_PROXY_PROBE__ +echo '{"event":"ci-base-image","phase":"service-image-publish","serviceId":"'"$(params.service-id)"'","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apk"}' +for 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 +mkdir -p /workspace/service-work/home +export HOME=/workspace/service-work/home +git config --global --add safe.directory /workspace/source/repo +cd /workspace/source/repo +test "$(git rev-parse HEAD)" = "$(params.revision)" +export HWLAB_ARTIFACT_LANE="$(params.lane)" +export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" +export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml" +export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" +mkdir -p /workspace/source/service-results +if [ -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 + node - "$(params.service-id)" <<'NODE' +const fs = require("node:fs"); +const serviceId = process.argv[2]; +const catalog = JSON.parse(fs.readFileSync(process.env.HWLAB_ARTIFACT_CATALOG_PATH, "utf8")); +const plan = fs.existsSync("/workspace/source/affected-services.json") ? JSON.parse(fs.readFileSync("/workspace/source/affected-services.json", "utf8")) : {}; +const service = (catalog.services || []).find((item) => item.serviceId === serviceId) || {}; +const planned = (plan.services || []).find((item) => item.serviceId === serviceId) || {}; +const envReuse = planned.runtimeMode === "env-reuse-git-mirror-checkout" || service.runtimeMode === "env-reuse-git-mirror-checkout" || planned.envReuse === true || service.envReuse === true; +const image = envReuse ? (service.environmentImage || service.image || "") : (service.image || ""); +const digest = envReuse ? (service.environmentDigest || service.digest || "not_published") : (service.digest || "not_published"); +const imageTag = service.imageTag || (image.includes(":") ? image.slice(image.lastIndexOf(":") + 1) : ""); +const repository = image.includes(":") ? image.slice(0, image.lastIndexOf(":")) : image; +const revision = process.env.HWLAB_SOURCE_REVISION || planned.bootCommit || service.bootCommit || service.sourceCommitId || service.commitId || imageTag; +const componentInputHash = envReuse ? (planned.componentInputHash || service.componentInputHash || "") : (service.componentInputHash || ""); +const environmentInputHash = envReuse ? (service.environmentInputHash || planned.environmentInputHash || "") : (service.environmentInputHash || ""); +const codeInputHash = envReuse ? (planned.codeInputHash || service.codeInputHash || "") : (service.codeInputHash || ""); +const values = { + "service-id": serviceId, + status: /^sha256:[a-f0-9]{64}$/.test(digest) ? "reused" : "blocked_reuse_unavailable", + image, + "image-tag": imageTag, + digest, + "repository-digest": /^sha256:[a-f0-9]{64}$/.test(digest) && repository ? repository + "@" + digest : "", + "source-commit-id": envReuse ? revision : (service.sourceCommitId || service.commitId || imageTag), + "component-input-hash": componentInputHash, + "environment-input-hash": environmentInputHash, + "code-input-hash": codeInputHash, + "runtime-mode": envReuse ? "env-reuse-git-mirror-checkout" : "service-image", + "boot-repo": planned.bootRepo || service.bootRepo || "", + "boot-commit": envReuse ? revision : (service.bootCommit || ""), + "boot-sh": planned.bootSh || service.bootSh || "", + "build-created-at": service.buildCreatedAt || "", + "build-backend": envReuse ? "reused-env-catalog" : "reused-catalog", + "reused-from": (envReuse ? service.environmentInputHash : service.componentInputHash) || service.commitId || imageTag || "catalog" +}; +for (const [name, value] of Object.entries(values)) fs.writeFileSync("/tekton/results/" + name, String(value || "")); +NODE + echo '{"event":"service-build-skip","serviceId":"'"$(params.service-id)"'","reason":"component-inputs-unchanged"}' + exit 0 +fi +rm -rf /workspace/service-work/repo +mkdir -p /workspace/service-work/repo +tar -C /workspace/source/repo -cf - . | tar -C /workspace/service-work/repo -xo -f - +cd /workspace/service-work/repo +export HWLAB_CI_ARTIFACT_RUN_ID="$(context.pipelineRun.name)-$(params.service-id)" +export HWLAB_CI_ARTIFACT_RUN_OWNER="tekton" +export HWLAB_DEV_REGISTRY_PREFIX="$(params.registry-prefix)" +export HWLAB_DEV_REGISTRY_K8S_NATIVE=1 +export HWLAB_NODE_CICD_TIMING=1 +export HWLAB_TEKTON_PIPELINERUN +export HWLAB_TEKTON_TASKRUN +export HWLAB_TEKTON_TASK +export HWLAB_SOURCE_REVISION +export PATH="/workspace/buildkit-bin:$PATH" +export HWLAB_BUILDKIT_ADDR="unix:///workspace/buildkit-run/buildkitd.sock" +if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi +if [ -n "${HWLAB_DEV_BASE_IMAGE:-}" ]; then + 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/"}' + registry_started_at="$(date +%s)" + curl -fsS --max-time 10 http://127.0.0.1:5000/v2/ >/dev/null + registry_finished_at="$(date +%s)" + 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))"'}' +fi +buildkit_ready=0 +for attempt in $(seq 1 60); do + if /workspace/buildkit-bin/buildctl --addr "$HWLAB_BUILDKIT_ADDR" debug workers >/dev/null 2>&1; then + buildkit_ready=1 + break + fi + sleep 1 +done +if [ "$buildkit_ready" != "1" ]; then + echo '{"event":"buildkit-sidecar-not-ready","serviceId":"'"$(params.service-id)"'","addr":"'"$HWLAB_BUILDKIT_ADDR"'"}' >&2 + exit 1 +fi +HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node 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)" diff --git a/scripts/src/gitops-render/templates/plan-artifacts.sh b/scripts/src/gitops-render/templates/plan-artifacts.sh new file mode 100644 index 00000000..8de35581 --- /dev/null +++ b/scripts/src/gitops-render/templates/plan-artifacts.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu +echo '{"event":"ci-base-image","phase":"plan-artifacts","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apk"}' +for 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 +cd /workspace/source/repo +ci_node_deps="${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}" +if [ -d "$ci_node_deps/yaml" ] && [ ! -e node_modules/yaml ]; then + mkdir -p node_modules + ln -s "$ci_node_deps/yaml" node_modules/yaml +fi +test "$(git rev-parse HEAD)" = "$(params.revision)" +HWLAB_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)" node scripts/ci/restore-artifact-catalog.mjs +node 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 +node - <<'NODE' +const fs = require("node:fs"); +const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8")); +const selected = String(process.env.HWLAB_SELECTED_SERVICES || "").split(",").map((item) => item.trim()).filter(Boolean); +const allServices = selected.length > 0 ? selected : ["__HWLAB_DEFAULT_SERVICE_IDS__"]; +const affected = new Set(plan.affectedServices || []); +const plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null; +const selectedSet = new Set(selected); +const byService = new Map((plan.services || []).map((service) => [service.serviceId, service])); +const entries = allServices.map((serviceId) => { + const service = byService.get(serviceId) || {}; + const serviceSelected = selectedSet.has(serviceId); + const rolloutAffected = serviceSelected && affected.has(serviceId); + const envReuse = service.runtimeMode === "env-reuse-git-mirror-checkout" || service.envReuse === true; + const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected)); + return { + serviceId, + selected: serviceSelected, + affected: rolloutAffected, + buildRequired, + rolloutAffected, + runtimeMode: service.runtimeMode || "service-image", + envChanged: service.envChanged ?? null, + codeChanged: service.codeChanged ?? null + }; +}); +for (const entry of entries) { + fs.writeFileSync("/tekton/results/affected-" + entry.serviceId, entry.affected ? "true" : "false"); + fs.writeFileSync("/tekton/results/build-" + entry.serviceId, entry.buildRequired ? "true" : "false"); +} +fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({ + sourceCommitId: plan.sourceCommitId, + affectedServices: plan.affectedServices || [], + rolloutServices: plan.rolloutServices || plan.affectedServices || [], + buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId), + reusedServices: plan.reusedServices || [], + buildSkippedCount: plan.buildSkippedCount || 0, + envArtifactGroups: plan.envArtifactGroups || [], + changedPathSummary: plan.changedPathSummary || null, + ciCdPlan: plan.ciCdPlan || null, + services: plan.services || [], + entries +}, null, 2) + String.fromCharCode(10)); +console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], reusedServices: plan.reusedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, envArtifactGroups: plan.envArtifactGroups || [] })); +NODE diff --git a/scripts/src/gitops-render/templates/poller.sh b/scripts/src/gitops-render/templates/poller.sh new file mode 100644 index 00000000..1d9b8d5b --- /dev/null +++ b/scripts/src/gitops-render/templates/poller.sh @@ -0,0 +1,142 @@ +#!/bin/sh +set -eu +# __HWLAB_DEPENDENCY_PROXY_PROBE__ +echo '{"event":"ci-base-image","phase":"poller","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apk"}' +for tool in node git ssh curl timeout; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"poller","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done +# __HWLAB_GIT_SSH_SHELL__ + +git_ssh_setup +workdir="$(mktemp -d)" +git_timed poller-clone 180 git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_READ_URL" "$workdir/repo" +cd "$workdir/repo" +git remote set-url origin "$GIT_READ_URL" +revision="$(git rev-parse HEAD)" +subject="$(git log -1 --pretty=%s)" +case "$subject" in + "chore: promote node GitOps source "*) + echo "Skipping generated GitOps promotion commit $revision" + exit 0 + ;; +esac +short="$(printf '%.12s' "$revision")" +: "${PIPELINERUN_PREFIX:?PIPELINERUN_PREFIX is required}" +: "${GITOPS_TARGET:?GITOPS_TARGET is required}" +prefix="$PIPELINERUN_PREFIX" +name="$prefix-$short" +export POLLER_REVISION="$revision" +export POLLER_PIPELINERUN="$name" +export POLLER_SOURCE_SUBJECT="$subject" +echo "Checking $GITOPS_TARGET source $revision with PipelineRun $name" +node <<'NODE' +const fs = require("node:fs"); +const https = require("node:https"); + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +} + +function optionalEnv(name) { + return process.env[name] || ""; +} + +const namespace = requiredEnv("POD_NAMESPACE"); +const name = requiredEnv("POLLER_PIPELINERUN"); +const revision = requiredEnv("POLLER_REVISION"); +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"); + +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": requiredEnv("GITOPS_TARGET"), + "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": optionalEnv("POLLER_SOURCE_SUBJECT"), + "hwlab.pikastech.local/source-branch": requiredEnv("SOURCE_BRANCH"), + "hwlab.pikastech.local/gitops-branch": requiredEnv("GITOPS_BRANCH") + } + }, + spec: { + pipelineRef: { name: requiredEnv("PIPELINE_NAME") }, + taskRunTemplate: { + serviceAccountName: requiredEnv("SERVICE_ACCOUNT_NAME"), + podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", securityContext: { fsGroup: 1000 } } + }, + params: [ + { name: "git-url", value: requiredEnv("GIT_URL") }, + { name: "git-read-url", value: requiredEnv("GIT_READ_URL") }, + { name: "source-branch", value: requiredEnv("SOURCE_BRANCH") }, + { name: "gitops-branch", value: requiredEnv("GITOPS_BRANCH") }, + { name: "lane", value: requiredEnv("LANE") }, + { name: "catalog-path", value: requiredEnv("CATALOG_PATH") }, + { name: "image-tag-mode", value: requiredEnv("IMAGE_TAG_MODE") }, + { name: "runtime-path", value: requiredEnv("RUNTIME_PATH") }, + { name: "revision", value: revision }, + { name: "registry-prefix", value: requiredEnv("REGISTRY_PREFIX") }, + { name: "services", value: requiredEnv("SERVICES") }, + { name: "base-image", value: requiredEnv("BASE_IMAGE") } + ], + 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 diff --git a/scripts/src/gitops-render/templates/prepare-source.sh b/scripts/src/gitops-render/templates/prepare-source.sh new file mode 100644 index 00000000..40896017 --- /dev/null +++ b/scripts/src/gitops-render/templates/prepare-source.sh @@ -0,0 +1,117 @@ +#!/bin/sh +set -eu +# __HWLAB_CI_TIMING_SHELL__ + +export HWLAB_TEKTON_PIPELINERUN="$(context.pipelineRun.name)" +export HWLAB_TEKTON_TASKRUN="$(context.taskRun.name)" +export HWLAB_TEKTON_TASK="prepare-source" +export HWLAB_SOURCE_REVISION="$(params.revision)" +echo '{"event":"ci-base-image","phase":"prepare-source","image":"__HWLAB_CI_TOOLS_IMAGE__","policy":"no-runtime-apk"}' +for 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 +node -e 'console.log(JSON.stringify({event:"ci-base-image",phase:"prepare-source",ok:true,node:process.version}))' +# __HWLAB_GIT_SSH_SHELL__ + +git_url_requires_ssh() { case "$1" in git@*|ssh://*) return 0 ;; *) return 1 ;; esac; } +git_read_url="$(params.git-read-url)" +if git_url_requires_ssh "$git_read_url"; then + 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 + git_ssh_setup +else + echo '{"event":"git-ssh-setup","phase":"prepare-source","status":"skipped","reason":"non-ssh-git-read-url"}' +fi +rm -rf /workspace/source/repo +source_clone_started_ms="$(ci_now_ms)" +git_timed source-clone 180 git clone --branch "$(params.source-branch)" "$git_read_url" /workspace/source/repo +cd /workspace/source/repo +git config --global --add safe.directory /workspace/source/repo +git remote set-url origin "$git_read_url" +git checkout "$(params.revision)" +test "$(git rev-parse HEAD)" = "$(params.revision)" +git 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; } +ci_timing_emit source-clone succeeded "$source_clone_started_ms" +prepare_source_dependencies_started_ms="$(ci_now_ms)" +echo '{"event":"prepare-source-dependencies","status":"skipped","reason":"renderer-dependency-install-disabled","dependency":"yaml"}' +ci_timing_emit prepare-source-dependencies succeeded "$prepare_source_dependencies_started_ms" +catalog_path="$(params.catalog-path)" +mkdir -p "$(dirname "$catalog_path")" +catalog_fetch_started_ms="$(ci_now_ms)" +if git_timed catalog-ls-remote 45 git ls-remote --exit-code --heads "$git_read_url" "$(params.gitops-branch)" >/dev/null; then + git remote set-url gitops-catalog "$git_read_url" 2>/dev/null || git remote add gitops-catalog "$git_read_url" + git_timed catalog-fetch 90 git fetch --depth 1 gitops-catalog "$(params.gitops-branch)" >/dev/null || true + if git cat-file -e FETCH_HEAD:"$catalog_path" 2>/dev/null; then + git show FETCH_HEAD:"$catalog_path" > /tmp/hwlab-gitops-artifact-catalog.json + if node --input-type=module - /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.yaml "$(params.services)" <<'NODE' +import fs from "node:fs"; +const [catalogPath, deployPath, selectedServices] = process.argv.slice(2); +const ids = (doc) => (doc.services || []).map((service) => service.serviceId).filter(Boolean); +const topLevelServiceIds = (text) => { + const values = []; + let inServices = false; + for (const line of text.split(/\r?\n/u)) { + if (/^services:\s*$/u.test(line)) { inServices = true; continue; } + if (!inServices) continue; + if (/^\S/u.test(line)) break; + const match = line.match(/^\s*-\s+serviceId:\s*['"]?([^'"#\s]+)['"]?/u); + if (match) values.push(match[1]); + } + return values; +}; +const selected = (selectedServices || "").split(",").map((item) => item.trim()).filter(Boolean); +const uniqueSorted = (items) => [...new Set(items)].sort(); +const gitops = JSON.parse(fs.readFileSync(catalogPath, "utf8")); +const expected = selected.length ? selected : topLevelServiceIds(fs.readFileSync(deployPath, "utf8")); +const actual = ids(gitops); +const expectedSet = uniqueSorted(expected); +const actualSet = uniqueSorted(actual); +const sameServices = expectedSet.length === actualSet.length && expectedSet.every((item, index) => item === actualSet[index]); +if (!sameServices) { + const missing = expectedSet.filter((item) => !actualSet.includes(item)); + const extra = actualSet.filter((item) => !expectedSet.includes(item)); + console.error(JSON.stringify({ event: "gitops-artifact-catalog", phase: "prepare-source", status: "ignored-stale", reason: "service-ids-mismatch", expected, actual, missing, extra })); + process.exit(42); +} +NODE + then + cp /tmp/hwlab-gitops-artifact-catalog.json "$catalog_path" + echo '{"event":"gitops-artifact-catalog","phase":"prepare-source","status":"loaded","branch":"'"$(params.gitops-branch)"'"}' + else + echo '{"event":"gitops-artifact-catalog","phase":"prepare-source","status":"ignored-stale","reason":"service-ids-mismatch","branch":"'"$(params.gitops-branch)"'"}' + fi + else + echo '{"event":"gitops-artifact-catalog","phase":"prepare-source","status":"seed","reason":"missing-on-gitops-branch"}' + fi +else + echo '{"event":"gitops-artifact-catalog","phase":"prepare-source","status":"seed","reason":"gitops-branch-missing"}' +fi +if [ ! -s "$catalog_path" ] && [ "$(params.lane)" = "v02" ]; then + node --input-type=module - "$catalog_path" "$(params.revision)" "$(params.registry-prefix)" "$(params.image-tag-mode)" "$(params.services)" <<'NODE' +import fs from 'node:fs'; +import path from 'node:path'; +const [catalogPath, revision, registryPrefix, imageTagMode, selectedServices] = process.argv.slice(2); +const topLevelServiceIds = (text) => { + const values = []; + let inServices = false; + for (const line of text.split(/\r?\n/u)) { + if (/^services:\s*$/u.test(line)) { inServices = true; continue; } + if (!inServices) continue; + if (/^\S/u.test(line)) break; + const match = line.match(/^\s*-\s+serviceId:\s*['"]?([^'"#\s]+)['"]?/u); + if (match) values.push(match[1]); + } + return values; +}; +const tag = imageTagMode === 'full' ? revision : revision.slice(0, 7); +const namespace = 'hwlab-v02'; +const selected = (selectedServices || '').split(',').filter(Boolean); +const serviceIds = selected.length ? selected : topLevelServiceIds(fs.readFileSync('deploy/deploy.yaml', 'utf8')); +const services = serviceIds.map((serviceId) => { + const service = { serviceId, profile: 'v02', namespace, commitId: tag, sourceCommitId: revision, image: `${registryPrefix}/${serviceId}:${tag}`, imageTag: tag, digest: null, buildBackend: 'contract-skeleton' }; + return service; +}); +fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); +fs.writeFileSync(catalogPath, JSON.stringify({ catalogVersion: 'v1', kind: 'hwlab-artifact-catalog', environment: 'v02', profile: 'v02', namespace, endpoint: "__HWLAB_V02_RUNTIME_ENDPOINT__", commitId: tag, artifactState: 'contract-skeleton', publish: { registryPrefix, sourceCommitId: revision, imageTag: tag, publishedAt: null }, allowedProfiles: ['v02'], forbiddenProfiles: ['dev', 'prod'], services }, null, 2) + '\n'); +NODE + echo '{"event":"gitops-artifact-catalog","phase":"prepare-source","status":"seeded-v02-skeleton"}' +fi +ci_timing_emit catalog-fetch succeeded "$catalog_fetch_started_ms" +echo 'node prepare-source complete; validation task count=__HWLAB_VALIDATION_TASK_COUNT__'