feat: add D601 HWLAB control-plane infra CLI

This commit is contained in:
Codex
2026-06-12 11:35:08 +00:00
parent 46773d944b
commit 3347cd87c1
6 changed files with 938 additions and 2 deletions
+11
View File
@@ -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
+66
View File
@@ -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
+2
View File
@@ -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` 输出命令索引,适合作为交互式入口。
+6 -1
View File
@@ -622,11 +622,13 @@ function hwlabNodeHelpSummary(): unknown {
command: "hwlab nodes control-plane|git-mirror|secret --node <node> --lane <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 <secret>",
],
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<unknown | nul
if (top === "agentrun") return loadHelp(async () => (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());
+843
View File
@@ -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<string, ControlPlaneNodeSpec>;
targets: readonly ControlPlaneTargetSpec[];
}
export function runHwlabNodeControlPlaneInfra(args: string[]): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> : { 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<string, unknown> {
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<string, unknown>): 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<string, unknown>, 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<string, unknown>): 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<string, unknown>, 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<string, unknown>[] {
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<string, unknown>[] = [
{ 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<string, string>, port: number): Record<string, unknown> {
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<string, string>, target: ControlPlaneTargetSpec, mode: "read" | "write"): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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 <<JSON
{"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),"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<string, unknown>;
} {
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<string, unknown> : {};
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<string, unknown> {
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<string, unknown>,
gitMirror: Record<string, unknown>,
argo: Record<string, unknown>,
ciNamespace: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> = {
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 <<JSON
{"registry":"$registry","toolsImage":"$tools_image","registryReady":$registry_ready,"toolsImageReady":$tools_image_ready}
JSON
`;
}
function manifestObjectSummary(manifest: readonly Record<string, unknown>[]): Record<string, unknown>[] {
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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringField(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
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")}`;
}
+10 -1
View File
@@ -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<Record<string, unknown>> {
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<string, unknown> {
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",