feat: add d601 k3s readonly observability

This commit is contained in:
HWLAB Code Queue
2026-05-21 17:37:09 +00:00
parent cef2befdd1
commit 0a066f2483
5 changed files with 807 additions and 2 deletions
+57
View File
@@ -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.
+3 -2
View File
@@ -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"
}
}
@@ -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."
}
@@ -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;
}
@@ -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 <path> Write report path",
" --timeout-ms <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;
}