diff --git a/AGENTS.md b/AGENTS.md index a3078e1f..0989df63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - 架构和 M3 主线:[docs/reference/architecture.md](docs/reference/architecture.md) - DEV 运行态、端口、k3s 和 DB DNS 边界:[docs/reference/dev-runtime-boundary.md](docs/reference/dev-runtime-boundary.md) - 部署正规化、`deploy.json` DEV CD 路径、SecretRef preflight、runner/host 边界和镜像发布:[docs/reference/deployment-publish.md](docs/reference/deployment-publish.md) +- G14 GitOps CI/CD、Tekton/Argo CD、集群内 registry 和无锁镜像化发布:[docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md) - Code Agent 对话就绪与真实回复判定:[docs/reference/code-agent-chat-readiness.md](docs/reference/code-agent-chat-readiness.md) - DEV runtime hotfix runbook 与只读审计:[docs/reference/dev-runtime-hotfix-runbook.md](docs/reference/dev-runtime-hotfix-runbook.md) - Gateway 主动出站 demo、poll/result 和本地 smoke:[docs/reference/gateway-outbound-demo.md](docs/reference/gateway-outbound-demo.md) @@ -61,6 +62,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - Cloud Workbench 布局/遮挡 smoke:`npm run web:layout`;local-build 用 `npm run web:layout:build`;DEV deploy 后用 `npm run web:layout:live`。 - DEV artifact 发布预检:`npm run dev-artifact:preflight` - DEV artifact CI 发布:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4` +- G14 GitOps 渲染:`npm run g14:gitops:render`;检查已生成 Tekton/Argo CD manifests:`npm run g14:gitops:check` - DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build` - DEV CD 单事务发布/应用/验证:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4` - CI/CD job 查询:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report ` diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md new file mode 100644 index 00000000..16b745fd --- /dev/null +++ b/docs/reference/g14-gitops-cicd.md @@ -0,0 +1,47 @@ +# G14 GitOps CI/CD + +G14 是 HWLAB 的旁路 DEV 集群目标。G14 CI/CD 必须只作用于 G14 k3s,不接管 D601 生产,不使用 UniDesk Code Queue 作为调度器,也不把 UniDesk backend、provider-gateway 或 microservice proxy 当作 HWLAB runtime。 + +## 目标模型 + +- Source of truth:业务版本以 Git source commit 为唯一身份;镜像 tag、OCI labels、runtime annotation 和 Argo CD desired state 都必须记录同一个 source commit。 +- CI:Tekton 在 G14 k3s 内运行 `hwlab-g14-ci-image-publish` Pipeline,读取 `CI.json` 命令,按 source commit 构建镜像并推送到 G14 registry。 +- Artifact:镜像使用 commit tag,例如 `127.0.0.1:5000/hwlab/hwlab-cloud-api:`;digest 由 registry 返回,CI report 只作为审计证据,不作为 CD 真相。 +- CD:Argo CD 只消费 `deploy/gitops/g14/runtime` 的 Git desired state,不重新构建镜像,不读取 D601 状态,不获取 legacy DEV CD Lease。 +- 并行性:不同 source commit 的 CI build 不共享发布锁;并行安全由 immutable commit tag/digest 和 Git desired state 保证。最终运行版本由 Argo CD 当前同步的 Git revision 决定。 + +## 生成入口 + +`scripts/g14-gitops-render.mjs` 是 G14 专用转换器: + +- 读取 `CI.json`,生成 Tekton Pipeline 和 PipelineRun 样板。 +- 读取 `deploy/deploy.json` 与 `deploy/k8s/*`,生成 Argo CD 可消费的 G14 runtime Kustomize path。 +- 默认输出到 `deploy/gitops/g14/`。 +- 默认 registry prefix 是 `127.0.0.1:5000/hwlab`,用于 G14 单节点 k3s 的 node-local registry。 + +常用命令: + +```sh +npm run g14:gitops:render -- --source-revision +npm run g14:gitops:check +``` + +## 集群资源 + +- `hwlab-ci`:registry、Tekton runner RBAC、Pipeline/PipelineRun 所在 namespace。 +- `argocd`:Argo CD 控制面和 `hwlab-g14-dev` Application 所在 namespace。 +- `hwlab-dev`:HWLAB runtime namespace,由 Argo CD 应用 `deploy/gitops/g14/runtime`。 + +G14 registry 由 Kubernetes Deployment `hwlab-ci/hwlab-registry` 承载,使用 host network 暴露 `127.0.0.1:5000` 给 k3s/containerd 和 host-network Tekton build pod。旧 host Docker registry 不能与该 Deployment 同时占用 5000 端口。 + +## 凭证边界 + +HWLAB repo 是私有仓库时,Tekton 和 Argo CD 需要各自的 Git SSH Secret。Secret 只能从 G14 本机已有 SSH key 创建,不能写入 Git,不能打印 key 内容。 + +Tekton 约定 Secret 名称:`hwlab-ci/hwlab-git-ssh`。 + +Argo CD 约定 repository Secret:`argocd/hwlab-git-ssh`,并带 label `argocd.argoproj.io/secret-type=repository`。 + +## D601 边界 + +G14 GitOps manifests 不包含 D601 kubeconfig、D601 node guard、D601 FRP 公网入口或 UniDesk Code Queue 调度入口。D601 仍由既有生产路径维护;G14 GitOps 的安装、PipelineRun、Argo sync 和 registry 操作都不能对 D601 执行 kubectl、docker 或流量切换动作。 diff --git a/package.json b/package.json index 5605b88d..07e845cb 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight", "dev-cd:apply": "node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4", "dev-artifact:publish": "node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4", + "g14:gitops:render": "node scripts/g14-gitops-render.mjs", + "g14:gitops:check": "node scripts/g14-gitops-render.mjs --check", "cicd:jobs": "node tools/hwlab-cli/bin/hwlab-cli.mjs cicd jobs", "docs:validate:m3-rollout": "node scripts/validate-m3-rollout-runbook.mjs", "d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs", diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs new file mode 100644 index 00000000..540a8ad0 --- /dev/null +++ b/scripts/g14-gitops-render.mjs @@ -0,0 +1,648 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultOutDir = "deploy/gitops/g14"; +const defaultRegistryPrefix = process.env.HWLAB_G14_REGISTRY_PREFIX || "127.0.0.1:5000/hwlab"; +const defaultSourceRepo = process.env.HWLAB_G14_GIT_URL || "git@github.com:pikasTech/HWLAB.git"; +const defaultBranch = process.env.HWLAB_G14_BRANCH || "G14"; +const defaultRuntimeEndpoint = "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667"; +const defaultWebEndpoint = "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080"; +const sourceCommitPattern = /^[a-f0-9]{7,40}$/u; + +function parseArgs(argv) { + const args = { + outDir: defaultOutDir, + registryPrefix: defaultRegistryPrefix, + sourceRevision: null, + sourceBranch: defaultBranch, + sourceRepo: defaultSourceRepo, + runtimeEndpoint: defaultRuntimeEndpoint, + webEndpoint: defaultWebEndpoint, + check: false, + write: true, + help: false + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--out") args.outDir = readOption(argv, ++index, arg); + else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg).replace(/\/+$/u, ""); + else if (arg === "--source-revision") args.sourceRevision = readOption(argv, ++index, arg); + else if (arg === "--source-branch") args.sourceBranch = readOption(argv, ++index, arg); + else if (arg === "--source-repo") args.sourceRepo = readOption(argv, ++index, arg); + else if (arg === "--runtime-endpoint") args.runtimeEndpoint = readOption(argv, ++index, arg); + else if (arg === "--web-endpoint") args.webEndpoint = readOption(argv, ++index, arg); + else if (arg === "--check") { + args.check = true; + args.write = false; + } else if (arg === "--no-write") args.write = false; + else if (arg === "--help" || arg === "-h") args.help = true; + else throw new Error(`unknown argument ${arg}`); + } + return args; +} + +function readOption(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function usage() { + return [ + "usage: node scripts/g14-gitops-render.mjs [options]", + "", + "Render G14-only Kubernetes-native CI/CD manifests from CI.json and deploy/deploy.json.", + "", + "options:", + ` --out DIR default: ${defaultOutDir}`, + ` --source-repo URL default: ${defaultSourceRepo}`, + ` --source-branch BRANCH default: ${defaultBranch}`, + " --source-revision SHA source commit used for image tags and runtime annotations; default: git rev-parse HEAD", + ` --registry-prefix PREFIX default: ${defaultRegistryPrefix}`, + ` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}`, + ` --web-endpoint URL default: ${defaultWebEndpoint}`, + " --check verify generated files are current without writing" + ].join("\n"); +} + +async function readJson(relativePath) { + return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); +} + +async function gitValue(args) { + const result = await execFileAsync("git", args, { cwd: repoRoot, timeout: 10000, maxBuffer: 1024 * 1024 }); + return result.stdout.trim(); +} + +async function resolveSourceRevision(value) { + const revision = value || await gitValue(["rev-parse", "HEAD"]); + const full = sourceCommitPattern.test(revision) && revision.length === 40 + ? revision + : await gitValue(["rev-parse", revision]); + assert.match(full, /^[a-f0-9]{40}$/u, "source revision must resolve to a 40-char git commit"); + return { full, short: full.slice(0, 7) }; +} + +function jsonManifest(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function textFile(value) { + return value.endsWith("\n") ? value : `${value}\n`; +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function ensureObject(value, name) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${name} must be an object`); + return value; +} + +function asItems(list, name) { + assert.equal(list?.kind, "List", `${name} must be a Kubernetes List`); + assert.ok(Array.isArray(list.items), `${name}.items must be an array`); + return list.items; +} + +function serviceIdForWorkload(item, container) { + return ( + item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || + item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || + container?.name + ); +} + +function upsertEnv(envList, name, value) { + if (!Array.isArray(envList)) return; + const existing = envList.find((entry) => entry?.name === name); + if (existing) { + delete existing.valueFrom; + existing.value = value; + } else { + envList.push({ name, value }); + } +} + +function annotate(metadata, annotations) { + metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations }; +} + +function label(metadata, labels) { + metadata.labels = { ...(metadata.labels ?? {}), ...labels }; +} + +function imageFor(registryPrefix, serviceId, shortCommit) { + return `${registryPrefix}/${serviceId}:${shortCommit}`; +} + +function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint }) { + const result = cloneJson(workloads); + const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service])); + const stableRuntimeLabels = { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": "dev", + "hwlab.pikastech.local/profile": "dev" + }; + for (const item of asItems(result, "workloads")) { + item.metadata.namespace = deploy.namespace; + label(item.metadata, { + ...stableRuntimeLabels, + "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/source-commit": source.full, + "unidesk.ai/g14-staging": "true" + }); + annotate(item.metadata, { + "hwlab.pikastech.local/gitops-note": "G14 GitOps target only; production traffic remains outside this target.", + "hwlab.pikastech.local/source-commit": source.full + }); + if (item.kind === "Job") { + annotate(item.metadata, { + "argocd.argoproj.io/sync-options": "Replace=true" + }); + } + if (serviceIdForWorkload(item, null) === "hwlab-tunnel-client" && typeof item.spec?.replicas === "number") { + item.spec.replicas = 0; + } + + const podTemplate = item?.spec?.template; + if (!podTemplate?.spec?.containers) continue; + label(podTemplate.metadata ??= {}, { + ...stableRuntimeLabels, + "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/source-commit": source.full + }); + if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) { + item.spec.selector.matchLabels = { + ...item.spec.selector.matchLabels, + ...stableRuntimeLabels + }; + } + annotate(podTemplate.metadata, { + "hwlab.pikastech.local/source-commit": source.full, + "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" + }); + for (const container of podTemplate.spec.containers) { + const serviceId = serviceIdForWorkload(item, container); + if (!deployServices.has(serviceId)) continue; + const image = imageFor(registryPrefix, serviceId, source.short); + container.image = image; + container.imagePullPolicy = "IfNotPresent"; + container.env ??= []; + upsertEnv(container.env, "HWLAB_COMMIT_ID", source.full); + upsertEnv(container.env, "HWLAB_IMAGE", image); + upsertEnv(container.env, "HWLAB_IMAGE_TAG", source.short); + upsertEnv(container.env, "HWLAB_GITOPS_TARGET", "g14"); + upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", source.full); + if (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-edge-proxy") { + upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint); + } + if (serviceId === "hwlab-cloud-web") { + upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); + } + if (serviceId === "hwlab-cli") { + upsertEnv(container.env, "HWLAB_CLI_ENDPOINT", runtimeEndpoint); + } + if (serviceId === "hwlab-tunnel-client") { + upsertEnv(container.env, "HWLAB_TUNNEL_MODE", "disabled-g14-gitops"); + upsertEnv(container.env, "HWLAB_FRP_SERVER_ADDR", ""); + upsertEnv(container.env, "HWLAB_FRP_PUBLIC_PORT", "0"); + upsertEnv(container.env, "HWLAB_FRP_WEB_PUBLIC_PORT", "0"); + } + } + } + return result; +} + +function transformHealthContract(value, namespace, labels, annotations, runtimeEndpoint, webEndpoint) { + const result = transformListNamespace(value, namespace, labels, annotations); + if (result.data && typeof result.data === "object") { + if (Object.hasOwn(result.data, "endpoint")) result.data.endpoint = runtimeEndpoint; + if (Object.hasOwn(result.data, "cloud-web")) { + result.data["cloud-web"] = `GET /health/live on ${webEndpoint}; consumes cloud-api only`; + } + if (Object.hasOwn(result.data, "cloud-api")) { + result.data["cloud-api"] = `GET /health/live through ${runtimeEndpoint}`; + } + if (Object.hasOwn(result.data, "tunnel-client")) { + result.data["tunnel-client"] = "disabled for G14 GitOps; no FRP or production traffic tunnel is required"; + } + } + return result; +} + +function transformListNamespace(value, namespace, labels, annotations) { + const result = cloneJson(value); + if (result.kind === "List") { + for (const item of result.items ?? []) { + item.metadata ??= {}; + item.metadata.namespace = namespace; + label(item.metadata, labels); + annotate(item.metadata, annotations); + } + } else { + result.metadata ??= {}; + result.metadata.namespace = namespace; + label(result.metadata, labels); + annotate(result.metadata, annotations); + } + return result; +} + +function shellSingleQuote(value) { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +function ciJsonScript(ci) { + const commands = ci.commands ?? []; + const lines = [ + "#!/bin/sh", + "set -eu", + "apk add --no-cache git python3 openssh-client", + "mkdir -p /root/.ssh", + "cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa", + "chmod 600 /root/.ssh/id_rsa", + "ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null", + "rm -rf /workspace/source/repo", + "git clone --branch \"$(params.source-branch)\" \"$(params.git-url)\" /workspace/source/repo", + "cd /workspace/source/repo", + "git checkout \"$(params.revision)\"", + "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", + "npm ci --ignore-scripts", + `echo ${shellSingleQuote(`CI.json ${ci.name ?? "hwlab"} command count=${commands.length}`)}` + ]; + for (const command of commands) { + lines.push(`echo ${shellSingleQuote(`CI.json command: ${command.name}`)}`); + lines.push(command.run); + } + return `${lines.join("\n")}\n`; +} + +function publishScript() { + return `#!/bin/sh +set -eu +apk add --no-cache docker-cli git openssh-client python3 +export DOCKER_HOST=unix:///var/run/docker.sock +export HWLAB_CI_ARTIFACT_RUN_ID="$(context.pipelineRun.name)" +export HWLAB_CI_ARTIFACT_RUN_OWNER="tekton" +export HWLAB_DEV_REGISTRY_PREFIX="$(params.registry-prefix)" +export HWLAB_DEV_REGISTRY_K8S_NATIVE=1 +if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi +for i in $(seq 1 90); do docker info >/dev/null 2>&1 && break; sleep 2; done +docker info >/dev/null +cd /workspace/source/repo +test "$(git rev-parse HEAD)" = "$(params.revision)" +node scripts/dev-artifact-publish.mjs --publish --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency "$(params.concurrency)" --services "$(params.services)" +node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write +`; +} + +function tektonRbac() { + return { + apiVersion: "v1", + kind: "List", + items: [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, + { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: "hwlab-tekton-runner", namespace: "hwlab-ci" } }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: "hwlab-tekton-runtime-observer", namespace: "hwlab-dev" }, + rules: [ + { apiGroups: [""], resources: ["pods", "services", "configmaps"], verbs: ["get", "list", "watch"] }, + { apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] }, + { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: "hwlab-tekton-runtime-observer", namespace: "hwlab-dev" }, + subjects: [{ kind: "ServiceAccount", name: "hwlab-tekton-runner", namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-tekton-runtime-observer" } + } + ] + }; +} + +function tektonPipeline(ci) { + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { + name: "hwlab-g14-ci-image-publish", + namespace: "hwlab-ci", + labels: { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/gitops-target": "g14" + }, + annotations: { + "hwlab.pikastech.local/source-config": "CI.json", + "hwlab.pikastech.local/policy": "parallel-no-lock-ci-image-publish" + } + }, + spec: { + params: [ + { name: "git-url", type: "string", default: defaultSourceRepo }, + { name: "source-branch", type: "string", default: defaultBranch }, + { name: "revision", type: "string", description: "Full git commit SHA; the only source truth for image tags." }, + { name: "registry-prefix", type: "string", default: defaultRegistryPrefix }, + { name: "services", type: "string", default: "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills" }, + { name: "base-image", type: "string", default: "node:20-bookworm-slim" }, + { name: "concurrency", type: "string", default: "4" } + ], + workspaces: [ + { name: "source" }, + { name: "git-ssh" } + ], + tasks: [ + { + name: "ci-json", + workspaces: [ + { name: "source", workspace: "source" }, + { name: "git-ssh", workspace: "git-ssh" } + ], + taskSpec: { + params: [ + { name: "git-url" }, + { name: "source-branch" }, + { name: "revision" } + ], + workspaces: [{ name: "source" }, { name: "git-ssh" }], + steps: [{ name: "ci-json", image: "node:22-alpine", script: ciJsonScript(ci) }] + }, + params: [ + { name: "git-url", value: "$(params.git-url)" }, + { name: "source-branch", value: "$(params.source-branch)" }, + { name: "revision", value: "$(params.revision)" } + ] + }, + { + name: "image-publish", + runAfter: ["ci-json"], + workspaces: [{ name: "source", workspace: "source" }], + taskSpec: { + params: [ + { name: "revision" }, + { name: "registry-prefix" }, + { name: "services" }, + { name: "base-image" }, + { name: "concurrency" } + ], + workspaces: [{ name: "source" }], + volumes: [{ name: "docker-run", emptyDir: {} }], + sidecars: [{ + name: "docker", + image: "docker:29-dind", + args: ["--host=unix:///var/run/docker.sock", "--storage-driver=vfs"], + env: [{ name: "DOCKER_TLS_CERTDIR", value: "" }], + securityContext: { privileged: true }, + volumeMounts: [{ name: "docker-run", mountPath: "/var/run" }] + }], + steps: [{ + name: "publish", + image: "node:22-alpine", + env: [{ name: "DOCKER_HOST", value: "unix:///var/run/docker.sock" }], + volumeMounts: [{ name: "docker-run", mountPath: "/var/run" }], + script: publishScript() + }] + }, + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "registry-prefix", value: "$(params.registry-prefix)" }, + { name: "services", value: "$(params.services)" }, + { name: "base-image", value: "$(params.base-image)" }, + { name: "concurrency", value: "$(params.concurrency)" } + ] + } + ] + } + }; +} + +function tektonPipelineRunTemplate({ source, args }) { + return { + apiVersion: "tekton.dev/v1", + kind: "PipelineRun", + metadata: { + generateName: "hwlab-g14-ci-", + namespace: "hwlab-ci", + labels: { + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/source-commit": source.full + } + }, + spec: { + pipelineRef: { name: "hwlab-g14-ci-image-publish" }, + taskRunTemplate: { + serviceAccountName: "hwlab-tekton-runner", + podTemplate: { + hostNetwork: true, + dnsPolicy: "ClusterFirstWithHostNet" + } + }, + params: [ + { name: "git-url", value: args.sourceRepo }, + { name: "source-branch", value: args.sourceBranch }, + { name: "revision", value: source.full }, + { name: "registry-prefix", value: args.registryPrefix }, + { name: "base-image", value: "node:20-bookworm-slim" }, + { name: "concurrency", value: "4" } + ], + workspaces: [ + { name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } }, + { name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } } + ] + } + }; +} + +function registryManifest() { + return { + apiVersion: "v1", + kind: "List", + items: [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: "hwlab-registry", namespace: "hwlab-ci", labels: { "app.kubernetes.io/name": "hwlab-registry" } }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": "hwlab-registry" } }, + template: { + metadata: { labels: { "app.kubernetes.io/name": "hwlab-registry" } }, + spec: { + hostNetwork: true, + dnsPolicy: "ClusterFirstWithHostNet", + containers: [{ + name: "registry", + image: "registry:2.8.3", + imagePullPolicy: "IfNotPresent", + ports: [{ name: "registry", containerPort: 5000, hostPort: 5000 }], + env: [{ name: "REGISTRY_STORAGE_DELETE_ENABLED", value: "true" }], + volumeMounts: [{ name: "storage", mountPath: "/var/lib/registry" }], + readinessProbe: { httpGet: { path: "/v2/", port: "registry" } }, + livenessProbe: { httpGet: { path: "/v2/", port: "registry" } } + }], + volumes: [{ name: "storage", hostPath: { path: "/var/lib/hwlab/registry", type: "DirectoryOrCreate" } }] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name: "hwlab-registry", namespace: "hwlab-ci", labels: { "app.kubernetes.io/name": "hwlab-registry" } }, + spec: { selector: { "app.kubernetes.io/name": "hwlab-registry" }, ports: [{ name: "registry", port: 5000, targetPort: "registry" }] } + } + ] + }; +} + +function argoProject() { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "AppProject", + metadata: { name: "hwlab-g14", namespace: "argocd" }, + spec: { + description: "HWLAB G14 GitOps project; D601 remains outside this project.", + sourceRepos: [defaultSourceRepo], + destinations: [{ server: "https://kubernetes.default.svc", namespace: "hwlab-dev" }], + clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], + namespaceResourceWhitelist: [{ group: "*", kind: "*" }] + } + }; +} + +function argoApplication(args) { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "Application", + metadata: { + name: "hwlab-g14-dev", + namespace: "argocd", + labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" } + }, + spec: { + project: "hwlab-g14", + source: { repoURL: args.sourceRepo, targetRevision: args.sourceBranch, path: "deploy/gitops/g14/runtime" }, + destination: { server: "https://kubernetes.default.svc", namespace: "hwlab-dev" }, + syncPolicy: { automated: { prune: false, selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } + } + }; +} + +function runtimeKustomization({ source }) { + return { + apiVersion: "kustomize.config.k8s.io/v1beta1", + kind: "Kustomization", + namespace: "hwlab-dev", + resources: ["namespace.yaml", "code-agent-codex-config.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml"] + }; +} + +function readme({ source, args }) { + return `# HWLAB G14 GitOps CI/CD + +This directory is generated from \`CI.json\`, \`deploy/deploy.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`. + +- Target: G14 k3s only. +- CI: Tekton Pipeline \`hwlab-ci/hwlab-g14-ci-image-publish\`. +- Artifact contract: images are tagged by source git commit \`${source.short}\` and pushed to \`${args.registryPrefix}\`. +- CD: Argo CD Application \`argocd/hwlab-g14-dev\` consumes \`deploy/gitops/g14/runtime\`. +- D601 boundary: this path does not target D601, does not use UniDesk Code Queue, and does not change D601 production traffic. +- Concurrency: CI builds are per source commit and do not acquire the legacy DEV CD Lease; Argo CD reconciles the Git desired state. +`; +} + +async function plannedFiles(args) { + const [ci, deploy, namespace, config, services, health, workloads] = await Promise.all([ + readJson("CI.json"), + readJson("deploy/deploy.json"), + readJson("deploy/k8s/base/namespace.yaml"), + readJson("deploy/k8s/base/code-agent-codex-config.yaml"), + readJson("deploy/k8s/base/services.yaml"), + readJson("deploy/k8s/dev/health-contract.yaml"), + readJson("deploy/k8s/base/workloads.yaml") + ]); + const source = await resolveSourceRevision(args.sourceRevision); + const labels = { "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": source.full }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" }; + const runtimeNamespace = cloneJson(namespace); + runtimeNamespace.metadata.name = deploy.namespace; + label(runtimeNamespace.metadata, labels); + annotate(runtimeNamespace.metadata, annotations); + const files = new Map(); + const putJson = (relative, value) => files.set(path.join(args.outDir, relative), jsonManifest(value)); + const putText = (relative, value) => files.set(path.join(args.outDir, relative), textFile(value)); + + putText("README.md", readme({ source, args })); + putJson("registry/registry.yaml", registryManifest()); + putJson("tekton/rbac.yaml", tektonRbac()); + putJson("tekton/pipeline.yaml", tektonPipeline(ci)); + putJson("tekton/pipelinerun.sample.yaml", tektonPipelineRunTemplate({ source, args })); + putJson("argocd/project.yaml", argoProject()); + putJson("argocd/application.yaml", argoApplication(args)); + putJson("runtime/kustomization.yaml", runtimeKustomization({ source })); + putJson("runtime/namespace.yaml", runtimeNamespace); + putJson("runtime/code-agent-codex-config.yaml", transformListNamespace(config, deploy.namespace, labels, annotations)); + putJson("runtime/services.yaml", transformListNamespace(services, deploy.namespace, labels, annotations)); + putJson("runtime/health-contract.yaml", transformHealthContract(health, deploy.namespace, labels, annotations, args.runtimeEndpoint, args.webEndpoint)); + putJson("runtime/workloads.yaml", transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint })); + return { files, source }; +} + +async function writeFiles(files) { + for (const [relativePath, content] of files) { + const absolutePath = path.join(repoRoot, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, content); + } +} + +async function checkFiles(files) { + const mismatches = []; + for (const [relativePath, expected] of files) { + let actual = null; + try { + actual = await readFile(path.join(repoRoot, relativePath), "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + if (actual !== expected) mismatches.push(relativePath); + } + return mismatches; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + ensureObject(args, "args"); + const { files, source } = await plannedFiles(args); + if (args.check) { + const mismatches = await checkFiles(files); + console.log(JSON.stringify({ ok: mismatches.length === 0, sourceCommit: source.full, outDir: args.outDir, checked: files.size, mismatches }, null, 2)); + process.exitCode = mismatches.length === 0 ? 0 : 1; + return; + } + if (args.write) await writeFiles(files); + console.log(JSON.stringify({ ok: true, sourceCommit: source.full, sourceShort: source.short, outDir: args.outDir, files: [...files.keys()] }, null, 2)); +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exitCode = 1; +}); diff --git a/scripts/src/registry-capabilities.mjs b/scripts/src/registry-capabilities.mjs index 32ea70a5..9fe14e55 100644 --- a/scripts/src/registry-capabilities.mjs +++ b/scripts/src/registry-capabilities.mjs @@ -211,21 +211,28 @@ async function probeDockerDaemonPushAccess(registryPrefix, timeoutMs) { const matchingContainers = registryContainersFromDockerPs(dockerPs.stdout); const registryVisible = !isLoopbackRegistry5000(registryPrefix) || matchingContainers.length > 0; + const k8sNativeLoopbackRegistry = + isLoopbackRegistry5000(registryPrefix) && + process.env.HWLAB_DEV_REGISTRY_K8S_NATIVE === "1"; + const registryAccepted = registryVisible || k8sNativeLoopbackRegistry; return { id: "docker-daemon-push-access", - status: registryVisible ? "pass" : "blocked", + status: registryAccepted ? "pass" : "blocked", requiredForPublish: true, requiredForDeploy: false, endpoint: registryPrefix, probeKind: "docker-ps-read-only", summary: registryVisible ? "Docker daemon can see a local/internal registry target for publish preflight purposes; no push was attempted." - : "Docker daemon did not show a registry container publishing port 5000.", + : (k8sNativeLoopbackRegistry + ? "Docker daemon did not show the registry container, but HWLAB_DEV_REGISTRY_K8S_NATIVE=1 declares the loopback registry is owned by Kubernetes and reachable through the node network." + : "Docker daemon did not show a registry container publishing port 5000."), evidence: { dockerVersion: summarizeCommand(dockerVersion), registryPrefix, loopbackPort5000: isLoopbackRegistry5000(registryPrefix), registryContainerVisible: registryVisible, + k8sNativeLoopbackRegistry, matchingContainers, pushAttempted: false, mutationAttempted: false