675 lines
27 KiB
JavaScript
675 lines
27 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 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) };
|
|
}
|
|
|
|
async function defaultCheckSourceRevision(args) {
|
|
if (!args.check || args.sourceRevision) return args;
|
|
try {
|
|
const raw = await readFile(path.join(repoRoot, args.outDir, "source.json"), "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed.sourceCommit === "string" && sourceCommitPattern.test(parsed.sourceCommit)) {
|
|
return { ...args, sourceRevision: parsed.sourceCommit };
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function jsonManifest(value) {
|
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
}
|
|
|
|
function textFile(value) {
|
|
return value.endsWith("\n") ? value : `${value}\n`;
|
|
}
|
|
|
|
function cloneJson(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function ensureObject(value, name) {
|
|
assert.ok(value && typeof value === "object" && !Array.isArray(value), `${name} must be an object`);
|
|
return value;
|
|
}
|
|
|
|
function asItems(list, name) {
|
|
assert.equal(list?.kind, "List", `${name} must be a Kubernetes List`);
|
|
assert.ok(Array.isArray(list.items), `${name}.items must be an array`);
|
|
return list.items;
|
|
}
|
|
|
|
function serviceIdForWorkload(item, container) {
|
|
return (
|
|
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
|
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
|
container?.name
|
|
);
|
|
}
|
|
|
|
function upsertEnv(envList, name, value) {
|
|
if (!Array.isArray(envList)) return;
|
|
const existing = envList.find((entry) => entry?.name === name);
|
|
if (existing) {
|
|
delete existing.valueFrom;
|
|
existing.value = value;
|
|
} else {
|
|
envList.push({ name, value });
|
|
}
|
|
}
|
|
|
|
function annotate(metadata, annotations) {
|
|
metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations };
|
|
}
|
|
|
|
function label(metadata, labels) {
|
|
metadata.labels = { ...(metadata.labels ?? {}), ...labels };
|
|
}
|
|
|
|
function imageFor(registryPrefix, serviceId, shortCommit) {
|
|
return `${registryPrefix}/${serviceId}:${shortCommit}`;
|
|
}
|
|
|
|
function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint }) {
|
|
const result = cloneJson(workloads);
|
|
const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service]));
|
|
const stableRuntimeLabels = {
|
|
"app.kubernetes.io/part-of": "hwlab",
|
|
"hwlab.pikastech.local/environment": "dev",
|
|
"hwlab.pikastech.local/profile": "dev"
|
|
};
|
|
for (const item of asItems(result, "workloads")) {
|
|
item.metadata.namespace = deploy.namespace;
|
|
label(item.metadata, {
|
|
...stableRuntimeLabels,
|
|
"hwlab.pikastech.local/gitops-target": "g14",
|
|
"hwlab.pikastech.local/source-commit": source.full,
|
|
"unidesk.ai/g14-staging": "true"
|
|
});
|
|
annotate(item.metadata, {
|
|
"hwlab.pikastech.local/gitops-note": "G14 GitOps target only; production traffic remains outside this target.",
|
|
"hwlab.pikastech.local/source-commit": source.full
|
|
});
|
|
if (item.kind === "Job") {
|
|
annotate(item.metadata, {
|
|
"argocd.argoproj.io/sync-options": "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));
|
|
|
|
putJson("source.json", {
|
|
kind: "hwlab-g14-gitops-source",
|
|
sourceCommit: source.full,
|
|
sourceShortCommit: source.short,
|
|
sourceBranch: args.sourceBranch,
|
|
sourceRepo: args.sourceRepo,
|
|
registryPrefix: args.registryPrefix,
|
|
runtimePath: "deploy/gitops/g14/runtime",
|
|
tektonPipeline: "hwlab-ci/hwlab-g14-ci-image-publish",
|
|
argoApplication: "argocd/hwlab-g14-dev"
|
|
});
|
|
putText("README.md", readme({ source, args }));
|
|
putJson("registry/registry.yaml", registryManifest());
|
|
putJson("tekton/rbac.yaml", tektonRbac());
|
|
putJson("tekton/pipeline.yaml", tektonPipeline(ci));
|
|
putJson("tekton/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() {
|
|
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;
|
|
});
|