From 0a066f2483d5fdb0358bb48274f172c8863973ea Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 17:37:09 +0000 Subject: [PATCH] feat: add d601 k3s readonly observability --- docs/d601-k3s-readonly-observability.md | 57 ++ package.json | 5 +- reports/d601-k3s-readonly-observability.json | 191 ++++++ scripts/d601-k3s-readonly-observability.mjs | 12 + .../src/d601-k3s-readonly-observability.mjs | 544 ++++++++++++++++++ 5 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 docs/d601-k3s-readonly-observability.md create mode 100644 reports/d601-k3s-readonly-observability.json create mode 100644 scripts/d601-k3s-readonly-observability.mjs create mode 100644 scripts/src/d601-k3s-readonly-observability.mjs diff --git a/docs/d601-k3s-readonly-observability.md b/docs/d601-k3s-readonly-observability.md new file mode 100644 index 00000000..4de653a0 --- /dev/null +++ b/docs/d601-k3s-readonly-observability.md @@ -0,0 +1,57 @@ +# D601 k3s Readonly Observability + +This entry point supports `pikasTech/HWLAB#46` and the DEV runtime blockers in +`#7`, `#33`, `#36`, `#38`, `#39`, and `#42`. It is a read-only preflight for +the D601 Code Queue runner and the `hwlab-dev` namespace. + +It does not authorize PROD, read Secret resources, print kubeconfig/token +material, print ConfigMap values, mutate cluster state, restart runtime +services, or run heavy checks on the master server. + +## Command + +Run from the repository root: + +```sh +node --check scripts/d601-k3s-readonly-observability.mjs +node --check scripts/src/d601-k3s-readonly-observability.mjs +node scripts/d601-k3s-readonly-observability.mjs +``` + +The command writes `reports/d601-k3s-readonly-observability.json` and prints a +short JSON summary. A `blocked` conclusion means the preflight ran and recorded +the missing binary, kubeconfig path, SSH bridge, or read permission. Use +`--fail-on-blocked` only in CI jobs that should fail when observability is not +ready. + +Useful options: + +```sh +node scripts/d601-k3s-readonly-observability.mjs --pretty +node scripts/d601-k3s-readonly-observability.mjs --timeout-ms 5000 +node scripts/d601-k3s-readonly-observability.mjs --no-write +node scripts/d601-k3s-readonly-observability.mjs --report reports/d601-k3s-readonly-observability.json +``` + +## What It Checks + +- `kubectl`, `k3s`, `k3sctl`, and `ssh` availability and version output. +- `KUBECONFIG`, `~/.kube/config`, `/etc/rancher/k3s/k3s.yaml`, and the k3s + admin kubeconfig path by file metadata only. +- SSH maintenance bridge hints from known environment variable names and + `~/.ssh` metadata only. +- If a Kubernetes executor is available, read-only access to `hwlab-dev` + namespace, pods, services, and ConfigMaps. +- Summaries for pods, services, and ConfigMap key names only. ConfigMap data + values are not printed. + +## Ready Criteria + +`ready` requires a direct executor, such as `kubectl` or `k3s kubectl`, to read +pods, services, and ConfigMaps from `hwlab-dev` and no open environment or +observability blocker. + +`blocked` keeps the report consumable by follow-up issues. The report includes +open blockers with `scope`, `summary`, and `nextTask` fields so runner image or +mount work can be tracked without guessing which binary, permission, or path is +missing. diff --git a/package.json b/package.json index 9edddaf1..6a138d6b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", @@ -17,6 +17,7 @@ "web:check": "node web/hwlab-cloud-web/scripts/check.mjs", "web:build": "node web/hwlab-cloud-web/scripts/build.mjs", "dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight", - "dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish" + "dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish", + "d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs" } } diff --git a/reports/d601-k3s-readonly-observability.json b/reports/d601-k3s-readonly-observability.json new file mode 100644 index 00000000..9d30df7e --- /dev/null +++ b/reports/d601-k3s-readonly-observability.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://hwlab.pikastech.local/schemas/d601-k3s-readonly-observability-report.schema.json", + "$id": "https://hwlab.pikastech.local/reports/d601-k3s-readonly-observability.json", + "reportVersion": "v1", + "reportKind": "d601-k3s-readonly-observability", + "issue": "pikasTech/HWLAB#46", + "supports": [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#33", + "pikasTech/HWLAB#36", + "pikasTech/HWLAB#38", + "pikasTech/HWLAB#39", + "pikasTech/HWLAB#42" + ], + "generatedAt": "2026-05-21T17:34:15.046Z", + "mode": "read-only", + "devOnly": true, + "prodDisabled": true, + "namespace": "hwlab-dev", + "runtimeTarget": { + "runner": "D601 Code Queue", + "cluster": "D601 k3s", + "namespace": "hwlab-dev", + "resources": [ + "pods", + "services", + "configmaps" + ] + }, + "safety": { + "noProd": true, + "noSecretsPrinted": true, + "noSecretResourcesRead": true, + "noClusterMutation": true, + "noRuntimeRestart": true, + "noHeavyE2E": true + }, + "forbiddenActions": [ + "prod-access", + "secret-material-read", + "cluster-mutation", + "runtime-restart", + "heavy-e2e", + "master-server-heavy-check" + ], + "validationCommands": [ + "node --check scripts/d601-k3s-readonly-observability.mjs", + "node --check scripts/src/d601-k3s-readonly-observability.mjs", + "node scripts/d601-k3s-readonly-observability.mjs" + ], + "conclusion": "blocked", + "environment": { + "binaries": { + "kubectl": { + "name": "kubectl", + "available": false, + "path": null + }, + "k3s": { + "name": "k3s", + "available": false, + "path": null + }, + "k3sctl": { + "name": "k3sctl", + "available": false, + "path": null + }, + "ssh": { + "name": "ssh", + "available": true, + "path": "/usr/bin/ssh", + "version": { + "ok": true, + "command": "ssh -V", + "output": "OpenSSH_10.0p2 Debian-7+deb13u2, OpenSSL 3.5.5 27 Jan 2026" + } + } + }, + "kubeconfig": [ + { + "source": "default", + "path": "~/.kube/config", + "exists": false, + "readable": false, + "error": "ENOENT" + }, + { + "source": "default", + "path": "/etc/rancher/k3s/k3s.yaml", + "exists": false, + "readable": false, + "error": "ENOENT" + }, + { + "source": "default", + "path": "/var/lib/rancher/k3s/server/cred/admin.kubeconfig", + "exists": false, + "readable": false, + "error": "ENOENT" + } + ], + "sshBridge": { + "sshBinaryAvailable": true, + "env": [ + { + "name": "HWLAB_D601_SSH_HOST", + "set": false, + "valueRedacted": null + }, + { + "name": "HWLAB_D601_SSH_TARGET", + "set": false, + "valueRedacted": null + }, + { + "name": "HWLAB_D601_MAINTENANCE_HOST", + "set": false, + "valueRedacted": null + }, + { + "name": "D601_SSH_HOST", + "set": false, + "valueRedacted": null + }, + { + "name": "HWLAB_MAINTENANCE_SSH", + "set": false, + "valueRedacted": null + } + ], + "paths": [ + { + "path": "~/.ssh", + "exists": true, + "readable": true, + "type": "directory" + }, + { + "path": "~/.ssh/config", + "exists": false, + "readable": false, + "error": "ENOENT" + }, + { + "path": "~/.ssh/known_hosts", + "exists": true, + "readable": true, + "type": "file", + "sizeBytes": 4208 + } + ], + "configured": false, + "note": "SSH bridge discovery checks only binary, environment variable presence, and file metadata; it does not read SSH config or private keys." + } + }, + "cluster": { + "namespace": "hwlab-dev", + "attemptedExecutors": [], + "readable": false, + "executors": [] + }, + "blockers": [ + { + "status": "open", + "type": "environment_blocker", + "scope": "d601-k3s-client-binary", + "summary": "Neither kubectl nor k3s is installed in this runner PATH.", + "nextTask": "Install kubectl in the D601 Code Queue runner image, or mount an approved k3s kubectl client path for read-only hwlab-dev observation." + }, + { + "status": "open", + "type": "environment_blocker", + "scope": "d601-kubeconfig-path", + "summary": "No readable KUBECONFIG/default k3s kubeconfig path was found by metadata checks.", + "nextTask": "Mount a read-only kubeconfig for hwlab-dev, or document the approved k3s local kubeconfig path without exposing token material." + }, + { + "status": "open", + "type": "environment_blocker", + "scope": "d601-maintenance-ssh-bridge", + "summary": "No SSH maintenance bridge was detected from ssh binary plus known environment variables or ~/.ssh/config metadata.", + "nextTask": "Provide a documented read-only maintenance bridge variable or config path if kubectl cannot be mounted directly in the runner." + } + ], + "runnerImageFollowUp": { + "needed": true, + "suggestedUniDeskIssue": "Code Queue runner image should include kubectl and a documented read-only hwlab-dev kubeconfig mount/permission path; keep Secret resources and token material inaccessible to reports." + }, + "notes": "This report uses command availability checks, file metadata checks, kubectl/k3s kubectl read-only get probes, and auth can-i checks only. It does not print kubeconfig contents, token material, Secret resources, ConfigMap values, SSH config contents, or private keys." +} diff --git a/scripts/d601-k3s-readonly-observability.mjs b/scripts/d601-k3s-readonly-observability.mjs new file mode 100644 index 00000000..52603bb4 --- /dev/null +++ b/scripts/d601-k3s-readonly-observability.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { + formatFailure, + runD601K3sReadonlyObservability +} from "./src/d601-k3s-readonly-observability.mjs"; + +try { + process.exitCode = await runD601K3sReadonlyObservability(process.argv.slice(2)); +} catch (error) { + process.stdout.write(`${JSON.stringify(formatFailure(error), null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/src/d601-k3s-readonly-observability.mjs b/scripts/src/d601-k3s-readonly-observability.mjs new file mode 100644 index 00000000..ba3faaaa --- /dev/null +++ b/scripts/src/d601-k3s-readonly-observability.mjs @@ -0,0 +1,544 @@ +import assert from "node:assert/strict"; +import { constants as fsConstants } from "node:fs"; +import { access, mkdir, stat, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const namespace = "hwlab-dev"; +const defaultReportPath = "reports/d601-k3s-readonly-observability.json"; +const issue = "pikasTech/HWLAB#46"; +const supports = ["#7", "#33", "#36", "#38", "#39", "#42"].map((id) => `pikasTech/HWLAB${id}`); +const forbiddenActions = [ + "prod-access", + "secret-material-read", + "cluster-mutation", + "runtime-restart", + "heavy-e2e", + "master-server-heavy-check" +]; +const kubeconfigCandidatePaths = [ + "~/.kube/config", + "/etc/rancher/k3s/k3s.yaml", + "/var/lib/rancher/k3s/server/cred/admin.kubeconfig" +]; +const sshEnvVars = [ + "HWLAB_D601_SSH_HOST", + "HWLAB_D601_SSH_TARGET", + "HWLAB_D601_MAINTENANCE_HOST", + "D601_SSH_HOST", + "HWLAB_MAINTENANCE_SSH" +]; +const blockerTypes = new Set(["environment_blocker", "observability_blocker", "safety_blocker"]); + +function parseArgs(argv) { + const args = { + reportPath: defaultReportPath, + timeoutMs: 5000, + writeReport: true, + pretty: false, + failOnBlocked: false + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--report") { + args.reportPath = argv[++index]; + } else if (arg === "--timeout-ms") { + args.timeoutMs = Number.parseInt(argv[++index], 10); + } else if (arg === "--no-write") { + args.writeReport = false; + } else if (arg === "--pretty") { + args.pretty = true; + } else if (arg === "--fail-on-blocked") { + args.failOnBlocked = true; + } else if (arg === "--help") { + args.help = true; + } else { + throw new Error(`unknown argument ${arg}`); + } + } + + assert.ok(Number.isInteger(args.timeoutMs) && args.timeoutMs > 0, "--timeout-ms must be positive"); + return args; +} + +function usage() { + return [ + "Usage: node scripts/d601-k3s-readonly-observability.mjs [options]", + "", + "Options:", + " --report Write report path", + " --timeout-ms Per-command timeout", + " --no-write Print only, do not write report", + " --pretty Pretty-print the full report", + " --fail-on-blocked Exit 2 when conclusion is blocked", + " --help Show this help" + ].join("\n"); +} + +function redacted(value) { + return String(value) + .replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1[REDACTED]") + .replace(/((?:token|password|client-key-data|client-certificate-data|certificate-authority-data)\s*[:=]\s*)\S+/giu, "$1[REDACTED]") + .replace(/(authorization:\s*)\S+/giu, "$1[REDACTED]") + .slice(0, 4000); +} + +function oneLine(value) { + return redacted(value).replace(/\s+/g, " ").trim(); +} + +function expandHome(filePath) { + return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath; +} + +function displayPath(filePath) { + const home = os.homedir(); + return filePath.startsWith(`${home}${path.sep}`) ? `~/${filePath.slice(home.length + 1)}` : filePath; +} + +function commandText(command, args) { + return [command, ...args].join(" "); +} + +async function run(command, args = [], options = {}) { + try { + const result = await execFileAsync(command, args, { + cwd: repoRoot, + timeout: options.timeoutMs ?? 5000, + maxBuffer: 1024 * 1024 + }); + return { + ok: true, + command: commandText(command, args), + exitCode: 0, + stdout: redacted(result.stdout.trim()), + stderr: redacted(result.stderr.trim()) + }; + } catch (error) { + return { + ok: false, + command: commandText(command, args), + exitCode: typeof error.code === "number" ? error.code : 1, + stdout: redacted(String(error.stdout ?? "").trim()), + stderr: redacted(String(error.stderr ?? "").trim()), + error: oneLine(error.message) + }; + } +} + +async function findExecutable(name) { + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, name); + try { + await access(candidate, fsConstants.X_OK); + return candidate; + } catch {} + } + return null; +} + +async function fileProbe(filePath) { + const absolutePath = expandHome(filePath); + try { + const info = await stat(absolutePath); + await access(absolutePath, fsConstants.R_OK); + return { + path: displayPath(absolutePath), + exists: true, + readable: true, + type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other", + sizeBytes: info.isFile() ? info.size : undefined + }; + } catch (error) { + return { + path: displayPath(absolutePath), + exists: error?.code !== "ENOENT", + readable: false, + error: error?.code ?? "UNKNOWN" + }; + } +} + +async function collectBinary(name, versionArgs) { + const executablePath = await findExecutable(name); + const result = { + name, + available: executablePath !== null, + path: executablePath ? displayPath(executablePath) : null + }; + if (executablePath && versionArgs) { + const version = await run(name, versionArgs, { timeoutMs: 5000 }); + result.version = { + ok: version.ok, + command: version.command, + output: oneLine(version.stdout || version.stderr || version.error) + }; + } + return result; +} + +async function collectBinaries() { + const entries = await Promise.all([ + collectBinary("kubectl", ["version", "--client=true"]), + collectBinary("k3s", ["--version"]), + collectBinary("k3sctl", ["version"]), + collectBinary("ssh", ["-V"]) + ]); + return Object.fromEntries(entries.map((entry) => [entry.name, entry])); +} + +function kubeconfigEnvPaths() { + const raw = process.env.KUBECONFIG; + if (!raw) return []; + return raw.split(path.delimiter).filter(Boolean); +} + +async function collectKubeconfigPaths() { + const envPaths = kubeconfigEnvPaths(); + const candidates = [ + ...envPaths.map((item) => ({ source: "KUBECONFIG", path: item })), + ...kubeconfigCandidatePaths.map((item) => ({ source: "default", path: item })) + ]; + const seen = new Set(); + const uniqueCandidates = candidates.filter((item) => { + const key = expandHome(item.path); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return Promise.all(uniqueCandidates.map(async (item) => ({ + source: item.source, + ...(await fileProbe(item.path)) + }))); +} + +async function collectSshBridge(binaries) { + const env = sshEnvVars.map((name) => ({ + name, + set: Object.hasOwn(process.env, name), + valueRedacted: Object.hasOwn(process.env, name) ? "[REDACTED]" : null + })); + const paths = await Promise.all(["~/.ssh", "~/.ssh/config", "~/.ssh/known_hosts"].map(fileProbe)); + return { + sshBinaryAvailable: binaries.ssh.available, + env, + paths, + configured: binaries.ssh.available && ( + env.some((item) => item.set) || + paths.some((item) => item.path.endsWith("/.ssh/config") && item.readable) + ), + note: "SSH bridge discovery checks only binary, environment variable presence, and file metadata; it does not read SSH config or private keys." + }; +} + +function parseJsonProbe(probe) { + if (!probe.ok || !probe.stdout) return null; + try { + return JSON.parse(probe.stdout); + } catch { + return null; + } +} + +function summarizePods(document) { + const pods = document?.items ?? []; + return { + count: pods.length, + items: pods.map((pod) => { + const statuses = pod.status?.containerStatuses ?? []; + return { + name: pod.metadata?.name, + phase: pod.status?.phase, + readyContainers: statuses.filter((item) => item.ready).length, + totalContainers: statuses.length, + restartCount: statuses.reduce((total, item) => total + (item.restartCount ?? 0), 0), + nodeName: pod.spec?.nodeName ?? null + }; + }) + }; +} + +function summarizeServices(document) { + const services = document?.items ?? []; + return { + count: services.length, + items: services.map((service) => ({ + name: service.metadata?.name, + type: service.spec?.type, + clusterIP: service.spec?.clusterIP ?? null, + ports: (service.spec?.ports ?? []).map((port) => ({ + name: port.name ?? null, + port: port.port, + targetPort: port.targetPort, + protocol: port.protocol + })) + })) + }; +} + +function summarizeConfigMaps(document) { + const configMaps = document?.items ?? []; + return { + count: configMaps.length, + items: configMaps.map((configMap) => ({ + name: configMap.metadata?.name, + keys: Object.keys(configMap.data ?? {}).sort() + })) + }; +} + +function readonlyExecutor(name, command, prefixArgs = []) { + return { name, command, prefixArgs }; +} + +async function runExecutorProbe(executor, args, timeoutMs) { + return run(executor.command, [...executor.prefixArgs, ...args], { timeoutMs }); +} + +function authAllowed(probe) { + return probe.ok && probe.stdout.trim().toLowerCase() === "yes"; +} + +function compactProbe(probe, { includeStdout = true } = {}) { + return { + ok: probe.ok, + command: probe.command, + exitCode: probe.exitCode, + stdout: includeStdout && probe.ok && probe.stdout.length <= 200 ? probe.stdout : undefined, + stderr: probe.stderr ? oneLine(probe.stderr) : undefined, + error: probe.error + }; +} + +async function collectClusterForExecutor(executor, timeoutMs) { + const version = await runExecutorProbe(executor, ["version", "--client=true"], timeoutMs); + const context = await runExecutorProbe(executor, ["config", "current-context"], timeoutMs); + const namespaceProbe = await runExecutorProbe(executor, ["get", "namespace", namespace, "-o", "json"], timeoutMs); + const auth = {}; + for (const resource of ["pods", "services", "configmaps"]) { + auth[resource] = await runExecutorProbe(executor, ["auth", "can-i", "get", resource, "-n", namespace], timeoutMs); + } + + const podsProbe = await runExecutorProbe(executor, ["-n", namespace, "get", "pods", "-o", "json"], timeoutMs); + const servicesProbe = await runExecutorProbe(executor, ["-n", namespace, "get", "services", "-o", "json"], timeoutMs); + const configMapsProbe = await runExecutorProbe(executor, ["-n", namespace, "get", "configmaps", "-o", "json"], timeoutMs); + const podsJson = parseJsonProbe(podsProbe); + const servicesJson = parseJsonProbe(servicesProbe); + const configMapsJson = parseJsonProbe(configMapsProbe); + const ready = Boolean(namespaceProbe.ok && podsJson && servicesJson && configMapsJson); + + return { + executor: executor.name, + ready, + probes: { + clientVersion: compactProbe(version), + currentContext: compactProbe(context), + namespace: compactProbe(namespaceProbe, { includeStdout: false }), + auth: Object.fromEntries(Object.entries(auth).map(([resource, probe]) => [ + resource, + { + allowed: authAllowed(probe), + ...compactProbe(probe) + } + ])), + pods: compactProbe(podsProbe, { includeStdout: false }), + services: compactProbe(servicesProbe, { includeStdout: false }), + configMaps: compactProbe(configMapsProbe, { includeStdout: false }) + }, + summaries: ready ? { + pods: summarizePods(podsJson), + services: summarizeServices(servicesJson), + configMaps: summarizeConfigMaps(configMapsJson) + } : null + }; +} + +async function collectClusterObservability(binaries, timeoutMs) { + const executors = []; + if (binaries.kubectl.available) { + executors.push(readonlyExecutor("kubectl", "kubectl")); + } + if (binaries.k3s.available) { + executors.push(readonlyExecutor("k3s-kubectl", "k3s", ["kubectl"])); + } + const results = []; + for (const executor of executors) { + results.push(await collectClusterForExecutor(executor, timeoutMs)); + } + return { + namespace, + attemptedExecutors: results.map((item) => item.executor), + readable: results.some((item) => item.ready), + executors: results + }; +} + +function addBlocker(blockers, blocker) { + assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`); + const key = `${blocker.type}:${blocker.scope}`; + if (!blockers.some((item) => `${item.type}:${item.scope}` === key)) { + blockers.push({ status: "open", ...blocker }); + } +} + +function buildBlockers({ binaries, kubeconfig, sshBridge, cluster }) { + const blockers = []; + const hasDirectClient = binaries.kubectl.available || binaries.k3s.available; + if (!hasDirectClient) { + addBlocker(blockers, { + type: "environment_blocker", + scope: "d601-k3s-client-binary", + summary: "Neither kubectl nor k3s is installed in this runner PATH.", + nextTask: "Install kubectl in the D601 Code Queue runner image, or mount an approved k3s kubectl client path for read-only hwlab-dev observation." + }); + } + if (!kubeconfig.some((item) => item.readable)) { + addBlocker(blockers, { + type: "environment_blocker", + scope: "d601-kubeconfig-path", + summary: "No readable KUBECONFIG/default k3s kubeconfig path was found by metadata checks.", + nextTask: "Mount a read-only kubeconfig for hwlab-dev, or document the approved k3s local kubeconfig path without exposing token material." + }); + } + if (!sshBridge.configured) { + addBlocker(blockers, { + type: "environment_blocker", + scope: "d601-maintenance-ssh-bridge", + summary: "No SSH maintenance bridge was detected from ssh binary plus known environment variables or ~/.ssh/config metadata.", + nextTask: "Provide a documented read-only maintenance bridge variable or config path if kubectl cannot be mounted directly in the runner." + }); + } + if (hasDirectClient && !cluster.readable) { + const attempted = cluster.attemptedExecutors.length > 0 ? cluster.attemptedExecutors.join(", ") : "none"; + addBlocker(blockers, { + type: "observability_blocker", + scope: "hwlab-dev-readonly-rbac", + summary: `Read-only cluster probes did not return pods, services, and ConfigMaps for ${namespace}; attempted executors: ${attempted}.`, + nextTask: "Grant or mount read-only access that can get namespace, pods, services, and ConfigMaps in hwlab-dev, then rerun this preflight." + }); + } + return blockers; +} + +function runnerImageFollowUp(blockers) { + const scopes = new Set(blockers.map((blocker) => blocker.scope)); + const needed = scopes.has("d601-k3s-client-binary") || scopes.has("d601-kubeconfig-path"); + return { + needed, + suggestedUniDeskIssue: needed + ? "Code Queue runner image should include kubectl and a documented read-only hwlab-dev kubeconfig mount/permission path; keep Secret resources and token material inaccessible to reports." + : null + }; +} + +async function writeReport(report, reportPath) { + const absoluteReportPath = path.join(repoRoot, reportPath); + await mkdir(path.dirname(absoluteReportPath), { recursive: true }); + await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`); +} + +async function buildReport(args) { + const binaries = await collectBinaries(); + const [kubeconfig, sshBridge, cluster] = await Promise.all([ + collectKubeconfigPaths(), + collectSshBridge(binaries), + collectClusterObservability(binaries, args.timeoutMs) + ]); + const blockers = buildBlockers({ binaries, kubeconfig, sshBridge, cluster }); + const conclusion = cluster.readable && blockers.length === 0 ? "ready" : "blocked"; + return { + $schema: "https://hwlab.pikastech.local/schemas/d601-k3s-readonly-observability-report.schema.json", + $id: "https://hwlab.pikastech.local/reports/d601-k3s-readonly-observability.json", + reportVersion: "v1", + reportKind: "d601-k3s-readonly-observability", + issue, + supports, + generatedAt: new Date().toISOString(), + mode: "read-only", + devOnly: true, + prodDisabled: true, + namespace, + runtimeTarget: { + runner: "D601 Code Queue", + cluster: "D601 k3s", + namespace, + resources: ["pods", "services", "configmaps"] + }, + safety: { + noProd: true, + noSecretsPrinted: true, + noSecretResourcesRead: true, + noClusterMutation: true, + noRuntimeRestart: true, + noHeavyE2E: true + }, + forbiddenActions, + validationCommands: [ + "node --check scripts/d601-k3s-readonly-observability.mjs", + "node --check scripts/src/d601-k3s-readonly-observability.mjs", + "node scripts/d601-k3s-readonly-observability.mjs" + ], + conclusion, + environment: { + binaries, + kubeconfig, + sshBridge + }, + cluster, + blockers, + runnerImageFollowUp: runnerImageFollowUp(blockers), + notes: "This report uses command availability checks, file metadata checks, kubectl/k3s kubectl read-only get probes, and auth can-i checks only. It does not print kubeconfig contents, token material, Secret resources, ConfigMap values, SSH config contents, or private keys." + }; +} + +function printSummary(args, report) { + const summary = { + issue, + reportKind: report.reportKind, + conclusion: report.conclusion, + namespace: report.namespace, + report: args.writeReport ? args.reportPath : null, + readable: report.cluster.readable, + attemptedExecutors: report.cluster.attemptedExecutors, + blockers: report.blockers.map((blocker) => ({ + type: blocker.type, + scope: blocker.scope, + nextTask: blocker.nextTask + })) + }; + process.stdout.write(`${JSON.stringify(args.pretty ? report : summary, null, args.pretty ? 2 : 0)}\n`); +} + +export function formatFailure(error) { + return { + issue, + reportKind: "d601-k3s-readonly-observability", + conclusion: "blocked", + error: error instanceof Error ? error.message : String(error) + }; +} + +export async function runD601K3sReadonlyObservability(argv) { + const args = parseArgs(argv); + if (args.help) { + process.stdout.write(`${usage()}\n`); + return 0; + } + + const report = await buildReport(args); + if (args.writeReport) { + await writeReport(report, args.reportPath); + } + printSummary(args, report); + if (report.conclusion === "blocked" && args.failOnBlocked) { + return 2; + } + return 0; +}