1583 lines
79 KiB
TypeScript
1583 lines
79 KiB
TypeScript
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";
|
|
type ToolsImageAction = "status" | "build" | "logs";
|
|
type ArgoAction = "status" | "apply" | "logs";
|
|
|
|
interface InfraOptions {
|
|
action: InfraAction;
|
|
node: string;
|
|
lane: string;
|
|
dryRun: boolean;
|
|
confirm: boolean;
|
|
timeoutSeconds: number;
|
|
}
|
|
|
|
interface ToolsImageOptions {
|
|
action: ToolsImageAction;
|
|
node: string;
|
|
lane: string;
|
|
dryRun: boolean;
|
|
confirm: boolean;
|
|
timeoutSeconds: number;
|
|
tailLines: number;
|
|
}
|
|
|
|
interface ArgoOptions {
|
|
action: ArgoAction;
|
|
node: string;
|
|
lane: string;
|
|
dryRun: boolean;
|
|
confirm: boolean;
|
|
timeoutSeconds: number;
|
|
tailLines: number;
|
|
}
|
|
|
|
interface ControlPlaneEgressProxySpec {
|
|
mode: "k8s-service-cluster-ip";
|
|
namespace: string;
|
|
serviceName: string;
|
|
port: number;
|
|
noProxy: readonly string[];
|
|
}
|
|
|
|
interface ControlPlaneNodeSpec {
|
|
id: string;
|
|
route: string;
|
|
kubeRoute: string;
|
|
registry: { endpoint: string };
|
|
egressProxy: ControlPlaneEgressProxySpec | null;
|
|
}
|
|
|
|
interface DockerfileInlineSpec {
|
|
filename: string;
|
|
lines: readonly string[];
|
|
}
|
|
|
|
interface ImageRewriteSpec {
|
|
source: string;
|
|
pullImage: string;
|
|
target: string;
|
|
}
|
|
|
|
interface ControlPlaneTargetSpec {
|
|
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;
|
|
dockerfileInline?: DockerfileInlineSpec;
|
|
composeFile?: string;
|
|
buildArgs: Readonly<Record<string, string>>;
|
|
buildNetwork: string | null;
|
|
publicBaseImages: readonly string[];
|
|
buildOwner: string;
|
|
buildMode: string;
|
|
};
|
|
};
|
|
argo: {
|
|
namespace: string;
|
|
projectName: string;
|
|
applicationName: string;
|
|
applicationFile: string;
|
|
install: {
|
|
enabled: boolean;
|
|
sourceKind: "url";
|
|
version: string;
|
|
manifestUrl: string;
|
|
fieldManager: string;
|
|
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
preloadImages: readonly string[];
|
|
imageRewrites: readonly ImageRewriteSpec[];
|
|
requiredCrds: readonly string[];
|
|
expectedDeployments: readonly string[];
|
|
expectedStatefulSets: readonly string[];
|
|
readinessTimeoutSeconds: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
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> {
|
|
if (args[0] === "tools-image") {
|
|
const options = parseToolsImageOptions(args.slice(1));
|
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
|
return runToolsImageCommand(config, node, target, options);
|
|
}
|
|
if (args[0] === "argo") {
|
|
const options = parseArgoOptions(args.slice(1));
|
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
|
return runArgoCommand(config, node, target, options);
|
|
}
|
|
const options = parseInfraOptions(args);
|
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
|
|
|
if (options.action === "plan") return infraPlan(config, node, target, options);
|
|
if (options.action === "status") return infraStatus(config, node, target, options);
|
|
return infraApply(config, node, target, options);
|
|
}
|
|
|
|
function controlPlaneContext(nodeId: string, lane: string): { config: ControlPlaneConfig; node: ControlPlaneNodeSpec; target: ControlPlaneTargetSpec } {
|
|
const config = readControlPlaneConfig();
|
|
const node = config.nodes[nodeId];
|
|
if (node === undefined) throw new Error(`unknown node ${nodeId}; known nodes: ${Object.keys(config.nodes).join(", ")}`);
|
|
const target = config.targets.find((item) => item.node === nodeId && item.lane === lane);
|
|
if (target === undefined) throw new Error(`no control-plane target for node=${nodeId} lane=${lane} in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`);
|
|
if (!target.enabled) throw new Error(`control-plane target ${target.id} is disabled in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`);
|
|
return { config, node, target };
|
|
}
|
|
|
|
export function hwlabNodeControlPlaneInfraHelp(): Record<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",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node D601 --lane v03",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node D601 --lane v03 --dry-run",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node D601 --lane v03 --confirm",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node D601 --lane v03",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --dry-run",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --confirm",
|
|
"bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node D601 --lane v03",
|
|
],
|
|
g14Consistency: "D601 target fields mirror the existing G14 runtime lane control-plane vocabulary: source branch, gitops branch/path, Pipeline, PipelineRun prefix, ServiceAccount, Argo Application, and git-mirror read/write/sync/flush status concepts.",
|
|
};
|
|
}
|
|
|
|
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`,
|
|
toolsImageBuild: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node ${node.id} --lane ${target.lane} --confirm`,
|
|
argoApply: `bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node ${node.id} --lane ${target.lane} --confirm`,
|
|
},
|
|
options: { timeoutSeconds: options.timeoutSeconds },
|
|
};
|
|
}
|
|
|
|
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 argoInstall = record(argo.install);
|
|
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, "projectExists")
|
|
&& boolField(argo, "applicationExists")
|
|
&& boolField(argoInstall, "crdsReady")
|
|
&& boolField(argoInstall, "deploymentsReady")
|
|
&& boolField(argoInstall, "statefulSetsReady");
|
|
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"),
|
|
argoProjectExists: boolField(argo, "projectExists"),
|
|
argoApplicationExists: boolField(argo, "applicationExists"),
|
|
argoCrdsReady: boolField(argoInstall, "crdsReady"),
|
|
argoDeploymentsReady: boolField(argoInstall, "deploymentsReady"),
|
|
argoStatefulSetsReady: boolField(argoInstall, "statefulSetsReady"),
|
|
registryReady: boolField(registry, "ready"),
|
|
toolsImageReady: boolField(registry, "toolsImageReady"),
|
|
},
|
|
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 runToolsImageCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record<string, unknown> {
|
|
if (options.action === "status") return toolsImageCommandStatus(node, target, options);
|
|
if (options.action === "logs") return remoteJobLogs(node, target, "tools-image", options);
|
|
return toolsImageBuild(node, target, options);
|
|
}
|
|
|
|
function runArgoCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ArgoOptions): Record<string, unknown> {
|
|
if (options.action === "status") return argoCommandStatus(node, target, options);
|
|
if (options.action === "logs") return remoteJobLogs(node, target, "argo", options);
|
|
return argoApply(node, target, options);
|
|
}
|
|
|
|
function toolsImageCommandStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record<string, unknown> {
|
|
const registry = toolsImageStatus(node, target, options.timeoutSeconds);
|
|
const jobResult = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, "tools-image", options.tailLines), options.timeoutSeconds);
|
|
const jobStatus = parseRemoteJson(jobResult.stdout);
|
|
const ok = registry.registryReady && registry.toolsImageReady;
|
|
return {
|
|
ok,
|
|
command: "hwlab nodes control-plane infra tools-image status",
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mutation: false,
|
|
image: target.tekton.toolsImage.output,
|
|
imageSource: target.tekton.toolsImage,
|
|
registry,
|
|
job: typeof jobStatus === "object" && jobStatus !== null ? jobStatus : { parseError: "remote job status did not return JSON", stdoutPreview: jobResult.stdout.slice(0, 1000) },
|
|
result: compactCommandResult(jobResult),
|
|
next: ok
|
|
? { infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}` }
|
|
: { build: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node ${node.id} --lane ${target.lane} --confirm`, logs: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node ${node.id} --lane ${target.lane}` },
|
|
};
|
|
}
|
|
|
|
function toolsImageBuild(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record<string, unknown> {
|
|
if (options.confirm && options.dryRun) throw new Error("tools-image build accepts only one of --dry-run or --confirm");
|
|
const dryRun = options.dryRun || !options.confirm;
|
|
const dockerfile = toolsImageDockerfile(target);
|
|
const buildPlan = {
|
|
outputImage: target.tekton.toolsImage.output,
|
|
sourceKind: target.tekton.toolsImage.sourceKind,
|
|
dockerfileInline: target.tekton.toolsImage.dockerfileInline,
|
|
buildArgs: target.tekton.toolsImage.buildArgs,
|
|
buildNetwork: target.tekton.toolsImage.buildNetwork,
|
|
publicBaseImages: target.tekton.toolsImage.publicBaseImages,
|
|
nodeLocalRegistryOutputOnly: true,
|
|
egressProxy: node.egressProxy,
|
|
stateDir: remoteJobStateDir(target, "tools-image"),
|
|
};
|
|
if (dryRun) {
|
|
return {
|
|
ok: true,
|
|
command: "hwlab nodes control-plane infra tools-image build",
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mode: "dry-run",
|
|
mutation: false,
|
|
buildPlan,
|
|
dockerfile: { bytes: Buffer.byteLength(dockerfile), sha256: sha256Short(dockerfile), preview: dockerfile },
|
|
next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image build --node ${node.id} --lane ${target.lane} --confirm` },
|
|
};
|
|
}
|
|
const result = runTransK3s(node.kubeRoute, toolsImageBuildStartScript(node, target, dockerfile), options.timeoutSeconds);
|
|
const parsed = parseRemoteJson(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
command: "hwlab nodes control-plane infra tools-image build",
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mode: "confirmed-start",
|
|
mutation: result.exitCode === 0,
|
|
buildPlan,
|
|
start: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) },
|
|
result: compactCommandResult(result),
|
|
next: {
|
|
status: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node ${node.id} --lane ${target.lane}`,
|
|
logs: `bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node ${node.id} --lane ${target.lane}`,
|
|
infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function argoCommandStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ArgoOptions): Record<string, unknown> {
|
|
const result = runTransK3s(node.kubeRoute, statusScript(target, node.registry.endpoint, target.tekton.toolsImage.output), options.timeoutSeconds);
|
|
const parsed = parseRemoteJson(result.stdout);
|
|
const status = typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : {};
|
|
const argo = record(record(status.components).argo);
|
|
const argoInstall = record(argo.install);
|
|
const jobResult = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, "argo", options.tailLines), options.timeoutSeconds);
|
|
const jobStatus = parseRemoteJson(jobResult.stdout);
|
|
const ok = boolField(argo, "installed")
|
|
&& boolField(argo, "projectExists")
|
|
&& boolField(argo, "applicationExists")
|
|
&& boolField(argoInstall, "crdsReady")
|
|
&& boolField(argoInstall, "deploymentsReady")
|
|
&& boolField(argoInstall, "statefulSetsReady");
|
|
return {
|
|
ok,
|
|
command: "hwlab nodes control-plane infra argo status",
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mutation: false,
|
|
expected: {
|
|
namespace: target.argo.namespace,
|
|
project: target.argo.projectName,
|
|
application: target.argo.applicationName,
|
|
install: target.argo.install,
|
|
},
|
|
readiness: {
|
|
installed: boolField(argo, "installed"),
|
|
projectExists: boolField(argo, "projectExists"),
|
|
applicationExists: boolField(argo, "applicationExists"),
|
|
crdsReady: boolField(argoInstall, "crdsReady"),
|
|
deploymentsReady: boolField(argoInstall, "deploymentsReady"),
|
|
statefulSetsReady: boolField(argoInstall, "statefulSetsReady"),
|
|
},
|
|
argo,
|
|
job: typeof jobStatus === "object" && jobStatus !== null ? jobStatus : { parseError: "remote job status did not return JSON", stdoutPreview: jobResult.stdout.slice(0, 1000) },
|
|
result: { k3s: compactCommandResult(result), job: compactCommandResult(jobResult) },
|
|
next: ok
|
|
? { infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}` }
|
|
: { apply: `bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node ${node.id} --lane ${target.lane} --confirm`, logs: `bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node ${node.id} --lane ${target.lane}` },
|
|
};
|
|
}
|
|
|
|
function argoApply(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ArgoOptions): Record<string, unknown> {
|
|
if (options.confirm && options.dryRun) throw new Error("argo apply accepts only one of --dry-run or --confirm");
|
|
if (!target.argo.install.enabled) throw new Error(`targets.${target.id}.argo.install.enabled=false`);
|
|
const dryRun = options.dryRun || !options.confirm;
|
|
const desired = argoDesiredManifest(target);
|
|
const desiredYaml = `${desired.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
|
const applyPlan = {
|
|
namespace: target.argo.namespace,
|
|
version: target.argo.install.version,
|
|
manifestUrl: target.argo.install.manifestUrl,
|
|
fieldManager: target.argo.install.fieldManager,
|
|
imageRewrites: target.argo.install.imageRewrites,
|
|
preloadImages: target.argo.install.preloadImages,
|
|
requiredCrds: target.argo.install.requiredCrds,
|
|
desired: manifestObjectSummary(desired),
|
|
desiredSha256: sha256Short(desiredYaml),
|
|
stateDir: remoteJobStateDir(target, "argo"),
|
|
egressProxy: node.egressProxy,
|
|
};
|
|
if (dryRun) {
|
|
return {
|
|
ok: true,
|
|
command: "hwlab nodes control-plane infra argo apply",
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mode: "dry-run",
|
|
mutation: false,
|
|
applyPlan,
|
|
desiredYaml: { objects: desired.length, bytes: Buffer.byteLength(desiredYaml), sha256: sha256Short(desiredYaml), preview: desiredYaml },
|
|
next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node ${node.id} --lane ${target.lane} --confirm` },
|
|
};
|
|
}
|
|
const result = runTransK3s(node.kubeRoute, argoApplyStartScript(node, target, desiredYaml), options.timeoutSeconds);
|
|
const parsed = parseRemoteJson(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
command: "hwlab nodes control-plane infra argo apply",
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mode: "confirmed-start",
|
|
mutation: result.exitCode === 0,
|
|
applyPlan,
|
|
start: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) },
|
|
result: compactCommandResult(result),
|
|
next: {
|
|
status: `bun scripts/cli.ts hwlab nodes control-plane infra argo status --node ${node.id} --lane ${target.lane}`,
|
|
logs: `bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node ${node.id} --lane ${target.lane}`,
|
|
infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${node.id} --lane ${target.lane}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseInfraOptions(args: string[]): InfraOptions {
|
|
const [actionRaw] = args;
|
|
if (actionRaw === undefined || actionRaw === "--help" || actionRaw === "-h" || actionRaw === "help") throw new Error("infra usage: infra plan|status|apply --node NODE --lane vNN [--dry-run|--confirm]");
|
|
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 parseToolsImageOptions(args: string[]): ToolsImageOptions {
|
|
const [actionRaw] = args;
|
|
if (actionRaw === undefined || actionRaw === "--help" || actionRaw === "-h" || actionRaw === "help") throw new Error("infra tools-image usage: tools-image status|build|logs --node NODE --lane vNN [--dry-run|--confirm]");
|
|
if (actionRaw !== "status" && actionRaw !== "build" && actionRaw !== "logs") throw new Error(`unsupported tools-image action ${actionRaw}; expected status|build|logs`);
|
|
const confirm = args.includes("--confirm");
|
|
const explicitDryRun = args.includes("--dry-run");
|
|
if (confirm && explicitDryRun) throw new Error("tools-image accepts only one of --confirm or --dry-run");
|
|
return {
|
|
action: actionRaw,
|
|
node: requiredOption(args, "--node"),
|
|
lane: requiredOption(args, "--lane"),
|
|
confirm,
|
|
dryRun: actionRaw === "build" ? explicitDryRun || !confirm : true,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 60, 60),
|
|
tailLines: positiveIntegerOption(args, "--tail-lines", 120, 1000),
|
|
};
|
|
}
|
|
|
|
function parseArgoOptions(args: string[]): ArgoOptions {
|
|
const [actionRaw] = args;
|
|
if (actionRaw === undefined || actionRaw === "--help" || actionRaw === "-h" || actionRaw === "help") throw new Error("infra argo usage: argo status|apply|logs --node NODE --lane vNN [--dry-run|--confirm]");
|
|
if (actionRaw !== "status" && actionRaw !== "apply" && actionRaw !== "logs") throw new Error(`unsupported argo action ${actionRaw}; expected status|apply|logs`);
|
|
const confirm = args.includes("--confirm");
|
|
const explicitDryRun = args.includes("--dry-run");
|
|
if (confirm && explicitDryRun) throw new Error("argo accepts only one of --confirm or --dry-run");
|
|
return {
|
|
action: actionRaw,
|
|
node: requiredOption(args, "--node"),
|
|
lane: requiredOption(args, "--lane"),
|
|
confirm,
|
|
dryRun: actionRaw === "apply" ? explicitDryRun || !confirm : true,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 60, 60),
|
|
tailLines: positiveIntegerOption(args, "--tail-lines", 120, 1000),
|
|
};
|
|
}
|
|
|
|
function readControlPlaneConfig(): ControlPlaneConfig {
|
|
const parsed = asRecord(Bun.YAML.parse(readFileSync(rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH), "utf8")) as unknown, HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH);
|
|
const version = numberField(parsed, "version", HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH);
|
|
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 dockerfileInline = raw.dockerfileInline === undefined ? undefined : dockerfileInlineSpec(asRecord(raw.dockerfileInline, `${path}.dockerfileInline`), `${path}.dockerfileInline`);
|
|
const composeFile = optionalStringField(raw, "composeFile", path);
|
|
if (sourceKind === "dockerfile" && dockerfile === undefined && dockerfileInline === undefined) throw new Error(`${path}.dockerfile or ${path}.dockerfileInline is required when sourceKind=dockerfile`);
|
|
if (dockerfile !== undefined && dockerfileInline !== undefined) throw new Error(`${path} must use only one of dockerfile or dockerfileInline`);
|
|
if (sourceKind === "docker-compose" && composeFile === undefined) throw new Error(`${path}.composeFile is required when sourceKind=docker-compose`);
|
|
const buildArgsRaw = raw.buildArgs === undefined ? {} : asRecord(raw.buildArgs, `${path}.buildArgs`);
|
|
const buildArgs = stringRecordField(buildArgsRaw, `${path}.buildArgs`);
|
|
for (const image of Object.values(buildArgs)) {
|
|
if (looksLikeImageReference(image)) validatePublicBaseImage(image, `${path}.buildArgs`);
|
|
}
|
|
return {
|
|
output: stringField(raw, "output", path),
|
|
sourceKind,
|
|
context: stringField(raw, "context", path),
|
|
dockerfile,
|
|
dockerfileInline,
|
|
composeFile,
|
|
buildArgs,
|
|
buildNetwork: optionalStringField(raw, "buildNetwork", path) ?? null,
|
|
publicBaseImages,
|
|
buildOwner: stringField(raw, "buildOwner", path),
|
|
buildMode: stringField(raw, "buildMode", path),
|
|
};
|
|
}
|
|
|
|
function dockerfileInlineSpec(raw: Record<string, unknown>, path: string): DockerfileInlineSpec {
|
|
const filename = stringField(raw, "filename", path);
|
|
if (!/^[A-Za-z0-9._/-]+$/u.test(filename) || filename.includes("..")) throw new Error(`${path}.filename has an unsupported format`);
|
|
const lines = stringArrayField(raw, "lines", path);
|
|
if (lines.length === 0) throw new Error(`${path}.lines must not be empty`);
|
|
return { filename, lines };
|
|
}
|
|
|
|
function argoInstallSpec(raw: Record<string, unknown>, path: string): ControlPlaneTargetSpec["argo"]["install"] {
|
|
const sourceKind = stringField(raw, "sourceKind", path);
|
|
if (sourceKind !== "url") throw new Error(`${path}.sourceKind must be url`);
|
|
const imagePullPolicy = optionalStringField(raw, "imagePullPolicy", path) ?? "IfNotPresent";
|
|
if (imagePullPolicy !== "Always" && imagePullPolicy !== "IfNotPresent" && imagePullPolicy !== "Never") throw new Error(`${path}.imagePullPolicy must be Always, IfNotPresent, or Never`);
|
|
const imageRewritesRaw = raw.imageRewrites === undefined ? [] : raw.imageRewrites;
|
|
if (!Array.isArray(imageRewritesRaw)) throw new Error(`${path}.imageRewrites must be an array`);
|
|
const imageRewrites = imageRewritesRaw.map((item, index) => imageRewriteSpec(asRecord(item, `${path}.imageRewrites[${index}]`), `${path}.imageRewrites[${index}]`));
|
|
const manifestUrl = stringField(raw, "manifestUrl", path);
|
|
validateHttpsUrl(manifestUrl, `${path}.manifestUrl`);
|
|
return {
|
|
enabled: booleanField(raw, "enabled", path),
|
|
sourceKind,
|
|
version: stringField(raw, "version", path),
|
|
manifestUrl,
|
|
fieldManager: stringField(raw, "fieldManager", path),
|
|
imagePullPolicy,
|
|
preloadImages: stringArrayField(raw, "preloadImages", path),
|
|
imageRewrites,
|
|
requiredCrds: stringArrayField(raw, "requiredCrds", path),
|
|
expectedDeployments: stringArrayField(raw, "expectedDeployments", path),
|
|
expectedStatefulSets: stringArrayField(raw, "expectedStatefulSets", path),
|
|
readinessTimeoutSeconds: positiveConfigIntegerField(raw, "readinessTimeoutSeconds", path),
|
|
};
|
|
}
|
|
|
|
function imageRewriteSpec(raw: Record<string, unknown>, path: string): ImageRewriteSpec {
|
|
const rewrite = {
|
|
source: stringField(raw, "source", path),
|
|
pullImage: stringField(raw, "pullImage", path),
|
|
target: stringField(raw, "target", path),
|
|
};
|
|
validatePublicBaseImage(rewrite.source, `${path}.source`);
|
|
validatePublicBaseImage(rewrite.pullImage, `${path}.pullImage`);
|
|
if (!isNodeLocalImage(rewrite.target)) throw new Error(`${path}.target must use a node-local registry output image`);
|
|
return rewrite;
|
|
}
|
|
|
|
function validateTargetImagePolicy(target: ControlPlaneTargetSpec, imagePolicy: ControlPlaneImagePolicy): void {
|
|
if (imagePolicy.requireReproducibleBuildSource && !imagePolicy.requiredSourceKinds.includes(target.tekton.toolsImage.sourceKind)) {
|
|
throw new Error(`targets.${target.id}.tekton.toolsImage.sourceKind is not allowed by imagePolicy.requiredSourceKinds`);
|
|
}
|
|
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/", "docker.m.daocloud.io/", "quay.m.daocloud.io/", "ghcr.m.daocloud.io/"];
|
|
if (!publicPrefixes.some((prefix) => image.startsWith(prefix))) throw new Error(`${path} image ${image} must use an explicit public registry prefix`);
|
|
}
|
|
|
|
function looksLikeImageReference(value: string): boolean {
|
|
return /^(docker\.io|registry\.k8s\.io|ghcr\.io|quay\.io|gcr\.io|public\.ecr\.aws|mcr\.microsoft\.com|cgr\.dev|docker\.m\.daocloud\.io|quay\.m\.daocloud\.io|ghcr\.m\.daocloud\.io)\//u.test(value)
|
|
|| /^(127\.|localhost(?::|\/)|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/u.test(value);
|
|
}
|
|
|
|
function isNodeLocalImage(image: string): boolean {
|
|
return /^(127\.|localhost(?::|\/)|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/u.test(image);
|
|
}
|
|
|
|
function nodeSpec(id: string, raw: Record<string, unknown>): ControlPlaneNodeSpec {
|
|
const registry = asRecord(raw.registry, `nodes.${id}.registry`);
|
|
const egressProxy = raw.egressProxy === undefined ? null : egressProxySpec(asRecord(raw.egressProxy, `nodes.${id}.egressProxy`), `nodes.${id}.egressProxy`);
|
|
return {
|
|
id,
|
|
route: stringField(raw, "route", `nodes.${id}`),
|
|
kubeRoute: stringField(raw, "kubeRoute", `nodes.${id}`),
|
|
registry: { endpoint: stringField(registry, "endpoint", `nodes.${id}.registry`) },
|
|
egressProxy,
|
|
};
|
|
}
|
|
|
|
function egressProxySpec(raw: Record<string, unknown>, path: string): ControlPlaneEgressProxySpec {
|
|
const mode = stringField(raw, "mode", path);
|
|
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`);
|
|
return {
|
|
mode,
|
|
namespace: stringField(raw, "namespace", path),
|
|
serviceName: stringField(raw, "serviceName", path),
|
|
port: positiveConfigIntegerField(raw, "port", path),
|
|
noProxy: stringArrayField(raw, "noProxy", path),
|
|
};
|
|
}
|
|
|
|
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`),
|
|
install: argoInstallSpec(asRecord(argo.install, `${path}.argo.install`), `${path}.argo.install`),
|
|
},
|
|
};
|
|
}
|
|
|
|
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: {
|
|
"project.yaml": Bun.YAML.stringify(argoProjectSkeleton(target)),
|
|
[target.argo.applicationFile]: Bun.YAML.stringify(argoApplicationSkeleton(target)),
|
|
"note.txt": "Argo CD CRDs/controller are installed by the node control-plane bootstrap path when available; this ConfigMap preserves the desired Application until Argo is ready.\n",
|
|
},
|
|
},
|
|
);
|
|
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 argoDesiredManifest(target: ControlPlaneTargetSpec): Record<string, unknown>[] {
|
|
return [argoProjectSkeleton(target), argoApplicationSkeleton(target)];
|
|
}
|
|
|
|
function argoProjectSkeleton(target: ControlPlaneTargetSpec): Record<string, unknown> {
|
|
return {
|
|
apiVersion: "argoproj.io/v1alpha1",
|
|
kind: "AppProject",
|
|
metadata: { name: target.argo.projectName, namespace: target.argo.namespace },
|
|
spec: {
|
|
sourceRepos: [target.gitMirror.readUrl],
|
|
destinations: [{ server: "https://kubernetes.default.svc", namespace: target.runtimeNamespace }],
|
|
clusterResourceWhitelist: [{ group: "*", kind: "*" }],
|
|
namespaceResourceWhitelist: [{ group: "*", kind: "*" }],
|
|
},
|
|
};
|
|
}
|
|
|
|
function argoApplicationSkeleton(target: ControlPlaneTargetSpec): Record<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,
|
|
egressProxy: node.egressProxy,
|
|
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,
|
|
argoInstall: {
|
|
enabled: target.argo.install.enabled,
|
|
version: target.argo.install.version,
|
|
manifestUrl: target.argo.install.manifestUrl,
|
|
imageRewrites: target.argo.install.imageRewrites,
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
argoInstall: {
|
|
enabled: target.argo.install.enabled,
|
|
sourceKind: target.argo.install.sourceKind,
|
|
version: target.argo.install.version,
|
|
manifestUrl: target.argo.install.manifestUrl,
|
|
preloadImages: target.argo.install.preloadImages,
|
|
imageRewrites: target.argo.install.imageRewrites,
|
|
requiredCrds: target.argo.install.requiredCrds,
|
|
expectedDeployments: target.argo.install.expectedDeployments,
|
|
expectedStatefulSets: target.argo.install.expectedStatefulSets,
|
|
},
|
|
registry: node.registry.endpoint,
|
|
imagePolicy: {
|
|
noPrivateInputImages: true,
|
|
buildInput: { sourceKind: target.tekton.toolsImage.sourceKind, context: target.tekton.toolsImage.context, dockerfile: target.tekton.toolsImage.dockerfile ?? null, dockerfileInline: target.tekton.toolsImage.dockerfileInline ?? null, composeFile: target.tekton.toolsImage.composeFile ?? null, publicBaseImages: target.tekton.toolsImage.publicBaseImages },
|
|
outputImage: target.tekton.toolsImage.output,
|
|
},
|
|
};
|
|
}
|
|
|
|
function statusScript(target: ControlPlaneTargetSpec, registryEndpoint: string, toolsImage: string): string {
|
|
const requiredCrds = shellJsonArray(target.argo.install.requiredCrds);
|
|
const argoDeployments = shellJsonArray(target.argo.install.expectedDeployments);
|
|
const argoStatefulSets = shellJsonArray(target.argo.install.expectedStatefulSets);
|
|
return `
|
|
set +e
|
|
node=${shQuote(target.node)}
|
|
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_project=${shQuote(target.argo.projectName)}
|
|
argo_app=${shQuote(target.argo.applicationName)}
|
|
registry=${shQuote(registryEndpoint)}
|
|
tools_image=${shQuote(toolsImage)}
|
|
required_crds_json=${shQuote(requiredCrds)}
|
|
argo_deployments_json=${shQuote(argoDeployments)}
|
|
argo_statefulsets_json=${shQuote(argoStatefulSets)}
|
|
exists_ns() { kubectl get ns "$1" >/dev/null 2>&1 && printf true || printf false; }
|
|
exists_res() { kubectl -n "$1" get "$2" "$3" >/dev/null 2>&1 && printf true || printf false; }
|
|
deploy_ready() { desired=$(kubectl -n "$1" get deploy "$2" -o 'jsonpath={.spec.replicas}' 2>/dev/null || true); ready=$(kubectl -n "$1" get deploy "$2" -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true); [ -n "$desired" ] && [ "$desired" -gt 0 ] 2>/dev/null && [ "\${ready:-0}" = "$desired" ] && printf true || printf false; }
|
|
sts_ready() { desired=$(kubectl -n "$1" get statefulset "$2" -o 'jsonpath={.spec.replicas}' 2>/dev/null || true); ready=$(kubectl -n "$1" get statefulset "$2" -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true); [ -n "$desired" ] && [ "$desired" -gt 0 ] 2>/dev/null && [ "\${ready:-0}" = "$desired" ] && printf true || printf false; }
|
|
endpoint_ready() { endpoints=$(kubectl -n "$1" get endpoints "$2" -o 'jsonpath={.subsets[*].addresses[*].ip}' 2>/dev/null || true); [ -n "$endpoints" ] && printf true || printf false; }
|
|
registry_ready=false
|
|
if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 3 "http://$registry/v2/" >/tmp/hwlab-registry.out 2>/tmp/hwlab-registry.err && registry_ready=true; fi
|
|
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
|
|
python3 - "$required_crds_json" "$argo_deployments_json" "$argo_statefulsets_json" <<'PY' >/tmp/hwlab-node-status-fragments.json
|
|
import json, subprocess, sys
|
|
required_crds=json.loads(sys.argv[1])
|
|
deployments=json.loads(sys.argv[2])
|
|
statefulsets=json.loads(sys.argv[3])
|
|
ns="${target.argo.namespace}"
|
|
def run(args):
|
|
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
def exists(args):
|
|
return run(args).returncode == 0
|
|
def ready(kind, name):
|
|
data = run(["kubectl", "-n", ns, "get", kind, name, "-o", "json"])
|
|
if data.returncode != 0:
|
|
return {"name": name, "exists": False, "ready": False, "desired": None, "readyReplicas": None}
|
|
obj=json.loads(data.stdout)
|
|
desired=int(obj.get("spec", {}).get("replicas") or 0)
|
|
ready_replicas=int(obj.get("status", {}).get("readyReplicas") or 0)
|
|
return {"name": name, "exists": True, "ready": desired > 0 and ready_replicas == desired, "desired": desired, "readyReplicas": ready_replicas}
|
|
crds=[{"name": name, "exists": exists(["kubectl", "get", "crd", name])} for name in required_crds]
|
|
deploy=[ready("deployment", name) for name in deployments]
|
|
sts=[ready("statefulset", name) for name in statefulsets]
|
|
print(json.dumps({"crds": crds, "deployments": deploy, "statefulSets": sts, "crdsReady": all(item["exists"] for item in crds), "deploymentsReady": all(item["ready"] for item in deploy) if deploy else True, "statefulSetsReady": all(item["ready"] for item in sts) if sts else True}))
|
|
PY
|
|
argo_fragment=$(cat /tmp/hwlab-node-status-fragments.json 2>/dev/null || printf '{}')
|
|
cat <<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),"projectExists":$(kubectl -n "$argo_ns" get appproject "$argo_project" >/dev/null 2>&1 && printf true || printf false),"applicationExists":$(kubectl -n "$argo_ns" get application "$argo_app" >/dev/null 2>&1 && printf true || printf false),"install":$argo_fragment},"registry":{"endpoint":"$registry","ready":$registry_ready,"toolsImage":"$tools_image","toolsImageReady":$tools_image_ready},"runtimeNamespace":{"name":"$runtime_ns","exists":$(exists_ns "$runtime_ns")}}}
|
|
JSON
|
|
`;
|
|
}
|
|
|
|
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");
|
|
const argoInstall = record(argo.install);
|
|
if (!boolField(argo, "installed")) blockers.push("argocd-not-installed");
|
|
else if (!boolField(argoInstall, "crdsReady")) blockers.push("argocd-crds-not-ready");
|
|
else if (!boolField(argoInstall, "deploymentsReady")) blockers.push("argocd-deployments-not-ready");
|
|
else if (!boolField(argoInstall, "statefulSetsReady")) blockers.push("argocd-statefulsets-not-ready");
|
|
else if (!boolField(argo, "projectExists")) blockers.push("argocd-project-missing");
|
|
else if (!boolField(argo, "applicationExists")) blockers.push("argocd-application-missing");
|
|
const next: Record<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 toolsImageDockerfile(target: ControlPlaneTargetSpec): string {
|
|
const inline = target.tekton.toolsImage.dockerfileInline;
|
|
if (inline === undefined) throw new Error(`targets.${target.id}.tekton.toolsImage.dockerfileInline is required for D601 node-local tools-image build`);
|
|
return `${inline.lines.join("\n")}\n`;
|
|
}
|
|
|
|
function toolsImageBuildStartScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, dockerfile: string): string {
|
|
const stateDir = remoteJobStateDir(target, "tools-image");
|
|
const dockerfileEncoded = Buffer.from(dockerfile, "utf8").toString("base64");
|
|
const buildArgs = Object.entries(target.tekton.toolsImage.buildArgs).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]);
|
|
const proxyArgs = node.egressProxy === null
|
|
? []
|
|
: ["--build-arg", "HTTP_PROXY", "--build-arg", "HTTPS_PROXY", "--build-arg", "ALL_PROXY", "--build-arg", "NO_PROXY", "--build-arg", "http_proxy", "--build-arg", "https_proxy", "--build-arg", "all_proxy", "--build-arg", "no_proxy"];
|
|
const networkArgs = target.tekton.toolsImage.buildNetwork === null ? [] : ["--network", target.tekton.toolsImage.buildNetwork];
|
|
const dockerBuildArgs = [...networkArgs, "--pull", ...buildArgs, ...proxyArgs, "-f", "$dockerfile", "-t", "$image", "$context_dir"].join(" ");
|
|
return `
|
|
set -eu
|
|
state_dir=${shQuote(stateDir)}
|
|
mkdir -p "$state_dir"
|
|
if [ -s "$state_dir/pid" ] && kill -0 "$(cat "$state_dir/pid")" >/dev/null 2>&1; then
|
|
printf '{"started":false,"reason":"job-already-running","pid":%s,"stateDir":"%s"}\\n' "$(cat "$state_dir/pid")" "$state_dir"
|
|
exit 0
|
|
fi
|
|
cat >"$state_dir/job.sh" <<'JOB'
|
|
#!/bin/sh
|
|
set -eu
|
|
state_dir=${shQuote(stateDir)}
|
|
image=${shQuote(target.tekton.toolsImage.output)}
|
|
context_dir="$state_dir/context"
|
|
dockerfile="$state_dir/${target.tekton.toolsImage.dockerfileInline?.filename ?? "Dockerfile"}"
|
|
log="$state_dir/job.log"
|
|
status="$state_dir/status.json"
|
|
write_status() {
|
|
state="$1"; shift
|
|
message="$1"; shift || true
|
|
python3 - "$status" "$state" "$message" "$image" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path=pathlib.Path(sys.argv[1])
|
|
payload={"state":sys.argv[2],"message":sys.argv[3],"image":sys.argv[4],"updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
|
path.write_text(json.dumps(payload, ensure_ascii=False) + "\\n")
|
|
PY
|
|
}
|
|
run_job() {
|
|
write_status running starting
|
|
rm -rf "$context_dir"
|
|
mkdir -p "$context_dir"
|
|
printf %s ${shQuote(dockerfileEncoded)} | base64 -d >"$dockerfile"
|
|
${proxyExportBlock(node)}
|
|
docker build ${dockerBuildArgs} || return "$?"
|
|
docker run --rm "$image" sh -lc 'node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V' || return "$?"
|
|
docker push "$image" || return "$?"
|
|
image_id="$(docker image inspect "$image" --format '{{.Id}}' 2>/dev/null || true)"
|
|
digest="$(docker image inspect "$image" --format '{{join .RepoDigests ","}}' 2>/dev/null || true)"
|
|
python3 - "$status" "$image" "$image_id" "$digest" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path=pathlib.Path(sys.argv[1])
|
|
path.write_text(json.dumps({"state":"succeeded","message":"image-built-and-pushed","image":sys.argv[2],"imageId":sys.argv[3] or None,"repoDigests":[item for item in sys.argv[4].split(",") if item],"updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, ensure_ascii=False) + "\\n")
|
|
PY
|
|
}
|
|
run_job >>"$log" 2>&1 || {
|
|
rc=$?
|
|
write_status failed "exit-$rc"
|
|
exit "$rc"
|
|
}
|
|
JOB
|
|
chmod +x "$state_dir/job.sh"
|
|
: >"$state_dir/job.log"
|
|
nohup "$state_dir/job.sh" >/dev/null 2>&1 &
|
|
pid=$!
|
|
printf '%s' "$pid" >"$state_dir/pid"
|
|
printf '{"started":true,"pid":%s,"stateDir":"%s","statusCommand":"bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node %s --lane %s","logsCommand":"bun scripts/cli.ts hwlab nodes control-plane infra tools-image logs --node %s --lane %s"}\\n' "$pid" "$state_dir" ${shQuote(node.id)} ${shQuote(target.lane)} ${shQuote(node.id)} ${shQuote(target.lane)}
|
|
`;
|
|
}
|
|
|
|
function argoApplyStartScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, desiredYaml: string): string {
|
|
const stateDir = remoteJobStateDir(target, "argo");
|
|
const desiredEncoded = Buffer.from(desiredYaml, "utf8").toString("base64");
|
|
const rewritesEncoded = Buffer.from(JSON.stringify(target.argo.install.imageRewrites), "utf8").toString("base64");
|
|
const preloadEncoded = Buffer.from(JSON.stringify(target.argo.install.preloadImages), "utf8").toString("base64");
|
|
return `
|
|
set -eu
|
|
state_dir=${shQuote(stateDir)}
|
|
mkdir -p "$state_dir"
|
|
if [ -s "$state_dir/pid" ] && kill -0 "$(cat "$state_dir/pid")" >/dev/null 2>&1; then
|
|
printf '{"started":false,"reason":"job-already-running","pid":%s,"stateDir":"%s"}\\n' "$(cat "$state_dir/pid")" "$state_dir"
|
|
exit 0
|
|
fi
|
|
cat >"$state_dir/job.sh" <<'JOB'
|
|
#!/bin/sh
|
|
set -eu
|
|
state_dir=${shQuote(stateDir)}
|
|
namespace=${shQuote(target.argo.namespace)}
|
|
manifest_url=${shQuote(target.argo.install.manifestUrl)}
|
|
field_manager=${shQuote(target.argo.install.fieldManager)}
|
|
readiness_timeout=${shQuote(String(target.argo.install.readinessTimeoutSeconds))}
|
|
log="$state_dir/job.log"
|
|
status="$state_dir/status.json"
|
|
install_yaml="$state_dir/install.yaml"
|
|
rendered_yaml="$state_dir/install.rendered.yaml"
|
|
desired_yaml="$state_dir/desired.yaml"
|
|
write_status() {
|
|
state="$1"; shift
|
|
message="$1"; shift || true
|
|
python3 - "$status" "$state" "$message" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path=pathlib.Path(sys.argv[1])
|
|
path.write_text(json.dumps({"state":sys.argv[2],"message":sys.argv[3],"updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, ensure_ascii=False) + "\\n")
|
|
PY
|
|
}
|
|
{
|
|
write_status running starting
|
|
${proxyExportBlock(node)}
|
|
printf %s ${shQuote(desiredEncoded)} | base64 -d >"$desired_yaml"
|
|
printf %s ${shQuote(rewritesEncoded)} | base64 -d >"$state_dir/image-rewrites.json"
|
|
printf %s ${shQuote(preloadEncoded)} | base64 -d >"$state_dir/preload-images.json"
|
|
kubectl create namespace "$namespace" --dry-run=client -o yaml | kubectl apply --server-side --field-manager="$field_manager" -f - || exit "$?"
|
|
python3 - "$state_dir/preload-images.json" "$state_dir/image-rewrites.json" <<'PY' >"$state_dir/pull-images.sh"
|
|
import json, pathlib, shlex, sys
|
|
preload=json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
rewrites=json.loads(pathlib.Path(sys.argv[2]).read_text())
|
|
print("#!/bin/sh")
|
|
print("set -eu")
|
|
seen=set()
|
|
for item in rewrites:
|
|
pull=item["pullImage"]
|
|
target=item["target"]
|
|
if target in seen:
|
|
continue
|
|
seen.add(target)
|
|
print("docker pull " + shlex.quote(pull))
|
|
print("docker tag " + shlex.quote(pull) + " " + shlex.quote(target))
|
|
print("docker push " + shlex.quote(target))
|
|
for image in preload:
|
|
if image not in seen and image.startswith("127.0.0.1:5000/"):
|
|
print("docker image inspect " + shlex.quote(image) + " >/dev/null")
|
|
PY
|
|
chmod +x "$state_dir/pull-images.sh"
|
|
"$state_dir/pull-images.sh" || exit "$?"
|
|
curl -fsSL --max-time 60 "$manifest_url" >"$install_yaml" || exit "$?"
|
|
python3 - "$install_yaml" "$state_dir/image-rewrites.json" "$rendered_yaml" ${shQuote(target.argo.install.imagePullPolicy)} <<'PY'
|
|
import json, pathlib, sys
|
|
text=pathlib.Path(sys.argv[1]).read_text()
|
|
rewrites=json.loads(pathlib.Path(sys.argv[2]).read_text())
|
|
for item in rewrites:
|
|
text=text.replace(item["source"], item["target"])
|
|
policy=sys.argv[4]
|
|
text=text.replace("imagePullPolicy: Always", "imagePullPolicy: " + policy)
|
|
pathlib.Path(sys.argv[3]).write_text(text)
|
|
PY
|
|
kubectl apply --server-side --field-manager="$field_manager" -n "$namespace" -f "$rendered_yaml" || exit "$?"
|
|
deadline=$(( $(date +%s) + readiness_timeout ))
|
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && break
|
|
sleep 5
|
|
done
|
|
kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null || exit "$?"
|
|
kubectl apply --server-side --field-manager="$field_manager" -f "$desired_yaml" || exit "$?"
|
|
write_status succeeded argocd-install-applied
|
|
} >>"$log" 2>&1 || {
|
|
rc=$?
|
|
write_status failed "exit-$rc"
|
|
exit "$rc"
|
|
}
|
|
JOB
|
|
chmod +x "$state_dir/job.sh"
|
|
: >"$state_dir/job.log"
|
|
nohup "$state_dir/job.sh" >/dev/null 2>&1 &
|
|
pid=$!
|
|
printf '%s' "$pid" >"$state_dir/pid"
|
|
printf '{"started":true,"pid":%s,"stateDir":"%s","statusCommand":"bun scripts/cli.ts hwlab nodes control-plane infra argo status --node %s --lane %s","logsCommand":"bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node %s --lane %s"}\\n' "$pid" "$state_dir" ${shQuote(node.id)} ${shQuote(target.lane)} ${shQuote(node.id)} ${shQuote(target.lane)}
|
|
`;
|
|
}
|
|
|
|
function remoteJobStatusScript(target: ControlPlaneTargetSpec, name: "tools-image" | "argo", tailLines: number): string {
|
|
const stateDir = remoteJobStateDir(target, name);
|
|
return `
|
|
set +e
|
|
state_dir=${shQuote(stateDir)}
|
|
status_file="$state_dir/status.json"
|
|
log_file="$state_dir/job.log"
|
|
pid_file="$state_dir/pid"
|
|
running=false
|
|
pid=null
|
|
if [ -s "$pid_file" ]; then
|
|
pid_raw="$(cat "$pid_file" 2>/dev/null || true)"
|
|
if [ -n "$pid_raw" ] && kill -0 "$pid_raw" >/dev/null 2>&1; then running=true; pid="$pid_raw"; else pid="$pid_raw"; fi
|
|
fi
|
|
python3 - "$state_dir" "$status_file" "$log_file" "$running" "$pid" ${shQuote(String(tailLines))} <<'PY'
|
|
import json, pathlib, sys
|
|
state_dir=pathlib.Path(sys.argv[1])
|
|
status_path=pathlib.Path(sys.argv[2])
|
|
log_path=pathlib.Path(sys.argv[3])
|
|
running=sys.argv[4] == "true"
|
|
pid=None if sys.argv[5] in ("", "null") else sys.argv[5]
|
|
tail_lines=int(sys.argv[6])
|
|
status=None
|
|
if status_path.exists():
|
|
try:
|
|
status=json.loads(status_path.read_text())
|
|
except Exception as error:
|
|
status={"parseError": str(error), "raw": status_path.read_text(errors="replace")[-1000:]}
|
|
log_tail=""
|
|
if log_path.exists():
|
|
lines=log_path.read_text(errors="replace").splitlines()
|
|
log_tail="\\n".join(lines[-tail_lines:])
|
|
print(json.dumps({"stateDir": str(state_dir), "pid": pid, "running": running, "status": status, "logBytes": log_path.stat().st_size if log_path.exists() else 0, "logTail": log_tail}, ensure_ascii=False))
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function remoteJobLogs(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, name: "tools-image" | "argo", options: ToolsImageOptions | ArgoOptions): Record<string, unknown> {
|
|
const result = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, name, options.tailLines), options.timeoutSeconds);
|
|
const parsed = parseRemoteJson(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
command: `hwlab nodes control-plane infra ${name === "tools-image" ? "tools-image" : "argo"} logs`,
|
|
configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
node: node.id,
|
|
lane: target.lane,
|
|
mutation: false,
|
|
job: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) },
|
|
result: compactCommandResult(result),
|
|
};
|
|
}
|
|
|
|
function manifestObjectSummary(manifest: readonly Record<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 proxyExportBlock(node: ControlPlaneNodeSpec): string {
|
|
const proxy = node.egressProxy;
|
|
if (proxy === null) return " : # no egress proxy configured\n";
|
|
const noProxy = [...new Set(["localhost", "127.0.0.1", "::1", "127.0.0.1:5000", "localhost:5000", ...proxy.noProxy])];
|
|
return `
|
|
proxy_ip="$(kubectl -n ${shQuote(proxy.namespace)} get svc ${shQuote(proxy.serviceName)} -o 'jsonpath={.spec.clusterIP}' 2>/dev/null || true)"
|
|
if [ -z "$proxy_ip" ]; then echo "egress proxy service missing: ${proxy.namespace}/${proxy.serviceName}" >&2; exit 41; fi
|
|
export HTTP_PROXY="http://$proxy_ip:${proxy.port}"
|
|
export HTTPS_PROXY="$HTTP_PROXY"
|
|
export ALL_PROXY="$HTTP_PROXY"
|
|
export http_proxy="$HTTP_PROXY"
|
|
export https_proxy="$HTTP_PROXY"
|
|
export all_proxy="$HTTP_PROXY"
|
|
export NO_PROXY=${shQuote(noProxy.join(","))}
|
|
export no_proxy="$NO_PROXY"
|
|
`;
|
|
}
|
|
|
|
function remoteJobStateDir(target: ControlPlaneTargetSpec, name: "tools-image" | "argo"): string {
|
|
return `/tmp/unidesk-hwlab-node-control-plane/${target.id}/${name}`;
|
|
}
|
|
|
|
function shellJsonArray(items: readonly string[]): string {
|
|
return JSON.stringify([...items]);
|
|
}
|
|
|
|
function parseRemoteJson(text: string): unknown {
|
|
const trimmed = text.trim();
|
|
if (trimmed.length === 0) return null;
|
|
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 positiveConfigIntegerField(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 stringRecordField(obj: Record<string, unknown>, path: string): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} has an unsupported key format`);
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function booleanField(obj: Record<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 validateHttpsUrl(value: string, path: string): void {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
throw new Error(`${path} must be a valid URL`);
|
|
}
|
|
if (parsed.protocol !== "https:") throw new Error(`${path} must use https://`);
|
|
}
|
|
|
|
function sha256Short(text: string): string {
|
|
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
}
|