|
|
|
@@ -0,0 +1,665 @@
|
|
|
|
|
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
|
|
|
|
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
|
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
|
|
|
import { repoRoot, rootPath } from "./config";
|
|
|
|
|
import { runCommand, type CommandResult } from "./command";
|
|
|
|
|
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
|
|
|
|
|
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
|
|
|
|
import type { RenderedCliResult } from "./output";
|
|
|
|
|
|
|
|
|
|
export type WebProbeSentinelConfigAction = "plan" | "status";
|
|
|
|
|
export type WebProbeSentinelImageAction = "status" | "build";
|
|
|
|
|
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
|
|
|
|
|
|
|
|
|
|
export type WebProbeSentinelOptions =
|
|
|
|
|
| {
|
|
|
|
|
readonly kind: "config";
|
|
|
|
|
readonly action: WebProbeSentinelConfigAction;
|
|
|
|
|
readonly node: string;
|
|
|
|
|
readonly lane: string;
|
|
|
|
|
readonly dryRun: boolean;
|
|
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
readonly kind: "image";
|
|
|
|
|
readonly action: WebProbeSentinelImageAction;
|
|
|
|
|
readonly node: string;
|
|
|
|
|
readonly lane: string;
|
|
|
|
|
readonly dryRun: boolean;
|
|
|
|
|
readonly confirm: boolean;
|
|
|
|
|
readonly wait: boolean;
|
|
|
|
|
readonly timeoutSeconds: number;
|
|
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
readonly kind: "control-plane";
|
|
|
|
|
readonly action: WebProbeSentinelControlPlaneAction;
|
|
|
|
|
readonly node: string;
|
|
|
|
|
readonly lane: string;
|
|
|
|
|
readonly dryRun: boolean;
|
|
|
|
|
readonly confirm: boolean;
|
|
|
|
|
readonly wait: boolean;
|
|
|
|
|
readonly timeoutSeconds: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface SentinelCicdState {
|
|
|
|
|
readonly spec: HwlabRuntimeLaneSpec;
|
|
|
|
|
readonly configReady: boolean;
|
|
|
|
|
readonly runtime: Record<string, unknown>;
|
|
|
|
|
readonly cicd: Record<string, unknown>;
|
|
|
|
|
readonly publicExposure: Record<string, unknown>;
|
|
|
|
|
readonly controlPlaneTarget: Record<string, unknown>;
|
|
|
|
|
readonly controlPlaneNode: Record<string, unknown>;
|
|
|
|
|
readonly sourceHead: SourceHead;
|
|
|
|
|
readonly image: SentinelImagePlan;
|
|
|
|
|
readonly manifests: readonly Record<string, unknown>[];
|
|
|
|
|
readonly manifestSha256: string;
|
|
|
|
|
readonly valuesRedacted: true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SourceHead {
|
|
|
|
|
readonly ok: boolean;
|
|
|
|
|
readonly repository: string;
|
|
|
|
|
readonly branch: string;
|
|
|
|
|
readonly commit: string | null;
|
|
|
|
|
readonly localHead: string | null;
|
|
|
|
|
readonly result: CompactCommandResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SentinelImagePlan {
|
|
|
|
|
readonly repository: string;
|
|
|
|
|
readonly tag: string;
|
|
|
|
|
readonly ref: string;
|
|
|
|
|
readonly digestRef: string | null;
|
|
|
|
|
readonly baseImage: string;
|
|
|
|
|
readonly buildContext: string;
|
|
|
|
|
readonly entrypoint: string;
|
|
|
|
|
readonly dockerfileSha256: string;
|
|
|
|
|
readonly dockerfilePreview: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CompactCommandResult {
|
|
|
|
|
readonly exitCode: number | null;
|
|
|
|
|
readonly timedOut: boolean;
|
|
|
|
|
readonly stdoutBytes: number;
|
|
|
|
|
readonly stderrBytes: number;
|
|
|
|
|
readonly stdoutPreview: string;
|
|
|
|
|
readonly stderrPreview: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
|
|
|
|
|
|
|
|
|
|
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
|
|
|
|
|
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action));
|
|
|
|
|
const state = loadSentinelCicdState(spec, options.timeoutSeconds);
|
|
|
|
|
if (options.kind === "image") return runSentinelImage(state, options);
|
|
|
|
|
return runSentinelControlPlane(state, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "image" }>): RenderedCliResult {
|
|
|
|
|
const command = `hwlab nodes web-probe sentinel image ${options.action}`;
|
|
|
|
|
const registry = options.action === "status" ? probeImageRegistry(state, options.timeoutSeconds) : null;
|
|
|
|
|
const mutationBlocked = options.confirm ? confirmBlocked("image build", state) : null;
|
|
|
|
|
const registryReady = options.action !== "status" || record(registry?.probe).present === true;
|
|
|
|
|
const result = {
|
|
|
|
|
ok: state.configReady && state.sourceHead.ok && registryReady && mutationBlocked === null,
|
|
|
|
|
command,
|
|
|
|
|
node: state.spec.nodeId,
|
|
|
|
|
lane: state.spec.lane,
|
|
|
|
|
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
|
|
|
|
|
mutation: false,
|
|
|
|
|
specRef: SPEC_REF,
|
|
|
|
|
source: state.sourceHead,
|
|
|
|
|
image: state.image,
|
|
|
|
|
registry,
|
|
|
|
|
blocker: mutationBlocked,
|
|
|
|
|
next: {
|
|
|
|
|
status: `bun scripts/cli.ts hwlab nodes web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
|
|
|
|
|
dryRun: `bun scripts/cli.ts hwlab nodes web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`,
|
|
|
|
|
controlPlanePlan: `bun scripts/cli.ts hwlab nodes web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`,
|
|
|
|
|
},
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
return rendered(result.ok, command, renderImageResult(result));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "control-plane" }>): RenderedCliResult {
|
|
|
|
|
const command = `hwlab nodes web-probe sentinel control-plane ${options.action}`;
|
|
|
|
|
const mutationAction = options.action === "apply" || options.action === "trigger-current";
|
|
|
|
|
const mutationBlocked = options.confirm && mutationAction ? confirmBlocked(options.action, state) : null;
|
|
|
|
|
const gitMirrorStatus = options.action === "status" ? runChildCli(["hwlab", "nodes", "git-mirror", "status", "--node", state.spec.nodeId, "--lane", state.spec.lane], options.timeoutSeconds) : null;
|
|
|
|
|
const nodeControlPlaneStatus = options.action === "status" ? runChildCli(["hwlab", "nodes", "control-plane", "status", "--node", state.spec.nodeId, "--lane", state.spec.lane], options.timeoutSeconds) : null;
|
|
|
|
|
const observedReady = options.action !== "status" || (record(gitMirrorStatus).ok === true && record(nodeControlPlaneStatus).ok === true);
|
|
|
|
|
const pipelineRun = sentinelPipelineRunName(state);
|
|
|
|
|
const result = {
|
|
|
|
|
ok: state.configReady && state.sourceHead.ok && observedReady && mutationBlocked === null,
|
|
|
|
|
command,
|
|
|
|
|
node: state.spec.nodeId,
|
|
|
|
|
lane: state.spec.lane,
|
|
|
|
|
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
|
|
|
|
|
mutation: false,
|
|
|
|
|
specRef: SPEC_REF,
|
|
|
|
|
source: state.sourceHead,
|
|
|
|
|
image: state.image,
|
|
|
|
|
pipelineRun,
|
|
|
|
|
gitops: {
|
|
|
|
|
path: stringField(state.cicd, "gitopsPath"),
|
|
|
|
|
targetRevision: stringAt(state.cicd, "argo.targetRevision"),
|
|
|
|
|
manifestObjects: state.manifests.length,
|
|
|
|
|
manifestSha256: state.manifestSha256,
|
|
|
|
|
},
|
|
|
|
|
argo: {
|
|
|
|
|
namespace: stringAt(state.cicd, "argo.namespace"),
|
|
|
|
|
projectName: stringAt(state.cicd, "argo.projectName"),
|
|
|
|
|
applicationName: stringAt(state.cicd, "argo.applicationName"),
|
|
|
|
|
},
|
|
|
|
|
maintenance: {
|
|
|
|
|
startCommand: stringAt(state.cicd, "maintenance.startCommand"),
|
|
|
|
|
stopCommand: stringAt(state.cicd, "maintenance.stopCommand"),
|
|
|
|
|
serviceUnavailablePolicy: stringAt(state.cicd, "targetValidation.serviceUnavailablePolicy"),
|
|
|
|
|
},
|
|
|
|
|
validation: {
|
|
|
|
|
scenarioId: stringAt(state.cicd, "targetValidation.scenarioId"),
|
|
|
|
|
maxSeconds: numberAt(state.cicd, "targetValidation.maxSeconds"),
|
|
|
|
|
automaticSecondPath: false,
|
|
|
|
|
},
|
|
|
|
|
manifests: {
|
|
|
|
|
objects: manifestObjectSummary(state.manifests),
|
|
|
|
|
sha256: state.manifestSha256,
|
|
|
|
|
},
|
|
|
|
|
observed: {
|
|
|
|
|
gitMirror: gitMirrorStatus,
|
|
|
|
|
nodeControlPlane: nodeControlPlaneStatus,
|
|
|
|
|
},
|
|
|
|
|
blocker: mutationBlocked,
|
|
|
|
|
next: controlPlaneNext(state, options.action),
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
return rendered(result.ok, command, renderControlPlaneResult(result));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): SentinelCicdState {
|
|
|
|
|
const sentinel = spec.observability.webProbe?.sentinel;
|
|
|
|
|
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`);
|
|
|
|
|
const configPlan = webProbeSentinelConfigPlan(spec, "status");
|
|
|
|
|
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime), sentinel.configRefs.runtime);
|
|
|
|
|
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd), sentinel.configRefs.cicd);
|
|
|
|
|
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure), sentinel.configRefs.publicExposure);
|
|
|
|
|
const controlPlaneRef = stringField(cicd, "controlPlaneConfigRef");
|
|
|
|
|
const controlPlaneTarget = recordTarget(readConfigRefTarget(controlPlaneRef), controlPlaneRef);
|
|
|
|
|
const controlPlaneConfig = recordTarget(readConfigFile(configRefFile(controlPlaneRef)), configRefFile(controlPlaneRef));
|
|
|
|
|
const nodeId = stringField(controlPlaneTarget, "node");
|
|
|
|
|
const controlPlaneNode = recordTarget(valueAtPath(controlPlaneConfig, `nodes.${nodeId}`), `${configRefFile(controlPlaneRef)}#nodes.${nodeId}`);
|
|
|
|
|
const sourceHead = resolveSourceHead(cicd, timeoutSeconds);
|
|
|
|
|
const image = sentinelImagePlan(cicd, sourceHead);
|
|
|
|
|
const manifests = renderSentinelManifests(spec, runtime, cicd, publicExposure, image);
|
|
|
|
|
const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
|
|
|
|
return {
|
|
|
|
|
spec,
|
|
|
|
|
configReady: configPlan.ok,
|
|
|
|
|
runtime,
|
|
|
|
|
cicd,
|
|
|
|
|
publicExposure,
|
|
|
|
|
controlPlaneTarget,
|
|
|
|
|
controlPlaneNode,
|
|
|
|
|
sourceHead,
|
|
|
|
|
image,
|
|
|
|
|
manifests,
|
|
|
|
|
manifestSha256: sha256(manifestYaml),
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveSourceHead(cicd: Record<string, unknown>, timeoutSeconds: number): SourceHead {
|
|
|
|
|
const repository = stringAt(cicd, "source.repository");
|
|
|
|
|
const branch = stringAt(cicd, "source.branch");
|
|
|
|
|
const remote = runCommand(["git", "ls-remote", "origin", `refs/heads/${branch}`], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
|
|
|
|
|
const local = runCommand(["git", "rev-parse", "HEAD"], repoRoot, { timeoutMs: 10_000 });
|
|
|
|
|
const commit = /^[0-9a-f]{40}\b/iu.exec(remote.stdout.trim())?.[0].toLowerCase() ?? null;
|
|
|
|
|
const localHead = /^[0-9a-f]{40}$/iu.test(local.stdout.trim()) ? local.stdout.trim().toLowerCase() : null;
|
|
|
|
|
return {
|
|
|
|
|
ok: remote.exitCode === 0 && commit !== null,
|
|
|
|
|
repository,
|
|
|
|
|
branch,
|
|
|
|
|
commit,
|
|
|
|
|
localHead,
|
|
|
|
|
result: compactCommand(remote),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sentinelImagePlan(cicd: Record<string, unknown>, sourceHead: SourceHead): SentinelImagePlan {
|
|
|
|
|
const repository = stringAt(cicd, "image.repository");
|
|
|
|
|
const tag = sourceHead.commit === null ? "source-unresolved" : sourceHead.commit.slice(0, 12);
|
|
|
|
|
const baseImageRef = stringAt(cicd, "image.baseImageRef");
|
|
|
|
|
const baseImage = stringTarget(readConfigRefTarget(baseImageRef), baseImageRef);
|
|
|
|
|
const entrypoint = stringAt(cicd, "source.entrypoint");
|
|
|
|
|
const dockerfile = sentinelDockerfile(baseImage, entrypoint);
|
|
|
|
|
return {
|
|
|
|
|
repository,
|
|
|
|
|
tag,
|
|
|
|
|
ref: `${repository}:${tag}`,
|
|
|
|
|
digestRef: null,
|
|
|
|
|
baseImage,
|
|
|
|
|
buildContext: stringAt(cicd, "source.buildContext"),
|
|
|
|
|
entrypoint,
|
|
|
|
|
dockerfileSha256: sha256(dockerfile),
|
|
|
|
|
dockerfilePreview: dockerfile,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sentinelDockerfile(baseImage: string, entrypoint: string): string {
|
|
|
|
|
return [
|
|
|
|
|
`FROM ${baseImage}`,
|
|
|
|
|
"WORKDIR /app",
|
|
|
|
|
"COPY . /app",
|
|
|
|
|
"ENV NODE_ENV=production",
|
|
|
|
|
`ENTRYPOINT ["bun", "${entrypoint}"]`,
|
|
|
|
|
"",
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSentinelManifests(
|
|
|
|
|
spec: HwlabRuntimeLaneSpec,
|
|
|
|
|
runtime: Record<string, unknown>,
|
|
|
|
|
cicd: Record<string, unknown>,
|
|
|
|
|
publicExposure: Record<string, unknown>,
|
|
|
|
|
image: SentinelImagePlan,
|
|
|
|
|
): readonly Record<string, unknown>[] {
|
|
|
|
|
const namespace = stringAt(runtime, "namespace");
|
|
|
|
|
const labels = {
|
|
|
|
|
"app.kubernetes.io/name": stringAt(runtime, "deploymentName"),
|
|
|
|
|
"app.kubernetes.io/part-of": "hwlab-web-probe-sentinel",
|
|
|
|
|
"app.kubernetes.io/managed-by": "unidesk",
|
|
|
|
|
"unidesk.ai/spec-ref": "PJ2026-01060508",
|
|
|
|
|
"unidesk.ai/node": spec.nodeId,
|
|
|
|
|
"unidesk.ai/lane": spec.lane,
|
|
|
|
|
};
|
|
|
|
|
const deploymentName = stringAt(runtime, "deploymentName");
|
|
|
|
|
const serviceName = stringAt(runtime, "serviceName");
|
|
|
|
|
const servicePort = numberAt(runtime, "servicePort");
|
|
|
|
|
const pvcStorage = stringAt(runtime, "pvcStorage");
|
|
|
|
|
const stateRoot = stringAt(runtime, "stateRoot");
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1",
|
|
|
|
|
kind: "ServiceAccount",
|
|
|
|
|
metadata: { name: stringAt(runtime, "serviceAccountName"), namespace, labels },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1",
|
|
|
|
|
kind: "PersistentVolumeClaim",
|
|
|
|
|
metadata: { name: stringAt(runtime, "pvcName"), namespace, labels },
|
|
|
|
|
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: pvcStorage } } },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1",
|
|
|
|
|
kind: "ConfigMap",
|
|
|
|
|
metadata: { name: `${deploymentName}-config`, namespace, labels },
|
|
|
|
|
data: {
|
|
|
|
|
"config-summary.json": JSON.stringify({
|
|
|
|
|
specRef: SPEC_REF,
|
|
|
|
|
node: spec.nodeId,
|
|
|
|
|
lane: spec.lane,
|
|
|
|
|
publicBaseUrl: stringAt(publicExposure, "publicBaseUrl"),
|
|
|
|
|
gitopsPath: stringAt(cicd, "gitopsPath"),
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
}, null, 2),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "apps/v1",
|
|
|
|
|
kind: "Deployment",
|
|
|
|
|
metadata: { name: deploymentName, namespace, labels },
|
|
|
|
|
spec: {
|
|
|
|
|
replicas: numberAt(runtime, "replicas"),
|
|
|
|
|
selector: { matchLabels: { "app.kubernetes.io/name": deploymentName } },
|
|
|
|
|
template: {
|
|
|
|
|
metadata: { labels },
|
|
|
|
|
spec: {
|
|
|
|
|
serviceAccountName: stringAt(runtime, "serviceAccountName"),
|
|
|
|
|
containers: [{
|
|
|
|
|
name: "sentinel",
|
|
|
|
|
image: image.ref,
|
|
|
|
|
imagePullPolicy: "IfNotPresent",
|
|
|
|
|
args: [
|
|
|
|
|
"--node",
|
|
|
|
|
spec.nodeId,
|
|
|
|
|
"--lane",
|
|
|
|
|
spec.lane,
|
|
|
|
|
"--state-root",
|
|
|
|
|
stateRoot,
|
|
|
|
|
"--host",
|
|
|
|
|
stringAt(runtime, "listenHost"),
|
|
|
|
|
"--port",
|
|
|
|
|
String(servicePort),
|
|
|
|
|
],
|
|
|
|
|
ports: [{ name: "http", containerPort: servicePort }],
|
|
|
|
|
readinessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
|
|
|
|
|
livenessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
|
|
|
|
|
volumeMounts: [{ name: "state", mountPath: stateRoot }],
|
|
|
|
|
}],
|
|
|
|
|
volumes: [{ name: "state", persistentVolumeClaim: { claimName: stringAt(runtime, "pvcName") } }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1",
|
|
|
|
|
kind: "Service",
|
|
|
|
|
metadata: { name: serviceName, namespace, labels },
|
|
|
|
|
spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": deploymentName }, ports: [{ name: "http", port: servicePort, targetPort: "http" }] },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "networking.k8s.io/v1",
|
|
|
|
|
kind: "NetworkPolicy",
|
|
|
|
|
metadata: { name: `${deploymentName}-egress`, namespace, labels },
|
|
|
|
|
spec: {
|
|
|
|
|
podSelector: { matchLabels: { "app.kubernetes.io/name": deploymentName } },
|
|
|
|
|
policyTypes: ["Ingress", "Egress"],
|
|
|
|
|
ingress: [{ from: [{ namespaceSelector: {} }], ports: [{ protocol: "TCP", port: servicePort }] }],
|
|
|
|
|
egress: [{ to: [{ namespaceSelector: {} }] }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "argoproj.io/v1alpha1",
|
|
|
|
|
kind: "Application",
|
|
|
|
|
metadata: { name: stringAt(cicd, "argo.applicationName"), namespace: stringAt(cicd, "argo.namespace"), labels },
|
|
|
|
|
spec: {
|
|
|
|
|
project: stringAt(cicd, "argo.projectName"),
|
|
|
|
|
source: {
|
|
|
|
|
repoURL: stringAt(cicd, "argo.repoURL"),
|
|
|
|
|
targetRevision: stringAt(cicd, "argo.targetRevision"),
|
|
|
|
|
path: stringAt(cicd, "gitopsPath"),
|
|
|
|
|
},
|
|
|
|
|
destination: { server: "https://kubernetes.default.svc", namespace },
|
|
|
|
|
syncPolicy: { automated: { prune: true, selfHeal: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function probeImageRegistry(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
|
|
|
|
|
const endpoint = stringAt(state.controlPlaneNode, "registry.endpoint");
|
|
|
|
|
const repoTag = state.image.ref.replace(`${endpoint}/`, "");
|
|
|
|
|
const repo = repoTag.slice(0, repoTag.lastIndexOf(":"));
|
|
|
|
|
const tag = repoTag.slice(repoTag.lastIndexOf(":") + 1);
|
|
|
|
|
const route = stringAt(state.controlPlaneNode, "route");
|
|
|
|
|
const script = [
|
|
|
|
|
"set +e",
|
|
|
|
|
`url=${shellQuote(`http://${endpoint}/v2/${repo}/manifests/${tag}`)}`,
|
|
|
|
|
"headers=$(mktemp)",
|
|
|
|
|
"if command -v curl >/dev/null 2>&1 && curl -fsSI --max-time 5 \"$url\" >\"$headers\" 2>/tmp/web-probe-sentinel-image.err; then present=true; else present=false; fi",
|
|
|
|
|
"digest=$(awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {gsub(/\\r/,\"\",$2); print $2; exit}' \"$headers\" 2>/dev/null)",
|
|
|
|
|
"python3 - \"$present\" \"$digest\" \"$url\" <<'PY'",
|
|
|
|
|
"import json, sys",
|
|
|
|
|
"print(json.dumps({'present': sys.argv[1] == 'true', 'digest': sys.argv[2] or None, 'url': sys.argv[3], 'valuesRedacted': True}))",
|
|
|
|
|
"PY",
|
|
|
|
|
].join("\n");
|
|
|
|
|
const result = runCommand(["trans", route, "sh", "--", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0,
|
|
|
|
|
probe: parseJsonObject(result.stdout),
|
|
|
|
|
result: compactCommand(result),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function confirmBlocked(action: string, state: SentinelCicdState): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
code: "sentinel-cicd-confirm-requires-remote-publish-job",
|
|
|
|
|
action,
|
|
|
|
|
reason: "P4 currently provides YAML-first render/status/trigger dry-run and refuses to report a deployment mutation before the remote publish job is wired to the node-local git mirror.",
|
|
|
|
|
sourceGitMirrorReadUrl: stringAt(state.cicd, "source.gitMirrorReadUrl"),
|
|
|
|
|
requiredNextImplementation: [
|
|
|
|
|
"clone source from source.gitMirrorReadUrl at selected commit",
|
|
|
|
|
"build and push digest-pinned image on the selected node",
|
|
|
|
|
"publish manifests to the HWLAB gitops branch/path through git-mirror",
|
|
|
|
|
"flush/recheck git-mirror and let Argo reconcile the Application",
|
|
|
|
|
],
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
|
|
|
|
|
const node = state.spec.nodeId;
|
|
|
|
|
const lane = state.spec.lane;
|
|
|
|
|
return {
|
|
|
|
|
plan: `bun scripts/cli.ts hwlab nodes web-probe sentinel control-plane plan --node ${node} --lane ${lane} --dry-run`,
|
|
|
|
|
status: `bun scripts/cli.ts hwlab nodes web-probe sentinel control-plane status --node ${node} --lane ${lane}`,
|
|
|
|
|
image: `bun scripts/cli.ts hwlab nodes web-probe sentinel image status --node ${node} --lane ${lane}`,
|
|
|
|
|
triggerCurrent: `bun scripts/cli.ts hwlab nodes web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane} --dry-run`,
|
|
|
|
|
issue: "https://github.com/pikasTech/unidesk/issues/889",
|
|
|
|
|
currentAction: action,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sentinelPipelineRunName(state: SentinelCicdState): string {
|
|
|
|
|
const commit = state.sourceHead.commit ?? "source";
|
|
|
|
|
return `hwlab-web-probe-sentinel-${commit.slice(0, 12)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runChildCli(args: string[], timeoutSeconds: number): Record<string, unknown> {
|
|
|
|
|
const result = runCommand(["bun", "scripts/cli.ts", ...args], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 120) * 1000 });
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0,
|
|
|
|
|
parsed: parseJsonObject(result.stdout),
|
|
|
|
|
result: compactCommand(result),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderImageResult(result: Record<string, unknown>): string {
|
|
|
|
|
const source = record(result.source);
|
|
|
|
|
const image = record(result.image);
|
|
|
|
|
const registry = record(result.registry);
|
|
|
|
|
const blocker = record(result.blocker);
|
|
|
|
|
const next = record(result.next);
|
|
|
|
|
return [
|
|
|
|
|
String(result.command),
|
|
|
|
|
"",
|
|
|
|
|
table(["NODE", "LANE", "STATUS", "MODE", "MUTATION"], [[result.node, result.lane, result.ok === true ? "ok" : "blocked", result.mode, result.mutation]]),
|
|
|
|
|
"",
|
|
|
|
|
table(["SOURCE_REPO", "BRANCH", "COMMIT", "LOCAL_HEAD"], [[source.repository, source.branch, short(source.commit), short(source.localHead)]]),
|
|
|
|
|
"",
|
|
|
|
|
table(["IMAGE", "BASE", "ENTRYPOINT", "DOCKERFILE"], [[image.ref, image.baseImage, image.entrypoint, short(image.dockerfileSha256)]]),
|
|
|
|
|
"",
|
|
|
|
|
Object.keys(registry).length === 0 ? "REGISTRY\n-" : table(["PROBED", "PRESENT", "DIGEST"], [[record(registry.probe).url ?? "-", record(registry.probe).present ?? "-", short(record(registry.probe).digest)]]),
|
|
|
|
|
"",
|
|
|
|
|
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : table(["CODE", "REASON"], [[blocker.code, blocker.reason]]),
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
` status: ${next.status ?? "-"}`,
|
|
|
|
|
` dry-run: ${next.dryRun ?? "-"}`,
|
|
|
|
|
` control-plane: ${next.controlPlanePlan ?? "-"}`,
|
|
|
|
|
"",
|
|
|
|
|
"DISCLOSURE",
|
|
|
|
|
" valuesRedacted=true; image status shows refs, hashes and object names only.",
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderControlPlaneResult(result: Record<string, unknown>): string {
|
|
|
|
|
const source = record(result.source);
|
|
|
|
|
const image = record(result.image);
|
|
|
|
|
const gitops = record(result.gitops);
|
|
|
|
|
const argo = record(result.argo);
|
|
|
|
|
const validation = record(result.validation);
|
|
|
|
|
const observed = record(result.observed);
|
|
|
|
|
const blocker = record(result.blocker);
|
|
|
|
|
const next = record(result.next);
|
|
|
|
|
return [
|
|
|
|
|
String(result.command),
|
|
|
|
|
"",
|
|
|
|
|
table(["NODE", "LANE", "STATUS", "MODE", "PIPELINERUN"], [[result.node, result.lane, result.ok === true ? "ok" : "blocked", result.mode, result.pipelineRun]]),
|
|
|
|
|
"",
|
|
|
|
|
table(["SOURCE", "COMMIT", "IMAGE", "MANIFEST"], [[`${source.repository}@${source.branch}`, short(source.commit), image.ref, short(gitops.manifestSha256)]]),
|
|
|
|
|
"",
|
|
|
|
|
table(["GITOPS_PATH", "ARGO_APP", "TARGET_REV", "OBJECTS"], [[gitops.path, argo.applicationName, gitops.targetRevision, gitops.manifestObjects]]),
|
|
|
|
|
"",
|
|
|
|
|
table(["SCENARIO", "MAX_SECONDS", "SECOND_PATH"], [[validation.scenarioId, validation.maxSeconds, validation.automaticSecondPath]]),
|
|
|
|
|
"",
|
|
|
|
|
renderObservedStatus(observed),
|
|
|
|
|
"",
|
|
|
|
|
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : table(["CODE", "REASON"], [[blocker.code, blocker.reason]]),
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
` plan: ${next.plan ?? "-"}`,
|
|
|
|
|
` status: ${next.status ?? "-"}`,
|
|
|
|
|
` image: ${next.image ?? "-"}`,
|
|
|
|
|
` trigger-current: ${next.triggerCurrent ?? "-"}`,
|
|
|
|
|
"",
|
|
|
|
|
"DISCLOSURE",
|
|
|
|
|
" default view is a bounded CI/CD summary; full manifest content is represented by object counts and sha256.",
|
|
|
|
|
" sentinel unavailable policy is structured-failure; no automatic second execution path is rendered.",
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderObservedStatus(observed: Record<string, unknown>): string {
|
|
|
|
|
const rows = [
|
|
|
|
|
observedStatusRow("git-mirror", observed.gitMirror),
|
|
|
|
|
observedStatusRow("control-plane", observed.nodeControlPlane),
|
|
|
|
|
].filter((row) => row !== null);
|
|
|
|
|
if (rows.length === 0) return "OBSERVED\n-";
|
|
|
|
|
return table(["CHECK", "OK", "EXIT", "TIMED_OUT", "STDOUT_BYTES", "PREVIEW"], rows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function observedStatusRow(name: string, value: unknown): unknown[] | null {
|
|
|
|
|
const item = record(value);
|
|
|
|
|
if (Object.keys(item).length === 0) return null;
|
|
|
|
|
const result = record(item.result);
|
|
|
|
|
return [name, item.ok, result.exitCode, result.timedOut, result.stdoutBytes, result.stdoutPreview];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rendered(ok: boolean, command: string, text: string): RenderedCliResult {
|
|
|
|
|
return { ok, command, renderedText: `${text.trimEnd()}\n`, contentType: "text/plain" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readConfigRefTarget(ref: string): unknown {
|
|
|
|
|
const file = configRefFile(ref);
|
|
|
|
|
const path = configRefPath(ref);
|
|
|
|
|
return valueAtPath(readConfigFile(file), path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readConfigFile(file: string): unknown {
|
|
|
|
|
if (file.startsWith("/") || file.includes("..") || !file.startsWith("config/")) throw new Error(`unsafe configRef file: ${file}`);
|
|
|
|
|
const abs = rootPath(file);
|
|
|
|
|
if (!existsSync(abs)) throw new Error(`${file} does not exist`);
|
|
|
|
|
return Bun.YAML.parse(readFileSync(abs, "utf8")) as unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function configRefFile(ref: string): string {
|
|
|
|
|
const [file, path, extra] = ref.split("#");
|
|
|
|
|
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) throw new Error(`invalid configRef: ${ref}`);
|
|
|
|
|
return file;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function configRefPath(ref: string): string {
|
|
|
|
|
const [, path] = ref.split("#");
|
|
|
|
|
if (path === undefined || path.length === 0) throw new Error(`invalid configRef: ${ref}`);
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function valueAtPath(value: unknown, path: string): unknown {
|
|
|
|
|
let current: unknown = value;
|
|
|
|
|
for (const segment of path.split(".")) {
|
|
|
|
|
const match = /^([A-Za-z0-9_-]+)?(?:\[(\d+)\])?$/u.exec(segment);
|
|
|
|
|
if (match === null) return undefined;
|
|
|
|
|
if (match[1] !== undefined) {
|
|
|
|
|
if (!isRecord(current)) return undefined;
|
|
|
|
|
current = current[match[1]];
|
|
|
|
|
}
|
|
|
|
|
if (match[2] !== undefined) {
|
|
|
|
|
if (!Array.isArray(current)) return undefined;
|
|
|
|
|
current = current[Number(match[2])];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringAt(value: unknown, path: string): string {
|
|
|
|
|
const found = valueAtPath(value, path);
|
|
|
|
|
if (typeof found !== "string" || found.length === 0) throw new Error(`${path} must be a non-empty string`);
|
|
|
|
|
return found;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringField(value: Record<string, unknown>, path: string): string {
|
|
|
|
|
return stringAt(value, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringTarget(value: unknown, label: string): string {
|
|
|
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must resolve to a non-empty string`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numberAt(value: unknown, path: string): number {
|
|
|
|
|
const found = valueAtPath(value, path);
|
|
|
|
|
if (typeof found !== "number" || !Number.isFinite(found)) throw new Error(`${path} must be a number`);
|
|
|
|
|
return found;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordTarget(value: unknown, label: string): Record<string, unknown> {
|
|
|
|
|
if (!isRecord(value)) throw new Error(`${label} must resolve to an object`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
|
|
|
return isRecord(value) ? value : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function manifestObjectSummary(items: readonly Record<string, unknown>[]): readonly Record<string, unknown>[] {
|
|
|
|
|
return items.map((item) => ({
|
|
|
|
|
kind: item.kind ?? null,
|
|
|
|
|
name: record(item.metadata).name ?? null,
|
|
|
|
|
namespace: record(item.metadata).namespace ?? null,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactCommand(result: CommandResult): CompactCommandResult {
|
|
|
|
|
return {
|
|
|
|
|
exitCode: result.exitCode,
|
|
|
|
|
timedOut: result.timedOut,
|
|
|
|
|
stdoutBytes: Buffer.byteLength(result.stdout),
|
|
|
|
|
stderrBytes: Buffer.byteLength(result.stderr),
|
|
|
|
|
stdoutPreview: result.stdout.trim().slice(0, 500),
|
|
|
|
|
stderrPreview: result.stderr.trim().slice(0, 500),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
|
|
|
|
const trimmed = text.trim();
|
|
|
|
|
if (trimmed.length === 0) return null;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(trimmed) as unknown;
|
|
|
|
|
return isRecord(parsed) ? parsed : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function table(headers: string[], rows: unknown[][]): string {
|
|
|
|
|
const normalized = [headers, ...rows.map((row) => row.map(text))];
|
|
|
|
|
const widths = headers.map((_, index) => Math.max(...normalized.map((row) => text(row[index] ?? "").length)));
|
|
|
|
|
return normalized.map((row) => row.map((cell, index) => text(cell).padEnd(widths[index])).join(" ").trimEnd()).join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function text(value: unknown): string {
|
|
|
|
|
if (value === undefined || value === null || value === "") return "-";
|
|
|
|
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
|
|
|
return String(value).replace(/\s+/gu, " ").trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function short(value: unknown): string {
|
|
|
|
|
const raw = text(value);
|
|
|
|
|
if (raw === "-") return raw;
|
|
|
|
|
if (/^sha256:[0-9a-f]{64}$/iu.test(raw)) return `${raw.slice(0, 19)}...`;
|
|
|
|
|
if (/^[0-9a-f]{40}$/iu.test(raw)) return raw.slice(0, 12);
|
|
|
|
|
return raw.length > 42 ? `${raw.slice(0, 39)}...` : raw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sha256(textValue: string): string {
|
|
|
|
|
return `sha256:${createHash("sha256").update(textValue).digest("hex")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shellQuote(value: string): string {
|
|
|
|
|
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
|
|
|
}
|