feat: add d601 k3s readonly observability
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user