From 3347cd87c18f30bf9c71ff33c0b70120e7dc0b74 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 12 Jun 2026 11:35:08 +0000 Subject: [PATCH] feat: add D601 HWLAB control-plane infra CLI --- .agents/skills/unidesk-cicd/SKILL.md | 11 + config/hwlab-node-control-plane.yaml | 66 ++ docs/reference/cli.md | 2 + scripts/src/help.ts | 7 +- scripts/src/hwlab-node-control-plane.ts | 843 ++++++++++++++++++++++++ scripts/src/hwlab-node.ts | 11 +- 6 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 config/hwlab-node-control-plane.yaml create mode 100644 scripts/src/hwlab-node-control-plane.ts diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 5a525ef8..137d24b3 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -77,6 +77,17 @@ bun scripts/cli.ts hwlab g14 control-plane apply --lane v02 [--dry-run|--confirm server-side apply v02 的 Tekton RBAC、Pipeline 和 Argo Application。 +### D601 节点本地 infra bootstrap + +```bash +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 +``` + +从 `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` 暴露。 + --- ## Git Mirror diff --git a/config/hwlab-node-control-plane.yaml b/config/hwlab-node-control-plane.yaml new file mode 100644 index 00000000..802a6b5c --- /dev/null +++ b/config/hwlab-node-control-plane.yaml @@ -0,0 +1,66 @@ +version: 1 +kind: hwlab-node-control-plane +metadata: + owner: unidesk + relatedIssues: + - 290 + - 1119 +imagePolicy: + requireReproducibleBuildSource: true + forbidPrivateOrNodeLocalImagesAsInputs: true + allowNodeLocalRegistryAsBuildOutput: true + requiredSourceKinds: + - dockerfile + - docker-compose +nodes: + D601: + route: D601 + kubeRoute: D601:k3s + registry: + endpoint: 127.0.0.1:5000 + +targets: + - id: d601-v03 + node: D601 + lane: v03 + enabled: true + ciNamespace: hwlab-ci + runtimeNamespace: hwlab-v03 + source: + repository: pikasTech/HWLAB + branch: v0.3 + gitops: + branch: v0.3-d601-gitops + path: deploy/gitops/node/d601/runtime-v03 + gitMirror: + namespace: devops-infra + serviceReadName: git-mirror-http + serviceWriteName: git-mirror-write + cachePvcName: hwlab-git-mirror-cache + cachePvcStorage: 20Gi + servicePort: 8080 + deploymentReplicas: 0 + secretName: git-mirror-github-ssh + syncConfigMapName: git-mirror-sync-script + syncJobPrefix: git-mirror-hwlab-d601-v03-sync-manual + flushJobPrefix: git-mirror-hwlab-d601-v03-flush-manual + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git + writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git + tekton: + pipelineName: hwlab-d601-v03-ci-image-publish + serviceAccountName: hwlab-d601-v03-tekton-runner + pipelineRunPrefix: hwlab-d601-v03-ci-poll + toolsImage: + 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 + publicBaseImages: + - docker.io/library/node:22-alpine + buildOwner: D601 + buildMode: node-local + argo: + namespace: argocd + projectName: hwlab-d601 + applicationName: hwlab-d601-v03 + applicationFile: application-d601-v03.yaml diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f99abb39..9eb56da6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -20,6 +20,8 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 `hwlab nodes secret status|ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider` 是 v03 Code Agent / MoonBridge provider SecretRef 的受控 bootstrap 入口;`ensure` 只从集群内既有 `hwlab-v02/hwlab-v02-code-agent-provider` 复制 `openai-api-key`、`opencode-api-key` 到 lane-local Secret,输出仅披露 source/target Secret 名、key presence、decoded byte count、mutation 和后续命令,禁止打印 base64、解码值、完整 API key 或可复用凭据。OpenFGA 和 master admin API key 继续使用同一命名空间下的 `hwlab nodes secret ... --name hwlab-v03-openfga|hwlab-v03-master-server-admin-api-key`。 +`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 当成输入基础镜像。 + ## Command Model - `help` 输出命令索引,适合作为交互式入口。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index d476b273..8511ceb3 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -622,11 +622,13 @@ function hwlabNodeHelpSummary(): unknown { command: "hwlab nodes control-plane|git-mirror|secret --node --lane ", output: "json", 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 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.", + 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.", }; } @@ -696,6 +698,9 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary()); if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary()); if (top === "platform-db") return platformDbHelp(); + if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && args[3] === "infra") { + return loadHelp(async () => (await import("./hwlab-node-control-plane")).hwlabNodeControlPlaneInfraHelp(), hwlabNodeHelpSummary()); + } if (top === "hwlab" && (sub === "node" || sub === "nodes")) return loadHelp(async () => (await import("./hwlab-node")).hwlabNodeHelp(), hwlabNodeHelpSummary()); if (top === "hwlab" && sub === "g14") return loadHelp(async () => (await import("./hwlab-g14")).hwlabG14Help(), hwlabG14HelpSummary()); if (top === "hwlab") return loadHelp(async () => (await import("./hwlab-cd")).hwlabHelp(), hwlabHelpSummary()); diff --git a/scripts/src/hwlab-node-control-plane.ts b/scripts/src/hwlab-node-control-plane.ts new file mode 100644 index 00000000..29b93214 --- /dev/null +++ b/scripts/src/hwlab-node-control-plane.ts @@ -0,0 +1,843 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { rootPath } from "./config"; +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"; + +interface InfraOptions { + action: InfraAction; + node: string; + lane: string; + dryRun: boolean; + confirm: boolean; + timeoutSeconds: number; +} + +interface ControlPlaneNodeSpec { + id: string; + route: string; + kubeRoute: string; + registry: { endpoint: string }; +} + +interface ControlPlaneTargetSpec { + id: string; + node: string; + lane: string; + enabled: boolean; + ciNamespace: string; + runtimeNamespace: string; + source: { repository: string; branch: string }; + gitops: { branch: string; path: string }; + gitMirror: { + namespace: string; + serviceReadName: string; + serviceWriteName: string; + cachePvcName: string; + cachePvcStorage: string; + servicePort: number; + deploymentReplicas: number; + secretName: string; + syncConfigMapName: string; + syncJobPrefix: string; + flushJobPrefix: string; + readUrl: string; + writeUrl: string; + }; + tekton: { + pipelineName: string; + serviceAccountName: string; + pipelineRunPrefix: string; + toolsImage: { + output: string; + sourceKind: "dockerfile" | "docker-compose"; + context: string; + dockerfile?: string; + composeFile?: string; + publicBaseImages: readonly string[]; + buildOwner: string; + buildMode: string; + }; + }; + argo: { + namespace: string; + projectName: string; + applicationName: string; + applicationFile: string; + }; +} + +interface ControlPlaneImagePolicy { + requireReproducibleBuildSource: boolean; + forbidPrivateOrNodeLocalImagesAsInputs: boolean; + allowNodeLocalRegistryAsBuildOutput: boolean; + requiredSourceKinds: readonly ("dockerfile" | "docker-compose")[]; +} + +interface ControlPlaneConfig { + version: number; + kind: string; + metadata: { owner: string; relatedIssues: readonly number[] }; + imagePolicy: ControlPlaneImagePolicy; + nodes: Record; + targets: readonly ControlPlaneTargetSpec[]; +} + +export function runHwlabNodeControlPlaneInfra(args: string[]): Record { + 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}`); + + 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); +} + +export function hwlabNodeControlPlaneInfraHelp(): Record { + return { + ok: true, + command: "hwlab nodes control-plane infra", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + description: "Plan/status/apply YAML-controlled HWLAB node-local CI/CD and git-mirror control-plane prerequisites. Cross-node PK01/Caddy/FRP/runtime rollout remains explicit semi-automatic CLI work.", + 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 apply --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes control-plane infra apply --node D601 --lane v03 --confirm", + ], + 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.", + }; +} + +function infraPlan(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: InfraOptions): Record { + return { + ok: true, + command: "hwlab nodes control-plane infra plan", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "plan", + mutation: false, + target: planSummary(node, target), + expected: expectedSummary(node, target), + imagePolicy: _config.imagePolicy, + g14Consistency: { + laneVocabulary: ["sourceBranch", "gitopsBranch", "catalogPath", "runtime.path", "runtime.namespace", "tekton.pipeline", "pipelineRunPrefix", "argo.application"], + gitMirrorStatusVocabulary: ["localSource", "githubSource", "localGitops", "githubGitops", "pendingFlush", "flushNeeded", "githubInSync"], + note: "D601 values differ only through YAML target fields; the control-plane model is intentionally aligned with G14 runtime lane semantics.", + }, + resources: manifestObjectSummary(renderInfraManifest(node, target)), + next: { + 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`, + }, + options: { timeoutSeconds: options.timeoutSeconds }, + }; +} + +function infraStatus(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: InfraOptions): Record { + const script = statusScript(target, node.registry.endpoint, target.tekton.toolsImage.output); + const result = runTransK3s(node.kubeRoute, script, options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + 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 gitMirror = record(components.gitMirror); + const tekton = record(components.tekton); + const ciNamespace = record(components.ciNamespace); + const registry = record(components.registry); + const ok = result.exitCode === 0 + && boolField(tekton, "installed") + && boolField(ciNamespace, "exists") + && boolField(gitMirror, "namespaceExists") + && boolField(gitMirror, "readServiceExists") + && boolField(gitMirror, "writeServiceExists") + && boolField(gitMirror, "cachePvcExists") + && boolField(registry, "ready") + && boolField(registry, "toolsImageReady") + && boolField(argo, "installed") + && boolField(argo, "applicationExists"); + return { + ok, + command: "hwlab nodes control-plane infra status", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "status", + mutation: false, + expected: expectedSummary(node, target), + status, + readiness: { + ok, + tektonInstalled: boolField(tekton, "installed"), + ciNamespaceExists: boolField(ciNamespace, "exists"), + gitMirrorNamespaceExists: boolField(gitMirror, "namespaceExists"), + gitMirrorReadServiceExists: boolField(gitMirror, "readServiceExists"), + gitMirrorWriteServiceExists: boolField(gitMirror, "writeServiceExists"), + gitMirrorCachePvcExists: boolField(gitMirror, "cachePvcExists"), + gitMirrorReadReady: boolField(gitMirror, "readDeploymentReady"), + gitMirrorWriteReady: boolField(gitMirror, "writeDeploymentReady"), + argoInstalled: boolField(argo, "installed"), + argoApplicationExists: boolField(argo, "applicationExists"), + registryReady: boolField(registry, "ready"), + toolsImageReady: boolField(registry, "toolsImageReady"), + }, + result: compactCommandResult(result), + next: ok ? { runtimePreparation: `bun scripts/cli.ts hwlab nodes control-plane plan --node ${node.id} --lane ${target.lane}` } : statusNext(node, target, registry, gitMirror, argo, ciNamespace), + }; +} + +function infraApply(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: InfraOptions): Record { + if (options.confirm && options.dryRun) throw new Error("infra apply accepts only one of --dry-run or --confirm"); + const dryRun = options.dryRun || !options.confirm; + const manifest = renderInfraManifest(node, target); + const yaml = `${manifest.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; + const imageStatus = toolsImageStatus(node, target, options.timeoutSeconds); + if (dryRun) { + return { + ok: true, + command: "hwlab nodes control-plane infra apply", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "dry-run", + mutation: false, + expected: expectedSummary(node, target), + preflight: { + registryReady: imageStatus.registryReady, + toolsImageReady: imageStatus.toolsImageReady, + toolsImage: target.tekton.toolsImage.output, + result: imageStatus.result, + }, + resources: manifestObjectSummary(manifest), + manifest: { objects: manifest.length, bytes: Buffer.byteLength(yaml), sha256: sha256Short(yaml) }, + note: "dry-run renders D601 node-local control-plane bootstrap resources only; it does not trigger HWLAB runtime rollout and does not touch PK01/Caddy/FRP.", + next: applyNext(node, target, imageStatus), + }; + } + const script = applyScript(yaml); + const result = runTransK3s(node.kubeRoute, script, options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + return { + ok: result.exitCode === 0, + command: "hwlab nodes control-plane infra apply", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "confirmed-apply", + mutation: result.exitCode === 0, + expected: expectedSummary(node, target), + preflight: { + registryReady: imageStatus.registryReady, + toolsImageReady: imageStatus.toolsImageReady, + toolsImage: target.tekton.toolsImage.output, + warning: imageStatus.toolsImageReady ? null : "tools-image-missing; bootstrap objects were applied but readiness still requires a controlled image build/publish stage", + result: imageStatus.result, + }, + resources: manifestObjectSummary(manifest), + apply: 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 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]"); + if (actionRaw !== "plan" && actionRaw !== "status" && actionRaw !== "apply") throw new Error(`unsupported infra action ${actionRaw}; expected plan|status|apply`); + const node = requiredOption(args, "--node"); + const lane = requiredOption(args, "--lane"); + const confirm = args.includes("--confirm"); + const explicitDryRun = args.includes("--dry-run"); + if (confirm && explicitDryRun) throw new Error("infra accepts only one of --confirm or --dry-run"); + return { + action: actionRaw, + node, + lane, + confirm, + dryRun: actionRaw === "apply" ? explicitDryRun || !confirm : true, + timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 60, 60), + }; +} + +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); + const kind = stringField(parsed, "kind", HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH); + if (kind !== "hwlab-node-control-plane") throw new Error(`${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}.kind must be hwlab-node-control-plane`); + const metadataRaw = asRecord(parsed.metadata, "metadata"); + const imagePolicy = imagePolicySpec(asRecord(parsed.imagePolicy, "imagePolicy")); + const nodes = Object.fromEntries(Object.entries(asRecord(parsed.nodes, "nodes")).map(([id, raw]) => [id, nodeSpec(id, asRecord(raw, `nodes.${id}`))])); + const targetsRaw = parsed.targets; + if (!Array.isArray(targetsRaw)) throw new Error(`${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}.targets must be an array`); + const targets = targetsRaw.map((raw, index) => targetSpec(asRecord(raw, `targets[${index}]`), index)); + for (const target of targets) { + if (nodes[target.node] === undefined) throw new Error(`targets.${target.id}.node references missing node ${target.node}`); + validateTargetImagePolicy(target, imagePolicy); + } + return { + version, + kind, + metadata: { + owner: stringField(metadataRaw, "owner", "metadata"), + relatedIssues: numberArrayField(metadataRaw, "relatedIssues", "metadata"), + }, + imagePolicy, + nodes, + targets, + }; +} + +function imagePolicySpec(raw: Record): ControlPlaneImagePolicy { + const requiredSourceKinds = stringArrayField(raw, "requiredSourceKinds", "imagePolicy").map((item) => { + if (item !== "dockerfile" && item !== "docker-compose") throw new Error("imagePolicy.requiredSourceKinds must contain only dockerfile or docker-compose"); + return item; + }); + return { + requireReproducibleBuildSource: booleanField(raw, "requireReproducibleBuildSource", "imagePolicy"), + forbidPrivateOrNodeLocalImagesAsInputs: booleanField(raw, "forbidPrivateOrNodeLocalImagesAsInputs", "imagePolicy"), + allowNodeLocalRegistryAsBuildOutput: booleanField(raw, "allowNodeLocalRegistryAsBuildOutput", "imagePolicy"), + requiredSourceKinds, + }; +} + +function toolsImageSpec(raw: Record, path: string): ControlPlaneTargetSpec["tekton"]["toolsImage"] { + const sourceKind = stringField(raw, "sourceKind", path); + if (sourceKind !== "dockerfile" && sourceKind !== "docker-compose") throw new Error(`${path}.sourceKind must be dockerfile or docker-compose`); + const publicBaseImages = stringArrayField(raw, "publicBaseImages", path); + 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 composeFile = optionalStringField(raw, "composeFile", path); + if (sourceKind === "dockerfile" && dockerfile === undefined) throw new Error(`${path}.dockerfile is required when sourceKind=dockerfile`); + if (sourceKind === "docker-compose" && composeFile === undefined) throw new Error(`${path}.composeFile is required when sourceKind=docker-compose`); + return { + output: stringField(raw, "output", path), + sourceKind, + context: stringField(raw, "context", path), + dockerfile, + composeFile, + publicBaseImages, + buildOwner: stringField(raw, "buildOwner", path), + buildMode: stringField(raw, "buildMode", path), + }; +} + +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`); + } + if (imagePolicy.forbidPrivateOrNodeLocalImagesAsInputs) { + for (const image of target.tekton.toolsImage.publicBaseImages) validatePublicBaseImage(image, `targets.${target.id}.tekton.toolsImage.publicBaseImages`); + } + if (!imagePolicy.allowNodeLocalRegistryAsBuildOutput && isNodeLocalImage(target.tekton.toolsImage.output)) { + throw new Error(`targets.${target.id}.tekton.toolsImage.output uses node-local registry but imagePolicy.allowNodeLocalRegistryAsBuildOutput=false`); + } +} + +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/"]; + if (!publicPrefixes.some((prefix) => image.startsWith(prefix))) throw new Error(`${path} image ${image} must use an explicit public registry prefix`); +} + +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`); + return { + id, + route: stringField(raw, "route", `nodes.${id}`), + kubeRoute: stringField(raw, "kubeRoute", `nodes.${id}`), + registry: { endpoint: stringField(registry, "endpoint", `nodes.${id}.registry`) }, + }; +} + +function targetSpec(raw: Record, index: number): ControlPlaneTargetSpec { + const path = `targets[${index}]`; + const source = asRecord(raw.source, `${path}.source`); + const gitops = asRecord(raw.gitops, `${path}.gitops`); + const gitMirror = asRecord(raw.gitMirror, `${path}.gitMirror`); + const tekton = asRecord(raw.tekton, `${path}.tekton`); + const argo = asRecord(raw.argo, `${path}.argo`); + const toolsImage = asRecord(tekton.toolsImage, `${path}.tekton.toolsImage`); + const node = stringField(raw, "node", path); + const lane = stringField(raw, "lane", path); + const gitMirrorNamespace = stringField(gitMirror, "namespace", `${path}.gitMirror`); + const serviceReadName = stringField(gitMirror, "serviceReadName", `${path}.gitMirror`); + const serviceWriteName = stringField(gitMirror, "serviceWriteName", `${path}.gitMirror`); + const sourceRepository = stringField(source, "repository", `${path}.source`); + return { + id: stringField(raw, "id", path), + node, + lane, + enabled: booleanField(raw, "enabled", path), + ciNamespace: stringField(raw, "ciNamespace", path), + runtimeNamespace: stringField(raw, "runtimeNamespace", path), + source: { repository: sourceRepository, branch: stringField(source, "branch", `${path}.source`) }, + gitops: { branch: stringField(gitops, "branch", `${path}.gitops`), path: stringField(gitops, "path", `${path}.gitops`) }, + gitMirror: { + namespace: gitMirrorNamespace, + serviceReadName, + serviceWriteName, + cachePvcName: stringField(gitMirror, "cachePvcName", `${path}.gitMirror`), + cachePvcStorage: stringField(gitMirror, "cachePvcStorage", `${path}.gitMirror`), + servicePort: numberField(gitMirror, "servicePort", `${path}.gitMirror`), + deploymentReplicas: nonNegativeIntegerField(gitMirror, "deploymentReplicas", `${path}.gitMirror`), + secretName: stringField(gitMirror, "secretName", `${path}.gitMirror`), + syncConfigMapName: stringField(gitMirror, "syncConfigMapName", `${path}.gitMirror`), + syncJobPrefix: stringField(gitMirror, "syncJobPrefix", `${path}.gitMirror`), + flushJobPrefix: stringField(gitMirror, "flushJobPrefix", `${path}.gitMirror`), + readUrl: optionalStringField(gitMirror, "readUrl", `${path}.gitMirror`) ?? `http://${serviceReadName}.${gitMirrorNamespace}.svc.cluster.local/${sourceRepository}.git`, + writeUrl: optionalStringField(gitMirror, "writeUrl", `${path}.gitMirror`) ?? `http://${serviceWriteName}.${gitMirrorNamespace}.svc.cluster.local/${sourceRepository}.git`, + }, + tekton: { + pipelineName: stringField(tekton, "pipelineName", `${path}.tekton`), + serviceAccountName: stringField(tekton, "serviceAccountName", `${path}.tekton`), + pipelineRunPrefix: stringField(tekton, "pipelineRunPrefix", `${path}.tekton`), + toolsImage: toolsImageSpec(toolsImage, `${path}.tekton.toolsImage`), + }, + argo: { + namespace: stringField(argo, "namespace", `${path}.argo`), + projectName: stringField(argo, "projectName", `${path}.argo`), + applicationName: stringField(argo, "applicationName", `${path}.argo`), + applicationFile: stringField(argo, "applicationFile", `${path}.argo`), + }, + }; +} + +function renderInfraManifest(_node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record[] { + const labels = { + "app.kubernetes.io/part-of": "hwlab-node-control-plane", + "hwlab.pikastech.local/node": target.node, + "hwlab.pikastech.local/lane": target.lane, + }; + const manifests: Record[] = [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: target.ciNamespace, labels } }, + ]; + if (target.gitMirror.namespace !== target.ciNamespace) { + manifests.push({ apiVersion: "v1", kind: "Namespace", metadata: { name: target.gitMirror.namespace, labels } }); + } + manifests.push( + { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: target.tekton.serviceAccountName, namespace: target.ciNamespace, labels } }, + { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: target.gitMirror.cachePvcName, namespace: target.gitMirror.namespace, labels: { ...labels, "app.kubernetes.io/name": "git-mirror" } }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: target.gitMirror.cachePvcStorage } } }, + }, + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: target.gitMirror.syncConfigMapName, namespace: target.gitMirror.namespace, labels: { ...labels, "app.kubernetes.io/name": "git-mirror" } }, + data: { + "repositories.json": JSON.stringify([{ repository: target.source.repository, sourceBranch: target.source.branch, gitopsBranch: target.gitops.branch }], null, 2), + "sync.sh": "#!/bin/sh\nset -eu\necho d601-hwlab-git-mirror-sync-placeholder\ncat /etc/git-mirror/repositories.json\n", + "flush.sh": "#!/bin/sh\nset -eu\necho d601-hwlab-git-mirror-flush-placeholder\ncat /etc/git-mirror/repositories.json\n", + }, + }, + service(target.gitMirror.serviceReadName, target.gitMirror.namespace, labels, target.gitMirror.servicePort), + service(target.gitMirror.serviceWriteName, target.gitMirror.namespace, labels, target.gitMirror.servicePort), + gitMirrorDeployment(target.gitMirror.serviceReadName, target.gitMirror.namespace, labels, target, "read"), + gitMirrorDeployment(target.gitMirror.serviceWriteName, target.gitMirror.namespace, labels, target, "write"), + { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { name: target.tekton.pipelineName, namespace: target.ciNamespace, labels }, + spec: { + params: [ + { name: "source-commit", type: "string" }, + { name: "source-branch", type: "string", default: target.source.branch }, + { name: "gitops-branch", type: "string", default: target.gitops.branch }, + ], + tasks: [{ name: "bootstrap-placeholder", taskSpec: { steps: [{ name: "notice", image: target.tekton.toolsImage.output, script: "#!/bin/sh\nset -eu\necho d601-hwlab-v03-pipeline-placeholder\n" }] } }], + }, + }, + { apiVersion: "v1", kind: "Namespace", metadata: { name: target.argo.namespace, labels } }, + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `${target.argo.applicationName}-desired`, namespace: target.argo.namespace, labels }, + data: { + "application.yaml": 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", + }, + }, + ); + return manifests; +} + +function service(name: string, namespace: string, labels: Record, port: number): Record { + return { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels: { ...labels, "app.kubernetes.io/name": "git-mirror" } }, + spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": name }, ports: [{ name: "http", port, targetPort: "http" }] }, + }; +} + +function gitMirrorDeployment(name: string, namespace: string, labels: Record, target: ControlPlaneTargetSpec, mode: "read" | "write"): Record { + return { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels: { ...labels, "app.kubernetes.io/name": name, "hwlab.pikastech.local/git-mirror-mode": mode } }, + spec: { + replicas: target.gitMirror.deploymentReplicas, + selector: { matchLabels: { "app.kubernetes.io/name": name } }, + template: { + metadata: { labels: { ...labels, "app.kubernetes.io/name": name, "hwlab.pikastech.local/git-mirror-mode": mode } }, + spec: { + containers: [{ name: "git-mirror", image: target.tekton.toolsImage.output, command: ["/bin/sh", "-c", "sleep infinity"], ports: [{ name: "http", containerPort: target.gitMirror.servicePort }], volumeMounts: [{ name: "cache", mountPath: "/cache" }, { name: "config", mountPath: "/etc/git-mirror" }] }], + volumes: [{ name: "cache", persistentVolumeClaim: { claimName: target.gitMirror.cachePvcName } }, { name: "config", configMap: { name: target.gitMirror.syncConfigMapName } }], + }, + }, + }, + }; +} + +function argoApplicationSkeleton(target: ControlPlaneTargetSpec): Record { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "Application", + metadata: { name: target.argo.applicationName, namespace: target.argo.namespace }, + spec: { + project: target.argo.projectName, + source: { repoURL: target.gitMirror.readUrl, targetRevision: target.gitops.branch, path: target.gitops.path }, + destination: { server: "https://kubernetes.default.svc", namespace: target.runtimeNamespace }, + syncPolicy: { automated: { prune: true, selfHeal: true } }, + }, + }; +} + +function planSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record { + return { + id: target.id, + node: node.id, + kubeRoute: node.kubeRoute, + lane: target.lane, + enabled: target.enabled, + ciNamespace: target.ciNamespace, + runtimeNamespace: target.runtimeNamespace, + registry: node.registry.endpoint, + sourceBranch: target.source.branch, + gitopsBranch: target.gitops.branch, + gitopsPath: target.gitops.path, + gitMirrorNamespace: target.gitMirror.namespace, + readUrl: target.gitMirror.readUrl, + writeUrl: target.gitMirror.writeUrl, + pipeline: target.tekton.pipelineName, + pipelineRunPrefix: target.tekton.pipelineRunPrefix, + serviceAccount: target.tekton.serviceAccountName, + toolsImage: target.tekton.toolsImage, + argoApplication: target.argo.applicationName, + }; +} + +function expectedSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record { + return { + sourceRepo: target.source.repository, + branch: target.source.branch, + gitopsBranch: target.gitops.branch, + runtimePath: target.gitops.path, + runtimeNamespace: target.runtimeNamespace, + namespace: target.ciNamespace, + gitMirror: { + namespace: target.gitMirror.namespace, + readUrl: target.gitMirror.readUrl, + writeUrl: target.gitMirror.writeUrl, + cachePvc: target.gitMirror.cachePvcName, + cachePvcStorage: target.gitMirror.cachePvcStorage, + servicePort: target.gitMirror.servicePort, + deploymentReplicas: target.gitMirror.deploymentReplicas, + syncConfigMap: target.gitMirror.syncConfigMapName, + statusSummaryKeys: ["localSource", "githubSource", "localGitops", "githubGitops", "pendingFlush", "flushNeeded", "githubInSync"], + }, + pipeline: target.tekton.pipelineName, + pipelineRunPrefix: target.tekton.pipelineRunPrefix, + serviceAccount: target.tekton.serviceAccountName, + toolsImage: target.tekton.toolsImage, + argoNamespace: target.argo.namespace, + argoApplication: target.argo.applicationName, + 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 }, + outputImage: target.tekton.toolsImage.output, + }, + }; +} + +function statusScript(target: ControlPlaneTargetSpec, registryEndpoint: string, toolsImage: string): string { + return ` +set +e +node=${shQuote(target.node)} +lane=${shQuote(target.lane)} +ci_ns=${shQuote(target.ciNamespace)} +runtime_ns=${shQuote(target.runtimeNamespace)} +gitmirror_ns=${shQuote(target.gitMirror.namespace)} +read_deploy=${shQuote(target.gitMirror.serviceReadName)} +write_deploy=${shQuote(target.gitMirror.serviceWriteName)} +read_svc=${shQuote(target.gitMirror.serviceReadName)} +write_svc=${shQuote(target.gitMirror.serviceWriteName)} +cache_pvc=${shQuote(target.gitMirror.cachePvcName)} +pipeline=${shQuote(target.tekton.pipelineName)} +service_account=${shQuote(target.tekton.serviceAccountName)} +argo_ns=${shQuote(target.argo.namespace)} +argo_app=${shQuote(target.argo.applicationName)} +registry=${shQuote(registryEndpoint)} +tools_image=${shQuote(toolsImage)} +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; } +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 +tools_repo_tag=\${tools_image#\${registry}/} +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 +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")}}} +JSON +`; +} + +function applyScript(yaml: string): string { + const encoded = Buffer.from(yaml, "utf8").toString("base64"); + return ` +set +e +manifest=$(mktemp /tmp/hwlab-node-infra.XXXXXX.yaml) +printf %s ${shQuote(encoded)} | base64 -d >"$manifest" +kubectl apply --server-side --field-manager=unidesk-hwlab-node-control-plane -f "$manifest" >/tmp/hwlab-node-infra-apply.out 2>/tmp/hwlab-node-infra-apply.err +rc=$? +python3 - "$rc" <<'PY' +import json, pathlib, sys +out=pathlib.Path('/tmp/hwlab-node-infra-apply.out').read_text(errors='replace') if pathlib.Path('/tmp/hwlab-node-infra-apply.out').exists() else '' +err=pathlib.Path('/tmp/hwlab-node-infra-apply.err').read_text(errors='replace') if pathlib.Path('/tmp/hwlab-node-infra-apply.err').exists() else '' +print(json.dumps({'applyExitCode': int(sys.argv[1]), 'stdoutPreview': out[-2000:], 'stderrPreview': err[-2000:], 'runtimeRolloutTriggered': False, 'pk01Touched': False}, ensure_ascii=False)) +PY +rm -f "$manifest" +exit "$rc" +`; +} + +function toolsImageStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, timeoutSeconds: number): { + registryReady: boolean; + toolsImageReady: boolean; + result: Record; +} { + const result = runTransK3s(node.kubeRoute, registryStatusScript(node.registry.endpoint, target.tekton.toolsImage.output), timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + const status = typeof parsed === "object" && parsed !== null ? parsed as Record : {}; + return { + registryReady: boolField(status, "registryReady"), + toolsImageReady: boolField(status, "toolsImageReady"), + result: { + status, + command: compactCommandResult(result), + }, + }; +} + +function applyNext(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, imageStatus: { registryReady: boolean; toolsImageReady: boolean }): Record { + if (!imageStatus.registryReady) { + return { + status: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`, + blockedBy: "node-local-registry-not-ready", + }; + } + if (!imageStatus.toolsImageReady) { + return { + status: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`, + blockedBy: "tools-image-missing", + applyBootstrap: `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --confirm`, + buildToolsImage: "准备受控 D601 tools-image build/publish 入口后提升 control-plane readiness。", + }; + } + return { apply: `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --confirm` }; +} + +function statusNext( + node: ControlPlaneNodeSpec, + target: ControlPlaneTargetSpec, + registry: Record, + gitMirror: Record, + argo: Record, + ciNamespace: Record, +): Record { + const bootstrapMissing = !boolField(ciNamespace, "exists") + || !boolField(gitMirror, "namespaceExists") + || !boolField(gitMirror, "readServiceExists") + || !boolField(gitMirror, "writeServiceExists") + || !boolField(gitMirror, "cachePvcExists"); + const blockers: string[] = []; + 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"); + if (!boolField(argo, "installed")) blockers.push("argocd-not-installed"); + 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}`, + dryRun: `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --dry-run`, + }; + if (blockers.length > 0) { + next.blockedBy = blockers[0]; + next.blockers = blockers; + } + if (!boolField(registry, "toolsImageReady")) { + next.buildToolsImage = "准备受控 D601 tools-image build/publish 入口后提升 control-plane readiness。"; + } + if (!boolField(argo, "installed")) { + next.installArgo = "准备受控 D601 Argo CD 安装入口后再进入 runtime rollout。"; + } + if (bootstrapMissing) next.applyBootstrap = `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --confirm`; + else next.reapplyBootstrap = `bun scripts/cli.ts hwlab nodes control-plane infra apply --node ${node.id} --lane ${target.lane} --confirm`; + return next; +} + +function registryStatusScript(registryEndpoint: string, toolsImage: string): string { + return ` +set +e +registry=${shQuote(registryEndpoint)} +tools_image=${shQuote(toolsImage)} +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 +tools_repo_tag=\${tools_image#\${registry}/} +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 +cat <[]): Record[] { + return manifest.map((item) => { + const metadata = record(item.metadata); + return { kind: item.kind ?? null, namespace: metadata.namespace ?? null, name: metadata.name ?? null }; + }); +} + +function runTransK3s(kubeRoute: string, script: string, timeoutSeconds: number): CommandResult { + return runCommand(["/root/.local/bin/trans", kubeRoute, "script", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 }); +} + +function parseRemoteJson(text: string): unknown { + const trimmed = text.trim(); + if (trimmed.length === 0) return null; + try { return JSON.parse(trimmed); } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { return JSON.parse(trimmed.slice(start, end + 1)); } catch {} + } + } + return null; +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function stringField(obj: Record, key: string, path: string): string { + const value = obj[key]; + if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); + return value; +} + +function optionalStringField(obj: Record, key: string, path: string): string | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); + return value; +} + +function numberField(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`); + return value; +} + +function numberArrayField(obj: Record, key: string, path: string): number[] { + const value = obj[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${path}.${key} must be an array of integers`); + return [...value] as number[]; +} + +function stringArrayField(obj: Record, key: string, path: string): string[] { + const value = obj[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path}.${key} must be an array of non-empty strings`); + return [...value] as string[]; +} + +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`); + return value; +} + +function boolField(obj: Record, key: string): boolean { + return obj[key] === true; +} + +function requiredOption(args: string[], name: string): string { + const index = args.indexOf(name); + if (index === -1) throw new Error(`${name} is required`); + const value = args[index + 1]; + if (value === undefined || value.startsWith("--") || value.length === 0) throw new Error(`${name} requires a value`); + return value; +} + +function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { + const index = args.indexOf(name); + if (index === -1) return defaultValue; + const raw = args[index + 1]; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return Math.min(value, maxValue); +} + +function compactCommandResult(result: CommandResult): Record { + return { + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + stdoutTail: result.stdout.slice(-2000), + stderrTail: result.stderr.slice(-2000), + }; +} + +function shQuote(value: string): string { + return `'${value.replace(/'/gu, `'"'"'`)}'`; +} + +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 b26f022e..b0c58836 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -3,6 +3,7 @@ import { repoRoot, type Config } from "./config"; import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; import { runHwlabG14Command } from "./hwlab-g14"; +import { hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "./hwlab-node-control-plane"; import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane } from "./hwlab-node-lanes"; type SecretAction = "status" | "ensure" | "cleanup-owned-postgres" | "cleanup-obsolete"; @@ -69,8 +70,13 @@ const CODE_AGENT_PROVIDER_SOURCE_NAMESPACE = "hwlab-v02"; const CODE_AGENT_PROVIDER_SOURCE_SECRET = "hwlab-v02-code-agent-provider"; export async function runHwlabNodeCommand(_config: Config, args: string[]): Promise> { - if (args.length === 0 || args.includes("--help") || args.includes("-h")) return hwlabNodeHelp(); + if (args.length === 0) return hwlabNodeHelp(); const [domain] = args; + if (domain === "control-plane" && args[1] === "infra") { + if (args.length === 2 || args.includes("--help") || args.includes("-h") || args[2] === "help") return hwlabNodeControlPlaneInfraHelp(); + return runHwlabNodeControlPlaneInfra(args.slice(2)); + } + if (args.includes("--help") || args.includes("-h")) return hwlabNodeHelp(); if (domain === "control-plane" || domain === "git-mirror") { return runNodeDelegatedDomain(_config, domain, args.slice(1)); } @@ -88,6 +94,9 @@ export function hwlabNodeHelp(): Record { description: "Node/lane oriented HWLAB operations. G14 is a node id value passed by --node, not a command family.", configPath: hwlabRuntimeLaneConfigPath(), examples: [ + "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 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",