diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.8.2_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.8.2_Task_Report.md new file mode 100644 index 00000000..6a62a891 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.8.2_Task_Report.md @@ -0,0 +1,63 @@ +# R9.8.2 命名 runtime lane renderer 任务报告 + +## 任务定位 + +- MDTODO:`R9.8.2` +- Issue:`pikasTech/HWLAB#2548` +- Target:`NC01` +- targetWorkspace:`/root/hwlab-v03/.worktree/2548-production-runtime-lane` +- repository:`pikasTech/HWLAB` +- ref:`fix/2548-production-runtime-lane` +- base:`release` +- verifiedCommit:`0d9747fd5b458459349d4efa9735d3f4e459f0f8` +- PR:`pikasTech/HWLAB#2551` + +## Primary workspace 轻量准备 + +- 只读取 AgentRun resource bundle 与以下 skill: + - `dad-dev`、`unidesk-daddev`、`unidesk-trans`。 + - `git-spec`、`docs-spec`、`cli-spec`。 + - `unidesk-gh`、`unidesk-cicd`。 +- GitHub Issue 读取和进展写入使用 UniDesk `gh` 受控 CLI。 +- 未把 runner primary workspace 当作 HWLAB 源码或 Target 验证面。 + +## Target 源码修改 + +- 将稳定 runtime lane id 识别与 `vNN` 版本默认推导拆分。 +- `vNN` 继续沿用已有 branch、catalog、namespace、runtime path、endpoint 与端口默认。 +- 命名 lane 从 `deploy/deploy.yaml` 读取: + - node、source/GitOps branch 与 Git read/write URL。 + - artifact catalog、runtime path、namespace 与 image tag mode。 + - public endpoints、service/NodePort 与 FRP 端点。 +- 命名 lane 缺少必填 YAML 字段时 fail-closed,不从 lane id 推导 branch、namespace、路径、端口或 endpoint。 +- 保持 `scripts/gitops-render.mjs` 单 renderer,未增加数据库 projector、migration、HTTP/SSE fallback、锁、租约或第二状态库。 +- 未生成或提交 `.tekton/hwlab-nc01-production-pac.yaml`。 + +## Target 原入口证据 + +- `node scripts/run-bun.mjs scripts/src/runtime-lane.test.mjs` + - 结果:通过。 + - 覆盖:命名 lane 参数解析、YAML 覆盖、namespace/runtime path、public NodePort、FRP 本地与远端端点、缺字段失败、`v03` 默认。 +- 使用 Target 临时目录分别运行 `release@0d9747fd5b458459349d4efa9735d3f4e459f0f8` 原 renderer 与当前 renderer。 + - `v02`:生成目录逐文件一致。 + - `v03`:生成目录逐文件一致。 +- `git diff --check` + - 结果:通过。 +- 模块导入 smoke + - 结果:`runtime-lane.ts`、gitops-render core、runtime manifests 与 Tekton manifests 全部导入成功。 + +## 执行边界 + +- 未合并 PR。 +- 受控 PR preflight:`MERGEABLE/CLEAN`,无 blocker 或 pending check。 +- 未触发 PipelineRun、git mirror、Argo、runtime patch 或 rollout。 +- 未操作 PK01。 +- 未修改 `/root/hwlab-v03/.worktree/2548-production-pac-source`。 + +## 结论 + +`R9.8.2` 已完成以下收口: + +- runtime lane renderer 支持 YAML 显式声明的稳定 lane id。 +- `v02`、`v03` 既有生成结果保持不变。 +- 命名 lane 只有在 owning YAML 完整声明时才能进入现有 GitOps renderer。 diff --git a/docs/reference/node-gitops-cicd.md b/docs/reference/node-gitops-cicd.md index 882eec59..4cb66cc4 100644 --- a/docs/reference/node-gitops-cicd.md +++ b/docs/reference/node-gitops-cicd.md @@ -11,14 +11,54 @@ - [PJ2026-010604 公开入口](https://github.com/pikasTech/unidesk/blob/master/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md) - [PJ2026-010605 运维监控](https://github.com/pikasTech/unidesk/blob/master/project-management/PJ2026-01/specs/PJ2026-010605-observability-monitoring.md) -目标 node/lane 的具体实现、PipelineRun 观察和运行命令仍按本仓 `AGENTS.md` 与受控 CLI 执行;需求边界、node/lane 规则和运维职责只更新 UniDesk OA。 +职责边界: + +- 目标 node/lane 的具体实现、PipelineRun 观察和运行命令按本仓 `AGENTS.md` 与受控 CLI 执行。 +- 需求边界、node/lane 规则和运维职责只更新 UniDesk OA。 ## 运行参考 -node/lane rollout 的 120 秒阈值是性能告警和诊断分界,不是继续盲等的理由。`trigger-current --wait` 超过阈值或返回 pending 时,先用定点 `hwlab nodes control-plane status --node --lane --pipeline-run `、`git-mirror status` 和 runtime workload 摘要确认卡在哪一层:PipelineRun task、Argo sync、runtime-ready、public probe 或 git mirror flush。 +`scripts/gitops-render.mjs` 只保留一条 runtime lane 渲染链: -只需要给 branch-follower 或 control-plane closeout 提供一轮新的真实验证源时,优先使用文档、注释或等价的非运行面小 PR,避免把 timeout、runtime 策略或 CI/CD 逻辑改动混入验证样本。 +- `vNN` lane: + - 继续使用既有版本号默认,包括 branch、catalog、namespace、runtime path、端口与 endpoint 推导。 + - 修改 renderer 时必须用同一 source commit 对比生成目录,确认既有 lane 不漂移。 +- 命名 runtime lane: + - 必须先在 `deploy/deploy.yaml` 的 `lanes.` 中声明稳定小写 id 与 node。 + - 必须显式声明 source/GitOps branch、Git read/write URL、artifact catalog、runtime path、namespace 与 image tag mode。 + - 必须显式声明 public endpoints、服务声明与 FRP 端点。 +- 命名 lane 的 node: + - 必须在 `deploy.nodes` 中声明 `gitopsRoot` 和 `sourceRepo`。 + - renderer 不从 lane 名称猜 branch、namespace、路径、端口或公开入口。 +- 命名 lane 缺失 owning YAML 字段时立即失败;不得增加按具体 lane 字符串、branch 名称或端口号分支的 fallback。 +- NodePort 服务只从 `lanes..publicServices.nodes.` 生成;名称、selector、service port、target port 与 node port 都由 YAML 声明。 -PR 合并后如果目标分支被并行 PR 推进,closeout 要同时记录本 PR merge commit、当前 source head 和 ancestry 证据;只要当前 head 包含本 PR merge commit,后续 rollout 应按当前 head 收敛,避免回滚到旧 source。定点 status 可证明某个旧 PipelineRun 是否 succeeded,但最终用户入口验收必须以当前 node/lane source、GitOps revision、Argo 和 runtime 状态为准。 +node/lane rollout 的 120 秒阈值是性能告警和诊断分界,不是继续盲等的理由: -runtime-ready 卡住时先看 `STATUS` 输出里的 notReady workload,再按 workload 日志和事件定位;修复应回到 source truth、PR、GitOps 和受控 sync/refresh,不把手工 patch Deployment、裸删 Pod 或临时容器改动作为交付路径。若 runtime 已 ready 但 Argo 仍 OutOfSync/Progressing,先走受控 `hwlab nodes control-plane sync|refresh --node --lane --confirm` 收敛控制面,再复查 bounded status。 +- `trigger-current --wait` 超过阈值或返回 pending 时,先执行定点 status。 +- 使用 `hwlab nodes control-plane status --node --lane --pipeline-run ` 查看 PipelineRun。 +- 配合 `git-mirror status` 和 runtime workload 摘要判断卡点。 +- 卡点范围包括 PipelineRun task、Argo sync、runtime-ready、public probe 或 git mirror flush。 + +只需要给 branch-follower 或 control-plane closeout 提供一轮新的真实验证源时: + +- 优先使用文档、注释或等价的非运行面小 PR。 +- 不把 timeout、runtime 策略或 CI/CD 逻辑改动混入验证样本。 + +PR 合并后如果目标分支被并行 PR 推进: + +- closeout 同时记录本 PR merge commit、当前 source head 和 ancestry 证据。 +- 当前 head 包含本 PR merge commit 时,后续 rollout 按当前 head 收敛,避免回滚到旧 source。 +- 定点 status 只证明指定旧 PipelineRun 是否 succeeded。 +- 最终用户入口验收以当前 node/lane source、GitOps revision、Argo 和 runtime 状态为准。 + +runtime-ready 卡住时: + +- 先查看 `STATUS` 输出里的 notReady workload,再按 workload 日志和事件定位。 +- 修复回到 source truth、PR、GitOps 和受控 sync/refresh。 +- 不把手工 patch Deployment、裸删 Pod 或临时容器改动作为交付路径。 + +runtime 已 ready 但 Argo 仍 OutOfSync/Progressing 时: + +- 走受控 `hwlab nodes control-plane sync|refresh --node --lane --confirm` 收敛控制面。 +- 再复查 bounded status。 diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index dae54112..da33cc8a 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -24,6 +24,7 @@ import { generatedPath, gitopsPathForProfile, imageTagForSource, + isNamedRuntimeLane, isRuntimeLane, jsonManifest, label, @@ -78,7 +79,7 @@ import { } 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 } = {}) { +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"); @@ -94,12 +95,12 @@ function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile == return { apiVersion: "kustomize.config.k8s.io/v1beta1", kind: "Kustomization", - namespace: namespaceNameForProfile(profile), + namespace: namespaceNameForProfile(profile, deploy), resources }; } -function readme({ source, args }) { +function readme({ source, args, deploy }) { if (args.lane === "v02") { return `# HWLAB v0.2 GitOps CI/CD @@ -116,6 +117,21 @@ This directory is generated by \`scripts/gitops-render.mjs --lane v02\` from sou - 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 @@ -160,7 +176,7 @@ async function plannedFiles(args) { 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 settings = ciLaneSettings(args, deploy); const profiles = laneRuntimeProfiles(args); const migrationSources = isRuntimeLane(args.lane) ? await Promise.all(CLOUD_CORE_MIGRATIONS.map(async (migration) => ({ @@ -195,9 +211,9 @@ async function plannedFiles(args) { gitReadUrl: args.gitReadUrl, gitWriteUrl: args.gitWriteUrl, registryPrefix: args.registryPrefix, - runtimePaths: profiles.map((profile) => gitopsPathForProfile(args, profile)), + runtimePaths: profiles.map((profile) => gitopsPathForProfile(args, profile, deploy)), publicEndpoints, - frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"}`])), + 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)}`) }; @@ -209,21 +225,21 @@ async function plannedFiles(args) { }; } putJson("source.json", sourceDescriptor); - putText("README.md", readme({ source, args })); + putText("README.md", readme({ source, args, deploy })); 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}/rbac.yaml`, tektonRbac(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 })); - putJson("argocd/project.yaml", argoProject(args)); - for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile)); + 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); + const namespace = namespaceNameForProfile(profile, deploy); const includeDeviceAgent = profile === "dev"; const externalPostgres = externalPostgresConfigForLane(deploy, profile, args); const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile); @@ -234,9 +250,9 @@ async function plannedFiles(args) { runtimeNamespace.metadata.name = namespace; label(runtimeNamespace.metadata, profileLabels); annotate(runtimeNamespace.metadata, annotations); - const runtimePath = runtimePathForProfile(profile); + 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) })); + 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 })); @@ -245,10 +261,10 @@ async function plannedFiles(args) { 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) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ profile, config: externalPostgres, source, deploy })); + 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 })); diff --git a/scripts/src/gitops-render/core.mjs b/scripts/src/gitops-render/core.mjs index 9a1a28eb..a2ddd1d9 100644 --- a/scripts/src/gitops-render/core.mjs +++ b/scripts/src/gitops-render/core.mjs @@ -12,7 +12,11 @@ import { applyRuntimeLaneDeployConfig, defaultEndpointForRuntimeLane, defaultPortForRuntimeLane, + isNamedRuntimeLane, isRuntimeLane, + isVersionRuntimeLane, + runtimeNamespaceForRuntimeLane, + runtimeRelativePathForRuntimeLane, runtimeLaneMirrorSpecs, runtimeLaneConfig, versionNameForRuntimeLane @@ -245,7 +249,7 @@ function parseArgs(argv) { else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } - if (isRuntimeLane(args.lane)) { + if (isVersionRuntimeLane(args.lane)) { const versionName = versionNameForRuntimeLane(args.lane); const defaultLaneEndpoint = defaultEndpointForRuntimeLane(args.lane); if (args.sourceBranch === defaultBranch) args.sourceBranch = versionName; @@ -261,7 +265,7 @@ function parseArgs(argv) { if (!args.gitWriteUrl) args.gitWriteUrl = args.sourceRepo; assert.ok(args.lane === "node" || isRuntimeLane(args.lane), `unknown lane ${args.lane}`); assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); - if (isRuntimeLane(args.lane)) { + if (isVersionRuntimeLane(args.lane)) { const versionName = versionNameForRuntimeLane(args.lane); assert.equal(args.sourceBranch, versionName, `${args.lane} source branch must be ${versionName}`); assert.equal(args.gitopsBranch, `${versionName}-gitops`, `${args.lane} GitOps branch must be ${versionName}-gitops`); @@ -282,7 +286,7 @@ function usage() { "Render node-scoped Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.yaml, and deploy/k8s/*.", "", "options:", - " --lane LANE node or runtime lane such as v02/v03/v04; default: node", + " --lane LANE node or a deploy.lanes runtime lane id such as v02/v03/staging; default: node", ` --node NODE deployment node id from deploy.nodes; runtime lanes default from deploy.lanes..node`, ` --out DIR default: ${defaultOutDir}`, ` --gitops-root DIR path inside GitOps repo; default comes from deploy.nodes..gitopsRoot`, @@ -807,7 +811,7 @@ function isV02RemovedServiceId(serviceId) { function artifactCatalogSkeleton({ args, deploy, source }) { const environment = artifactEnvironment(args); - const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane) : "hwlab-dev"; + const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane, deploy) : "hwlab-dev"; const tag = imageTagForSource(args, source); return { catalogVersion: "v1", @@ -869,13 +873,13 @@ function artifactCatalogSkeletonService({ args, deploy, source, serviceId, envir }; } -function namespaceNameForProfile(profile) { - if (isRuntimeLane(profile)) return `hwlab-${profile}`; +function namespaceNameForProfile(profile, deploy = null) { + if (isRuntimeLane(profile)) return runtimeNamespaceForRuntimeLane(deploy, profile); return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; } -function runtimePathForProfile(profile) { - if (isRuntimeLane(profile)) return `runtime-${profile}`; +function runtimePathForProfile(profile, deploy = null) { + if (isRuntimeLane(profile)) return runtimeRelativePathForRuntimeLane(deploy, profile); return profile === "prod" ? "runtime-prod" : "runtime-dev"; } @@ -892,8 +896,8 @@ function gitopsRootForArgs(args) { return String(args.gitopsRoot || defaultOutDir).replace(/\/+$/u, ""); } -function gitopsPathForProfile(args, profile) { - return `${gitopsRootForArgs(args)}/${runtimePathForProfile(profile)}`; +function gitopsPathForProfile(args, profile, deploy = null) { + return `${gitopsRootForArgs(args)}/${runtimePathForProfile(profile, deploy)}`; } function gitopsPathFor(args, relativePath) { @@ -911,9 +915,9 @@ function laneRuntimeProfiles(args) { return isRuntimeLane(args.lane) ? [args.lane] : ["dev", "prod"]; } -function laneNames(args) { +function laneNames(args, deploy = null) { if (isRuntimeLane(args.lane)) { - const namespace = namespaceNameForProfile(args.lane); + const namespace = namespaceNameForProfile(args.lane, deploy); return { gitopsTarget: args.lane, pipeline: `${namespace}-ci-image-publish`, @@ -922,7 +926,7 @@ function laneNames(args) { serviceAccount: `${namespace}-tekton-runner`, pipelineRunPrefix: `${namespace}-ci-poll-`, runtimeNamespace: namespace, - runtimePath: gitopsPathForProfile(args, args.lane), + runtimePath: gitopsPathForProfile(args, args.lane, deploy), argoApplications: [`argocd/hwlab-node-${args.lane}`] }; } @@ -1177,7 +1181,8 @@ function gitopsRootNodeId(gitopsRoot) { } function runtimeFrpConfigForProfile(deploy, profile, args = {}) { - const fallbackWebRemotePort = isRuntimeLane(profile) ? defaultPortForRuntimeLane(profile) : profile === "prod" ? 18666 : 17666; + const namedRuntimeLane = isNamedRuntimeLane(profile); + const fallbackWebRemotePort = isVersionRuntimeLane(profile) ? defaultPortForRuntimeLane(profile) : profile === "prod" ? 18666 : 17666; const fallbackEdgeRemotePort = isRuntimeLane(profile) ? fallbackWebRemotePort + 1 : profile === "prod" ? 18667 : 17667; const baseFrp = isRuntimeLane(profile) ? runtimeLaneConfig(deploy, profile)?.frp : deploy?.frp; const nodeId = effectiveSecretPlaneNodeId(args); @@ -1190,17 +1195,31 @@ function runtimeFrpConfigForProfile(deploy, profile, args = {}) { .map((proxy) => [String(proxy.endpointId || proxy.name || ""), proxy])); const proxyValue = (endpointId, key, fallback) => { const value = proxyByEndpoint.get(endpointId)?.[key]; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + assert.ok(!namedRuntimeLane, `deploy.lanes.${profile}.frp.proxies.${endpointId}.${key} is required`); + return fallback; }; - const proxyPort = (endpointId, fallback) => { - const value = proxyByEndpoint.get(endpointId)?.remotePort; - return Number.isInteger(value) ? value : fallback; + const proxyPort = (endpointId, fallback, key = "remotePort") => { + const value = proxyByEndpoint.get(endpointId)?.[key]; + if (Number.isInteger(value) && value > 0) return value; + assert.ok(!namedRuntimeLane, `deploy.lanes.${profile}.frp.proxies.${endpointId}.${key} must be a positive integer`); + return fallback; }; + if (namedRuntimeLane) { + assert.ok(server && typeof server === "object" && !Array.isArray(server), `deploy.lanes.${profile}.frp.server is required`); + assert.ok(typeof server.address === "string" && server.address.trim().length > 0, `deploy.lanes.${profile}.frp.server.address is required`); + assert.ok(Number.isInteger(server.bindPort) && server.bindPort > 0, `deploy.lanes.${profile}.frp.server.bindPort must be a positive integer`); + for (const endpointId of ["frontend", "api"]) assert.ok(proxyByEndpoint.has(endpointId), `deploy.lanes.${profile}.frp.proxies must declare endpointId ${endpointId}`); + } return { serverAddr: typeof server?.address === "string" && server.address.trim().length > 0 ? server.address.trim() : "74.48.78.17", serverPort: Number.isInteger(server?.bindPort) ? server.bindPort : 7000, webRemotePort: proxyPort("frontend", fallbackWebRemotePort), edgeRemotePort: proxyPort("api", fallbackEdgeRemotePort), + webLocalHost: proxyValue("frontend", "localHost", null), + edgeLocalHost: proxyValue("api", "localHost", null), + webLocalPort: proxyPort("frontend", 8080, "localPort"), + edgeLocalPort: proxyPort("api", 6667, "localPort"), webProxyName: proxyValue("frontend", "name", null), edgeProxyName: proxyValue("api", "name", null), authSecretName: typeof frp?.auth?.secretName === "string" && frp.auth.secretName.trim().length > 0 ? frp.auth.secretName.trim() : null, @@ -1347,7 +1366,7 @@ function workbenchRuntimeRedisConfigForProfile(deploy, profile) { if (!redis || redis.enabled !== true) return null; configObject(redis, label); const namespace = requiredConfigString(redis, "namespace", label); - assert.equal(namespace, namespaceNameForProfile(profile), `${label}.namespace must match ${namespaceNameForProfile(profile)}`); + assert.equal(namespace, namespaceNameForProfile(profile, deploy), `${label}.namespace must match ${namespaceNameForProfile(profile, deploy)}`); const serviceName = requiredConfigString(redis, "serviceName", label); const image = requiredConfigString(redis, "image", label); const port = requiredConfigPositiveInteger(redis, "port", label); @@ -1446,7 +1465,7 @@ function upsertV02MetricsPort(service) { function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = defaultBranch, gitReadUrl, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", nodeId = "node", useDeployImages = false, metricsSidecarSha256 = null }) { const result = cloneJson(workloads); const deployServices = deployServicesForProfile(deploy, profile); - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const profileLabel = runtimeLabelForProfile(profile); const gitopsTarget = gitopsTargetForProfile(profile); const runtimeLane = isRuntimeLane(profile); @@ -1730,6 +1749,7 @@ function transformServices({ services, namespace, labels, annotations, profile = function observabilityManifest({ deploy, profile, namespace, labels, annotations, metricsSidecarScript }) { assert.ok(isRuntimeLane(profile), `observability profile must be a runtime lane, got ${profile}`); + const laneDisplayName = runtimeLaneConfig(deploy, profile).name ?? versionNameForRuntimeLane(profile); const includePrometheusOperatorResources = runtimePrometheusOperatorResourcesEnabled(deploy, profile); const baseLabels = { ...labels, @@ -1785,7 +1805,7 @@ function observabilityManifest({ deploy, profile, namespace, labels, annotations expr: `up{namespace="${namespace}"} == 0`, for: "5m", labels: { severity: "warning", lane: profile }, - annotations: { summary: `HWLAB ${versionNameForRuntimeLane(profile)} metrics target is down`, description: `Prometheus cannot scrape a HWLAB ${versionNameForRuntimeLane(profile)} metrics target.` } + annotations: { summary: `HWLAB ${laneDisplayName} metrics target is down`, description: `Prometheus cannot scrape a HWLAB ${laneDisplayName} metrics target.` } }, { alert: `HWLAB${profile.toUpperCase()}ServiceHealthProbeFailed`, @@ -1822,7 +1842,7 @@ export { effectiveSecretPlaneNodeId, ensureObject, envReuseBootShForContainer, envReuseServiceIdsForLane, generatedPath, gitopsPathFor, gitopsPathForProfile, gitopsRootForArgs, gitopsRootNodeId, gitopsTargetForProfile, gitValue, imageFor, imageTagForSource, - imageTagFromReference, isRuntimeLane, isV02RemovedServiceId, jsonManifest, + imageTagFromReference, isNamedRuntimeLane, isRuntimeLane, isV02RemovedServiceId, jsonManifest, k8sLabelHash, label, laneNames, laneRuntimeProfiles, mergeEnvMaps, moonBridgeImage, moonBridgeSourceRef, namespaceNameForProfile, namespaceScopedHost, normalizeAccessControlUser, normalizeNavProfile, normalizeProbeTiming, diff --git a/scripts/src/gitops-render/runtime-manifests.mjs b/scripts/src/gitops-render/runtime-manifests.mjs index 13685771..7f1b97e1 100644 --- a/scripts/src/gitops-render/runtime-manifests.mjs +++ b/scripts/src/gitops-render/runtime-manifests.mjs @@ -44,15 +44,16 @@ import { versionNameForRuntimeLane } from "./core.mjs"; -function argoProject(args = { lane: "node" }) { +function argoProject(args = { lane: "node" }, deploy = null) { if (isRuntimeLane(args.lane)) { - const namespace = namespaceNameForProfile(args.lane); + const namespace = namespaceNameForProfile(args.lane, deploy); + const laneName = runtimeLaneConfig(deploy, args.lane).name ?? versionNameForRuntimeLane(args.lane); return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", metadata: { name: namespace, namespace: "argocd" }, spec: { - description: `HWLAB ${versionNameForRuntimeLane(args.lane)} additive GitOps project on target node; DEV/PROD remain outside this lane.`, + description: `HWLAB ${laneName} additive GitOps project on target node; DEV/PROD remain outside this lane.`, sourceRepos: [args.gitReadUrl], destinations: [{ server: "https://kubernetes.default.svc", namespace }], clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], @@ -77,9 +78,9 @@ function argoProject(args = { lane: "node" }) { }; } -function argoApplication(args, profile = "dev") { - const namespace = namespaceNameForProfile(profile); - const runtimePath = runtimePathForProfile(profile); +function argoApplication(args, profile = "dev", deploy = null) { + const namespace = namespaceNameForProfile(profile, deploy); + const runtimePath = runtimePathForProfile(profile, deploy); const gitopsTarget = gitopsTargetForProfile(profile); const project = isRuntimeLane(profile) ? namespace : "hwlab-node"; const repoURL = isRuntimeLane(profile) ? args.gitReadUrl : args.sourceRepo; @@ -93,7 +94,7 @@ function argoApplication(args, profile = "dev") { }, spec: { project, - source: { repoURL, targetRevision: args.gitopsBranch, path: gitopsPathForProfile(args, profile) }, + source: { repoURL, targetRevision: args.gitopsBranch, path: gitopsPathForProfile(args, profile, deploy) }, destination: { server: "https://kubernetes.default.svc", namespace }, syncPolicy: { automated: { prune: isRuntimeLane(profile), selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } } @@ -101,7 +102,7 @@ function argoApplication(args, profile = "dev") { } function nodeFrpcManifest({ profile = "dev", source = null, deploy = null, args = {} } = {}) { - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const profileLabel = runtimeLabelForProfile(profile); const deploymentName = isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"; const configName = `${deploymentName}-config`; @@ -131,15 +132,15 @@ ${authConfigText} [[proxies]] name = "${webProxyName}" type = "tcp" -localIP = "hwlab-cloud-web.${namespace}.svc.cluster.local" -localPort = 8080 +localIP = "${frpConfig.webLocalHost || `hwlab-cloud-web.${namespace}.svc.cluster.local`}" +localPort = ${frpConfig.webLocalPort} remotePort = ${frpConfig.webRemotePort} [[proxies]] name = "${edgeProxyName}" type = "tcp" -localIP = "hwlab-edge-proxy.${namespace}.svc.cluster.local" -localPort = 6667 +localIP = "${frpConfig.edgeLocalHost || `hwlab-edge-proxy.${namespace}.svc.cluster.local`}" +localPort = ${frpConfig.edgeLocalPort} remotePort = ${frpConfig.edgeRemotePort} `; const configSha256 = createHash("sha256").update(configText).digest("hex"); @@ -194,7 +195,7 @@ remotePort = ${frpConfig.edgeRemotePort} } function deepSeekProxyManifest({ profile = "dev", source, sourceBranch = defaultBranch, sourceRepo = defaultSourceRepo, gitReadUrl, deploy = null, registryPrefix = defaultRegistryPrefix, catalog = null, useDeployImages = false, metricsSidecarSha256 = null } = {}) { - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const bridgeServiceId = "hwlab-cloud-api"; const runtimeLane = isRuntimeLane(profile); const digestPin = runtimeLane; @@ -642,8 +643,8 @@ function runtimePostgresImageForProfile(deploy, profile) { return typeof image === "string" && image.trim().length > 0 ? image.trim() : "postgres:16-alpine"; } -function v02PostgresManifest({ profile = "v02", migrationSources, source, image = "postgres:16-alpine" }) { - const namespace = namespaceNameForProfile(profile); +function v02PostgresManifest({ profile = "v02", migrationSources, source, image = "postgres:16-alpine", deploy = null }) { + const namespace = namespaceNameForProfile(profile, deploy); const name = `${namespace}-postgres`; const dbName = `hwlab_${profile}`; const labels = { @@ -738,8 +739,8 @@ function externalPostgresConfigForLane(deploy, profile, args = {}) { return { serviceName: effective.serviceName, endpointAddress: effective.endpointAddress, port }; } -function externalPostgresManifest({ profile = "v03", config, source }) { - const namespace = namespaceNameForProfile(profile); +function externalPostgresManifest({ profile = "v03", config, source, deploy = null }) { + const namespace = namespaceNameForProfile(profile, deploy); const labels = { "app.kubernetes.io/name": config.serviceName, "app.kubernetes.io/part-of": "hwlab", @@ -772,8 +773,8 @@ function externalPostgresManifest({ profile = "v03", config, source }) { }; } -function v02OpenFgaManifest({ profile = "v02", source }) { - const namespace = namespaceNameForProfile(profile); +function v02OpenFgaManifest({ profile = "v02", source, deploy = null }) { + const namespace = namespaceNameForProfile(profile, deploy); const name = "hwlab-openfga"; const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0"; const labels = { @@ -1200,7 +1201,7 @@ function opencodeConfigContent(config) { function opencodeServerManifest({ profile = "v03", source, deploy = null, catalog = null, registryPrefix = defaultRegistryPrefix, useDeployImages = false, sourceBranch = defaultBranch, gitReadUrl = defaultV02GitReadUrl, sourceRepo = defaultSourceRepo }) { assert.ok(isRuntimeLane(profile), `opencode-server profile must be a runtime lane, got ${profile}`); - const namespace = namespaceNameForProfile(profile); + const namespace = namespaceNameForProfile(profile, deploy); const name = "opencode-server"; const helperServiceId = "hwlab-cloud-web"; const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile); diff --git a/scripts/src/gitops-render/tekton-manifests.mjs b/scripts/src/gitops-render/tekton-manifests.mjs index 5d555130..d4d299de 100644 --- a/scripts/src/gitops-render/tekton-manifests.mjs +++ b/scripts/src/gitops-render/tekton-manifests.mjs @@ -29,10 +29,10 @@ import { primitiveValidationTaskNames } from "./tekton-scripts.mjs"; -function ciLaneSettings(argsOrLane = "node") { +function ciLaneSettings(argsOrLane = "node", deploy = null) { const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane; if (isRuntimeLane(lane)) { - const namespace = namespaceNameForProfile(lane); + const namespace = namespaceNameForProfile(lane, deploy); return { lane, profile: lane, @@ -64,8 +64,8 @@ function ciLaneSettings(argsOrLane = "node") { }; } -function tektonRbac(args = { lane: "node" }) { - const settings = ciLaneSettings(args); +function tektonRbac(args = { lane: "node" }, deploy = null) { + const settings = ciLaneSettings(args, deploy); if (isRuntimeLane(settings.lane)) { const runtimeObserverName = `${settings.runtimeNamespace}-runtime-observer`; const pipelineRunWriterName = `${settings.runtimeNamespace}-pipelinerun-writer`; @@ -435,7 +435,7 @@ node scripts/ci/runtime-ready.mjs `; } -function runtimeReadyTask({ profile = "dev" } = {}) { +function runtimeReadyTask({ profile = "dev", deploy = null } = {}) { return { name: "runtime-ready", runAfter: ["gitops-promote"], @@ -457,7 +457,7 @@ function runtimeReadyTask({ profile = "dev" } = {}) { }, params: [ { name: "revision", value: "$(params.revision)" }, - { name: "runtime-namespace", value: namespaceNameForProfile(profile) }, + { name: "runtime-namespace", value: namespaceNameForProfile(profile, deploy) }, { name: "gitops-target", value: gitopsTargetForProfile(profile) }, { name: "argo-application", value: argoApplicationName(profile) }, { name: "timeout-ms", value: "$(params.runtime-ready-timeout-ms)" } @@ -478,12 +478,12 @@ function imagePublishTaskSet(args = { lane: "node" }, deploy = null) { } function tektonPipeline(args = { lane: "node" }, deploy = null) { - const settings = ciLaneSettings(args); - const runtimePath = gitopsPathForProfile(args, settings.profile); + const settings = ciLaneSettings(args, deploy); + const runtimePath = gitopsPathForProfile(args, settings.profile, deploy); const preFlushRuntimeReadyTasks = isRuntimeLane(settings.lane) ? [] : [{ - ...runtimeReadyTask({ profile: settings.profile }), + ...runtimeReadyTask({ profile: settings.profile, deploy }), when: [{ input: "$(tasks.gitops-promote.results.runtime-ready-required)", operator: "in", values: ["true"] }] }]; return { @@ -607,9 +607,9 @@ function tektonPipeline(args = { lane: "node" }, deploy = null) { }; } -function tektonPipelineRunTemplate({ source, args }) { - const settings = ciLaneSettings(args); - const runtimePath = gitopsPathForProfile(args, settings.profile); +function tektonPipelineRunTemplate({ source, args, deploy = null }) { + const settings = ciLaneSettings(args, deploy); + const runtimePath = gitopsPathForProfile(args, settings.profile, deploy); return { apiVersion: "tekton.dev/v1", kind: "PipelineRun", @@ -656,7 +656,7 @@ function tektonPipelineRunTemplate({ source, args }) { } function tektonPollerCronJob(args, deploy) { - const settings = ciLaneSettings(args); + const settings = ciLaneSettings(args, deploy); if (settings.lane === "v02") throw new Error("v02 CI/CD is manually triggered by UniDesk CLI and must not render a branch poller CronJob"); return { apiVersion: "batch/v1", @@ -707,7 +707,7 @@ function tektonPollerCronJob(args, deploy) { { name: "LANE", value: settings.lane }, { name: "CATALOG_PATH", value: args.catalogPath || settings.catalogPath }, { name: "IMAGE_TAG_MODE", value: args.imageTagMode || settings.imageTagMode }, - { name: "RUNTIME_PATH", value: gitopsPathForProfile(args, settings.profile) }, + { name: "RUNTIME_PATH", value: gitopsPathForProfile(args, settings.profile, deploy) }, { name: "GIT_URL", value: args.sourceRepo }, { name: "GIT_READ_URL", value: args.gitReadUrl }, { name: "SOURCE_BRANCH", value: args.sourceBranch }, diff --git a/scripts/src/runtime-lane.test.mjs b/scripts/src/runtime-lane.test.mjs new file mode 100644 index 00000000..480e9d5b --- /dev/null +++ b/scripts/src/runtime-lane.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; + +import { + applyRuntimeLaneDeployConfig, + isNamedRuntimeLane, + isRuntimeLane, + isVersionRuntimeLane, + runtimeNamespaceForRuntimeLane, + runtimeRelativePathForRuntimeLane +} from "./runtime-lane.ts"; +import { + namespaceNameForProfile, + parseArgs, + runtimeFrpConfigForProfile, + runtimePathForProfile, + transformServices +} from "./gitops-render/core.mjs"; + +const namedLaneDeploy = { + nodes: { + NC01: { + gitopsRoot: "deploy/gitops/node/NC01", + sourceRepo: "git@github.com:pikasTech/HWLAB.git" + } + }, + lanes: { + production: { + name: "Production", + node: "NC01", + sourceBranch: "release", + gitopsBranch: "hwlab-production", + gitReadUrl: "http://gitea.example/mirrors/hwlab.git", + gitWriteUrl: "http://git-mirror-write.example/pikasTech/HWLAB.git", + artifactCatalog: "deploy/artifact-catalog.production.json", + runtimePath: "runtime-production", + namespace: "hwlab-production", + imageTagMode: "full", + publicEndpoints: { + frontend: "https://hwlab.example", + api: "https://api.hwlab.example" + }, + publicServices: { + nodes: { + NC01: [{ + name: "hwlab-cloud-web-public", + selector: { "app.kubernetes.io/name": "hwlab-cloud-web" }, + port: 80, + targetPort: 8080, + nodePort: 32010 + }] + } + }, + frp: { + server: { address: "203.0.113.10", bindPort: 22000 }, + proxies: [ + { name: "production-web", endpointId: "frontend", localHost: "web.hwlab-production.svc.cluster.local", localPort: 8080, remotePort: 22090 }, + { name: "production-api", endpointId: "api", localHost: "api.hwlab-production.svc.cluster.local", localPort: 6667, remotePort: 22088 } + ] + }, + envReuseServices: ["hwlab-cloud-web"], + serviceDeclarations: { + "hwlab-cloud-web": { healthPort: 8080, healthPath: "/health/live" } + } + } + } +}; + +const defaultArgs = { + lane: "production", + nodeId: "node", + outDir: "deploy/gitops/node", + gitopsRoot: "deploy/gitops/node", + sourceBranch: "node", + gitopsBranch: "node-gitops", + catalogPath: "deploy/artifact-catalog.dev.json", + imageTagMode: "full", + runtimeEndpoint: "http://74.48.78.17:17667", + webEndpoint: "http://74.48.78.17:17666", + sourceRepo: "git@github.com:pikasTech/HWLAB.git" +}; + +assert.equal(isRuntimeLane("production"), true); +assert.equal(isNamedRuntimeLane("production"), true); +assert.equal(isVersionRuntimeLane("production"), false); +assert.equal(isVersionRuntimeLane("v03"), true); +assert.equal(parseArgs(["--lane", "production", "--no-write"]).lane, "production"); + +const resolved = applyRuntimeLaneDeployConfig(defaultArgs, namedLaneDeploy); +assert.deepEqual({ + nodeId: resolved.nodeId, + gitopsRoot: resolved.gitopsRoot, + sourceBranch: resolved.sourceBranch, + gitopsBranch: resolved.gitopsBranch, + gitReadUrl: resolved.gitReadUrl, + gitWriteUrl: resolved.gitWriteUrl, + catalogPath: resolved.catalogPath, + runtimeEndpoint: resolved.runtimeEndpoint, + webEndpoint: resolved.webEndpoint +}, { + nodeId: "NC01", + gitopsRoot: "deploy/gitops/node/NC01", + sourceBranch: "release", + gitopsBranch: "hwlab-production", + gitReadUrl: "http://gitea.example/mirrors/hwlab.git", + gitWriteUrl: "http://git-mirror-write.example/pikasTech/HWLAB.git", + catalogPath: "deploy/artifact-catalog.production.json", + runtimeEndpoint: "https://api.hwlab.example", + webEndpoint: "https://hwlab.example" +}); +assert.equal(runtimeNamespaceForRuntimeLane(namedLaneDeploy, "production"), "hwlab-production"); +assert.equal(runtimeRelativePathForRuntimeLane(namedLaneDeploy, "production"), "runtime-production"); +assert.equal(namespaceNameForProfile("production", namedLaneDeploy), "hwlab-production"); +assert.equal(runtimePathForProfile("production", namedLaneDeploy), "runtime-production"); + +const services = transformServices({ + services: { apiVersion: "v1", kind: "List", items: [] }, + namespace: "hwlab-production", + labels: {}, + annotations: {}, + profile: "production", + deploy: namedLaneDeploy, + nodeId: "NC01" +}); +assert.deepEqual(services.items[0].spec.ports[0], { name: "http", port: 80, targetPort: 8080, nodePort: 32010 }); + +const frp = runtimeFrpConfigForProfile(namedLaneDeploy, "production", { nodeId: "NC01" }); +assert.deepEqual({ + serverAddr: frp.serverAddr, + serverPort: frp.serverPort, + webLocalHost: frp.webLocalHost, + webLocalPort: frp.webLocalPort, + webRemotePort: frp.webRemotePort, + edgeLocalHost: frp.edgeLocalHost, + edgeLocalPort: frp.edgeLocalPort, + edgeRemotePort: frp.edgeRemotePort +}, { + serverAddr: "203.0.113.10", + serverPort: 22000, + webLocalHost: "web.hwlab-production.svc.cluster.local", + webLocalPort: 8080, + webRemotePort: 22090, + edgeLocalHost: "api.hwlab-production.svc.cluster.local", + edgeLocalPort: 6667, + edgeRemotePort: 22088 +}); + +const incompleteDeploy = structuredClone(namedLaneDeploy); +delete incompleteDeploy.lanes.production.namespace; +assert.throws( + () => applyRuntimeLaneDeployConfig(defaultArgs, incompleteDeploy), + /deploy\.lanes\.production\.namespace must be a non-empty string/u +); + +const versionLaneDeploy = { + nodes: { G14: { gitopsRoot: "deploy/gitops/node/G14", publicHost: "74.48.78.17" } }, + lanes: { v03: { node: "G14" } } +}; +const versionResolved = applyRuntimeLaneDeployConfig({ ...defaultArgs, lane: "v03" }, versionLaneDeploy); +assert.equal(versionResolved.sourceBranch, "v0.3"); +assert.equal(versionResolved.gitopsBranch, "v0.3-gitops"); +assert.equal(versionResolved.catalogPath, "deploy/artifact-catalog.v03.json"); +assert.equal(runtimeNamespaceForRuntimeLane(versionLaneDeploy, "v03"), "hwlab-v03"); +assert.equal(runtimeRelativePathForRuntimeLane(versionLaneDeploy, "v03"), "runtime-v03"); + +console.log(JSON.stringify({ ok: true, tests: 18, namedLane: "production", versionLane: "v03" })); diff --git a/scripts/src/runtime-lane.ts b/scripts/src/runtime-lane.ts index dcd1800f..2f31cb58 100644 --- a/scripts/src/runtime-lane.ts +++ b/scripts/src/runtime-lane.ts @@ -26,11 +26,15 @@ export type GitOpsRenderArgs = { }; export type RuntimeLaneConfig = { + name?: string; node?: string; sourceBranch?: string; gitopsBranch?: string; + gitReadUrl?: string; + gitWriteUrl?: string; artifactCatalog?: string; runtimePath?: string; + namespace?: string; imageTagMode?: string; endpoint?: string; externalPostgres?: { @@ -75,11 +79,20 @@ const defaultGitopsRoot = "deploy/gitops/node"; const defaultPublicHost = "74.48.78.17"; export function isRuntimeLane(lane: unknown): lane is string { + const value = String(lane ?? ""); + return !["node", "dev", "prod"].includes(value) && /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(value); +} + +export function isVersionRuntimeLane(lane: unknown): lane is string { return /^v[0-9]{2,}$/u.test(String(lane ?? "")); } +export function isNamedRuntimeLane(lane: unknown): lane is string { + return isRuntimeLane(lane) && !isVersionRuntimeLane(lane); +} + export function runtimeLaneMinor(lane: string): number { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`); return Number.parseInt(lane.slice(1), 10); } @@ -88,12 +101,12 @@ export function versionNameForRuntimeLane(lane: string): string { } export function namespaceNameForRuntimeLane(lane: string): string { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`); return `hwlab-${lane}`; } export function runtimePathForRuntimeLane(lane: string): string { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`); return `runtime-${lane}`; } @@ -133,9 +146,10 @@ export function defaultEndpointForRuntimeLane(lane: string, options: { host?: st } export function runtimeLaneConfig(deploy: DeployConfig, lane: string): RuntimeLaneConfig { - invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + invariant(isRuntimeLane(lane), `runtime lane must be a stable lowercase id, got ${lane}`); const config = deploy.lanes?.[lane]; invariant(isObject(config), `deploy.lanes.${lane} is required`); + if (isNamedRuntimeLane(lane)) validateNamedRuntimeLaneConfig(config, lane); return config; } @@ -145,13 +159,14 @@ export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: Dep const laneConfig = runtimeLaneConfig(deploy, args.lane); const configuredNodeId = nodeIdForRuntimeLane(deploy, args.lane, defaults); const rootNodeId = nodeIdFromGitopsRoot(result.gitopsRoot); - const nodeId = result.nodeId && result.nodeId !== defaultNodeId ? result.nodeId : rootNodeId ?? configuredNodeId; + const requestedNodeId = result.nodeId && result.nodeId !== defaultNodeId ? result.nodeId : rootNodeId ?? configuredNodeId; + const nodeId = isNamedRuntimeLane(args.lane) ? configuredNodeId : requestedNodeId; const node = nodeConfig(deploy, nodeId); - const versionName = versionNameForRuntimeLane(args.lane); + const versionName = isVersionRuntimeLane(args.lane) ? versionNameForRuntimeLane(args.lane) : null; const defaultEndpointOptions: { host?: string; v02Endpoint?: string } = {}; if (node.publicHost) defaultEndpointOptions.host = node.publicHost; if (defaults.defaultV02RuntimeEndpoint) defaultEndpointOptions.v02Endpoint = defaults.defaultV02RuntimeEndpoint; - const defaultEndpoint = defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions); + const defaultEndpoint = versionName ? defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions) : null; const configuredEndpoint = laneConfig.endpoint ?? defaultEndpoint; const configuredWebEndpoint = laneConfig.publicEndpoints?.frontend ?? configuredEndpoint; const configuredRuntimeEndpoint = laneConfig.publicEndpoints?.api ?? configuredEndpoint; @@ -163,14 +178,32 @@ export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: Dep const defaultSourceRepo = defaults.defaultSourceRepo ?? "git@github.com:pikasTech/HWLAB.git"; const defaultCatalog = `deploy/artifact-catalog.${args.lane}.json`; + if (isNamedRuntimeLane(args.lane)) { + const configuredSourceRepo = laneConfig.sourceRepo ?? node.sourceRepo; + invariant(typeof node.gitopsRoot === "string" && node.gitopsRoot.trim().length > 0, `deploy.nodes.${nodeId}.gitopsRoot is required for named runtime lane ${args.lane}`); + requiredString(configuredSourceRepo, `deploy.lanes.${args.lane}.sourceRepo or deploy.nodes.${nodeId}.sourceRepo`); + result.nodeId = configuredNodeId; + result.gitopsRoot = normalizeRepoPath(node.gitopsRoot); + result.sourceBranch = requiredString(laneConfig.sourceBranch, `deploy.lanes.${args.lane}.sourceBranch`); + result.gitopsBranch = requiredString(laneConfig.gitopsBranch, `deploy.lanes.${args.lane}.gitopsBranch`); + result.catalogPath = normalizeRepoPath(requiredString(laneConfig.artifactCatalog, `deploy.lanes.${args.lane}.artifactCatalog`)); + result.imageTagMode = requiredString(laneConfig.imageTagMode, `deploy.lanes.${args.lane}.imageTagMode`); + result.runtimeEndpoint = requiredString(laneConfig.publicEndpoints?.api, `deploy.lanes.${args.lane}.publicEndpoints.api`); + result.webEndpoint = requiredString(laneConfig.publicEndpoints?.frontend, `deploy.lanes.${args.lane}.publicEndpoints.frontend`); + result.sourceRepo = configuredSourceRepo as string; + result.gitReadUrl = requiredString(laneConfig.gitReadUrl, `deploy.lanes.${args.lane}.gitReadUrl`); + result.gitWriteUrl = requiredString(laneConfig.gitWriteUrl, `deploy.lanes.${args.lane}.gitWriteUrl`); + return result; + } + if (!result.nodeId || result.nodeId === defaultNodeId) result.nodeId = nodeId; if (!result.gitopsRoot || result.gitopsRoot === (defaults.defaultGitopsRoot ?? defaultGitopsRoot)) result.gitopsRoot = gitopsRootForNode(deploy, nodeId, defaults); - if (result.sourceBranch === defaultBranch || result.sourceBranch === versionName) result.sourceBranch = laneConfig.sourceBranch ?? versionName; - if (result.gitopsBranch === defaultGitopsBranch || result.gitopsBranch === `${versionName}-gitops`) result.gitopsBranch = laneConfig.gitopsBranch ?? `${versionName}-gitops`; + if (result.sourceBranch === defaultBranch || result.sourceBranch === versionName) result.sourceBranch = laneConfig.sourceBranch ?? versionName ?? result.sourceBranch; + if (result.gitopsBranch === defaultGitopsBranch || result.gitopsBranch === `${versionName}-gitops`) result.gitopsBranch = laneConfig.gitopsBranch ?? (versionName ? `${versionName}-gitops` : result.gitopsBranch); if (result.catalogPath === defaultCatalogPath || result.catalogPath === defaultCatalog) result.catalogPath = laneConfig.artifactCatalog ?? defaultCatalog; if (result.imageTagMode === "full") result.imageTagMode = laneConfig.imageTagMode ?? result.imageTagMode; - if (result.runtimeEndpoint === defaultRuntimeEndpoint || result.runtimeEndpoint === defaultEndpoint) result.runtimeEndpoint = configuredRuntimeEndpoint; - if (result.webEndpoint === defaultWebEndpoint || result.webEndpoint === defaultEndpoint) result.webEndpoint = configuredWebEndpoint; + if (result.runtimeEndpoint === defaultRuntimeEndpoint || result.runtimeEndpoint === defaultEndpoint) result.runtimeEndpoint = configuredRuntimeEndpoint ?? result.runtimeEndpoint; + if (result.webEndpoint === defaultWebEndpoint || result.webEndpoint === defaultEndpoint) result.webEndpoint = configuredWebEndpoint ?? result.webEndpoint; if (result.sourceRepo === defaultSourceRepo) result.sourceRepo = laneConfig.sourceRepo ?? node.sourceRepo ?? result.sourceRepo; return result; } @@ -185,20 +218,48 @@ function nodeIdFromGitopsRoot(gitopsRoot: unknown): string | undefined { export function runtimeLaneMirrorSpecs(deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): RuntimeLaneMirrorSpec[] { return Object.entries(deploy.lanes ?? {}) .filter(([lane, config]) => isRuntimeLane(lane) && isObject(config)) - .sort(([left], [right]) => runtimeLaneMinor(left) - runtimeLaneMinor(right)) + .sort(([left], [right]) => left.localeCompare(right, "en")) .map(([lane, config]) => { const laneConfig = config as RuntimeLaneConfig; - const versionName = versionNameForRuntimeLane(lane); + const versionName = isVersionRuntimeLane(lane) ? versionNameForRuntimeLane(lane) : null; return { lane, - sourceBranch: laneConfig.sourceBranch ?? versionName, - gitopsBranch: laneConfig.gitopsBranch ?? `${versionName}-gitops`, + sourceBranch: laneConfig.sourceBranch ?? versionName ?? requiredString(laneConfig.sourceBranch, `deploy.lanes.${lane}.sourceBranch`), + gitopsBranch: laneConfig.gitopsBranch ?? (versionName ? `${versionName}-gitops` : requiredString(laneConfig.gitopsBranch, `deploy.lanes.${lane}.gitopsBranch`)), artifactCatalog: laneConfig.artifactCatalog ?? `deploy/artifact-catalog.${lane}.json`, runtimePath: runtimeGitopsPathForRuntimeLane(deploy, lane, defaults), }; }); } +export function runtimeNamespaceForRuntimeLane(deploy: DeployConfig, lane: string): string { + const laneConfig = runtimeLaneConfig(deploy, lane); + return laneConfig.namespace ?? namespaceNameForRuntimeLane(lane); +} + +export function runtimeRelativePathForRuntimeLane(deploy: DeployConfig, lane: string): string { + const laneConfig = runtimeLaneConfig(deploy, lane); + return normalizeRepoPath(laneConfig.runtimePath ?? runtimePathForRuntimeLane(lane)); +} + +function validateNamedRuntimeLaneConfig(config: RuntimeLaneConfig, lane: string): void { + const label = `deploy.lanes.${lane}`; + for (const key of ["name", "node", "sourceBranch", "gitopsBranch", "gitReadUrl", "gitWriteUrl", "artifactCatalog", "runtimePath", "namespace", "imageTagMode"] as const) { + requiredString(config[key], `${label}.${key}`); + } + invariant(config.imageTagMode === "short" || config.imageTagMode === "full", `${label}.imageTagMode must be short or full`); + invariant(isObject(config.publicEndpoints), `${label}.publicEndpoints is required`); + requiredString(config.publicEndpoints.frontend, `${label}.publicEndpoints.frontend`); + requiredString(config.publicEndpoints.api, `${label}.publicEndpoints.api`); + invariant(Array.isArray((config as Record).envReuseServices) && ((config as Record).envReuseServices as unknown[]).length > 0, `${label}.envReuseServices must not be empty`); + invariant(isObject((config as Record).serviceDeclarations), `${label}.serviceDeclarations is required`); +} + +function requiredString(value: unknown, label: string): string { + invariant(typeof value === "string" && value.trim().length > 0, `${label} must be a non-empty string`); + return value.trim(); +} + function normalizeRepoPath(value: string): string { return value.replaceAll("\\\\", "/").replace(/^\.\//u, "").replace(/\/+$/u, ""); }