Files
2026-07-21 17:18:47 +02:00

337 lines
19 KiB
JavaScript

#!/usr/bin/env node
// SPEC: PJ2026-01060505 Workbench Performance; PJ2026-0106 平台运维
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
applyRuntimeLaneDeployConfig,
argoApplicationName,
artifactCatalogSkeleton,
attachAccessControlEnv,
annotate,
cloneJson,
defaultBranch,
defaultCatalogPath,
defaultGitopsBranch,
defaultOutDir,
defaultRuntimeEndpoint,
defaultSourceRepo,
defaultV02RuntimeEndpoint,
defaultWebEndpoint,
ensureObject,
generatedPath,
gitopsPathForProfile,
imageTagForSource,
isNamedRuntimeLane,
isRuntimeLane,
jsonManifest,
label,
laneRuntimeProfiles,
namespaceNameForProfile,
observabilityManifest,
parseArgs,
profileEndpoint,
readJson,
readJsonIfPresent,
repoRoot,
resolveSourceRevision,
runtimeLabelForProfile,
runtimePathForProfile,
textFile,
transformHealthContract,
transformListNamespace,
transformServices,
transformWorkloads,
usage,
workbenchRuntimeRedisConfigForProfile
} from "./src/gitops-render/core.mjs";
import { registryManifest } from "./src/gitops-render/infra-manifests.mjs";
import {
argoApplication,
argoProject,
deepSeekProxyManifest,
deviceAgent71FreqManifest,
externalPostgresConfigForLane,
externalPostgresManifest,
nodeFrpcManifest,
opencodeServerManifest,
runtimeConfigMapsForProfile,
runtimeConfigMapsManifest,
runtimePostgresImageForProfile,
runtimeSecretPlaneConfigForProfile,
runtimeSecretPlaneManifest,
v02OpenFgaManifest,
v02PostgresManifest,
workbenchRuntimeRedisManifest
} from "./src/gitops-render/runtime-manifests.mjs";
import {
ciLaneSettings,
tektonControlPlaneReconcilerCronJob,
tektonPipeline,
tektonTasks,
tektonPipelineRunTemplate,
tektonPollerCronJob,
tektonRbac
} from "./src/gitops-render/tekton-manifests.mjs";
import { CLOUD_CORE_MIGRATIONS } from "../internal/db/schema.ts";
function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false, externalSecrets = false, deploy = null } = {}) {
const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"];
if (!isRuntimeLane(profile)) resources.splice(1, 0, "code-agent-codex-config.yaml");
if (runtimeConfigMaps) resources.splice(3, 0, "configmaps.yaml");
if (externalSecrets) resources.splice(resources.indexOf("workloads.yaml"), 0, "external-secrets.yaml");
if (isRuntimeLane(profile)) {
if (externalPostgres) resources.push("external-postgres.yaml");
else resources.push("postgres.yaml");
if (workbenchRedis) resources.push("workbench-redis.yaml");
resources.push("openfga.yaml", "observability.yaml", "opencode.yaml");
}
if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml");
resources.push("node-frpc.yaml");
return {
apiVersion: "kustomize.config.k8s.io/v1beta1",
kind: "Kustomization",
namespace: namespaceNameForProfile(profile, deploy),
resources
};
}
function readme({ source, args, deploy }) {
if (args.lane === "v02") {
return `# HWLAB v0.2 GitOps CI/CD
This directory is generated by \`scripts/gitops-render.mjs --lane v02\` from source branch \`${args.sourceBranch}\`.
- Target: additive node-scoped v0.2 lane only.
- CI: Tekton Pipeline \`hwlab-ci/hwlab-v02-ci-image-publish\`.
- Git mirror/relay: v0.2 CI reads source/catalog from \`${args.gitReadUrl}\` and writes GitOps promotion to \`${args.gitWriteUrl}\`; canonical repo \`${args.sourceRepo}\` remains the source identity and manual flush upstream.
- Artifact contract: runtime images come from \`${args.catalogPath}\`; source branch \`v0.2\` does not track this generated catalog.
- Manual trigger: run \`bun scripts/cli.ts hwlab node control-plane trigger-current --lane v02 --confirm\` from UniDesk to create commit-pinned PipelineRun \`hwlab-v02-ci-poll-<short12>\` for the current \`origin/v0.2\` commit.
- Branch split: source branch \`${args.sourceBranch}\` remains human-authored source; generated desired state is promoted to local mirror branch \`${args.gitopsBranch}\`.
- CD: Argo CD Application \`argocd/hwlab-node-v02\` consumes \`${args.gitReadUrl}:${args.gitopsBranch}:deploy/gitops/node/runtime-v02\` and deploys only to namespace \`hwlab-v02\`.
- Public preview: FRP exposes v0.2 web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-v02/hwlab-v02-frpc\`.
- node DEV/PROD boundary: this lane does not modify archived DEV/PROD branches, \`hwlab-dev\`, \`hwlab-prod\`, 17666/17667 or 18666/18667.
- CronJob policy: v0.2 does not generate \`hwlab-v02-branch-poller\` or \`hwlab-v02-control-plane-reconciler\`; obsolete live CronJobs should be deleted by the UniDesk control-plane apply command.
- Gate policy: old DEV/D601/main gates do not enter this lane; new checks stay limited to the fixed branch, namespace, catalog, runtime path and Argo boundaries.
`;
}
if (isNamedRuntimeLane(args.lane)) {
const namespace = namespaceNameForProfile(args.lane, deploy);
const runtimePath = gitopsPathForProfile(args, args.lane, deploy);
return `# HWLAB ${args.lane} GitOps CI/CD
This directory is generated by \`scripts/gitops-render.mjs --lane ${args.lane}\` from the owning runtime lane declaration in \`deploy/deploy.yaml\`.
- Source: \`${args.sourceRepo}\` branch \`${args.sourceBranch}\` at \`${source.full}\`.
- GitOps: generated desired state is promoted to \`${args.gitWriteUrl}\` branch \`${args.gitopsBranch}\`.
- Artifact contract: runtime images come from \`${args.catalogPath}\`.
- Runtime: Argo CD consumes \`${args.gitReadUrl}:${args.gitopsBranch}:${runtimePath}\` and deploys only to namespace \`${namespace}\`.
- Public endpoints: web \`${args.webEndpoint}\`; API \`${args.runtimeEndpoint}\`.
- Authority: branch, catalog, runtime path, namespace, public services and endpoints are read from \`deploy/deploy.yaml\`; missing named-lane fields fail closed.
`;
}
return `# HWLAB node GitOps CI/CD
This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.yaml\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/gitops-render.mjs\`.
- Target: node k3s only.
- CI: Tekton Pipeline \`hwlab-ci/hwlab-node-ci-image-publish\`.
- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.yaml\` only carries human-authored config such as env and topology.
- Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`.
- CD: Argo CD Applications \`argocd/hwlab-node-dev\` and \`argocd/hwlab-node-prod\` consume \`${args.gitopsBranch}:deploy/gitops/node/runtime-dev\` and \`runtime-prod\`.
- Public preview: FRP exposes node DEV web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-dev/hwlab-node-frpc\`, and node PROD web \`${args.prodWebEndpoint}\` / edge \`${args.prodRuntimeEndpoint}\` through \`hwlab-prod/hwlab-node-prod-frpc\`; D601 keeps \`:16666/:16667\`.
- Manual polling: run \`kubectl -n hwlab-ci create job --from=cronjob/hwlab-node-branch-poller hwlab-node-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-node-control-plane-reconciler\` polls \`${args.sourceBranch}\`, runs \`scripts/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 [deploy, namespaceTemplate, config, services, health, workloads] = await Promise.all([
readJson("deploy/deploy.yaml"),
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")
]);
args = applyRuntimeLaneDeployConfig(args, deploy, {
defaultBranch,
defaultGitopsBranch,
defaultCatalogPath,
defaultGitopsRoot: defaultOutDir,
defaultRuntimeEndpoint,
defaultWebEndpoint,
defaultV02RuntimeEndpoint,
defaultSourceRepo
});
await attachAccessControlEnv(deploy);
const source = await resolveSourceRevision(args.sourceRevision);
source.imageTag = imageTagForSource(args, source);
let artifactCatalog = await readJsonIfPresent(args.catalogPath, null);
if (!artifactCatalog && isRuntimeLane(args.lane)) artifactCatalog = artifactCatalogSkeleton({ args, deploy, source });
if (!artifactCatalog) artifactCatalog = await readJson(args.catalogPath);
const settings = ciLaneSettings(args, deploy);
const profiles = laneRuntimeProfiles(args);
const migrationSources = isRuntimeLane(args.lane)
? await Promise.all(CLOUD_CORE_MIGRATIONS.map(async (migration) => ({
id: migration.id,
path: migration.path,
sql: await readFile(path.join(repoRoot, migration.path), "utf8")
})))
: [];
const metricsSidecarScript = isRuntimeLane(args.lane)
? await readFile(path.join(repoRoot, "internal/dev-entrypoint/metrics-sidecar.mjs"), "utf8")
: null;
const metricsSidecarSha256 = metricsSidecarScript ? createHash("sha256").update(metricsSidecarScript).digest("hex") : null;
const baseLabels = { "hwlab.pikastech.local/gitops-target": settings.gitopsTarget, "hwlab.pikastech.local/source-commit": source.full };
const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/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));
const publicEndpoints = Object.fromEntries(profiles.map((profile) => {
const endpoints = profileEndpoint(args, profile);
return [profile, { web: endpoints.webEndpoint, runtime: endpoints.runtimeEndpoint }];
}));
const sourceDescriptor = {
kind: isRuntimeLane(args.lane) ? `hwlab-${args.lane}-gitops-source` : "hwlab-node-gitops-source",
sourceCommit: source.full,
sourceShortCommit: source.short,
sourceImageTag: source.imageTag,
sourceBranch: args.sourceBranch,
gitopsBranch: args.gitopsBranch,
lane: settings.lane,
sourceRepo: args.sourceRepo,
gitReadUrl: args.gitReadUrl,
gitWriteUrl: args.gitWriteUrl,
registryPrefix: args.registryPrefix,
runtimePaths: profiles.map((profile) => gitopsPathForProfile(args, profile, deploy)),
publicEndpoints,
frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile, deploy)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"}`])),
tektonPipeline: `hwlab-ci/${settings.pipelineName}`,
argoApplications: profiles.map((profile) => `argocd/${argoApplicationName(profile)}`)
};
if (args.useDeployImages) {
sourceDescriptor.renderMode = {
imageSource: args.catalogPath,
configSource: "deploy/deploy.yaml",
mixedDesiredState: true
};
}
putJson("source.json", sourceDescriptor);
putText("README.md", readme({ source, args, deploy }));
putJson("registry/registry.yaml", registryManifest());
putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args, deploy));
putJson(`${settings.tektonDir}/tasks.yaml`, { apiVersion: "v1", kind: "List", items: tektonTasks(args, deploy) });
putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy));
if (!isRuntimeLane(args.lane)) {
putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args, deploy));
putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args));
}
putJson(`${settings.tektonDir}/pipelinerun.sample.yaml`, tektonPipelineRunTemplate({ source, args, deploy }));
putJson("argocd/project.yaml", argoProject(args, deploy));
for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile, deploy));
for (const profile of profiles) {
const namespace = namespaceNameForProfile(profile, deploy);
const includeDeviceAgent = profile === "dev";
const externalPostgres = externalPostgresConfigForLane(deploy, profile, args);
const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile);
const runtimeConfigMaps = runtimeConfigMapsForProfile(deploy, profile);
const runtimeSecretPlane = await runtimeSecretPlaneConfigForProfile(deploy, profile, args);
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, deploy);
const endpoints = profileEndpoint(args, profile);
putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0, externalSecrets: Boolean(runtimeSecretPlane), deploy }));
putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace);
if (!isRuntimeLane(profile)) putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy, nodeId: args.nodeId }));
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
if (runtimeConfigMaps.length > 0) putJson(`${runtimePath}/configmaps.yaml`, runtimeConfigMapsManifest({ configMaps: runtimeConfigMaps, namespace, labels: profileLabels, annotations }));
if (runtimeSecretPlane) putJson(`${runtimePath}/external-secrets.yaml`, runtimeSecretPlaneManifest({ config: runtimeSecretPlane, namespace, labels: profileLabels, annotations }));
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, nodeId: args.nodeId, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({
profile,
config: externalPostgres,
source,
deploy,
migrationSources,
catalog: artifactCatalog,
registryPrefix: args.registryPrefix,
useDeployImages: args.useDeployImages
}));
if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSources, source, image: runtimePostgresImageForProfile(deploy, profile), deploy }));
if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source, deploy }));
if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source, deploy }));
if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source, deploy, catalog: artifactCatalog, registryPrefix: args.registryPrefix, useDeployImages: args.useDeployImages, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, sourceRepo: args.sourceRepo }));
if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));
putJson(`${runtimePath}/node-frpc.yaml`, nodeFrpcManifest({ profile, source, deploy, args }));
}
return { files, source };
}
async function writeFiles(files) {
for (const [filePath, content] of files) {
const absolutePath = generatedPath(filePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
}
async function cleanStaleGeneratedOutput(args) {
if (!isRuntimeLane(args.lane)) return;
for (const relativePath of [`runtime-${args.lane}`, `tekton-${args.lane}`]) {
await rm(generatedPath(path.join(args.outDir, relativePath)), { recursive: true, force: true });
}
}
async function checkFiles(files) {
const mismatches = [];
for (const [filePath, expected] of files) {
let actual = null;
try {
actual = await readFile(generatedPath(filePath), "utf8");
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (actual !== expected) mismatches.push(filePath);
}
return mismatches;
}
async function main() {
let args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
ensureObject(args, "args");
const { files, source } = await plannedFiles(args);
if (args.check) {
const mismatches = await checkFiles(files);
console.log(JSON.stringify({ ok: mismatches.length === 0, sourceCommit: source.full, outDir: args.outDir, checked: files.size, mismatches }, null, 2));
process.exitCode = mismatches.length === 0 ? 0 : 1;
return;
}
if (args.write) {
await cleanStaleGeneratedOutput(args);
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;
});