feat: split G14 GitOps promotion branch

This commit is contained in:
Codex
2026-05-25 08:59:01 +08:00
parent 9d6dd2a579
commit 2fe40c637a
17 changed files with 311 additions and 2800 deletions
+295 -2
View File
@@ -12,6 +12,7 @@ const defaultOutDir = "deploy/gitops/g14";
const defaultRegistryPrefix = process.env.HWLAB_G14_REGISTRY_PREFIX || "127.0.0.1:5000/hwlab";
const defaultSourceRepo = process.env.HWLAB_G14_GIT_URL || "git@github.com:pikasTech/HWLAB.git";
const defaultBranch = process.env.HWLAB_G14_BRANCH || "G14";
const defaultGitopsBranch = process.env.HWLAB_G14_GITOPS_BRANCH || "G14-gitops";
const defaultRuntimeEndpoint = "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667";
const defaultWebEndpoint = "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080";
const defaultProxyUrl = process.env.HWLAB_G14_PROXY_URL || "http://127.0.0.1:10808";
@@ -38,6 +39,7 @@ function parseArgs(argv) {
registryPrefix: defaultRegistryPrefix,
sourceRevision: null,
sourceBranch: defaultBranch,
gitopsBranch: defaultGitopsBranch,
sourceRepo: defaultSourceRepo,
runtimeEndpoint: defaultRuntimeEndpoint,
webEndpoint: defaultWebEndpoint,
@@ -51,6 +53,7 @@ function parseArgs(argv) {
else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg).replace(/\/+$/u, "");
else if (arg === "--source-revision") args.sourceRevision = readOption(argv, ++index, arg);
else if (arg === "--source-branch") args.sourceBranch = readOption(argv, ++index, arg);
else if (arg === "--gitops-branch") args.gitopsBranch = readOption(argv, ++index, arg);
else if (arg === "--source-repo") args.sourceRepo = readOption(argv, ++index, arg);
else if (arg === "--runtime-endpoint") args.runtimeEndpoint = readOption(argv, ++index, arg);
else if (arg === "--web-endpoint") args.webEndpoint = readOption(argv, ++index, arg);
@@ -80,6 +83,7 @@ function usage() {
` --out DIR default: ${defaultOutDir}`,
` --source-repo URL default: ${defaultSourceRepo}`,
` --source-branch BRANCH default: ${defaultBranch}`,
` --gitops-branch BRANCH default: ${defaultGitopsBranch}`,
" --source-revision SHA source commit used for image tags and runtime annotations; default: git rev-parse HEAD",
` --registry-prefix PREFIX default: ${defaultRegistryPrefix}`,
` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}`,
@@ -344,6 +348,184 @@ node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report /wo
`;
}
function gitopsPromoteScript() {
return `#!/bin/sh
set -eu
if ! command -v git >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends git openssh-client ca-certificates
rm -rf /var/lib/apt/lists/*
fi
mkdir -p /root/.ssh
cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa
chmod 600 /root/.ssh/id_rsa
ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true
export GIT_SSH_COMMAND="ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
cd /workspace/source/repo
test "$(git rev-parse HEAD)" = "$(params.revision)"
node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)"
node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --check
git config --global user.name "HWLAB G14 GitOps Bot"
git config --global user.email "hwlab-g14-gitops-bot@users.noreply.github.com"
workdir="$(mktemp -d)"
git clone --no-checkout "$(params.git-url)" "$workdir/gitops"
cd "$workdir/gitops"
if git ls-remote --exit-code --heads origin "$(params.gitops-branch)" >/dev/null 2>&1; then
git fetch origin "$(params.gitops-branch)"
git checkout -B "$(params.gitops-branch)" "origin/$(params.gitops-branch)"
rm -rf deploy/gitops/g14
else
git checkout --orphan "$(params.gitops-branch)"
git rm -rf . >/dev/null 2>&1 || true
fi
mkdir -p deploy/gitops
cp -a /workspace/source/repo/deploy/gitops/g14 deploy/gitops/g14
git add deploy/gitops/g14
if git diff --cached --quiet; then
echo '{"status":"unchanged","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}'
exit 0
fi
short="$(printf '%.7s' "$(params.revision)")"
git commit -m "chore: promote G14 GitOps source $short"
git push origin "HEAD:$(params.gitops-branch)"
echo '{"status":"pushed","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}'
`;
}
function pollerScript() {
return `#!/bin/sh
set -eu
apk add --no-cache git openssh-client ca-certificates
mkdir -p /root/.ssh
cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa
chmod 600 /root/.ssh/id_rsa
ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null || true
export GIT_SSH_COMMAND="ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
workdir="$(mktemp -d)"
git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_URL" "$workdir/repo"
cd "$workdir/repo"
revision="$(git rev-parse HEAD)"
subject="$(git log -1 --pretty=%s)"
case "$subject" in
"chore: promote G14 GitOps source "*)
echo "Skipping generated GitOps promotion commit $revision"
exit 0
;;
esac
short="$(printf '%.12s' "$revision")"
name="hwlab-g14-ci-poll-$short"
export POLLER_REVISION="$revision"
export POLLER_PIPELINERUN="$name"
export POLLER_SOURCE_SUBJECT="$subject"
echo "Checking G14 source $revision with PipelineRun $name"
node <<'NODE'
const fs = require("node:fs");
const https = require("node:https");
function env(name, fallback) {
return process.env[name] || fallback;
}
const namespace = env("POD_NAMESPACE", "hwlab-ci");
const name = env("POLLER_PIPELINERUN", "");
const revision = env("POLLER_REVISION", "");
const host = process.env.KUBERNETES_SERVICE_HOST;
const port = process.env.KUBERNETES_SERVICE_PORT || "443";
if (!name || !revision) throw new Error("poller did not resolve a PipelineRun name and revision");
if (!host) throw new Error("KUBERNETES_SERVICE_HOST is not available");
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const ca = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
function request(method, path, body) {
const payload = body ? JSON.stringify(body) : "";
const headers = { Authorization: "Bearer " + token };
if (payload) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(payload);
}
return new Promise((resolve, reject) => {
const req = https.request({ host, port, method, path, ca, headers }, (res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => resolve({ statusCode: res.statusCode || 0, body: data }));
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
const labels = {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/gitops-target": "g14",
"hwlab.pikastech.local/source-commit": revision,
"hwlab.pikastech.local/trigger": "polling"
};
const pipelineRun = {
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
metadata: {
name,
namespace,
labels,
annotations: {
"hwlab.pikastech.local/source-subject": env("POLLER_SOURCE_SUBJECT", ""),
"hwlab.pikastech.local/source-branch": env("SOURCE_BRANCH", "G14"),
"hwlab.pikastech.local/gitops-branch": env("GITOPS_BRANCH", "G14-gitops")
}
},
spec: {
pipelineRef: { name: env("PIPELINE_NAME", "hwlab-g14-ci-image-publish") },
taskRunTemplate: {
serviceAccountName: "hwlab-tekton-runner",
podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" }
},
params: [
{ name: "git-url", value: env("GIT_URL", "git@github.com:pikasTech/HWLAB.git") },
{ name: "source-branch", value: env("SOURCE_BRANCH", "G14") },
{ name: "gitops-branch", value: env("GITOPS_BRANCH", "G14-gitops") },
{ name: "revision", value: revision },
{ name: "registry-prefix", value: env("REGISTRY_PREFIX", "127.0.0.1:5000/hwlab") },
{ name: "services", value: env("SERVICES", "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills") },
{ name: "base-image", value: env("BASE_IMAGE", "node:20-bookworm-slim") },
{ name: "concurrency", value: env("CONCURRENCY", "4") }
],
workspaces: [
{ name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } },
{ name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } }
]
}
};
const base = "/apis/tekton.dev/v1/namespaces/" + encodeURIComponent(namespace) + "/pipelineruns";
const item = base + "/" + encodeURIComponent(name);
(async () => {
const existing = await request("GET", item);
if (existing.statusCode === 200) {
console.log(JSON.stringify({ status: "exists", pipelineRun: name, revision }));
return;
}
if (existing.statusCode !== 404) {
throw new Error("unexpected PipelineRun lookup status " + existing.statusCode + ": " + existing.body.slice(0, 500));
}
const created = await request("POST", base, pipelineRun);
if (created.statusCode < 200 || created.statusCode > 299) {
throw new Error("PipelineRun create failed with status " + created.statusCode + ": " + created.body.slice(0, 1000));
}
console.log(JSON.stringify({ status: "created", pipelineRun: name, revision }));
})().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
NODE
`;
}
function tektonRbac() {
return {
apiVersion: "v1",
@@ -367,6 +549,21 @@ function tektonRbac() {
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"] }
]
},
{
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" }
}
]
};
@@ -392,6 +589,7 @@ function tektonPipeline(ci) {
params: [
{ name: "git-url", type: "string", default: defaultSourceRepo },
{ name: "source-branch", type: "string", default: defaultBranch },
{ name: "gitops-branch", type: "string", default: defaultGitopsBranch },
{ name: "revision", type: "string", description: "Full git commit SHA; the only source truth for image tags." },
{ name: "registry-prefix", type: "string", default: defaultRegistryPrefix },
{ name: "services", type: "string", default: "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills" },
@@ -461,6 +659,32 @@ function tektonPipeline(ci) {
{ name: "base-image", value: "$(params.base-image)" },
{ name: "concurrency", value: "$(params.concurrency)" }
]
},
{
name: "gitops-promote",
runAfter: ["image-publish"],
workspaces: [
{ name: "source", workspace: "source" },
{ name: "git-ssh", workspace: "git-ssh" }
],
taskSpec: {
params: [
{ name: "git-url" },
{ name: "source-branch" },
{ name: "gitops-branch" },
{ name: "revision" },
{ name: "registry-prefix" }
],
workspaces: [{ name: "source" }, { name: "git-ssh" }],
steps: [{ name: "promote", image: "node:22-bookworm-slim", env: proxyEnv(), script: gitopsPromoteScript() }]
},
params: [
{ name: "git-url", value: "$(params.git-url)" },
{ name: "source-branch", value: "$(params.source-branch)" },
{ name: "gitops-branch", value: "$(params.gitops-branch)" },
{ name: "revision", value: "$(params.revision)" },
{ name: "registry-prefix", value: "$(params.registry-prefix)" }
]
}
]
}
@@ -492,6 +716,7 @@ function tektonPipelineRunTemplate({ source, args }) {
params: [
{ name: "git-url", value: args.sourceRepo },
{ name: "source-branch", value: args.sourceBranch },
{ name: "gitops-branch", value: args.gitopsBranch },
{ name: "revision", value: source.full },
{ name: "registry-prefix", value: args.registryPrefix },
{ name: "base-image", value: "node:20-bookworm-slim" },
@@ -505,6 +730,71 @@ function tektonPipelineRunTemplate({ source, args }) {
};
}
function tektonPollerCronJob(args) {
return {
apiVersion: "batch/v1",
kind: "CronJob",
metadata: {
name: "hwlab-g14-branch-poller",
namespace: "hwlab-ci",
labels: {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/gitops-target": "g14"
},
annotations: {
"hwlab.pikastech.local/source-branch": args.sourceBranch,
"hwlab.pikastech.local/gitops-branch": args.gitopsBranch
}
},
spec: {
schedule: "*/5 * * * *",
concurrencyPolicy: "Forbid",
successfulJobsHistoryLimit: 3,
failedJobsHistoryLimit: 3,
jobTemplate: {
spec: {
backoffLimit: 1,
template: {
metadata: {
labels: {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/gitops-target": "g14"
}
},
spec: {
serviceAccountName: "hwlab-tekton-runner",
restartPolicy: "Never",
hostNetwork: true,
dnsPolicy: "ClusterFirstWithHostNet",
containers: [{
name: "poll",
image: "node:22-alpine",
imagePullPolicy: "IfNotPresent",
env: [
...proxyEnv(),
{ name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } },
{ name: "PIPELINE_NAME", value: "hwlab-g14-ci-image-publish" },
{ name: "GIT_URL", value: args.sourceRepo },
{ name: "SOURCE_BRANCH", value: args.sourceBranch },
{ name: "GITOPS_BRANCH", value: args.gitopsBranch },
{ name: "REGISTRY_PREFIX", value: args.registryPrefix },
{ name: "SERVICES", value: "hwlab-cloud-api,hwlab-cloud-web,hwlab-agent-mgr,hwlab-agent-worker,hwlab-gateway,hwlab-gateway-simu,hwlab-box-simu,hwlab-patch-panel,hwlab-router,hwlab-tunnel-client,hwlab-edge-proxy,hwlab-cli,hwlab-agent-skills" },
{ name: "BASE_IMAGE", value: "node:20-bookworm-slim" },
{ name: "CONCURRENCY", value: "4" }
],
volumeMounts: [{ name: "git-ssh", mountPath: "/workspace/git-ssh", readOnly: true }],
command: ["/bin/sh", "-c"],
args: [pollerScript()]
}],
volumes: [{ name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } }]
}
}
}
}
}
};
}
function registryManifest() {
return {
apiVersion: "v1",
@@ -574,7 +864,7 @@ function argoApplication(args) {
},
spec: {
project: "hwlab-g14",
source: { repoURL: args.sourceRepo, targetRevision: args.sourceBranch, path: "deploy/gitops/g14/runtime" },
source: { repoURL: args.sourceRepo, targetRevision: args.gitopsBranch, path: "deploy/gitops/g14/runtime" },
destination: { server: "https://kubernetes.default.svc", namespace: "hwlab-dev" },
syncPolicy: { automated: { prune: false, selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] }
}
@@ -598,7 +888,8 @@ This directory is generated from \`CI.json\`, \`deploy/deploy.json\`, and \`depl
- 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\`.
- Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`.
- CD: Argo CD Application \`argocd/hwlab-g14-dev\` consumes \`${args.gitopsBranch}:deploy/gitops/g14/runtime\`.
- 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.
`;
@@ -630,6 +921,7 @@ async function plannedFiles(args) {
sourceCommit: source.full,
sourceShortCommit: source.short,
sourceBranch: args.sourceBranch,
gitopsBranch: args.gitopsBranch,
sourceRepo: args.sourceRepo,
registryPrefix: args.registryPrefix,
runtimePath: "deploy/gitops/g14/runtime",
@@ -640,6 +932,7 @@ async function plannedFiles(args) {
putJson("registry/registry.yaml", registryManifest());
putJson("tekton/rbac.yaml", tektonRbac());
putJson("tekton/pipeline.yaml", tektonPipeline(ci));
putJson("tekton/poller.yaml", tektonPollerCronJob(args));
putJson("tekton/pipelinerun.sample.yaml", tektonPipelineRunTemplate({ source, args }));
putJson("argocd/project.yaml", argoProject());
putJson("argocd/application.yaml", argoApplication(args));