530b005505
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
314 lines
17 KiB
JavaScript
314 lines
17 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,
|
|
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 {
|
|
devopsInfraGitMirrorManifest,
|
|
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,
|
|
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 } = {}) {
|
|
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),
|
|
resources
|
|
};
|
|
}
|
|
|
|
function readme({ source, args }) {
|
|
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.
|
|
`;
|
|
}
|
|
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);
|
|
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)),
|
|
publicEndpoints,
|
|
frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${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 }));
|
|
putJson("registry/registry.yaml", registryManifest());
|
|
if (isRuntimeLane(args.lane)) putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest(args, deploy));
|
|
putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args));
|
|
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 }));
|
|
putJson("argocd/project.yaml", argoProject(args));
|
|
for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile));
|
|
|
|
for (const profile of profiles) {
|
|
const namespace = namespaceNameForProfile(profile);
|
|
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);
|
|
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) }));
|
|
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 }));
|
|
if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSources, source, image: runtimePostgresImageForProfile(deploy, profile) }));
|
|
if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source }));
|
|
if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source }));
|
|
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;
|
|
});
|