diff --git a/AGENTS.md b/AGENTS.md index 7ca6a641..ba258acb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 ## 规格 - 规格文档是微服务、稳定外部服务、短连接 CLI 和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 `docs/reference/spec-*.md`,再修改实现或测试。 +- 平行 runtime lane 扩容规格、`v0.3` 之后 `v0.4`/`v0.5`/`v0.6` 只改 `deploy/deploy.yaml` 配置并通过 repo-owned CLI 扩容的规则:[docs/reference/spec-lane-expansion.md](docs/reference/spec-lane-expansion.md)。 - v0.2 服务规格总览、保留服务清单、稳定外部服务边界和废弃范围:[docs/reference/spec-v02-services.md](docs/reference/spec-v02-services.md);`hwlab-gateway-simu`、`hwlab-box-simu` 和 `hwlab-patch-panel` 已废弃,不再保留 spec。 - v0.2 登录与鉴权规格、Keycloak OIDC、Web session 和 CLI API key:[docs/reference/spec-v02-auth.md](docs/reference/spec-v02-auth.md)。 - v0.2 用户和权限管理规格、code agent session 归属和工具能力授权:[docs/reference/spec-user-access.md](docs/reference/spec-user-access.md)。 @@ -148,6 +149,8 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - G14 artifact build helper:`node scripts/g14-artifact-publish.mjs --publish ...`,只能由 G14 Tekton task 携带 CI artifact identity 调用;人工发布走 G14 poller/GitOps,不走 legacy CLI CD。 - G14 monorepo 组件计划:`node scripts/g14-ci-plan.mjs --base-ref --target-ref --pretty`,只读分析 affected/reused services,基于内建 service-path component model 与 `deploy/deploy.yaml`;细则见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。 - G14 GitOps 渲染:`npm run g14:gitops:render`;source 分支不再运行生成物 drift check,发布态由 Tekton 写入 `G14-gitops`。 +- 平行 lane 配置扩容:`npm run lane:expand -- configure --lane v03 --from v02 --write`,只写 `deploy/deploy.yaml` 的新 lane 声明,后续 `v04`/`v05`/`v06` 复用同一入口。 +- GitOps lane 严格 TS 检查:`npm run gitops:ts:check`;新拆 GitOps 模块直接进入 `scripts/src/*.ts` 和 `tsconfig.gitops.json`,不要继续堆进单个 `.mjs`。 - DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build` - Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。 - v0.2 WEB 等价短连接 CLI:在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 直接运行 `bun tools/hwlab-cli/bin/hwlab-cli.ts client ... --base-url http://74.48.78.17:19666`,默认走 Cloud Web 同源 API;细则见 [docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。 diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 9021dada..40ba9234 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -308,21 +308,21 @@ }, "lanes": { "type": "object", - "properties": { - "v02": { - "$ref": "#/$defs/v02Lane" + "patternProperties": { + "^v[0-9]{2,}$": { + "$ref": "#/$defs/runtimeLane" } }, "additionalProperties": false }, - "v02Lane": { + "runtimeLane": { "type": "object", "required": ["name", "sourceBranch", "gitopsBranch", "namespace", "envReuseServices", "bootScripts", "serviceDeclarations", "envRecipe", "bootConfig"], "properties": { - "name": { "type": "string", "const": "v0.2" }, - "sourceBranch": { "type": "string", "const": "v0.2" }, - "gitopsBranch": { "type": "string", "const": "v0.2-gitops" }, - "namespace": { "type": "string", "const": "hwlab-v02" }, + "name": { "type": "string", "pattern": "^v0\\.[0-9]+$" }, + "sourceBranch": { "type": "string", "pattern": "^v0\\.[0-9]+$" }, + "gitopsBranch": { "type": "string", "pattern": "^v0\\.[0-9]+-gitops$" }, + "namespace": { "type": "string", "pattern": "^hwlab-v[0-9]{2,}$" }, "endpoint": { "type": "string" }, "publicEndpoints": { "type": "object", diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index cb9ffaf5..d359ba03 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -216,6 +216,142 @@ lanes: HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v01 HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v01 HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git + v03: + name: v0.3 + sourceBranch: v0.3 + gitopsBranch: v0.3-gitops + namespace: hwlab-v03 + endpoint: https://hwlab-v03.74-48-78-17.nip.io + publicEndpoints: + frontend: https://hwlab-v03.74-48-78-17.nip.io + api: https://hwlab-v03.74-48-78-17.nip.io + legacyFrontend: http://74.48.78.17:19766 + legacyApi: http://74.48.78.17:19767 + artifactCatalog: deploy/artifact-catalog.v03.json + runtimePath: deploy/gitops/g14/runtime-v03 + imageTagMode: full + envReuseServices: + - hwlab-cloud-api + - hwlab-cloud-web + - hwlab-gateway + - hwlab-edge-proxy + - hwlab-agent-skills + bootScripts: + hwlab-cloud-api: deploy/runtime/boot/hwlab-cloud-api.sh + hwlab-cloud-web: deploy/runtime/boot/hwlab-cloud-web.sh + hwlab-gateway: deploy/runtime/boot/hwlab-gateway.sh + hwlab-edge-proxy: deploy/runtime/boot/hwlab-edge-proxy.sh + hwlab-agent-skills: deploy/runtime/boot/hwlab-agent-skills.sh + serviceDeclarations: + hwlab-cloud-api: + runtimeKind: bun-command + entrypoint: cmd/hwlab-cloud-api/main.ts + artifactKind: bun-command + healthPath: /health/live + healthPort: 6667 + componentPaths: + - cmd/hwlab-cloud-api/ + - cmd/hwlab-codex-api-responses-forwarder/ + - cmd/hwlab-deepseek-responses-bridge/ + - deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh + - deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh + - internal/cloud/ + - internal/db/ + - internal/audit/ + - internal/agent/agentrun-dispatch.mjs + - internal/agent/prompts/ + - skills/hwlab-agent-runtime/ + env: {} + observable: true + hwlab-cloud-web: + runtimeKind: cloud-web + entrypoint: web/hwlab-cloud-web/index.html + artifactKind: cloud-web + healthPath: /health/live + healthPort: 8080 + componentPaths: + - web/hwlab-cloud-web/ + - internal/dev-entrypoint/cloud-web-runtime.mjs + - internal/dev-entrypoint/cloud-web-proxy.mjs + - internal/dev-entrypoint/cloud-web-routes.mjs + - tools/src/hwlab-cli/trace-renderer.ts + env: {} + observable: true + hwlab-gateway: + runtimeKind: bun-command + entrypoint: cmd/hwlab-gateway/main.ts + artifactKind: bun-command + healthPath: /health/live + healthPort: 7001 + componentPaths: + - cmd/hwlab-gateway/ + - internal/sim/ + env: {} + observable: false + hwlab-edge-proxy: + runtimeKind: bun-command + entrypoint: cmd/hwlab-edge-proxy/main.ts + artifactKind: bun-command + healthPath: /health + healthPort: 6667 + componentPaths: + - cmd/hwlab-edge-proxy/ + - internal/dev-entrypoint/http.mjs + env: {} + observable: true + hwlab-agent-skills: + runtimeKind: skills-bundle + entrypoint: skills/hwlab-agent-runtime/SKILL.md + artifactKind: skills-bundle + healthPath: /health/live + healthPort: 7430 + componentPaths: + - skills/ + env: {} + observable: true + envRecipe: + osPackages: + - ca-certificates + - git + bunVersion: 1.3.13 + launcherPath: deploy/runtime/launcher/hwlab-env-reuse-launcher.ts + runtimeNodeModulesPath: /opt/hwlab-env/node_modules + additionalEnvPaths: + - internal/dev-entrypoint/artifact-runtime.mjs + - scripts/artifact-publish.mjs + - scripts/g14-artifact-publish.mjs + - scripts/src/dev-artifact-services.mjs + hwpodAliases: + - command: hwpod + script: tools/hwpod-cli.ts + - command: hwpod-ctl + script: tools/hwpod-ctl.ts + - command: hwpod-compiler + script: tools/hwpod-compiler-cli.ts + bootConfig: + template: default + overrides: {} + services: + - serviceId: hwlab-cloud-api + env: + HWLAB_CLOUD_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url + HWLAB_CLOUD_DB_CONTRACT: v03-redacted-presence-only + HWLAB_ACCESS_CONTROL_REQUIRED: "1" + HWLAB_BOOTSTRAP_ADMIN_ID: usr_v03_admin + HWLAB_BOOTSTRAP_ADMIN_USERNAME: admin + HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME: HWLAB v0.3 Admin + HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH: secretRef:hwlab-v03-bootstrap-admin/password-hash + HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID: key_master_server_admin + HWLAB_BOOTSTRAP_ADMIN_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key + HWLAB_KEYCLOAK_ISSUER: https://auth.74-48-78-17.nip.io/realms/hwlab + HWLAB_KEYCLOAK_CLIENT_ID: hwlab-cloud-web + HWLAB_KEYCLOAK_CLIENT_SECRET: secretRef:hwlab-cloud-web-client/client-secret + HWLAB_CODE_AGENT_ADAPTER: agentrun-v01 + AGENTRUN_MGR_URL: http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080 + HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: G14 + HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v01 + HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v01 + HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git services: - serviceId: hwlab-cloud-api namespace: hwlab-dev diff --git a/docs/reference/spec-lane-expansion.md b/docs/reference/spec-lane-expansion.md new file mode 100644 index 00000000..6d2b52f8 --- /dev/null +++ b/docs/reference/spec-lane-expansion.md @@ -0,0 +1,72 @@ +# HWLAB 平行 Runtime Lane 扩容规格 + +本文是 HWLAB `v0.x` 平行 runtime lane 的长期扩容规格。`v0.3` 之后的 `v0.4`、`v0.5`、`v0.6` 等扩容必须沿用同一模式:人工只维护 `deploy/deploy.yaml` 中的 lane 声明,然后通过 repo-owned CLI 生成或应用扩容动作;不得再为每个新 lane 复制多处代码、手写 GitOps YAML、手动拼 namespace/Secret/FRP/Tekton/Argo 对象,或恢复 D601/G14 旧兼容路径。 + +## 标准入口 + +新增 lane 的配置入口是: + +```bash +node scripts/hwlab-lane-expand.mjs configure --lane v03 --from v02 --write +``` + +该命令只写入 `deploy/deploy.yaml` 的 `lanes.` 配置,并按 lane id 推导以下稳定映射: + +| 输入 | 派生结果 | +|---|---| +| `v03` | source branch `v0.3` | +| `v03` | GitOps branch `v0.3-gitops` | +| `v03` | namespace `hwlab-v03` | +| `v03` | runtime path `deploy/gitops/g14/runtime-v03` | +| `v03` | artifact catalog `deploy/artifact-catalog.v03.json` | +| `v03` | Argo Application `hwlab-g14-v03` | +| `v03` | Tekton Pipeline `hwlab-v03-ci-image-publish` | +| `v03` | default legacy FRP ports `19766/19767` | + +后续 lane 按同一公式递增,默认端口为 `19466 + minor * 100` 和后一位 API 端口;如端口已被占用,必须在 CLI 参数中显式给出 `--web-port` / `--api-port`,并把结果写回 `deploy/deploy.yaml`,不要在 render、Tekton 或 FRP 文件里手改。 + +## 单一配置点 + +`deploy/deploy.yaml` 是平行 runtime lane 的人写配置源。每个 `lanes.` 必须包含: + +- `name`、`sourceBranch`、`gitopsBranch`、`namespace`、`artifactCatalog`、`runtimePath` 和公网 endpoint 映射。 +- `envReuseServices`、`bootScripts`、`serviceDeclarations`、`envRecipe`、`bootConfig` 和 per-service runtime env。 +- lane 独立 SecretRef 名称,例如 `hwlab-cloud-api-v03-db`、`hwlab-v03-bootstrap-admin`、`hwlab-v03-master-server-admin-api-key`。 + +新增服务、调整组件边界、调整 env image 配方或变更 HWPOD alias,也只允许先改 lane 配置和 schema;planner、renderer、artifact helper 和受控发布 CLI 必须读取 `deploy.lanes.`,不得新增 `if lane === "v04"` 这类 per-lane 分支。 + +## GitOps 模块化与严格 TS + +`v0.3` 起,GitOps 扩容逻辑不得继续堆进单个 `scripts/g14-gitops-render.mjs`。该文件只保留历史入口兼容、参数解析和高层编排;新增或抽出的 GitOps 逻辑必须进入 `scripts/src/` 下的领域模块,并优先使用 `.ts`。 + +第一批严格 TS 模块是 `scripts/src/g14-gitops-lane.ts`,负责 runtime lane 的命名、端口、endpoint、branch、catalog 和 `deploy.lanes.` 回填。该模块由 `tsconfig.gitops.json` 以 `strict`、`exactOptionalPropertyTypes`、`noUncheckedIndexedAccess` 等选项检查;验证入口是: + +```bash +npm run gitops:ts:check +``` + +后续从 renderer、Tekton、Argo、FRP、Git mirror 或 artifact helper 继续拆出的模块,必须直接纳入该严格 TS 检查范围;不要先拆成 `.mjs` 再另开长期迁移债。`scripts/g14-gitops-render.mjs` 因为导入 TS 模块,实际执行必须走 Bun 入口: + +```bash +npm run g14:gitops:render -- --lane v03 +``` + +生成的 Tekton 任务和 control-plane reconciler 也必须使用 `node scripts/run-bun.mjs scripts/g14-gitops-render.mjs ...`,不得再用裸 `node scripts/g14-gitops-render.mjs ...`。 + +## 废弃路径 + +`G14` 和 D601 旧分支/runtime 只保留归档或事故对照价值,不再是新 HWLAB runtime lane 的 CI/CD 支持对象。`G14` 的归档分支名是 `v0.1`;CI/CD 不再为了 `v0.1` 保持 namespace、Pipeline、GitOps branch、poller、reconciler 或 FRP 兼容。若旧对象仍存在,应通过受控 CLI 分阶段退役,不能作为 v0.3+ 发布证据。 + +## 验收边界 + +一个新 lane 只有同时满足以下条件才算扩容完成: + +- 远端 source branch 和 GitOps branch 存在,并与 `deploy.lanes.` 声明一致。 +- G14 k3s 中存在独立 namespace、Tekton Pipeline/RBAC、Argo AppProject/Application 和 runtime desired state。 +- runtime namespace、SecretRef、PVC、ServiceAccount、FRP proxy、artifact catalog 和 observability 对象均使用 lane 独立命名。 +- `control-plane trigger-current --lane ` 能创建 commit-pinned PipelineRun,promotion 只写入对应 GitOps branch/path。 +- 公网 Web/API health 命中该 lane 的 namespace 和 revision;不得用 DEV/PROD、D601 或 `v0.1` health 作为通过证据。 + +## 当前状态 + +`v0.2` 是既有加法 lane,`v0.3` 是本规格收敛后的下一条 lane。`v0.3` 之后新增 lane 时,优先使用 `scripts/hwlab-lane-expand.mjs` 从最新稳定 lane 派生配置,再补受控 CLI 的 `--lane ` 验证;如果某处仍要求改 JS/TS 代码才能识别新 lane,应视为扩容抽象缺口并修通用读取逻辑,而不是复制旧分支。 diff --git a/package-lock.json b/package-lock.json index 3d4eaffc..e9fc18d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "pg": "^8.21.0", "playwright": "1.59.1", "yaml": "^2.8.3" + }, + "devDependencies": { + "typescript": "^5.9.3" } }, "node_modules/@openai/codex": { @@ -323,6 +326,19 @@ "node": ">= 10.x" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 7636d963..13ce3e23 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "web:check": "cd web/hwlab-cloud-web && bun run check", "web:build": "(cd web/hwlab-cloud-web && bun run build)", "artifact-catalog:preview-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write", - "g14:gitops:render": "node scripts/g14-gitops-render.mjs", + "lane:expand": "node scripts/hwlab-lane-expand.mjs", + "g14:gitops:render": "node scripts/run-bun.mjs scripts/g14-gitops-render.mjs", + "gitops:ts:check": "tsc --noEmit -p tsconfig.gitops.json", "docs:validate:m3-rollout": "node scripts/validate-m3-rollout-runbook.mjs", "d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs", "report:lifecycle:test": "node scripts/report-lifecycle.test.mjs", @@ -47,5 +49,8 @@ "pg": "^8.21.0", "playwright": "1.59.1", "yaml": "^2.8.3" + }, + "devDependencies": { + "typescript": "^5.9.3" } } diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 1299b9b9..f11566b3 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -7,6 +7,14 @@ import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { + applyRuntimeLaneDeployConfig, + defaultEndpointForRuntimeLane, + defaultPortForRuntimeLane, + isRuntimeLane, + runtimeLaneConfig, + versionNameForRuntimeLane +} from "./src/g14-gitops-lane.ts"; import { readStructuredFile } from "./src/structured-config.mjs"; const execFileAsync = promisify(execFile); @@ -17,6 +25,8 @@ const defaultSourceRepo = process.env.HWLAB_G14_GIT_URL || "git@github.com:pikas const defaultGitReadUrl = process.env.HWLAB_G14_GIT_READ_URL || null; const defaultV02GitReadUrl = process.env.HWLAB_V02_GIT_READ_URL || "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; const defaultV02GitWriteUrl = process.env.HWLAB_V02_GIT_WRITE_URL || "http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"; +const defaultRuntimeLaneGitReadUrl = process.env.HWLAB_RUNTIME_LANE_GIT_READ_URL || defaultV02GitReadUrl; +const defaultRuntimeLaneGitWriteUrl = process.env.HWLAB_RUNTIME_LANE_GIT_WRITE_URL || defaultV02GitWriteUrl; const defaultBranch = process.env.HWLAB_G14_BRANCH || "G14"; const defaultGitopsBranch = process.env.HWLAB_G14_GITOPS_BRANCH || "G14-gitops"; const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; @@ -97,11 +107,12 @@ const primitiveValidationTasks = Object.freeze([ { name: "g14-contract-check", commands: [ - "node --check scripts/g14-gitops-render.mjs", + "npm run gitops:ts:check", + "node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --help >/dev/null", "render_check_revision=$(git rev-parse HEAD)", "render_check_dir=$(mktemp -d)", - "node scripts/g14-gitops-render.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --image-tag-mode \"$(params.image-tag-mode)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --out \"$render_check_dir\" --source-revision \"$render_check_revision\"", - "node scripts/g14-gitops-render.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --image-tag-mode \"$(params.image-tag-mode)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --out \"$render_check_dir\" --check --source-revision \"$render_check_revision\"", + "node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --image-tag-mode \"$(params.image-tag-mode)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --out \"$render_check_dir\" --source-revision \"$render_check_revision\"", + "node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --image-tag-mode \"$(params.image-tag-mode)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --out \"$render_check_dir\" --check --source-revision \"$render_check_revision\"", `node --input-type=module <<'NODE' import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; @@ -124,6 +135,7 @@ const forbiddenCiFragments = [ ]; const scanTargets = [ "scripts/g14-gitops-render.mjs", + "scripts/src/g14-gitops-lane.ts", "scripts/artifact-publish.mjs", "scripts/g14-artifact-publish.mjs", "scripts/src/g14-ci-plan-lib.mjs", @@ -234,23 +246,26 @@ function parseArgs(argv) { else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } - if (args.lane === "v02") { - if (args.sourceBranch === defaultBranch) args.sourceBranch = "v0.2"; - if (args.gitopsBranch === defaultGitopsBranch) args.gitopsBranch = "v0.2-gitops"; - if (args.catalogPath === defaultCatalogPath) args.catalogPath = "deploy/artifact-catalog.v02.json"; - if (args.runtimeEndpoint === defaultRuntimeEndpoint) args.runtimeEndpoint = defaultV02RuntimeEndpoint; - if (args.webEndpoint === defaultWebEndpoint) args.webEndpoint = defaultV02WebEndpoint; + if (isRuntimeLane(args.lane)) { + const versionName = versionNameForRuntimeLane(args.lane); + const defaultLaneEndpoint = defaultEndpointForRuntimeLane(args.lane); + if (args.sourceBranch === defaultBranch) args.sourceBranch = versionName; + if (args.gitopsBranch === defaultGitopsBranch) args.gitopsBranch = `${versionName}-gitops`; + if (args.catalogPath === defaultCatalogPath) args.catalogPath = `deploy/artifact-catalog.${args.lane}.json`; + if (args.runtimeEndpoint === defaultRuntimeEndpoint) args.runtimeEndpoint = defaultLaneEndpoint; + if (args.webEndpoint === defaultWebEndpoint) args.webEndpoint = defaultLaneEndpoint; if (args.imageTagMode === "short") args.imageTagMode = "full"; - if (!args.gitReadUrl) args.gitReadUrl = defaultV02GitReadUrl; - if (!args.gitWriteUrl) args.gitWriteUrl = defaultV02GitWriteUrl; + if (!args.gitReadUrl) args.gitReadUrl = defaultRuntimeLaneGitReadUrl; + if (!args.gitWriteUrl) args.gitWriteUrl = defaultRuntimeLaneGitWriteUrl; } if (!args.gitReadUrl) args.gitReadUrl = args.sourceRepo; if (!args.gitWriteUrl) args.gitWriteUrl = args.sourceRepo; - assert.ok(["g14", "v02"].includes(args.lane), `unknown lane ${args.lane}`); + assert.ok(args.lane === "g14" || isRuntimeLane(args.lane), `unknown lane ${args.lane}`); assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); - if (args.lane === "v02") { - assert.equal(args.sourceBranch, "v0.2", "v02 source branch must be v0.2"); - assert.equal(args.gitopsBranch, "v0.2-gitops", "v02 GitOps branch must be v0.2-gitops"); + if (isRuntimeLane(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`); } return args; } @@ -263,24 +278,24 @@ function readOption(argv, index, name) { function usage() { return [ - "usage: node scripts/g14-gitops-render.mjs [options]", + "usage: node scripts/run-bun.mjs scripts/g14-gitops-render.mjs [options]", "", "Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.yaml, and deploy/k8s/*.", "", "options:", - " --lane LANE g14 or v02; default: g14", + " --lane LANE g14 or runtime lane such as v02/v03/v04; default: g14", ` --out DIR default: ${defaultOutDir}`, ` --catalog-path PATH default: ${defaultCatalogPath}`, " --image-tag-mode MODE short or full; v02 uses full source commit IDs", ` --source-repo URL default: ${defaultSourceRepo}`, - " --git-read-url URL read-only source URL; v02 defaults to devops-infra git mirror", - " --git-write-url URL GitOps write URL; v02 defaults to devops-infra git mirror write relay", + " --git-read-url URL read-only source URL; runtime lanes default to devops-infra git mirror", + " --git-write-url URL GitOps write URL; runtime lanes default to devops-infra git mirror write relay", ` --source-branch BRANCH default: ${defaultBranch}`, ` --gitops-branch BRANCH default: ${defaultGitopsBranch}`, " --source-revision SHA source commit used for image tags and runtime annotations; default: git rev-parse HEAD", ` --registry-prefix PREFIX default: ${defaultRegistryPrefix}`, - ` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}; v02 default: ${defaultV02RuntimeEndpoint}`, - ` --web-endpoint URL default: ${defaultWebEndpoint}; v02 default: ${defaultV02WebEndpoint}`, + ` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}; runtime lane default is derived from deploy.yaml/public URL`, + ` --web-endpoint URL default: ${defaultWebEndpoint}; runtime lane default is derived from deploy.yaml/public URL`, ` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`, ` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`, " --use-deploy-images default and only supported mode: render workload images from deploy/artifact-catalog.dev.json per service", @@ -559,32 +574,38 @@ function runtimeImageTagForService(options) { } function artifactEnvironment(args) { - return args.lane === "v02" ? "v02" : "dev"; + return isRuntimeLane(args.lane) ? args.lane : "dev"; } function artifactEndpoint(args) { - return args.lane === "v02" ? defaultV02RuntimeEndpoint : defaultRuntimeEndpoint; + return isRuntimeLane(args.lane) ? args.runtimeEndpoint : defaultRuntimeEndpoint; } function serviceIdsForLane(lane, deploy = null) { - return lane === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds; + return isRuntimeLane(lane) ? runtimeLaneServiceIdsFromDeploy(deploy, lane) : defaultServiceIds; } function serviceIdsForProfile(profile, deploy = null) { - return profile === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds; + return isRuntimeLane(profile) ? runtimeLaneServiceIdsFromDeploy(deploy, profile) : defaultServiceIds; } -function v02ServiceIdsFromDeploy(deploy) { - const serviceIds = uniquePreserveOrder((Array.isArray(deploy?.lanes?.v02?.envReuseServices) ? deploy.lanes.v02.envReuseServices : []) +function runtimeLaneServiceIdsFromDeploy(deploy, lane) { + const laneConfig = runtimeLaneConfig(deploy, lane); + const serviceIds = uniquePreserveOrder((Array.isArray(laneConfig.envReuseServices) ? laneConfig.envReuseServices : []) .map((item) => String(item ?? "").trim()) .filter(Boolean)); - assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty"); + assert.ok(serviceIds.length > 0, `deploy.lanes.${lane}.envReuseServices must not be empty`); return serviceIds; } -function v02ObservableServicesForDeploy(deploy) { - const declarations = deploy?.lanes?.v02?.serviceDeclarations ?? {}; - const serviceIds = new Set(v02ServiceIdsFromDeploy(deploy)); +function v02ServiceIdsFromDeploy(deploy) { + return runtimeLaneServiceIdsFromDeploy(deploy, "v02"); +} + +function runtimeLaneObservableServicesForDeploy(deploy, lane) { + const laneConfig = runtimeLaneConfig(deploy, lane); + const declarations = laneConfig.serviceDeclarations ?? {}; + const serviceIds = new Set(runtimeLaneServiceIdsFromDeploy(deploy, lane)); const declared = Object.entries(declarations) .filter(([serviceId, declaration]) => serviceIds.has(serviceId) && declaration?.observable !== false && Number.isInteger(declaration?.healthPort)) .map(([serviceId, declaration]) => ({ @@ -595,12 +616,20 @@ function v02ObservableServicesForDeploy(deploy) { return [...declared, ...v02ExtraObservableServices]; } +function v02ObservableServicesForDeploy(deploy) { + return runtimeLaneObservableServicesForDeploy(deploy, "v02"); +} + function v02ObservableService(deploy, serviceId) { return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null; } +function runtimeLaneObservableServiceIdsForDeploy(deploy, lane) { + return new Set(runtimeLaneObservableServicesForDeploy(deploy, lane).map((item) => item.serviceId)); +} + function v02ObservableServiceIdsForDeploy(deploy) { - return new Set(v02ObservableServicesForDeploy(deploy).map((item) => item.serviceId)); + return runtimeLaneObservableServiceIdsForDeploy(deploy, "v02"); } function servicesParamForLane(lane, deploy = null) { @@ -624,7 +653,7 @@ function isV02RemovedServiceId(serviceId) { function artifactCatalogSkeleton({ args, deploy, source }) { const environment = artifactEnvironment(args); - const namespace = args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"; + const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane) : "hwlab-dev"; const tag = imageTagForSource(args, source); return { catalogVersion: "v1", @@ -642,7 +671,7 @@ function artifactCatalogSkeleton({ args, deploy, source }) { publishedAt: null }, allowedProfiles: [environment], - forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"], + forbiddenProfiles: isRuntimeLane(args.lane) ? ["dev", "prod"] : ["prod"], services: serviceIdsForLane(args.lane, deploy).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag })) }; } @@ -687,47 +716,48 @@ function artifactCatalogSkeletonService({ args, deploy, source, serviceId, envir } function namespaceNameForProfile(profile) { - if (profile === "v02") return "hwlab-v02"; + if (isRuntimeLane(profile)) return `hwlab-${profile}`; return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; } function runtimePathForProfile(profile) { - if (profile === "v02") return "runtime-v02"; + if (isRuntimeLane(profile)) return `runtime-${profile}`; return profile === "prod" ? "runtime-prod" : "runtime-dev"; } function runtimeLabelForProfile(profile) { - if (profile === "v02") return "v02"; + if (isRuntimeLane(profile)) return profile; return profile === "prod" ? "prod" : "dev"; } function gitopsTargetForProfile(profile) { - return profile === "v02" ? "v02" : "g14"; + return isRuntimeLane(profile) ? profile : "g14"; } function profileEndpoint(args, profile) { - if (profile === "v02") return { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; + if (isRuntimeLane(profile)) return { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; return profile === "prod" ? { runtimeEndpoint: args.prodRuntimeEndpoint, webEndpoint: args.prodWebEndpoint } : { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; } function laneRuntimeProfiles(args) { - return args.lane === "v02" ? ["v02"] : ["dev", "prod"]; + return isRuntimeLane(args.lane) ? [args.lane] : ["dev", "prod"]; } function laneNames(args) { - if (args.lane === "v02") { + if (isRuntimeLane(args.lane)) { + const namespace = namespaceNameForProfile(args.lane); return { - gitopsTarget: "v02", - pipeline: "hwlab-v02-ci-image-publish", - poller: "hwlab-v02-branch-poller", - reconciler: "hwlab-v02-control-plane-reconciler", - serviceAccount: "hwlab-v02-tekton-runner", - pipelineRunPrefix: "hwlab-v02-ci-poll-", - runtimeNamespace: "hwlab-v02", - runtimePath: "deploy/gitops/g14/runtime-v02", - argoApplications: ["argocd/hwlab-g14-v02"] + gitopsTarget: args.lane, + pipeline: `${namespace}-ci-image-publish`, + poller: `${namespace}-branch-poller`, + reconciler: `${namespace}-control-plane-reconciler`, + serviceAccount: `${namespace}-tekton-runner`, + pipelineRunPrefix: `${namespace}-ci-poll-`, + runtimeNamespace: namespace, + runtimePath: `deploy/gitops/g14/runtime-${args.lane}`, + argoApplications: [`argocd/hwlab-g14-${args.lane}`] }; } return { @@ -744,7 +774,7 @@ function laneNames(args) { } function argoApplicationName(profile) { - return profile === "v02" ? "hwlab-g14-v02" : `hwlab-g14-${profile}`; + return `hwlab-g14-${profile}`; } function imageTagForSource(args, source) { @@ -1126,13 +1156,13 @@ function transformListNamespace(value, namespace, labels, annotations) { } function transformServices({ services, namespace, labels, annotations, profile = "dev", deploy = null }) { - const v02ObservableServiceIds = profile === "v02" ? v02ObservableServiceIdsForDeploy(deploy) : new Set(); + const observableServiceIds = isRuntimeLane(profile) ? runtimeLaneObservableServiceIdsForDeploy(deploy, profile) : new Set(); const result = transformListNamespace(services, namespace, labels, annotations); if (result.kind === "List") { - result.items = (result.items ?? []).filter((item) => !(profile === "v02" && isV02RemovedServiceId(serviceIdForWorkload(item, null)))); + result.items = (result.items ?? []).filter((item) => !(isRuntimeLane(profile) && isV02RemovedServiceId(serviceIdForWorkload(item, null)))); for (const item of result.items) { const serviceId = serviceIdForWorkload(item, null); - if (profile !== "v02" || !v02ObservableServiceIds.has(serviceId)) continue; + if (!isRuntimeLane(profile) || !observableServiceIds.has(serviceId)) continue; label(item.metadata ??= {}, v02MetricsLabels(serviceId)); upsertV02MetricsPort(item); } @@ -1140,17 +1170,18 @@ function transformServices({ services, namespace, labels, annotations, profile = return result; } -function observabilityManifest({ deploy, namespace, labels, annotations, metricsSidecarScript }) { +function observabilityManifest({ deploy, profile, namespace, labels, annotations, metricsSidecarScript }) { + assert.ok(isRuntimeLane(profile), `observability profile must be a runtime lane, got ${profile}`); const baseLabels = { ...labels, "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/monitoring": "enabled" }; - const serviceMonitors = v02ObservableServicesForDeploy(deploy).map((service) => ({ + const serviceMonitors = runtimeLaneObservableServicesForDeploy(deploy, profile).map((service) => ({ apiVersion: "monitoring.coreos.com/v1", kind: "ServiceMonitor", metadata: { - name: `hwlab-v02-${service.serviceId}`, + name: `hwlab-${profile}-${service.serviceId}`, namespace, labels: { ...baseLabels, "hwlab.pikastech.local/service-id": service.serviceId }, annotations @@ -1159,7 +1190,7 @@ function observabilityManifest({ deploy, namespace, labels, annotations, metrics namespaceSelector: { matchNames: [namespace] }, selector: { matchLabels: { - "hwlab.pikastech.local/gitops-target": "v02", + "hwlab.pikastech.local/gitops-target": profile, "hwlab.pikastech.local/monitoring": "enabled", "hwlab.pikastech.local/service-id": service.serviceId } @@ -1178,30 +1209,30 @@ function observabilityManifest({ deploy, namespace, labels, annotations, metrics { apiVersion: "v1", kind: "ConfigMap", - metadata: { name: "hwlab-v02-metrics-sidecar", namespace, labels: baseLabels, annotations }, + metadata: { name: `hwlab-${profile}-metrics-sidecar`, namespace, labels: baseLabels, annotations }, data: { "metrics-sidecar.mjs": metricsSidecarScript } }, ...serviceMonitors, { apiVersion: "monitoring.coreos.com/v1", kind: "PrometheusRule", - metadata: { name: "hwlab-v02-observability", namespace, labels: baseLabels, annotations }, + metadata: { name: `hwlab-${profile}-observability`, namespace, labels: baseLabels, annotations }, spec: { groups: [{ - name: "hwlab-v02-observability", + name: `hwlab-${profile}-observability`, rules: [ { - alert: "HWLABV02MetricsTargetDown", - expr: 'up{namespace="hwlab-v02"} == 0', + alert: `HWLAB${profile.toUpperCase()}MetricsTargetDown`, + expr: `up{namespace="${namespace}"} == 0`, for: "5m", - labels: { severity: "warning", lane: "v02" }, - annotations: { summary: "HWLAB v0.2 metrics target is down", description: "Prometheus cannot scrape a HWLAB v0.2 metrics target." } + labels: { severity: "warning", lane: profile }, + annotations: { summary: `HWLAB ${versionNameForRuntimeLane(profile)} metrics target is down`, description: `Prometheus cannot scrape a HWLAB ${versionNameForRuntimeLane(profile)} metrics target.` } }, { - alert: "HWLABV02ServiceHealthProbeFailed", - expr: 'hwlab_service_health_probe_success{namespace="hwlab-v02"} == 0', + alert: `HWLAB${profile.toUpperCase()}ServiceHealthProbeFailed`, + expr: `hwlab_service_health_probe_success{namespace="${namespace}"} == 0`, for: "5m", - labels: { severity: "warning", lane: "v02" }, + labels: { severity: "warning", lane: profile }, annotations: { summary: "HWLAB v0.2 service health probe failed", description: "The metrics sidecar cannot reach the service health endpoint inside the pod network." } } ] @@ -1920,8 +1951,8 @@ if [ -s /workspace/source/dev-artifacts.json ]; then node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write fi gitops_render_started_ms="$(ci_now_ms)" -node scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images -node scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check +node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images +node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check ci_timing_emit gitops-render succeeded "$gitops_render_started_ms" check_source_head before-push workdir="$(mktemp -d)" @@ -2087,8 +2118,8 @@ revision="$(git rev-parse HEAD)" : "\${LANE:?LANE is required}" : "\${CATALOG_PATH:?CATALOG_PATH is required}" : "\${IMAGE_TAG_MODE:?IMAGE_TAG_MODE is required}" -node scripts/g14-gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" -node scripts/g14-gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" --check +node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" +node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" --check node <<'NODE' const fs = require("node:fs"); const https = require("node:https"); @@ -2357,20 +2388,21 @@ NODE function ciLaneSettings(argsOrLane = "g14") { const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane; - if (lane === "v02") { + if (isRuntimeLane(lane)) { + const namespace = namespaceNameForProfile(lane); return { - lane: "v02", - profile: "v02", - gitopsTarget: "v02", - pipelineName: "hwlab-v02-ci-image-publish", - pipelineRunPrefix: "hwlab-v02-ci-poll", - serviceAccountName: "hwlab-v02-tekton-runner", - pollerName: "hwlab-v02-branch-poller", - controlPlaneReconcilerName: "hwlab-v02-control-plane-reconciler", - tektonDir: "tekton-v02", - catalogPath: "deploy/artifact-catalog.v02.json", - imageTagMode: "full", - runtimeNamespace: "hwlab-v02" + lane, + profile: lane, + gitopsTarget: lane, + pipelineName: `${namespace}-ci-image-publish`, + pipelineRunPrefix: `${namespace}-ci-poll`, + serviceAccountName: `${namespace}-tekton-runner`, + pollerName: `${namespace}-branch-poller`, + controlPlaneReconcilerName: `${namespace}-control-plane-reconciler`, + tektonDir: `tekton-${lane}`, + catalogPath: typeof argsOrLane === "object" ? argsOrLane.catalogPath : `deploy/artifact-catalog.${lane}.json`, + imageTagMode: typeof argsOrLane === "object" ? argsOrLane.imageTagMode : "full", + runtimeNamespace: namespace }; } return { @@ -2391,7 +2423,10 @@ function ciLaneSettings(argsOrLane = "g14") { function tektonRbac(args = { lane: "g14" }) { const settings = ciLaneSettings(args); - if (settings.lane === "v02") { + if (isRuntimeLane(settings.lane)) { + const runtimeObserverName = `${settings.runtimeNamespace}-runtime-observer`; + const pipelineRunWriterName = `${settings.runtimeNamespace}-pipelinerun-writer`; + const argoReconcilerName = `${settings.runtimeNamespace}-argocd-reconciler`; return { apiVersion: "v1", kind: "List", @@ -2401,7 +2436,7 @@ function tektonRbac(args = { lane: "g14" }) { { apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", - metadata: { name: "hwlab-v02-runtime-observer", namespace: "hwlab-v02" }, + metadata: { name: runtimeObserverName, namespace: settings.runtimeNamespace }, rules: [ { apiGroups: [""], resources: ["pods", "services", "configmaps"], verbs: ["get", "list", "watch"] }, { apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] }, @@ -2411,14 +2446,14 @@ function tektonRbac(args = { lane: "g14" }) { { apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", - metadata: { name: "hwlab-v02-runtime-observer", namespace: "hwlab-v02" }, + metadata: { name: runtimeObserverName, namespace: settings.runtimeNamespace }, subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-runtime-observer" } + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: runtimeObserverName } }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", - metadata: { name: "hwlab-v02-pipelinerun-writer", namespace: "hwlab-ci" }, + metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" }, rules: [ { apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "create"] }, { apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "patch", "create"] }, @@ -2430,14 +2465,14 @@ function tektonRbac(args = { lane: "g14" }) { { apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", - metadata: { name: "hwlab-v02-pipelinerun-writer", namespace: "hwlab-ci" }, + metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" }, subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-pipelinerun-writer" } + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: pipelineRunWriterName } }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", - metadata: { name: "hwlab-v02-argocd-reconciler", namespace: "argocd" }, + metadata: { name: argoReconcilerName, namespace: "argocd" }, rules: [ { apiGroups: ["argoproj.io"], resources: ["applications", "appprojects"], verbs: ["get", "patch", "create"] }, { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] } @@ -2446,9 +2481,9 @@ function tektonRbac(args = { lane: "g14" }) { { apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", - metadata: { name: "hwlab-v02-argocd-reconciler", namespace: "argocd" }, + metadata: { name: argoReconcilerName, namespace: "argocd" }, subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], - roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-argocd-reconciler" } + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: argoReconcilerName } } ] }; @@ -4004,15 +4039,16 @@ function contentType(filePath) { } function argoProject(args = { lane: "g14" }) { - if (args.lane === "v02") { + if (isRuntimeLane(args.lane)) { + const namespace = namespaceNameForProfile(args.lane); return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", - metadata: { name: "hwlab-v02", namespace: "argocd" }, + metadata: { name: namespace, namespace: "argocd" }, spec: { - description: "HWLAB v0.2 additive GitOps project on G14; DEV/PROD remain outside this lane.", + description: `HWLAB ${versionNameForRuntimeLane(args.lane)} additive GitOps project on G14; DEV/PROD remain outside this lane.`, sourceRepos: [args.gitReadUrl], - destinations: [{ server: "https://kubernetes.default.svc", namespace: "hwlab-v02" }], + destinations: [{ server: "https://kubernetes.default.svc", namespace }], clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], namespaceResourceWhitelist: [{ group: "*", kind: "*" }] } @@ -4039,8 +4075,8 @@ function argoApplication(args, profile = "dev") { const namespace = namespaceNameForProfile(profile); const runtimePath = runtimePathForProfile(profile); const gitopsTarget = gitopsTargetForProfile(profile); - const project = profile === "v02" ? "hwlab-v02" : "hwlab-g14"; - const repoURL = profile === "v02" ? args.gitReadUrl : args.sourceRepo; + const project = isRuntimeLane(profile) ? namespace : "hwlab-g14"; + const repoURL = isRuntimeLane(profile) ? args.gitReadUrl : args.sourceRepo; return { apiVersion: "argoproj.io/v1alpha1", kind: "Application", @@ -4053,7 +4089,7 @@ function argoApplication(args, profile = "dev") { project, source: { repoURL, targetRevision: args.gitopsBranch, path: `deploy/gitops/g14/${runtimePath}` }, destination: { server: "https://kubernetes.default.svc", namespace }, - syncPolicy: { automated: { prune: profile === "v02", selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } + syncPolicy: { automated: { prune: isRuntimeLane(profile), selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } } }; } @@ -4061,11 +4097,11 @@ function argoApplication(args, profile = "dev") { function g14FrpcManifest({ profile = "dev", source = null } = {}) { const namespace = namespaceNameForProfile(profile); const profileLabel = runtimeLabelForProfile(profile); - const deploymentName = profile === "v02" ? "hwlab-v02-frpc" : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"; + const deploymentName = isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"; const configName = `${deploymentName}-config`; - const proxyPrefix = profile === "v02" ? "hwlab-v02" : profile === "prod" ? "hwlab-g14-prod" : "hwlab-g14"; - const webRemotePort = profile === "v02" ? 19666 : profile === "prod" ? 18666 : 17666; - const edgeRemotePort = profile === "v02" ? 19667 : profile === "prod" ? 18667 : 17667; + const proxyPrefix = isRuntimeLane(profile) ? `hwlab-${profile}` : profile === "prod" ? "hwlab-g14-prod" : "hwlab-g14"; + const webRemotePort = isRuntimeLane(profile) ? defaultPortForRuntimeLane(profile) : profile === "prod" ? 18666 : 17666; + const edgeRemotePort = isRuntimeLane(profile) ? webRemotePort + 1 : profile === "prod" ? 18667 : 17667; const labels = { "app.kubernetes.io/name": deploymentName, "app.kubernetes.io/part-of": "hwlab", @@ -4075,7 +4111,7 @@ function g14FrpcManifest({ profile = "dev", source = null } = {}) { }; const sourceCommitLabel = source?.full ? { "hwlab.pikastech.local/source-commit": source.full } : {}; const renderedLabels = { ...labels, ...sourceCommitLabel }; - const podNetwork = profile === "v02" ? { + const podNetwork = isRuntimeLane(profile) ? { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" } : {}; @@ -4589,24 +4625,25 @@ function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, ca }; } -function v02PostgresManifest({ migrationSql, source }) { - const namespace = "hwlab-v02"; - const name = "hwlab-v02-postgres"; +function v02PostgresManifest({ profile = "v02", migrationSql, source }) { + const namespace = namespaceNameForProfile(profile); + const name = `${namespace}-postgres`; + const dbName = `hwlab_${profile}`; const labels = { "app.kubernetes.io/name": name, "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": "v02", - "hwlab.pikastech.local/gitops-target": "v02", - "hwlab.pikastech.local/profile": "v02", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, "hwlab.pikastech.local/source-commit": source.full }; const migrationSha256 = createHash("sha256").update(migrationSql).digest("hex"); const templateLabels = { "app.kubernetes.io/name": name, "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/environment": "v02", - "hwlab.pikastech.local/gitops-target": "v02", - "hwlab.pikastech.local/profile": "v02", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, "hwlab.pikastech.local/migration-sha256": k8sLabelHash(migrationSha256) }; const templateAnnotations = { "hwlab.pikastech.local/migration-sha256": migrationSha256 }; @@ -4642,9 +4679,9 @@ function v02PostgresManifest({ migrationSql, source }) { image: "127.0.0.1:5000/hwlab/postgres:16-alpine", imagePullPolicy: "IfNotPresent", env: [ - { name: "POSTGRES_DB", value: "hwlab_v02" }, - { name: "POSTGRES_USER", value: "hwlab_v02" }, - { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name: "hwlab-v02-postgres", key: "POSTGRES_PASSWORD" } } } + { name: "POSTGRES_DB", value: dbName }, + { name: "POSTGRES_USER", value: dbName }, + { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name, key: "POSTGRES_PASSWORD" } } } ], ports: [{ name: "postgres", containerPort: 5432 }], readinessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 5, periodSeconds: 10 }, @@ -4667,17 +4704,17 @@ function v02PostgresManifest({ migrationSql, source }) { }; } -function v02OpenFgaManifest({ source }) { - const namespace = "hwlab-v02"; +function v02OpenFgaManifest({ profile = "v02", source }) { + const namespace = namespaceNameForProfile(profile); const name = "hwlab-openfga"; const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0"; const labels = { "app.kubernetes.io/name": name, "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/component": "authorization", - "hwlab.pikastech.local/environment": "v02", - "hwlab.pikastech.local/gitops-target": "v02", - "hwlab.pikastech.local/profile": "v02", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, "hwlab.pikastech.local/service-id": name, "hwlab.pikastech.local/source-commit": source.full }; @@ -4685,9 +4722,9 @@ function v02OpenFgaManifest({ source }) { const selector = { "app.kubernetes.io/name": name }; const env = [ { name: "OPENFGA_DATASTORE_ENGINE", value: "postgres" }, - { name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: "hwlab-v02-openfga", key: "datastore-uri" } } }, + { name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "datastore-uri" } } }, { name: "OPENFGA_AUTHN_METHOD", value: "preshared" }, - { name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: "hwlab-v02-openfga", key: "authn-preshared-key" } } }, + { name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "authn-preshared-key" } } }, { name: "OPENFGA_PLAYGROUND_ENABLED", value: "false" }, { name: "OPENFGA_HTTP_ADDR", value: "0.0.0.0:8080" }, { name: "OPENFGA_GRPC_ADDR", value: "0.0.0.0:8081" } @@ -4815,17 +4852,26 @@ async function plannedFiles(args) { readJson("deploy/k8s/dev/health-contract.yaml"), readJson("deploy/k8s/base/workloads.yaml") ]); + args = applyRuntimeLaneDeployConfig(args, deploy, { + defaultBranch, + defaultGitopsBranch, + defaultCatalogPath, + defaultRuntimeEndpoint, + defaultWebEndpoint, + defaultV02RuntimeEndpoint, + defaultSourceRepo + }); const source = await resolveSourceRevision(args.sourceRevision); source.imageTag = imageTagForSource(args, source); let artifactCatalog = await readJsonIfPresent(args.catalogPath, null); - if (!artifactCatalog && args.lane === "v02") artifactCatalog = artifactCatalogSkeleton({ args, deploy, source }); + 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 migrationSql = args.lane === "v02" + const migrationSql = isRuntimeLane(args.lane) ? await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8") : null; - const metricsSidecarScript = args.lane === "v02" + 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; @@ -4840,7 +4886,7 @@ async function plannedFiles(args) { })); const sourceDescriptor = { - kind: args.lane === "v02" ? "hwlab-v02-gitops-source" : "hwlab-g14-gitops-source", + kind: isRuntimeLane(args.lane) ? `hwlab-${args.lane}-gitops-source` : "hwlab-g14-gitops-source", sourceCommit: source.full, sourceShortCommit: source.short, sourceImageTag: source.imageTag, @@ -4853,7 +4899,7 @@ async function plannedFiles(args) { registryPrefix: args.registryPrefix, runtimePaths: profiles.map((profile) => `deploy/gitops/g14/${runtimePathForProfile(profile)}`), publicEndpoints, - frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${profile === "v02" ? "hwlab-v02-frpc" : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"}`])), + frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"}`])), tektonPipeline: `hwlab-ci/${settings.pipelineName}`, argoApplications: profiles.map((profile) => `argocd/${argoApplicationName(profile)}`) }; @@ -4867,10 +4913,10 @@ async function plannedFiles(args) { putJson("source.json", sourceDescriptor); putText("README.md", readme({ source, args })); putJson("registry/registry.yaml", registryManifest()); - if (args.lane === "v02") putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest()); + if (isRuntimeLane(args.lane)) putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest(args)); putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args)); putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy)); - if (args.lane !== "v02") { + if (!isRuntimeLane(args.lane)) { putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args, deploy)); putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args)); } @@ -4890,14 +4936,14 @@ async function plannedFiles(args) { const endpoints = profileEndpoint(args, profile); putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent })); putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace); - if (profile !== "v02") putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations)); + 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 })); putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile)); putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); - if (profile === "v02") putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ migrationSql, source })); - if (profile === "v02") putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ source })); - if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, namespace, labels: profileLabels, annotations, metricsSidecarScript })); + if (isRuntimeLane(profile)) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSql, 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 (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile, source })); } @@ -4913,8 +4959,8 @@ async function writeFiles(files) { } async function cleanStaleGeneratedOutput(args) { - if (args.lane !== "v02") return; - for (const relativePath of ["runtime-v02", "tekton-v02"]) { + 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 }); } } diff --git a/scripts/g14-gitops-render.test.ts b/scripts/g14-gitops-render.test.ts index cc664eac..d7b9a5a6 100644 --- a/scripts/g14-gitops-render.test.ts +++ b/scripts/g14-gitops-render.test.ts @@ -90,6 +90,7 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots await writeFile(staleTektonFile, "hwlab-router\n"); const render = spawnSync(process.execPath, [ + "scripts/run-bun.mjs", "scripts/g14-gitops-render.mjs", "--lane", "v02", "--catalog-path", "deploy/artifact-catalog.v02.json", diff --git a/scripts/hwlab-lane-expand.mjs b/scripts/hwlab-lane-expand.mjs new file mode 100644 index 00000000..7af9047c --- /dev/null +++ b/scripts/hwlab-lane-expand.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { readDeployConfig, writeDeployConfig } from "./src/deploy-config.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultDeployPath = "deploy/deploy.yaml"; +const defaultHost = "74.48.78.17"; + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + process.stdout.write(`${usage()}\n`); + process.exit(0); +} + +const deploy = await readDeployConfig(repoRoot, args.deployConfig); +const result = await configureLane(deploy, args); + +if (args.write) { + await writeDeployConfig(repoRoot, args.deployConfig, deploy); + for (const file of result.copiedFiles) { + await mkdir(path.dirname(path.join(repoRoot, file.to)), { recursive: true }); + await writeFile(path.join(repoRoot, file.to), file.content, "utf8"); + } +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + command: "hwlab-lane-expand", + action: args.write ? "configured" : "planned", + deployConfig: args.deployConfig, + lane: result.lane, + fromLane: result.fromLane, + wrote: args.write, + copiedFiles: result.copiedFiles.map((file) => ({ from: file.from, to: file.to })), + config: laneSummary(result.config), + next: { + render: `node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane ${result.lane} --source-branch ${result.config.sourceBranch} --gitops-branch ${result.config.gitopsBranch} --catalog-path ${result.config.artifactCatalog} --image-tag-mode ${result.config.imageTagMode ?? "full"}`, + controlPlane: `bun scripts/cli.ts hwlab g14 control-plane apply --lane ${result.lane} --confirm`, + trigger: `bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane ${result.lane} --confirm` + } +}, null, 2)}\n`); + +function parseArgs(argv) { + const parsed = { + action: "configure", + lane: null, + fromLane: "v02", + deployConfig: defaultDeployPath, + host: defaultHost, + write: false, + force: false, + copyLaneFiles: true, + help: false, + webPort: null, + apiPort: null, + publicUrl: null + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "configure" || arg === "plan") parsed.action = arg; + else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg); + else if (arg === "--from") parsed.fromLane = readOption(argv, ++index, arg); + else if (arg === "--deploy-config") parsed.deployConfig = readOption(argv, ++index, arg); + else if (arg === "--host") parsed.host = readOption(argv, ++index, arg); + else if (arg === "--web-port") parsed.webPort = parsePort(readOption(argv, ++index, arg), arg); + else if (arg === "--api-port") parsed.apiPort = parsePort(readOption(argv, ++index, arg), arg); + else if (arg === "--public-url") parsed.publicUrl = readOption(argv, ++index, arg).replace(/\/+$/u, ""); + else if (arg === "--write") parsed.write = true; + else if (arg === "--force") parsed.force = true; + else if (arg === "--no-copy-lane-files") parsed.copyLaneFiles = false; + else if (arg === "--help" || arg === "-h") parsed.help = true; + else throw new Error(`unknown argument ${arg}`); + } + if (!parsed.help) { + assertLane(parsed.lane, "--lane"); + assertLane(parsed.fromLane, "--from"); + assert.notEqual(parsed.lane, parsed.fromLane, "--lane must differ from --from"); + } + return parsed; +} + +function readOption(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function parsePort(value, name) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error(`${name} must be a TCP port`); + return parsed; +} + +function usage() { + return [ + "usage: node scripts/hwlab-lane-expand.mjs configure --lane v03 [--from v02] [--write]", + "", + "Create or preview a new HWLAB additive runtime lane by editing deploy/deploy.yaml only.", + "The command derives branch, namespace, GitOps branch, artifact catalog, runtime path, FRP ports,", + "and lane-specific SecretRef names from the target lane id so v04/v05/v06 do not require code edits.", + "", + "options:", + " --lane LANE target lane id such as v03, v04, v05", + " --from LANE source lane template; default: v02", + " --deploy-config PATH default: deploy/deploy.yaml", + " --host HOST public host for default legacy endpoints; default: 74.48.78.17", + " --web-port PORT override derived web FRP port", + " --api-port PORT override derived API FRP port", + " --public-url URL override derived HTTPS public endpoint", + " --write write deploy config and copied lane prompt files", + " --force replace an existing target lane", + " --no-copy-lane-files do not copy lane-scoped source files referenced by config" + ].join("\n"); +} + +async function configureLane(deploy, options) { + deploy.lanes ??= {}; + const source = deploy.lanes[options.fromLane]; + assert.ok(source && typeof source === "object", `deploy.lanes.${options.fromLane} is required`); + if (deploy.lanes[options.lane] && !options.force) { + throw new Error(`deploy.lanes.${options.lane} already exists; pass --force to replace it`); + } + + const sourceInfo = laneInfo(options.fromLane, { host: options.host }); + const targetInfo = laneInfo(options.lane, { + host: options.host, + webPort: options.webPort, + apiPort: options.apiPort, + publicUrl: options.publicUrl + }); + const replacements = laneReplacements(source, sourceInfo, targetInfo); + const target = deepTransform(clone(source), replacements); + + target.name = targetInfo.versionName; + target.sourceBranch = targetInfo.sourceBranch; + target.gitopsBranch = targetInfo.gitopsBranch; + target.namespace = targetInfo.namespace; + target.endpoint = targetInfo.publicUrl; + target.publicEndpoints = { + ...(target.publicEndpoints ?? {}), + frontend: targetInfo.publicUrl, + api: targetInfo.publicUrl, + legacyFrontend: `http://${options.host}:${targetInfo.webPort}`, + legacyApi: `http://${options.host}:${targetInfo.apiPort}` + }; + target.artifactCatalog = `deploy/artifact-catalog.${options.lane}.json`; + target.runtimePath = `deploy/gitops/g14/runtime-${options.lane}`; + target.imageTagMode = target.imageTagMode ?? "full"; + + deploy.lanes[options.lane] = target; + const copiedFiles = options.copyLaneFiles ? await copiedLaneFiles(target, replacements, sourceInfo, targetInfo) : []; + return { lane: options.lane, fromLane: options.fromLane, config: target, copiedFiles }; +} + +function assertLane(value, label) { + if (!/^v[0-9]{2,}$/u.test(String(value ?? ""))) throw new Error(`${label} must look like v03, v04, v05`); +} + +function laneInfo(lane, options = {}) { + assertLane(lane, "lane"); + const minor = Number.parseInt(lane.slice(1), 10); + const webPort = options.webPort ?? 19466 + minor * 100; + const apiPort = options.apiPort ?? webPort + 1; + const versionName = `v0.${minor}`; + const host = options.host ?? defaultHost; + const hostLabel = host.replaceAll(".", "-"); + return { + lane, + minor, + host, + versionName, + sourceBranch: versionName, + gitopsBranch: `${versionName}-gitops`, + namespace: `hwlab-${lane}`, + webPort, + apiPort, + publicUrl: options.publicUrl ?? `https://hwlab-${lane}.${hostLabel}.nip.io` + }; +} + +function laneReplacements(sourceConfig, sourceInfo, targetInfo) { + const sourceLegacy = sourceConfig?.publicEndpoints ?? {}; + return [ + [sourceInfo.versionName, targetInfo.versionName], + [sourceInfo.sourceBranch, targetInfo.sourceBranch], + [sourceInfo.gitopsBranch, targetInfo.gitopsBranch], + [sourceInfo.namespace, targetInfo.namespace], + [sourceInfo.lane, targetInfo.lane], + [`runtime-${sourceInfo.lane}`, `runtime-${targetInfo.lane}`], + [`artifact-catalog.${sourceInfo.lane}.json`, `artifact-catalog.${targetInfo.lane}.json`], + [`usr_${sourceInfo.lane}_admin`, `usr_${targetInfo.lane}_admin`], + [`${sourceInfo.lane}-redacted-presence-only`, `${targetInfo.lane}-redacted-presence-only`], + [String(sourceLegacy.legacyFrontend ?? `http://${sourceInfo.host}:${sourceInfo.webPort}`), `http://${targetInfo.host}:${targetInfo.webPort}`], + [String(sourceLegacy.legacyApi ?? `http://${sourceInfo.host}:${sourceInfo.apiPort}`), `http://${targetInfo.host}:${targetInfo.apiPort}`], + [String(sourceLegacy.frontend ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl], + [String(sourceLegacy.api ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl] + ].filter(([from]) => from); +} + +function deepTransform(value, replacements) { + if (typeof value === "string") return replaceAll(value, replacements); + if (Array.isArray(value)) return value.map((item) => deepTransform(item, replacements)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [replaceAll(key, replacements), deepTransform(item, replacements)])); + } + return value; +} + +function replaceAll(value, replacements) { + let text = String(value); + for (const [from, to] of replacements) text = text.split(from).join(to); + return text; +} + +async function copiedLaneFiles(target, replacements, sourceInfo, targetInfo) { + const paths = new Set(); + collectRepoPaths(target, paths); + const copied = []; + for (const targetPath of paths) { + if (!targetPath.includes(targetInfo.lane) && !targetPath.includes(targetInfo.versionName)) continue; + const sourcePath = targetPath + .split(targetInfo.lane).join(sourceInfo.lane) + .split(targetInfo.versionName).join(sourceInfo.versionName); + const sourceAbs = path.join(repoRoot, sourcePath); + const targetAbs = path.join(repoRoot, targetPath); + if (sourceAbs === targetAbs) continue; + let raw; + try { + raw = await readFile(sourceAbs, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw error; + } + try { + await readFile(targetAbs, "utf8"); + continue; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + copied.push({ from: sourcePath, to: targetPath, content: replaceAll(raw, replacements) }); + } + return copied; +} + +function collectRepoPaths(value, result) { + if (typeof value === "string") { + if (/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/u.test(value) && !value.startsWith("http")) result.add(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectRepoPaths(item, result); + return; + } + if (value && typeof value === "object") { + for (const item of Object.values(value)) collectRepoPaths(item, result); + } +} + +function laneSummary(config) { + return { + name: config.name, + sourceBranch: config.sourceBranch, + gitopsBranch: config.gitopsBranch, + namespace: config.namespace, + artifactCatalog: config.artifactCatalog, + runtimePath: config.runtimePath, + publicEndpoints: config.publicEndpoints, + serviceCount: Array.isArray(config.envReuseServices) ? config.envReuseServices.length : null + }; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 939ea86d..af0513f9 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -40,7 +40,9 @@ export const DEFAULT_DOCS_ONLY_PATHS = Object.freeze([ export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([ "deploy/gitops/", "deploy/frp/", - "scripts/g14-gitops-render.mjs" + "scripts/g14-gitops-render.mjs", + "scripts/src/g14-gitops-lane.ts", + "tsconfig.gitops.json" ]); export async function createG14CiPlan(options = {}) { diff --git a/scripts/src/g14-gitops-lane.ts b/scripts/src/g14-gitops-lane.ts new file mode 100644 index 00000000..e40865da --- /dev/null +++ b/scripts/src/g14-gitops-lane.ts @@ -0,0 +1,120 @@ +type RuntimeLaneDefaults = { + defaultBranch?: string; + defaultGitopsBranch?: string; + defaultCatalogPath?: string; + defaultRuntimeEndpoint?: string; + defaultWebEndpoint?: string; + defaultV02RuntimeEndpoint?: string; + defaultSourceRepo?: string; +}; + +export type GitOpsRenderArgs = { + lane: string; + sourceBranch: string; + gitopsBranch: string; + catalogPath: string; + imageTagMode: string; + runtimeEndpoint: string; + webEndpoint: string; + sourceRepo: string; + [key: string]: unknown; +}; + +export type RuntimeLaneConfig = { + sourceBranch?: string; + gitopsBranch?: string; + artifactCatalog?: string; + imageTagMode?: string; + endpoint?: string; + publicEndpoints?: { + frontend?: string; + api?: string; + [key: string]: string | undefined; + }; + sourceRepo?: string; +}; + +export type DeployConfig = { + lanes?: Record; +}; + +const defaultPublicHost = "74.48.78.17"; + +export function isRuntimeLane(lane: unknown): lane is string { + return /^v[0-9]{2,}$/u.test(String(lane ?? "")); +} + +export function runtimeLaneMinor(lane: string): number { + invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + return Number.parseInt(lane.slice(1), 10); +} + +export function versionNameForRuntimeLane(lane: string): string { + return `v0.${runtimeLaneMinor(lane)}`; +} + +export function namespaceNameForRuntimeLane(lane: string): string { + invariant(isRuntimeLane(lane), `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}`); + return `runtime-${lane}`; +} + +export function defaultPortForRuntimeLane(lane: string): number { + return 19466 + runtimeLaneMinor(lane) * 100; +} + +export function publicHostLabel(host = defaultPublicHost): string { + return host.replaceAll(".", "-"); +} + +export function defaultEndpointForRuntimeLane(lane: string, options: { host?: string; v02Endpoint?: string } = {}): string { + if (lane === "v02" && options.v02Endpoint) return options.v02Endpoint; + return `https://hwlab-${lane}.${publicHostLabel(options.host ?? defaultPublicHost)}.nip.io`; +} + +export function runtimeLaneConfig(deploy: DeployConfig, lane: string): RuntimeLaneConfig { + invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`); + const config = deploy.lanes?.[lane]; + invariant(isObject(config), `deploy.lanes.${lane} is required`); + return config; +} + +export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): GitOpsRenderArgs { + if (!isRuntimeLane(args.lane)) return args; + const result: GitOpsRenderArgs = { ...args }; + const laneConfig = runtimeLaneConfig(deploy, args.lane); + const versionName = versionNameForRuntimeLane(args.lane); + const defaultEndpointOptions = defaults.defaultV02RuntimeEndpoint ? { v02Endpoint: defaults.defaultV02RuntimeEndpoint } : {}; + const defaultEndpoint = defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions); + const configuredEndpoint = laneConfig.endpoint ?? defaultEndpoint; + const configuredWebEndpoint = laneConfig.publicEndpoints?.frontend ?? configuredEndpoint; + const configuredRuntimeEndpoint = laneConfig.publicEndpoints?.api ?? configuredEndpoint; + const defaultBranch = defaults.defaultBranch ?? "G14"; + const defaultGitopsBranch = defaults.defaultGitopsBranch ?? "G14-gitops"; + const defaultCatalogPath = defaults.defaultCatalogPath ?? "deploy/artifact-catalog.dev.json"; + const defaultRuntimeEndpoint = defaults.defaultRuntimeEndpoint ?? "http://74.48.78.17:17667"; + const defaultWebEndpoint = defaults.defaultWebEndpoint ?? "http://74.48.78.17:17666"; + const defaultSourceRepo = defaults.defaultSourceRepo ?? "git@github.com:pikasTech/HWLAB.git"; + const defaultCatalog = `deploy/artifact-catalog.${args.lane}.json`; + + 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.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.sourceRepo === defaultSourceRepo && laneConfig.sourceRepo) result.sourceRepo = laneConfig.sourceRepo; + return result; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} diff --git a/tsconfig.gitops.json b/tsconfig.gitops.json new file mode 100644 index 00000000..6510f0f7 --- /dev/null +++ b/tsconfig.gitops.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "declaration": false, + "exactOptionalPropertyTypes": true, + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "target": "ES2022", + "useUnknownInCatchVariables": true + }, + "include": [ + "scripts/src/g14-gitops-lane.ts" + ] +}