From 2a1e3f1e9f84bddaea6d9a3b017757543a2a8cb6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 12 Jun 2026 12:39:50 +0000 Subject: [PATCH] feat(hwlab): add d601 yaml argo tools image controls --- .agents/skills/unidesk-cicd/SKILL.md | 6 +- config/hwlab-node-control-plane.yaml | 101 +++- docs/reference/cli.md | 2 + scripts/src/help.ts | 6 +- scripts/src/hwlab-node-control-plane.ts | 763 +++++++++++++++++++++++- scripts/src/hwlab-node.ts | 4 + 6 files changed, 865 insertions(+), 17 deletions(-) diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 137d24b3..95847a3b 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -84,9 +84,13 @@ bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03 bun scripts/cli.ts hwlab nodes control-plane infra status --node D601 --lane v03 bun scripts/cli.ts hwlab nodes control-plane infra apply --node D601 --lane v03 --dry-run bun scripts/cli.ts hwlab nodes control-plane infra apply --node D601 --lane v03 --confirm +bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node D601 --lane v03 +bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node D601 --lane v03 --confirm +bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03 +bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --confirm ``` -从 `config/hwlab-node-control-plane.yaml` 渲染 D601 HWLAB v03 的节点本地 CI/CD、git-mirror、Tekton 和 Argo 占位前置对象。confirmed apply 只做 control-plane bootstrap,不触发 runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。node-local registry 镜像只能作为 tools image 输出 artifact;输入 base image 必须是 YAML 中声明的公开 registry 来源,缺失 output image 时通过 `status.next.blockers` 暴露。 +从 `config/hwlab-node-control-plane.yaml` 渲染 D601 HWLAB v03 的节点本地 CI/CD、git-mirror、Tekton 和 Argo 前置对象。confirmed apply 只做 control-plane bootstrap,不触发 runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。node-local registry 镜像只能作为 tools image 输出 artifact;输入 base image 必须是 YAML 中声明的公开 registry 来源,缺失 output image 时通过 `status.next.blockers` 暴露。D601 Argo CD 安装也必须由 YAML 声明:官方 manifest URL、版本、镜像 rewrite/preload、CRD、期望 workload 和 AppProject/Application 都来自 YAML,不能使用手工 kubectl/argo CLI 作为正式安装路径。 --- diff --git a/config/hwlab-node-control-plane.yaml b/config/hwlab-node-control-plane.yaml index 802a6b5c..4c359290 100644 --- a/config/hwlab-node-control-plane.yaml +++ b/config/hwlab-node-control-plane.yaml @@ -18,6 +18,32 @@ nodes: kubeRoute: D601:k3s registry: endpoint: 127.0.0.1:5000 + egressProxy: + mode: k8s-service-cluster-ip + namespace: platform-infra + serviceName: sub2api-egress-proxy + port: 10808 + noProxy: + - localhost + - 127.0.0.1 + - ::1 + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - kubernetes + - kubernetes.default + - kubernetes.default.svc + - 10.0.0.0/8 + - 10.42.0.0/16 + - 10.43.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 82.156.23.220 + - 74.48.78.17 + - hyueapi.com + - .hyueapi.com targets: - id: d601-v03 @@ -54,9 +80,46 @@ targets: output: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 sourceKind: dockerfile context: . - dockerfile: deploy/ci/hwlab-ci-node-tools.Dockerfile + dockerfileInline: + filename: hwlab-ci-node-tools.public.Dockerfile + lines: + - ARG NODE_IMAGE=docker.io/library/node:22-bookworm-slim + - ARG BUN_IMAGE=docker.io/oven/bun:1.3.13 + - ARG TOOLS_BASE_IMAGE=docker.io/buildpack-deps:bookworm-scm + - ARG PYTHON_IMAGE=docker.io/library/python:3.12-slim-bookworm + - ARG DOCKER_CLI_IMAGE=docker.io/docker:29-cli + - FROM ${BUN_IMAGE} AS bun-runtime + - FROM ${NODE_IMAGE} AS node-runtime + - FROM ${PYTHON_IMAGE} AS python-runtime + - FROM ${DOCKER_CLI_IMAGE} AS docker-cli-runtime + - FROM ${TOOLS_BASE_IMAGE} + - ARG HTTP_PROXY + - ARG HTTPS_PROXY + - ARG ALL_PROXY + - ARG NO_PROXY + - ARG http_proxy + - ARG https_proxy + - ARG all_proxy + - ARG no_proxy + - COPY --from=node-runtime /usr/local /usr/local + - COPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun + - COPY --from=python-runtime /usr/local /usr/local + - COPY --from=docker-cli-runtime /usr/local/bin/docker /usr/local/bin/docker + - RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx + - RUN node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V + buildArgs: + NODE_IMAGE: docker.io/library/node:22-bookworm-slim + BUN_IMAGE: docker.io/oven/bun:1.3.13 + TOOLS_BASE_IMAGE: docker.io/buildpack-deps:bookworm-scm + PYTHON_IMAGE: docker.io/library/python:3.12-slim-bookworm + DOCKER_CLI_IMAGE: docker.io/docker:29-cli + buildNetwork: host publicBaseImages: - - docker.io/library/node:22-alpine + - docker.io/library/node:22-bookworm-slim + - docker.io/oven/bun:1.3.13 + - docker.io/buildpack-deps:bookworm-scm + - docker.io/library/python:3.12-slim-bookworm + - docker.io/docker:29-cli buildOwner: D601 buildMode: node-local argo: @@ -64,3 +127,37 @@ targets: projectName: hwlab-d601 applicationName: hwlab-d601-v03 applicationFile: application-d601-v03.yaml + install: + enabled: true + sourceKind: url + version: v3.4.2 + manifestUrl: https://raw.githubusercontent.com/argoproj/argo-cd/v3.4.2/manifests/install.yaml + fieldManager: unidesk-hwlab-node-argocd + imagePullPolicy: IfNotPresent + preloadImages: + - 127.0.0.1:5000/hwlab/argocd:v3.4.2 + - 127.0.0.1:5000/hwlab/dex:v2.45.0 + - 127.0.0.1:5000/hwlab/redis:8.2.3-alpine + imageRewrites: + - source: quay.io/argoproj/argocd:v3.4.2 + pullImage: quay.m.daocloud.io/argoproj/argocd:v3.4.2 + target: 127.0.0.1:5000/hwlab/argocd:v3.4.2 + - source: ghcr.io/dexidp/dex:v2.45.0 + pullImage: ghcr.m.daocloud.io/dexidp/dex:v2.45.0 + target: 127.0.0.1:5000/hwlab/dex:v2.45.0 + - source: public.ecr.aws/docker/library/redis:8.2.3-alpine + pullImage: docker.m.daocloud.io/library/redis:8.2.3-alpine + target: 127.0.0.1:5000/hwlab/redis:8.2.3-alpine + requiredCrds: + - applications.argoproj.io + - appprojects.argoproj.io + expectedDeployments: + - argocd-applicationset-controller + - argocd-dex-server + - argocd-notifications-controller + - argocd-redis + - argocd-repo-server + - argocd-server + expectedStatefulSets: + - argocd-application-controller + readinessTimeoutSeconds: 600 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9eb56da6..0aa4ed74 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -22,6 +22,8 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 `hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。 +`hwlab nodes control-plane infra tools-image status|build|logs --node D601 --lane v03` 是 D601 tools image 的受控入口。Dockerfile 必须由 `config/hwlab-node-control-plane.yaml` 的 `tekton.toolsImage.dockerfileInline` 声明,输入镜像必须列在 `publicBaseImages`,构建参数和网络模式也来自 YAML;confirmed build 只在 D601 后台异步构建并推送到 node-local registry,返回 status/logs 轮询命令。`hwlab nodes control-plane infra argo status|apply|logs --node D601 --lane v03` 是 D601 Argo CD 的声明式安装入口。Argo 版本、官方 manifest URL、镜像 rewrite/preload、field manager、imagePullPolicy、CRD 列表、期望 Deployment/StatefulSet 以及生成的 AppProject/Application 都必须来自同一个 YAML;`argo apply --confirm` 只执行可重复 server-side apply 和后台轮询,不把原生 `kubectl apply`、手工 Argo CLI 或临时 manifest 作为正式安装路径。 + ## Command Model - `help` 输出命令索引,适合作为交互式入口。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 8511ceb3..355b6a0f 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -56,7 +56,7 @@ export function rootHelp(): unknown { { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." }, - { command: "hwlab nodes control-plane|git-mirror|secret --node G14 --lane v03", description: "Manage HWLAB node/lane runtime prerequisites for v0.3+ with the node identity passed as data instead of a command family." }, + { command: "hwlab nodes control-plane|git-mirror|secret --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap and G14 v0.3+ runtime lanes, with the node identity passed as data." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, { command: "platform-infra sub2api plan|apply|status|validate|codex-pool", description: "Deploy Sub2API in G14 platform-infra, manage the YAML-controlled Codex upstream pool, expose the unified API, and inspect marker sentinel state with low-noise reports without printing API keys." }, @@ -624,11 +624,13 @@ function hwlabNodeHelpSummary(): unknown { usage: [ "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", ], - description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD and git-mirror prerequisites for D601 v03 while keeping cross-node work semi-automatic.", + description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic.", }; } diff --git a/scripts/src/hwlab-node-control-plane.ts b/scripts/src/hwlab-node-control-plane.ts index 29b93214..72b8571d 100644 --- a/scripts/src/hwlab-node-control-plane.ts +++ b/scripts/src/hwlab-node-control-plane.ts @@ -6,6 +6,8 @@ import { runCommand, type CommandResult } from "./command"; export const HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH = "config/hwlab-node-control-plane.yaml"; type InfraAction = "plan" | "status" | "apply"; +type ToolsImageAction = "status" | "build" | "logs"; +type ArgoAction = "status" | "apply" | "logs"; interface InfraOptions { action: InfraAction; @@ -16,11 +18,51 @@ interface InfraOptions { timeoutSeconds: number; } +interface ToolsImageOptions { + action: ToolsImageAction; + node: string; + lane: string; + dryRun: boolean; + confirm: boolean; + timeoutSeconds: number; + tailLines: number; +} + +interface ArgoOptions { + action: ArgoAction; + node: string; + lane: string; + dryRun: boolean; + confirm: boolean; + timeoutSeconds: number; + tailLines: number; +} + +interface ControlPlaneEgressProxySpec { + mode: "k8s-service-cluster-ip"; + namespace: string; + serviceName: string; + port: number; + noProxy: readonly string[]; +} + interface ControlPlaneNodeSpec { id: string; route: string; kubeRoute: string; registry: { endpoint: string }; + egressProxy: ControlPlaneEgressProxySpec | null; +} + +interface DockerfileInlineSpec { + filename: string; + lines: readonly string[]; +} + +interface ImageRewriteSpec { + source: string; + pullImage: string; + target: string; } interface ControlPlaneTargetSpec { @@ -56,7 +98,10 @@ interface ControlPlaneTargetSpec { sourceKind: "dockerfile" | "docker-compose"; context: string; dockerfile?: string; + dockerfileInline?: DockerfileInlineSpec; composeFile?: string; + buildArgs: Readonly>; + buildNetwork: string | null; publicBaseImages: readonly string[]; buildOwner: string; buildMode: string; @@ -67,6 +112,20 @@ interface ControlPlaneTargetSpec { projectName: string; applicationName: string; applicationFile: string; + install: { + enabled: boolean; + sourceKind: "url"; + version: string; + manifestUrl: string; + fieldManager: string; + imagePullPolicy: "Always" | "IfNotPresent" | "Never"; + preloadImages: readonly string[]; + imageRewrites: readonly ImageRewriteSpec[]; + requiredCrds: readonly string[]; + expectedDeployments: readonly string[]; + expectedStatefulSets: readonly string[]; + readinessTimeoutSeconds: number; + }; }; } @@ -87,19 +146,34 @@ interface ControlPlaneConfig { } export function runHwlabNodeControlPlaneInfra(args: string[]): Record { + if (args[0] === "tools-image") { + const options = parseToolsImageOptions(args.slice(1)); + const { config, node, target } = controlPlaneContext(options.node, options.lane); + return runToolsImageCommand(config, node, target, options); + } + if (args[0] === "argo") { + const options = parseArgoOptions(args.slice(1)); + const { config, node, target } = controlPlaneContext(options.node, options.lane); + return runArgoCommand(config, node, target, options); + } const options = parseInfraOptions(args); - const config = readControlPlaneConfig(); - const node = config.nodes[options.node]; - if (node === undefined) throw new Error(`unknown node ${options.node}; known nodes: ${Object.keys(config.nodes).join(", ")}`); - const target = config.targets.find((item) => item.node === options.node && item.lane === options.lane); - if (target === undefined) throw new Error(`no control-plane target for node=${options.node} lane=${options.lane} in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`); - if (!target.enabled) throw new Error(`control-plane target ${target.id} is disabled in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`); + const { config, node, target } = controlPlaneContext(options.node, options.lane); if (options.action === "plan") return infraPlan(config, node, target, options); if (options.action === "status") return infraStatus(config, node, target, options); return infraApply(config, node, target, options); } +function controlPlaneContext(nodeId: string, lane: string): { config: ControlPlaneConfig; node: ControlPlaneNodeSpec; target: ControlPlaneTargetSpec } { + const config = readControlPlaneConfig(); + const node = config.nodes[nodeId]; + if (node === undefined) throw new Error(`unknown node ${nodeId}; known nodes: ${Object.keys(config.nodes).join(", ")}`); + const target = config.targets.find((item) => item.node === nodeId && item.lane === lane); + if (target === undefined) throw new Error(`no control-plane target for node=${nodeId} lane=${lane} in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`); + if (!target.enabled) throw new Error(`control-plane target ${target.id} is disabled in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`); + return { config, node, target }; +} + export function hwlabNodeControlPlaneInfraHelp(): Record { return { ok: true, @@ -111,6 +185,14 @@ export function hwlabNodeControlPlaneInfraHelp(): Record { "bun scripts/cli.ts hwlab nodes control-plane infra status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra apply --node D601 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane infra apply --node D601 --lane v03 --confirm", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node D601 --lane v03 --confirm", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --confirm", + "bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node D601 --lane v03", ], g14Consistency: "D601 target fields mirror the existing G14 runtime lane control-plane vocabulary: source branch, gitops branch/path, Pipeline, PipelineRun prefix, ServiceAccount, Argo Application, and git-mirror read/write/sync/flush status concepts.", }; @@ -138,6 +220,8 @@ function infraPlan(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, targ status: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`, dryRun: `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --dry-run`, apply: `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --confirm`, + toolsImageBuild: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node ${node.id} --lane ${target.lane} --confirm`, + argoApply: `bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node ${node.id} --lane ${target.lane} --confirm`, }, options: { timeoutSeconds: options.timeoutSeconds }, }; @@ -150,6 +234,7 @@ function infraStatus(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, ta const status = typeof parsed === "object" && parsed !== null ? parsed as Record : { parseError: "remote status did not return a JSON object", stdoutPreview: result.stdout.slice(0, 1000) }; const components = record(status.components); const argo = record(components.argo); + const argoInstall = record(argo.install); const gitMirror = record(components.gitMirror); const tekton = record(components.tekton); const ciNamespace = record(components.ciNamespace); @@ -164,7 +249,11 @@ function infraStatus(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, ta && boolField(registry, "ready") && boolField(registry, "toolsImageReady") && boolField(argo, "installed") - && boolField(argo, "applicationExists"); + && boolField(argo, "projectExists") + && boolField(argo, "applicationExists") + && boolField(argoInstall, "crdsReady") + && boolField(argoInstall, "deploymentsReady") + && boolField(argoInstall, "statefulSetsReady"); return { ok, command: "hwlab nodes control-plane infra status", @@ -186,7 +275,11 @@ function infraStatus(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, ta gitMirrorReadReady: boolField(gitMirror, "readDeploymentReady"), gitMirrorWriteReady: boolField(gitMirror, "writeDeploymentReady"), argoInstalled: boolField(argo, "installed"), + argoProjectExists: boolField(argo, "projectExists"), argoApplicationExists: boolField(argo, "applicationExists"), + argoCrdsReady: boolField(argoInstall, "crdsReady"), + argoDeploymentsReady: boolField(argoInstall, "deploymentsReady"), + argoStatefulSetsReady: boolField(argoInstall, "statefulSetsReady"), registryReady: boolField(registry, "ready"), toolsImageReady: boolField(registry, "toolsImageReady"), }, @@ -249,6 +342,189 @@ function infraApply(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, tar }; } +function runToolsImageCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record { + if (options.action === "status") return toolsImageCommandStatus(node, target, options); + if (options.action === "logs") return remoteJobLogs(node, target, "tools-image", options); + return toolsImageBuild(node, target, options); +} + +function runArgoCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ArgoOptions): Record { + if (options.action === "status") return argoCommandStatus(node, target, options); + if (options.action === "logs") return remoteJobLogs(node, target, "argo", options); + return argoApply(node, target, options); +} + +function toolsImageCommandStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record { + const registry = toolsImageStatus(node, target, options.timeoutSeconds); + const jobResult = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, "tools-image", options.tailLines), options.timeoutSeconds); + const jobStatus = parseRemoteJson(jobResult.stdout); + const ok = registry.registryReady && registry.toolsImageReady; + return { + ok, + command: "hwlab nodes control-plane infra tools-image status", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mutation: false, + image: target.tekton.toolsImage.output, + imageSource: target.tekton.toolsImage, + registry, + job: typeof jobStatus === "object" && jobStatus !== null ? jobStatus : { parseError: "remote job status did not return JSON", stdoutPreview: jobResult.stdout.slice(0, 1000) }, + result: compactCommandResult(jobResult), + next: ok + ? { infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}` } + : { build: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node ${node.id} --lane ${target.lane} --confirm`, logs: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node ${node.id} --lane ${target.lane}` }, + }; +} + +function toolsImageBuild(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record { + if (options.confirm && options.dryRun) throw new Error("tools-image build accepts only one of --dry-run or --confirm"); + const dryRun = options.dryRun || !options.confirm; + const dockerfile = toolsImageDockerfile(target); + const buildPlan = { + outputImage: target.tekton.toolsImage.output, + sourceKind: target.tekton.toolsImage.sourceKind, + dockerfileInline: target.tekton.toolsImage.dockerfileInline, + buildArgs: target.tekton.toolsImage.buildArgs, + buildNetwork: target.tekton.toolsImage.buildNetwork, + publicBaseImages: target.tekton.toolsImage.publicBaseImages, + nodeLocalRegistryOutputOnly: true, + egressProxy: node.egressProxy, + stateDir: remoteJobStateDir(target, "tools-image"), + }; + if (dryRun) { + return { + ok: true, + command: "hwlab nodes control-plane infra tools-image build", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "dry-run", + mutation: false, + buildPlan, + dockerfile: { bytes: Buffer.byteLength(dockerfile), sha256: sha256Short(dockerfile), preview: dockerfile }, + next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node ${node.id} --lane ${target.lane} --confirm` }, + }; + } + const result = runTransK3s(node.kubeRoute, toolsImageBuildStartScript(node, target, dockerfile), options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + return { + ok: result.exitCode === 0, + command: "hwlab nodes control-plane infra tools-image build", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "confirmed-start", + mutation: result.exitCode === 0, + buildPlan, + start: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: compactCommandResult(result), + next: { + status: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node ${node.id} --lane ${target.lane}`, + logs: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node ${node.id} --lane ${target.lane}`, + infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`, + }, + }; +} + +function argoCommandStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ArgoOptions): Record { + const result = runTransK3s(node.kubeRoute, statusScript(target, node.registry.endpoint, target.tekton.toolsImage.output), options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + const status = typeof parsed === "object" && parsed !== null ? parsed as Record : {}; + const argo = record(record(status.components).argo); + const argoInstall = record(argo.install); + const jobResult = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, "argo", options.tailLines), options.timeoutSeconds); + const jobStatus = parseRemoteJson(jobResult.stdout); + const ok = boolField(argo, "installed") + && boolField(argo, "projectExists") + && boolField(argo, "applicationExists") + && boolField(argoInstall, "crdsReady") + && boolField(argoInstall, "deploymentsReady") + && boolField(argoInstall, "statefulSetsReady"); + return { + ok, + command: "hwlab nodes control-plane infra argo status", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mutation: false, + expected: { + namespace: target.argo.namespace, + project: target.argo.projectName, + application: target.argo.applicationName, + install: target.argo.install, + }, + readiness: { + installed: boolField(argo, "installed"), + projectExists: boolField(argo, "projectExists"), + applicationExists: boolField(argo, "applicationExists"), + crdsReady: boolField(argoInstall, "crdsReady"), + deploymentsReady: boolField(argoInstall, "deploymentsReady"), + statefulSetsReady: boolField(argoInstall, "statefulSetsReady"), + }, + argo, + job: typeof jobStatus === "object" && jobStatus !== null ? jobStatus : { parseError: "remote job status did not return JSON", stdoutPreview: jobResult.stdout.slice(0, 1000) }, + result: { k3s: compactCommandResult(result), job: compactCommandResult(jobResult) }, + next: ok + ? { infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}` } + : { apply: `bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node ${node.id} --lane ${target.lane} --confirm`, logs: `bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node ${node.id} --lane ${target.lane}` }, + }; +} + +function argoApply(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ArgoOptions): Record { + if (options.confirm && options.dryRun) throw new Error("argo apply accepts only one of --dry-run or --confirm"); + if (!target.argo.install.enabled) throw new Error(`targets.${target.id}.argo.install.enabled=false`); + const dryRun = options.dryRun || !options.confirm; + const desired = argoDesiredManifest(target); + const desiredYaml = `${desired.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; + const applyPlan = { + namespace: target.argo.namespace, + version: target.argo.install.version, + manifestUrl: target.argo.install.manifestUrl, + fieldManager: target.argo.install.fieldManager, + imageRewrites: target.argo.install.imageRewrites, + preloadImages: target.argo.install.preloadImages, + requiredCrds: target.argo.install.requiredCrds, + desired: manifestObjectSummary(desired), + desiredSha256: sha256Short(desiredYaml), + stateDir: remoteJobStateDir(target, "argo"), + egressProxy: node.egressProxy, + }; + if (dryRun) { + return { + ok: true, + command: "hwlab nodes control-plane infra argo apply", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "dry-run", + mutation: false, + applyPlan, + desiredYaml: { objects: desired.length, bytes: Buffer.byteLength(desiredYaml), sha256: sha256Short(desiredYaml), preview: desiredYaml }, + next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node ${node.id} --lane ${target.lane} --confirm` }, + }; + } + const result = runTransK3s(node.kubeRoute, argoApplyStartScript(node, target, desiredYaml), options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + return { + ok: result.exitCode === 0, + command: "hwlab nodes control-plane infra argo apply", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "confirmed-start", + mutation: result.exitCode === 0, + applyPlan, + start: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: compactCommandResult(result), + next: { + status: `bun scripts/cli.ts hwlab nodes control-plane infra argo status --node ${node.id} --lane ${target.lane}`, + logs: `bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node ${node.id} --lane ${target.lane}`, + infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`, + }, + }; +} + function parseInfraOptions(args: string[]): InfraOptions { const [actionRaw] = args; if (actionRaw === undefined || actionRaw === "--help" || actionRaw === "-h" || actionRaw === "help") throw new Error("infra usage: infra plan|status|apply --node NODE --lane vNN [--dry-run|--confirm]"); @@ -268,6 +544,42 @@ function parseInfraOptions(args: string[]): InfraOptions { }; } +function parseToolsImageOptions(args: string[]): ToolsImageOptions { + const [actionRaw] = args; + if (actionRaw === undefined || actionRaw === "--help" || actionRaw === "-h" || actionRaw === "help") throw new Error("infra tools-image usage: tools-image status|build|logs --node NODE --lane vNN [--dry-run|--confirm]"); + if (actionRaw !== "status" && actionRaw !== "build" && actionRaw !== "logs") throw new Error(`unsupported tools-image action ${actionRaw}; expected status|build|logs`); + const confirm = args.includes("--confirm"); + const explicitDryRun = args.includes("--dry-run"); + if (confirm && explicitDryRun) throw new Error("tools-image accepts only one of --confirm or --dry-run"); + return { + action: actionRaw, + node: requiredOption(args, "--node"), + lane: requiredOption(args, "--lane"), + confirm, + dryRun: actionRaw === "build" ? explicitDryRun || !confirm : true, + timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 60, 60), + tailLines: positiveIntegerOption(args, "--tail-lines", 120, 1000), + }; +} + +function parseArgoOptions(args: string[]): ArgoOptions { + const [actionRaw] = args; + if (actionRaw === undefined || actionRaw === "--help" || actionRaw === "-h" || actionRaw === "help") throw new Error("infra argo usage: argo status|apply|logs --node NODE --lane vNN [--dry-run|--confirm]"); + if (actionRaw !== "status" && actionRaw !== "apply" && actionRaw !== "logs") throw new Error(`unsupported argo action ${actionRaw}; expected status|apply|logs`); + const confirm = args.includes("--confirm"); + const explicitDryRun = args.includes("--dry-run"); + if (confirm && explicitDryRun) throw new Error("argo accepts only one of --confirm or --dry-run"); + return { + action: actionRaw, + node: requiredOption(args, "--node"), + lane: requiredOption(args, "--lane"), + confirm, + dryRun: actionRaw === "apply" ? explicitDryRun || !confirm : true, + timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 60, 60), + tailLines: positiveIntegerOption(args, "--tail-lines", 120, 1000), + }; +} + function readControlPlaneConfig(): ControlPlaneConfig { const parsed = asRecord(Bun.YAML.parse(readFileSync(rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH), "utf8")) as unknown, HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH); const version = numberField(parsed, "version", HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH); @@ -316,21 +628,77 @@ function toolsImageSpec(raw: Record, path: string): ControlPlan if (publicBaseImages.length === 0) throw new Error(`${path}.publicBaseImages must list at least one public base image`); for (const image of publicBaseImages) validatePublicBaseImage(image, `${path}.publicBaseImages`); const dockerfile = optionalStringField(raw, "dockerfile", path); + const dockerfileInline = raw.dockerfileInline === undefined ? undefined : dockerfileInlineSpec(asRecord(raw.dockerfileInline, `${path}.dockerfileInline`), `${path}.dockerfileInline`); const composeFile = optionalStringField(raw, "composeFile", path); - if (sourceKind === "dockerfile" && dockerfile === undefined) throw new Error(`${path}.dockerfile is required when sourceKind=dockerfile`); + if (sourceKind === "dockerfile" && dockerfile === undefined && dockerfileInline === undefined) throw new Error(`${path}.dockerfile or ${path}.dockerfileInline is required when sourceKind=dockerfile`); + if (dockerfile !== undefined && dockerfileInline !== undefined) throw new Error(`${path} must use only one of dockerfile or dockerfileInline`); if (sourceKind === "docker-compose" && composeFile === undefined) throw new Error(`${path}.composeFile is required when sourceKind=docker-compose`); + const buildArgsRaw = raw.buildArgs === undefined ? {} : asRecord(raw.buildArgs, `${path}.buildArgs`); + const buildArgs = stringRecordField(buildArgsRaw, `${path}.buildArgs`); + for (const image of Object.values(buildArgs)) { + if (looksLikeImageReference(image)) validatePublicBaseImage(image, `${path}.buildArgs`); + } return { output: stringField(raw, "output", path), sourceKind, context: stringField(raw, "context", path), dockerfile, + dockerfileInline, composeFile, + buildArgs, + buildNetwork: optionalStringField(raw, "buildNetwork", path) ?? null, publicBaseImages, buildOwner: stringField(raw, "buildOwner", path), buildMode: stringField(raw, "buildMode", path), }; } +function dockerfileInlineSpec(raw: Record, path: string): DockerfileInlineSpec { + const filename = stringField(raw, "filename", path); + if (!/^[A-Za-z0-9._/-]+$/u.test(filename) || filename.includes("..")) throw new Error(`${path}.filename has an unsupported format`); + const lines = stringArrayField(raw, "lines", path); + if (lines.length === 0) throw new Error(`${path}.lines must not be empty`); + return { filename, lines }; +} + +function argoInstallSpec(raw: Record, path: string): ControlPlaneTargetSpec["argo"]["install"] { + const sourceKind = stringField(raw, "sourceKind", path); + if (sourceKind !== "url") throw new Error(`${path}.sourceKind must be url`); + const imagePullPolicy = optionalStringField(raw, "imagePullPolicy", path) ?? "IfNotPresent"; + if (imagePullPolicy !== "Always" && imagePullPolicy !== "IfNotPresent" && imagePullPolicy !== "Never") throw new Error(`${path}.imagePullPolicy must be Always, IfNotPresent, or Never`); + const imageRewritesRaw = raw.imageRewrites === undefined ? [] : raw.imageRewrites; + if (!Array.isArray(imageRewritesRaw)) throw new Error(`${path}.imageRewrites must be an array`); + const imageRewrites = imageRewritesRaw.map((item, index) => imageRewriteSpec(asRecord(item, `${path}.imageRewrites[${index}]`), `${path}.imageRewrites[${index}]`)); + const manifestUrl = stringField(raw, "manifestUrl", path); + validateHttpsUrl(manifestUrl, `${path}.manifestUrl`); + return { + enabled: booleanField(raw, "enabled", path), + sourceKind, + version: stringField(raw, "version", path), + manifestUrl, + fieldManager: stringField(raw, "fieldManager", path), + imagePullPolicy, + preloadImages: stringArrayField(raw, "preloadImages", path), + imageRewrites, + requiredCrds: stringArrayField(raw, "requiredCrds", path), + expectedDeployments: stringArrayField(raw, "expectedDeployments", path), + expectedStatefulSets: stringArrayField(raw, "expectedStatefulSets", path), + readinessTimeoutSeconds: positiveConfigIntegerField(raw, "readinessTimeoutSeconds", path), + }; +} + +function imageRewriteSpec(raw: Record, path: string): ImageRewriteSpec { + const rewrite = { + source: stringField(raw, "source", path), + pullImage: stringField(raw, "pullImage", path), + target: stringField(raw, "target", path), + }; + validatePublicBaseImage(rewrite.source, `${path}.source`); + validatePublicBaseImage(rewrite.pullImage, `${path}.pullImage`); + if (!isNodeLocalImage(rewrite.target)) throw new Error(`${path}.target must use a node-local registry output image`); + return rewrite; +} + function validateTargetImagePolicy(target: ControlPlaneTargetSpec, imagePolicy: ControlPlaneImagePolicy): void { if (imagePolicy.requireReproducibleBuildSource && !imagePolicy.requiredSourceKinds.includes(target.tekton.toolsImage.sourceKind)) { throw new Error(`targets.${target.id}.tekton.toolsImage.sourceKind is not allowed by imagePolicy.requiredSourceKinds`); @@ -347,21 +715,40 @@ function validatePublicBaseImage(image: string, path: string): void { if (isNodeLocalImage(image)) { throw new Error(`${path} contains non-public base image ${image}`); } - const publicPrefixes = ["docker.io/", "registry.k8s.io/", "ghcr.io/", "quay.io/", "gcr.io/", "public.ecr.aws/", "mcr.microsoft.com/", "cgr.dev/"]; + const publicPrefixes = ["docker.io/", "registry.k8s.io/", "ghcr.io/", "quay.io/", "gcr.io/", "public.ecr.aws/", "mcr.microsoft.com/", "cgr.dev/", "docker.m.daocloud.io/", "quay.m.daocloud.io/", "ghcr.m.daocloud.io/"]; if (!publicPrefixes.some((prefix) => image.startsWith(prefix))) throw new Error(`${path} image ${image} must use an explicit public registry prefix`); } +function looksLikeImageReference(value: string): boolean { + return /^(docker\.io|registry\.k8s\.io|ghcr\.io|quay\.io|gcr\.io|public\.ecr\.aws|mcr\.microsoft\.com|cgr\.dev|docker\.m\.daocloud\.io|quay\.m\.daocloud\.io|ghcr\.m\.daocloud\.io)\//u.test(value) + || /^(127\.|localhost(?::|\/)|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/u.test(value); +} + function isNodeLocalImage(image: string): boolean { return /^(127\.|localhost(?::|\/)|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/u.test(image); } function nodeSpec(id: string, raw: Record): ControlPlaneNodeSpec { const registry = asRecord(raw.registry, `nodes.${id}.registry`); + const egressProxy = raw.egressProxy === undefined ? null : egressProxySpec(asRecord(raw.egressProxy, `nodes.${id}.egressProxy`), `nodes.${id}.egressProxy`); return { id, route: stringField(raw, "route", `nodes.${id}`), kubeRoute: stringField(raw, "kubeRoute", `nodes.${id}`), registry: { endpoint: stringField(registry, "endpoint", `nodes.${id}.registry`) }, + egressProxy, + }; +} + +function egressProxySpec(raw: Record, path: string): ControlPlaneEgressProxySpec { + const mode = stringField(raw, "mode", path); + if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`); + return { + mode, + namespace: stringField(raw, "namespace", path), + serviceName: stringField(raw, "serviceName", path), + port: positiveConfigIntegerField(raw, "port", path), + noProxy: stringArrayField(raw, "noProxy", path), }; } @@ -414,6 +801,7 @@ function targetSpec(raw: Record, index: number): ControlPlaneTa projectName: stringField(argo, "projectName", `${path}.argo`), applicationName: stringField(argo, "applicationName", `${path}.argo`), applicationFile: stringField(argo, "applicationFile", `${path}.argo`), + install: argoInstallSpec(asRecord(argo.install, `${path}.argo.install`), `${path}.argo.install`), }, }; } @@ -471,7 +859,8 @@ function renderInfraManifest(_node: ControlPlaneNodeSpec, target: ControlPlaneTa kind: "ConfigMap", metadata: { name: `${target.argo.applicationName}-desired`, namespace: target.argo.namespace, labels }, data: { - "application.yaml": Bun.YAML.stringify(argoApplicationSkeleton(target)), + "project.yaml": Bun.YAML.stringify(argoProjectSkeleton(target)), + [target.argo.applicationFile]: Bun.YAML.stringify(argoApplicationSkeleton(target)), "note.txt": "Argo CD CRDs/controller are installed by the node control-plane bootstrap path when available; this ConfigMap preserves the desired Application until Argo is ready.\n", }, }, @@ -507,6 +896,24 @@ function gitMirrorDeployment(name: string, namespace: string, labels: Record[] { + return [argoProjectSkeleton(target), argoApplicationSkeleton(target)]; +} + +function argoProjectSkeleton(target: ControlPlaneTargetSpec): Record { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "AppProject", + metadata: { name: target.argo.projectName, namespace: target.argo.namespace }, + spec: { + sourceRepos: [target.gitMirror.readUrl], + destinations: [{ server: "https://kubernetes.default.svc", namespace: target.runtimeNamespace }], + clusterResourceWhitelist: [{ group: "*", kind: "*" }], + namespaceResourceWhitelist: [{ group: "*", kind: "*" }], + }, + }; +} + function argoApplicationSkeleton(target: ControlPlaneTargetSpec): Record { return { apiVersion: "argoproj.io/v1alpha1", @@ -531,6 +938,7 @@ function planSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec) ciNamespace: target.ciNamespace, runtimeNamespace: target.runtimeNamespace, registry: node.registry.endpoint, + egressProxy: node.egressProxy, sourceBranch: target.source.branch, gitopsBranch: target.gitops.branch, gitopsPath: target.gitops.path, @@ -542,6 +950,12 @@ function planSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec) serviceAccount: target.tekton.serviceAccountName, toolsImage: target.tekton.toolsImage, argoApplication: target.argo.applicationName, + argoInstall: { + enabled: target.argo.install.enabled, + version: target.argo.install.version, + manifestUrl: target.argo.install.manifestUrl, + imageRewrites: target.argo.install.imageRewrites, + }, }; } @@ -570,16 +984,30 @@ function expectedSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetS toolsImage: target.tekton.toolsImage, argoNamespace: target.argo.namespace, argoApplication: target.argo.applicationName, + argoInstall: { + enabled: target.argo.install.enabled, + sourceKind: target.argo.install.sourceKind, + version: target.argo.install.version, + manifestUrl: target.argo.install.manifestUrl, + preloadImages: target.argo.install.preloadImages, + imageRewrites: target.argo.install.imageRewrites, + requiredCrds: target.argo.install.requiredCrds, + expectedDeployments: target.argo.install.expectedDeployments, + expectedStatefulSets: target.argo.install.expectedStatefulSets, + }, registry: node.registry.endpoint, imagePolicy: { noPrivateInputImages: true, - buildInput: { sourceKind: target.tekton.toolsImage.sourceKind, context: target.tekton.toolsImage.context, dockerfile: target.tekton.toolsImage.dockerfile ?? null, composeFile: target.tekton.toolsImage.composeFile ?? null, publicBaseImages: target.tekton.toolsImage.publicBaseImages }, + buildInput: { sourceKind: target.tekton.toolsImage.sourceKind, context: target.tekton.toolsImage.context, dockerfile: target.tekton.toolsImage.dockerfile ?? null, dockerfileInline: target.tekton.toolsImage.dockerfileInline ?? null, composeFile: target.tekton.toolsImage.composeFile ?? null, publicBaseImages: target.tekton.toolsImage.publicBaseImages }, outputImage: target.tekton.toolsImage.output, }, }; } function statusScript(target: ControlPlaneTargetSpec, registryEndpoint: string, toolsImage: string): string { + const requiredCrds = shellJsonArray(target.argo.install.requiredCrds); + const argoDeployments = shellJsonArray(target.argo.install.expectedDeployments); + const argoStatefulSets = shellJsonArray(target.argo.install.expectedStatefulSets); return ` set +e node=${shQuote(target.node)} @@ -595,12 +1023,17 @@ cache_pvc=${shQuote(target.gitMirror.cachePvcName)} pipeline=${shQuote(target.tekton.pipelineName)} service_account=${shQuote(target.tekton.serviceAccountName)} argo_ns=${shQuote(target.argo.namespace)} +argo_project=${shQuote(target.argo.projectName)} argo_app=${shQuote(target.argo.applicationName)} registry=${shQuote(registryEndpoint)} tools_image=${shQuote(toolsImage)} +required_crds_json=${shQuote(requiredCrds)} +argo_deployments_json=${shQuote(argoDeployments)} +argo_statefulsets_json=${shQuote(argoStatefulSets)} exists_ns() { kubectl get ns "$1" >/dev/null 2>&1 && printf true || printf false; } exists_res() { kubectl -n "$1" get "$2" "$3" >/dev/null 2>&1 && printf true || printf false; } deploy_ready() { desired=$(kubectl -n "$1" get deploy "$2" -o 'jsonpath={.spec.replicas}' 2>/dev/null || true); ready=$(kubectl -n "$1" get deploy "$2" -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true); [ -n "$desired" ] && [ "$desired" -gt 0 ] 2>/dev/null && [ "\${ready:-0}" = "$desired" ] && printf true || printf false; } +sts_ready() { desired=$(kubectl -n "$1" get statefulset "$2" -o 'jsonpath={.spec.replicas}' 2>/dev/null || true); ready=$(kubectl -n "$1" get statefulset "$2" -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true); [ -n "$desired" ] && [ "$desired" -gt 0 ] 2>/dev/null && [ "\${ready:-0}" = "$desired" ] && printf true || printf false; } endpoint_ready() { endpoints=$(kubectl -n "$1" get endpoints "$2" -o 'jsonpath={.subsets[*].addresses[*].ip}' 2>/dev/null || true); [ -n "$endpoints" ] && printf true || printf false; } registry_ready=false if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 3 "http://$registry/v2/" >/tmp/hwlab-registry.out 2>/tmp/hwlab-registry.err && registry_ready=true; fi @@ -609,8 +1042,32 @@ tools_repo=\${tools_repo_tag%:*} tools_tag=\${tools_repo_tag##*:} tools_image_ready=false if [ "$tools_repo" != "$tools_repo_tag" ] && command -v curl >/dev/null 2>&1; then curl -fsS --max-time 5 "http://$registry/v2/$tools_repo/manifests/$tools_tag" >/tmp/hwlab-tools-image.out 2>/tmp/hwlab-tools-image.err && tools_image_ready=true; fi +python3 - "$required_crds_json" "$argo_deployments_json" "$argo_statefulsets_json" <<'PY' >/tmp/hwlab-node-status-fragments.json +import json, subprocess, sys +required_crds=json.loads(sys.argv[1]) +deployments=json.loads(sys.argv[2]) +statefulsets=json.loads(sys.argv[3]) +ns="${target.argo.namespace}" +def run(args): + return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +def exists(args): + return run(args).returncode == 0 +def ready(kind, name): + data = run(["kubectl", "-n", ns, "get", kind, name, "-o", "json"]) + if data.returncode != 0: + return {"name": name, "exists": False, "ready": False, "desired": None, "readyReplicas": None} + obj=json.loads(data.stdout) + desired=int(obj.get("spec", {}).get("replicas") or 0) + ready_replicas=int(obj.get("status", {}).get("readyReplicas") or 0) + return {"name": name, "exists": True, "ready": desired > 0 and ready_replicas == desired, "desired": desired, "readyReplicas": ready_replicas} +crds=[{"name": name, "exists": exists(["kubectl", "get", "crd", name])} for name in required_crds] +deploy=[ready("deployment", name) for name in deployments] +sts=[ready("statefulset", name) for name in statefulsets] +print(json.dumps({"crds": crds, "deployments": deploy, "statefulSets": sts, "crdsReady": all(item["exists"] for item in crds), "deploymentsReady": all(item["ready"] for item in deploy) if deploy else True, "statefulSetsReady": all(item["ready"] for item in sts) if sts else True})) +PY +argo_fragment=$(cat /tmp/hwlab-node-status-fragments.json 2>/dev/null || printf '{}') cat </dev/null 2>&1 && printf true || printf false),"controllerReady":$(deploy_ready tekton-pipelines tekton-pipelines-controller),"webhookReady":$(deploy_ready tekton-pipelines tekton-pipelines-webhook)},"ciNamespace":{"name":"$ci_ns","exists":$(exists_ns "$ci_ns"),"serviceAccountExists":$(exists_res "$ci_ns" serviceaccount "$service_account"),"pipelineExists":$(exists_res "$ci_ns" pipeline "$pipeline")},"gitMirror":{"namespace":"$gitmirror_ns","namespaceExists":$(exists_ns "$gitmirror_ns"),"readDeploymentReady":$(deploy_ready "$gitmirror_ns" "$read_deploy"),"writeDeploymentReady":$(deploy_ready "$gitmirror_ns" "$write_deploy"),"readServiceExists":$(exists_res "$gitmirror_ns" service "$read_svc"),"writeServiceExists":$(exists_res "$gitmirror_ns" service "$write_svc"),"readEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$read_svc"),"writeEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$write_svc"),"cachePvcExists":$(exists_res "$gitmirror_ns" pvc "$cache_pvc"),"summary":{"localSource":null,"githubSource":null,"localGitops":null,"githubGitops":null,"pendingFlush":null,"flushNeeded":null,"githubInSync":null}},"argo":{"namespace":"$argo_ns","namespaceExists":$(exists_ns "$argo_ns"),"installed":$(kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && printf true || printf false),"applicationExists":$(kubectl -n "$argo_ns" get application "$argo_app" >/dev/null 2>&1 && printf true || printf false)},"registry":{"endpoint":"$registry","ready":$registry_ready,"toolsImage":"$tools_image","toolsImageReady":$tools_image_ready},"runtimeNamespace":{"name":"$runtime_ns","exists":$(exists_ns "$runtime_ns")}}} +{"observedAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","node":"$node","lane":"$lane","components":{"tekton":{"installed":$(kubectl get crd pipelines.tekton.dev pipelineruns.tekton.dev >/dev/null 2>&1 && printf true || printf false),"controllerReady":$(deploy_ready tekton-pipelines tekton-pipelines-controller),"webhookReady":$(deploy_ready tekton-pipelines tekton-pipelines-webhook)},"ciNamespace":{"name":"$ci_ns","exists":$(exists_ns "$ci_ns"),"serviceAccountExists":$(exists_res "$ci_ns" serviceaccount "$service_account"),"pipelineExists":$(exists_res "$ci_ns" pipeline "$pipeline")},"gitMirror":{"namespace":"$gitmirror_ns","namespaceExists":$(exists_ns "$gitmirror_ns"),"readDeploymentReady":$(deploy_ready "$gitmirror_ns" "$read_deploy"),"writeDeploymentReady":$(deploy_ready "$gitmirror_ns" "$write_deploy"),"readServiceExists":$(exists_res "$gitmirror_ns" service "$read_svc"),"writeServiceExists":$(exists_res "$gitmirror_ns" service "$write_svc"),"readEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$read_svc"),"writeEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$write_svc"),"cachePvcExists":$(exists_res "$gitmirror_ns" pvc "$cache_pvc"),"summary":{"localSource":null,"githubSource":null,"localGitops":null,"githubGitops":null,"pendingFlush":null,"flushNeeded":null,"githubInSync":null}},"argo":{"namespace":"$argo_ns","namespaceExists":$(exists_ns "$argo_ns"),"installed":$(kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && printf true || printf false),"projectExists":$(kubectl -n "$argo_ns" get appproject "$argo_project" >/dev/null 2>&1 && printf true || printf false),"applicationExists":$(kubectl -n "$argo_ns" get application "$argo_app" >/dev/null 2>&1 && printf true || printf false),"install":$argo_fragment},"registry":{"endpoint":"$registry","ready":$registry_ready,"toolsImage":"$tools_image","toolsImageReady":$tools_image_ready},"runtimeNamespace":{"name":"$runtime_ns","exists":$(exists_ns "$runtime_ns")}}} JSON `; } @@ -687,7 +1144,12 @@ function statusNext( if (!boolField(registry, "ready")) blockers.push("node-local-registry-not-ready"); if (!boolField(registry, "toolsImageReady")) blockers.push("tools-image-missing"); if (bootstrapMissing) blockers.push("control-plane-bootstrap-missing"); + const argoInstall = record(argo.install); if (!boolField(argo, "installed")) blockers.push("argocd-not-installed"); + else if (!boolField(argoInstall, "crdsReady")) blockers.push("argocd-crds-not-ready"); + else if (!boolField(argoInstall, "deploymentsReady")) blockers.push("argocd-deployments-not-ready"); + else if (!boolField(argoInstall, "statefulSetsReady")) blockers.push("argocd-statefulsets-not-ready"); + else if (!boolField(argo, "projectExists")) blockers.push("argocd-project-missing"); else if (!boolField(argo, "applicationExists")) blockers.push("argocd-application-missing"); const next: Record = { status: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`, @@ -726,6 +1188,231 @@ JSON `; } +function toolsImageDockerfile(target: ControlPlaneTargetSpec): string { + const inline = target.tekton.toolsImage.dockerfileInline; + if (inline === undefined) throw new Error(`targets.${target.id}.tekton.toolsImage.dockerfileInline is required for D601 node-local tools-image build`); + return `${inline.lines.join("\n")}\n`; +} + +function toolsImageBuildStartScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, dockerfile: string): string { + const stateDir = remoteJobStateDir(target, "tools-image"); + const dockerfileEncoded = Buffer.from(dockerfile, "utf8").toString("base64"); + const buildArgs = Object.entries(target.tekton.toolsImage.buildArgs).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]); + const proxyArgs = node.egressProxy === null + ? [] + : ["--build-arg", "HTTP_PROXY", "--build-arg", "HTTPS_PROXY", "--build-arg", "ALL_PROXY", "--build-arg", "NO_PROXY", "--build-arg", "http_proxy", "--build-arg", "https_proxy", "--build-arg", "all_proxy", "--build-arg", "no_proxy"]; + const networkArgs = target.tekton.toolsImage.buildNetwork === null ? [] : ["--network", target.tekton.toolsImage.buildNetwork]; + const dockerBuildArgs = [...networkArgs, "--pull", ...buildArgs, ...proxyArgs, "-f", "$dockerfile", "-t", "$image", "$context_dir"].join(" "); + return ` +set -eu +state_dir=${shQuote(stateDir)} +mkdir -p "$state_dir" +if [ -s "$state_dir/pid" ] && kill -0 "$(cat "$state_dir/pid")" >/dev/null 2>&1; then + printf '{"started":false,"reason":"job-already-running","pid":%s,"stateDir":"%s"}\\n' "$(cat "$state_dir/pid")" "$state_dir" + exit 0 +fi +cat >"$state_dir/job.sh" <<'JOB' +#!/bin/sh +set -eu +state_dir=${shQuote(stateDir)} +image=${shQuote(target.tekton.toolsImage.output)} +context_dir="$state_dir/context" +dockerfile="$state_dir/${target.tekton.toolsImage.dockerfileInline?.filename ?? "Dockerfile"}" +log="$state_dir/job.log" +status="$state_dir/status.json" +write_status() { + state="$1"; shift + message="$1"; shift || true + python3 - "$status" "$state" "$message" "$image" <<'PY' +import json, pathlib, sys, time +path=pathlib.Path(sys.argv[1]) +payload={"state":sys.argv[2],"message":sys.argv[3],"image":sys.argv[4],"updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} +path.write_text(json.dumps(payload, ensure_ascii=False) + "\\n") +PY +} +run_job() { + write_status running starting + rm -rf "$context_dir" + mkdir -p "$context_dir" + printf %s ${shQuote(dockerfileEncoded)} | base64 -d >"$dockerfile" +${proxyExportBlock(node)} + docker build ${dockerBuildArgs} || return "$?" + docker run --rm "$image" sh -lc 'node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V' || return "$?" + docker push "$image" || return "$?" + image_id="$(docker image inspect "$image" --format '{{.Id}}' 2>/dev/null || true)" + digest="$(docker image inspect "$image" --format '{{join .RepoDigests ","}}' 2>/dev/null || true)" + python3 - "$status" "$image" "$image_id" "$digest" <<'PY' +import json, pathlib, sys, time +path=pathlib.Path(sys.argv[1]) +path.write_text(json.dumps({"state":"succeeded","message":"image-built-and-pushed","image":sys.argv[2],"imageId":sys.argv[3] or None,"repoDigests":[item for item in sys.argv[4].split(",") if item],"updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, ensure_ascii=False) + "\\n") +PY +} +run_job >>"$log" 2>&1 || { + rc=$? + write_status failed "exit-$rc" + exit "$rc" +} +JOB +chmod +x "$state_dir/job.sh" +: >"$state_dir/job.log" +nohup "$state_dir/job.sh" >/dev/null 2>&1 & +pid=$! +printf '%s' "$pid" >"$state_dir/pid" +printf '{"started":true,"pid":%s,"stateDir":"%s","statusCommand":"bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node %s --lane %s","logsCommand":"bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node %s --lane %s"}\\n' "$pid" "$state_dir" ${shQuote(node.id)} ${shQuote(target.lane)} ${shQuote(node.id)} ${shQuote(target.lane)} +`; +} + +function argoApplyStartScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, desiredYaml: string): string { + const stateDir = remoteJobStateDir(target, "argo"); + const desiredEncoded = Buffer.from(desiredYaml, "utf8").toString("base64"); + const rewritesEncoded = Buffer.from(JSON.stringify(target.argo.install.imageRewrites), "utf8").toString("base64"); + const preloadEncoded = Buffer.from(JSON.stringify(target.argo.install.preloadImages), "utf8").toString("base64"); + return ` +set -eu +state_dir=${shQuote(stateDir)} +mkdir -p "$state_dir" +if [ -s "$state_dir/pid" ] && kill -0 "$(cat "$state_dir/pid")" >/dev/null 2>&1; then + printf '{"started":false,"reason":"job-already-running","pid":%s,"stateDir":"%s"}\\n' "$(cat "$state_dir/pid")" "$state_dir" + exit 0 +fi +cat >"$state_dir/job.sh" <<'JOB' +#!/bin/sh +set -eu +state_dir=${shQuote(stateDir)} +namespace=${shQuote(target.argo.namespace)} +manifest_url=${shQuote(target.argo.install.manifestUrl)} +field_manager=${shQuote(target.argo.install.fieldManager)} +readiness_timeout=${shQuote(String(target.argo.install.readinessTimeoutSeconds))} +log="$state_dir/job.log" +status="$state_dir/status.json" +install_yaml="$state_dir/install.yaml" +rendered_yaml="$state_dir/install.rendered.yaml" +desired_yaml="$state_dir/desired.yaml" +write_status() { + state="$1"; shift + message="$1"; shift || true + python3 - "$status" "$state" "$message" <<'PY' +import json, pathlib, sys, time +path=pathlib.Path(sys.argv[1]) +path.write_text(json.dumps({"state":sys.argv[2],"message":sys.argv[3],"updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, ensure_ascii=False) + "\\n") +PY +} +{ + write_status running starting +${proxyExportBlock(node)} + printf %s ${shQuote(desiredEncoded)} | base64 -d >"$desired_yaml" + printf %s ${shQuote(rewritesEncoded)} | base64 -d >"$state_dir/image-rewrites.json" + printf %s ${shQuote(preloadEncoded)} | base64 -d >"$state_dir/preload-images.json" + kubectl create namespace "$namespace" --dry-run=client -o yaml | kubectl apply --server-side --field-manager="$field_manager" -f - || exit "$?" + python3 - "$state_dir/preload-images.json" "$state_dir/image-rewrites.json" <<'PY' >"$state_dir/pull-images.sh" +import json, pathlib, shlex, sys +preload=json.loads(pathlib.Path(sys.argv[1]).read_text()) +rewrites=json.loads(pathlib.Path(sys.argv[2]).read_text()) +print("#!/bin/sh") +print("set -eu") +seen=set() +for item in rewrites: + pull=item["pullImage"] + target=item["target"] + if target in seen: + continue + seen.add(target) + print("docker pull " + shlex.quote(pull)) + print("docker tag " + shlex.quote(pull) + " " + shlex.quote(target)) + print("docker push " + shlex.quote(target)) +for image in preload: + if image not in seen and image.startswith("127.0.0.1:5000/"): + print("docker image inspect " + shlex.quote(image) + " >/dev/null") +PY + chmod +x "$state_dir/pull-images.sh" + "$state_dir/pull-images.sh" || exit "$?" + curl -fsSL --max-time 60 "$manifest_url" >"$install_yaml" || exit "$?" + python3 - "$install_yaml" "$state_dir/image-rewrites.json" "$rendered_yaml" ${shQuote(target.argo.install.imagePullPolicy)} <<'PY' +import json, pathlib, sys +text=pathlib.Path(sys.argv[1]).read_text() +rewrites=json.loads(pathlib.Path(sys.argv[2]).read_text()) +for item in rewrites: + text=text.replace(item["source"], item["target"]) +policy=sys.argv[4] +text=text.replace("imagePullPolicy: Always", "imagePullPolicy: " + policy) +pathlib.Path(sys.argv[3]).write_text(text) +PY + kubectl apply --server-side --field-manager="$field_manager" -n "$namespace" -f "$rendered_yaml" || exit "$?" + deadline=$(( $(date +%s) + readiness_timeout )) + while [ "$(date +%s)" -lt "$deadline" ]; do + kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && break + sleep 5 + done + kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null || exit "$?" + kubectl apply --server-side --field-manager="$field_manager" -f "$desired_yaml" || exit "$?" + write_status succeeded argocd-install-applied +} >>"$log" 2>&1 || { + rc=$? + write_status failed "exit-$rc" + exit "$rc" +} +JOB +chmod +x "$state_dir/job.sh" +: >"$state_dir/job.log" +nohup "$state_dir/job.sh" >/dev/null 2>&1 & +pid=$! +printf '%s' "$pid" >"$state_dir/pid" +printf '{"started":true,"pid":%s,"stateDir":"%s","statusCommand":"bun scripts/cli.ts hwlab nodes control-plane infra argo status --node %s --lane %s","logsCommand":"bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node %s --lane %s"}\\n' "$pid" "$state_dir" ${shQuote(node.id)} ${shQuote(target.lane)} ${shQuote(node.id)} ${shQuote(target.lane)} +`; +} + +function remoteJobStatusScript(target: ControlPlaneTargetSpec, name: "tools-image" | "argo", tailLines: number): string { + const stateDir = remoteJobStateDir(target, name); + return ` +set +e +state_dir=${shQuote(stateDir)} +status_file="$state_dir/status.json" +log_file="$state_dir/job.log" +pid_file="$state_dir/pid" +running=false +pid=null +if [ -s "$pid_file" ]; then + pid_raw="$(cat "$pid_file" 2>/dev/null || true)" + if [ -n "$pid_raw" ] && kill -0 "$pid_raw" >/dev/null 2>&1; then running=true; pid="$pid_raw"; else pid="$pid_raw"; fi +fi +python3 - "$state_dir" "$status_file" "$log_file" "$running" "$pid" ${shQuote(String(tailLines))} <<'PY' +import json, pathlib, sys +state_dir=pathlib.Path(sys.argv[1]) +status_path=pathlib.Path(sys.argv[2]) +log_path=pathlib.Path(sys.argv[3]) +running=sys.argv[4] == "true" +pid=None if sys.argv[5] in ("", "null") else sys.argv[5] +tail_lines=int(sys.argv[6]) +status=None +if status_path.exists(): + try: + status=json.loads(status_path.read_text()) + except Exception as error: + status={"parseError": str(error), "raw": status_path.read_text(errors="replace")[-1000:]} +log_tail="" +if log_path.exists(): + lines=log_path.read_text(errors="replace").splitlines() + log_tail="\\n".join(lines[-tail_lines:]) +print(json.dumps({"stateDir": str(state_dir), "pid": pid, "running": running, "status": status, "logBytes": log_path.stat().st_size if log_path.exists() else 0, "logTail": log_tail}, ensure_ascii=False)) +PY +`; +} + +function remoteJobLogs(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, name: "tools-image" | "argo", options: ToolsImageOptions | ArgoOptions): Record { + const result = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, name, options.tailLines), options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + return { + ok: result.exitCode === 0, + command: `hwlab nodes control-plane infra ${name === "tools-image" ? "tools-image" : "argo"} logs`, + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mutation: false, + job: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: compactCommandResult(result), + }; +} + function manifestObjectSummary(manifest: readonly Record[]): Record[] { return manifest.map((item) => { const metadata = record(item.metadata); @@ -737,6 +1424,32 @@ function runTransK3s(kubeRoute: string, script: string, timeoutSeconds: number): return runCommand(["/root/.local/bin/trans", kubeRoute, "script", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 }); } +function proxyExportBlock(node: ControlPlaneNodeSpec): string { + const proxy = node.egressProxy; + if (proxy === null) return " : # no egress proxy configured\n"; + const noProxy = [...new Set(["localhost", "127.0.0.1", "::1", "127.0.0.1:5000", "localhost:5000", ...proxy.noProxy])]; + return ` + proxy_ip="$(kubectl -n ${shQuote(proxy.namespace)} get svc ${shQuote(proxy.serviceName)} -o 'jsonpath={.spec.clusterIP}' 2>/dev/null || true)" + if [ -z "$proxy_ip" ]; then echo "egress proxy service missing: ${proxy.namespace}/${proxy.serviceName}" >&2; exit 41; fi + export HTTP_PROXY="http://$proxy_ip:${proxy.port}" + export HTTPS_PROXY="$HTTP_PROXY" + export ALL_PROXY="$HTTP_PROXY" + export http_proxy="$HTTP_PROXY" + export https_proxy="$HTTP_PROXY" + export all_proxy="$HTTP_PROXY" + export NO_PROXY=${shQuote(noProxy.join(","))} + export no_proxy="$NO_PROXY" +`; +} + +function remoteJobStateDir(target: ControlPlaneTargetSpec, name: "tools-image" | "argo"): string { + return `/tmp/unidesk-hwlab-node-control-plane/${target.id}/${name}`; +} + +function shellJsonArray(items: readonly string[]): string { + return JSON.stringify([...items]); +} + function parseRemoteJson(text: string): unknown { const trimmed = text.trim(); if (trimmed.length === 0) return null; @@ -778,6 +1491,12 @@ function numberField(obj: Record, key: string, path: string): n return value; } +function positiveConfigIntegerField(obj: Record, key: string, path: string): number { + const value = obj[key]; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`); + return value; +} + function nonNegativeIntegerField(obj: Record, key: string, path: string): number { const value = obj[key]; if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${path}.${key} must be a non-negative integer`); @@ -796,6 +1515,16 @@ function stringArrayField(obj: Record, key: string, path: strin return [...value] as string[]; } +function stringRecordField(obj: Record, path: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} has an unsupported key format`); + if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); + result[key] = value; + } + return result; +} + function booleanField(obj: Record, key: string, path: string): boolean { const value = obj[key]; if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`); @@ -838,6 +1567,16 @@ function shQuote(value: string): string { return `'${value.replace(/'/gu, `'"'"'`)}'`; } +function validateHttpsUrl(value: string, path: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${path} must be a valid URL`); + } + if (parsed.protocol !== "https:") throw new Error(`${path} must use https://`); +} + function sha256Short(text: string): string { return `sha256:${createHash("sha256").update(text).digest("hex")}`; } diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index b0c58836..06094af5 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -97,6 +97,10 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra apply --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node D601 --lane v03 --confirm", + "bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --confirm", "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane apply --node G14 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane refresh --node G14 --lane v03 --confirm",