Files
pikasTech-HWLAB/scripts/g14-gitops-render.mjs
T
2026-05-26 01:47:11 +08:00

1557 lines
67 KiB
JavaScript

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