From 97213b8e70d0c0d0f3df72ac4664ebba5d23c341 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 11:07:51 -0600 Subject: [PATCH 1/8] test(presentations): add disposable Gateway qualification fixture --- .../presentation-qualification/README.md | 248 ++++++++++ .../presentation-qualification/check_pins.py | 117 +++++ .../lib/__init__.py | 0 .../presentation-qualification/lib/framing.py | 292 ++++++++++++ .../lib/lifecycle.py | 77 +++ .../lib/template_rules.py | 138 ++++++ .../lib/verify_artifacts.py | 53 +++ .../tests/presentation-qualification/pins.env | 46 ++ .../runtime/client/client.py | 447 ++++++++++++++++++ .../runtime/client/probe_pod_invariants.py | 89 ++++ .../runtime/client/snapshot_diff.py | 57 +++ .../runtime/images/gateway.Dockerfile | 35 ++ .../images/gateway_files/kernelspec_patch.py | 103 ++++ .../images/gateway_files/render_check.py | 96 ++++ .../runtime/images/worker.Dockerfile | 24 + .../runtime/images/worker_files/build_deck.js | 27 ++ .../runtime/images/worker_files/build_task.py | 42 ++ .../runtime/images/worker_files/package.json | 10 + .../images/worker_files/render_task.py | 109 +++++ .../runtime/k8s/gateway.yaml.in | 87 ++++ .../runtime/k8s/kernel-pod.yaml.j2 | 82 ++++ .../runtime/k8s/namespace.yaml | 7 + .../runtime/k8s/networkpolicy.yaml | 42 ++ .../runtime/k8s/rbac.yaml | 36 ++ .../runtime/run_runtime_suite.sh | 326 +++++++++++++ .../unit/__init__.py | 0 .../unit/test_channel_assembler.py | 389 +++++++++++++++ .../unit/test_lifecycle.py | 109 +++++ .../unit/test_pins.py | 137 ++++++ .../unit/test_snapshot_diff.py | 50 ++ .../unit/test_template_security.py | 334 +++++++++++++ .../unit/test_transport_verify.py | 80 ++++ .../workflows/presentation-qualification.yml | 54 +++ 33 files changed, 3743 insertions(+) create mode 100644 .github/tests/presentation-qualification/README.md create mode 100644 .github/tests/presentation-qualification/check_pins.py create mode 100644 .github/tests/presentation-qualification/lib/__init__.py create mode 100644 .github/tests/presentation-qualification/lib/framing.py create mode 100644 .github/tests/presentation-qualification/lib/lifecycle.py create mode 100644 .github/tests/presentation-qualification/lib/template_rules.py create mode 100644 .github/tests/presentation-qualification/lib/verify_artifacts.py create mode 100644 .github/tests/presentation-qualification/pins.env create mode 100644 .github/tests/presentation-qualification/runtime/client/client.py create mode 100644 .github/tests/presentation-qualification/runtime/client/probe_pod_invariants.py create mode 100644 .github/tests/presentation-qualification/runtime/client/snapshot_diff.py create mode 100644 .github/tests/presentation-qualification/runtime/images/gateway.Dockerfile create mode 100644 .github/tests/presentation-qualification/runtime/images/gateway_files/kernelspec_patch.py create mode 100644 .github/tests/presentation-qualification/runtime/images/gateway_files/render_check.py create mode 100644 .github/tests/presentation-qualification/runtime/images/worker.Dockerfile create mode 100644 .github/tests/presentation-qualification/runtime/images/worker_files/build_deck.js create mode 100644 .github/tests/presentation-qualification/runtime/images/worker_files/build_task.py create mode 100644 .github/tests/presentation-qualification/runtime/images/worker_files/package.json create mode 100644 .github/tests/presentation-qualification/runtime/images/worker_files/render_task.py create mode 100644 .github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in create mode 100644 .github/tests/presentation-qualification/runtime/k8s/kernel-pod.yaml.j2 create mode 100644 .github/tests/presentation-qualification/runtime/k8s/namespace.yaml create mode 100644 .github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml create mode 100644 .github/tests/presentation-qualification/runtime/k8s/rbac.yaml create mode 100644 .github/tests/presentation-qualification/runtime/run_runtime_suite.sh create mode 100644 .github/tests/presentation-qualification/unit/__init__.py create mode 100644 .github/tests/presentation-qualification/unit/test_channel_assembler.py create mode 100644 .github/tests/presentation-qualification/unit/test_lifecycle.py create mode 100644 .github/tests/presentation-qualification/unit/test_pins.py create mode 100644 .github/tests/presentation-qualification/unit/test_snapshot_diff.py create mode 100644 .github/tests/presentation-qualification/unit/test_template_security.py create mode 100644 .github/tests/presentation-qualification/unit/test_transport_verify.py create mode 100644 .github/workflows/presentation-qualification.yml diff --git a/.github/tests/presentation-qualification/README.md b/.github/tests/presentation-qualification/README.md new file mode 100644 index 0000000..2fbbef5 --- /dev/null +++ b/.github/tests/presentation-qualification/README.md @@ -0,0 +1,248 @@ +# Presentation qualification — FIRST stage (CI-only core proof) + +This directory is a self-contained CI fixture for the makeitworkcloud/charts +repository. It proves, on a disposable hosted-runner Kubernetes cluster, the +core runtime claims of the proposed internal PPTX MCP + Jupyter Enterprise +Gateway design. It is **not** a production backend, chart change, or GitOps +rollout: nothing outside `.github/workflows/presentation-qualification.yml` +and `.github/tests/presentation-qualification/` is touched, and the +opencode-server chart is deliberately unmodified (no `Chart.yaml`, version, +config, or Deployment edits). + +## Qualification status semantics (no silent skips, no false overall PASS) + +The runtime suite emits three separate statuses and writes +`qualification-status.json` plus a job step summary: + +| Field | Meaning | Value in this fixture | +| --- | --- | --- | +| `core_rbac_render_transport` | fixed-namespace RBAC, pod hardening, authenticated REST+websocket, fresh build+render kernels, bounded digest-verified transport, cancellation | `PASS` gates CI | +| `network_isolation` | NetworkPolicy enforcement with negative probing | always `INCOMPLETE` here | +| `overall` | never claimed from a partial scope | always `INCOMPLETE` here | + +This FIRST fixture always runs kind's default kindnet CNI, which does **not** +enforce NetworkPolicy. `runtime/k8s/networkpolicy.yaml` is a static, +unqualified example: it is never applied, and no isolation is claimed from +manifest text. There is deliberately no optional CNI branch and no pins for +one; network isolation cannot be promoted inside this fixture. The CI exit +gate covers core scope only. The canonical cluster context is documented +in-repo (flannel with a dated NetworkPolicy waiver in the mcp-gateway +workloads area); production remains blocked on a target-CNI negative proof +regardless of what this CI fixture shows. + +## Core proof flow + +1. **Disposable cluster** — kind v0.30.0 binary (SHA256-verified) creating a + `kindest/node:v1.33.4@sha256:…` single-node cluster into a scoped runner + temp kubeconfig written under `umask 077`; every kubectl call uses the + explicit `kind-pptx-qual` context. Binary install is used instead of + helm/kind-action per parent preference (that action pin was verified but + is intentionally unused). +2. **Gateway** — Jupyter Enterprise Gateway 3.3.0 (pip distribution + `jupyter-enterprise-gateway`) at upstream revision + `0344929cbca688440ba1bc8f5faa074fe54bd595`, running as an in-cluster pod + (required: `enterprise_gateway/services/processproxies/k8s.py` imports + `config.load_incluster_config` at module level). Its service account holds + exactly one namespace-scoped Role: get/list/watch/create/delete on `pods` + in `pptx-jobs`. Auth is a synthetic per-run `EG_AUTH_TOKEN`; unauthenticated + and wrong-token GET **and kernel POST** requests must be rejected 401/403 + with label-independent all-namespace snapshots showing no observed pod + endpoint differences, not a continuous zero-side-effect proof. +3. **Kernelspec replacement (deliberate, at build)** — the upstream + `python_kubernetes` kernelspec is copied and then rewritten by + `kernelspec_patch.py`: parent-verified upstream argv is + `[, /usr/local/share/jupyter/kernels/python_kubernetes/scripts/launch_kubernetes.py, + --RemoteProcessProxy.kernel-id {kernel_id}, --RemoteProcessProxy.port-range {port_range}, + --RemoteProcessProxy.response-address {response_address}, + --RemoteProcessProxy.public-key {public_key}]` with + `metadata.process_proxy.class_name` = + `enterprise_gateway.services.processproxies.k8s.KubernetesProcessProxy` and + `metadata.config.image_name` defaulting to `elyra/kernel-py:VERSION`. The + patch replaces argv[0] with `/opt/venv/bin/python` (upstream assumes + `/opt/conda/bin/python`), argv[1] with the copied launcher at + `/usr/local/bin/kernel-launchers/kubernetes/scripts/launch_kubernetes.py`, + and `config.image_name` with the fixture worker image; the launcher + shebang is rewritten to the same venv interpreter. The build fails on any + structural surprise. Nothing about the launcher's env context is inferred, + and argv/env values are never printed anywhere in this fixture. +4. **Fixed kernel pod template** — the fixture-authored + `runtime/k8s/kernel-pod.yaml.j2` is installed at the exact path + `launch_kubernetes.py` reads (template adjacent to the script) and replaces + upstream's. Every security-relevant knob is a literal: namespace + `pptx-jobs`, build-time image pin, `serviceAccountName: pptx-worker`, + `automountServiceAccountToken: false`, uid/gid/fsGroup 1000, + `readOnlyRootFilesystem: true`, drop ALL, no privilege escalation, seccomp + `RuntimeDefault`, `restartPolicy: Never`, emptyDir-only bounded volumes. + The `component: kernel` label is mandatory: k8s.py `get_container_status` + (line 109 at the pinned revision) selects pods with + `label_selector='kernel_id=,component=kernel'` — without it the proxy + cannot find the worker pod. +5. **Env contract (source-backed)** — `launch_kubernetes.py` maps only + `KERNEL_*` env vars to Jinja keywords; `PORT_RANGE`, `PUBLIC_KEY`, and + `RESPONSE_ADDRESS` are placed in the launcher process environment by the + gateway and appended to the pod **after** rendering by `extend_pod_env`, + which also appends every other gateway env var. The template therefore + uses only `{{ kernel_id }}` and omits those three env entries; the pod + command forwards the appended real values as explicit quoted CLI arguments + to `launch_ipykernel.py` (verified 648 lines: it does not read them + itself), plus `--cluster-type none`. `LOG_LEVEL`/`EG_LOG_LEVEL` are pinned + to `"30"` because the launcher defaults to DEBUG (10), which can print + channel connection material; the gateway runs `--log-level=WARNING`. + Kernel and gateway logs are never collected, tailed, or uploaded. +6. **Two fresh kernels per proof run** — a BUILD kernel runs `build_task.py` + (PptxGenJS 4.0.1 authors a one-slide deck) and streams the PPTX back over + the same bounded framed transport; the client then kills it and requires + HTTP 404 confirmation before starting a separate RENDER kernel. The render + kernel receives the bounded deck inline (base64 inside the + `execute_request` code, capped at a fixture constant; the render task + enforces its own byte/base64 bounds and end-to-end SHA-256 continuity), + reports `preexisting_deckpptx=false` **before** writing anything + (fresh-pod evidence), renders via LibreOffice → PDF → Poppler PNG, and + streams the PNG back. The client asserts distinct kernel UUIDs, digest + continuity, PPTX zip magic, PNG signature/IHDR/CRC, and a full Pillow + decode. +7. **Negative probes (all required, none skippable, snapshot-guarded)** — + - client `KERNEL_IMAGE` override must be ignored: the live pod still runs + the pinned image; + - client `KERNEL_NAMESPACE=pptx-wrong` (claim delimitation below); + - impersonated gateway-identity pod creation outside `pptx-jobs` must be + denied `forbidden`, with an in-namespace positive control; + - live pod invariant probe asserts by **name and boolean only** (never env + values): no credential-shaped env names, no `valueFrom`, automount + false, pinned image, non-root ids, read-only rootfs, dropped + capabilities, emptyDir volumes, `LOG_LEVEL=30`, `component=kernel`. + Authentication and namespace-tamper probes are bracketed by fail-closed, + metadata-only all-namespace snapshots (namespace, name, UID). Authentication + requires zero endpoint differences; namespace tampering permits differences + only inside `pptx-jobs`. Pods created and removed between snapshots are not + observed, so this is not a continuous zero-side-effect proof. +8. **Cancellation** — every client run kills its kernels via + `DELETE /api/kernels/{id}`; the harness requires labeled pods to disappear + within a bounded timeout. +9. **Always cleanup** — an EXIT trap deletes the namespace, destroys the kind + cluster, removes local images, and unlinks the scoped kubeconfig and + rendered gateway manifest (`rm` on the CI-ephemeral runner filesystem — + accurate unlink, not a claim of secure erasure). The synthetic token is + generated per run, never echoed, never committed, and never appears in + results (booleans/counts/digests/dimensions only). No artifact upload + steps exist in the workflow. + +## Wrong-namespace semantics (explicit claim delimitation) + +At the pinned revision, `K8sProcessProxy` trusts a client-supplied +`KERNEL_NAMESPACE` for status polling (`list_namespaced_pod`), while the +rendered template pins the literal namespace. A `KERNEL_NAMESPACE=pptx-wrong` +override therefore does **not** demonstrate caller-side input validation: +the failure mode is containment evidence — any created pod still lands in +`pptx-jobs` (literal template), and the proxy's polling of the wrong +namespace fails under the namespace-scoped Role. The client reports +`rejected=true` **only** for an explicit non-201 kernel POST status (recorded +in the result as `http_status`), taken only after an auth-positive control; +websocket/URL/timeout errors never count as rejection. A POST that is +accepted and then fails (or even starts) is a test failure, never laundered +into a rejection. No future production adapter authentication is claimed or +tested here. + +## Wire protocol + +jupyter-server 1.24.0 `services/kernels/handlers.py` `on_message` does +`json.loads(frame)` then `frame.pop('channel', None)` and reads +`frame['header']` as a dict, so the client sends and receives JSON **dicts** +(`header`, `parent_header`, `metadata`, `content`, `channel`, `buffers`) — +not ZMQ-style frame arrays. Received text is length-bounded before +`json.loads`, and unexpected binary frames are rejected. Documented +limitation: `websocket-client` allocates each incoming frame internally +before that bound can be applied, so the bound limits parsing and +reassembly, not the initial allocation. + +## Pinned-rev upstream behaviors this design depends on + +- `launch_kubernetes.py` loads the kernel-pod template from its own script + directory, renders with jinja2, creates objects from the rendered body, and + `extend_pod_env` runs **after** rendering, overwriting every templated env + name present in the gateway process environment and then appending + **every** gateway environment variable into the kernel pod. A template env + list alone cannot prevent leakage; the gateway Deployment env allowlist is + the isolation boundary. Gateway env beyond the synthetic + `EG_AUTH_TOKEN`/`KG_AUTH_TOKEN` pair must never include provider, broker, + or MCP credentials, because `remotemanager.py _launch_kernel` deletes only + those two names after applying user overrides. +- Kernel status polling uses the `kernel_id` + `component=kernel` label + selector and namespace-scoped list/delete; the worker service account has + no bindings at all. +- KIP is not used; no runtime sockets; no cloud model credentials exist in + this fixture's gateway; images are never pushed anywhere. + +## Version island (fixture-only) + +EG 3.3.0 requires `jupyter-client<7,>=6.1.12` and `pyzmq<25,>=20`; +ipykernel 6.30.1 needs jupyter-client>=8 and pyzmq>=25 and cannot co-install. +The fixture therefore uses one shared legacy venv per image: EG 3.3.0 + +ipykernel 6.29.5 + jupyter-client 6.1.12 + pyzmq 24.0.1 (+ pycryptodomex +3.20.0 for the launcher, whose imports are `Cryptodome` — the `-domex` +distribution, not `pycryptodome`), with kubernetes 31.0.0, jinja2 3.1.6 +(3.1.4 is vulnerable and must not be pinned), pyyaml 6.0.3, requests 2.32.5, +websocket-client 1.8.0, Pillow 11.3.0 on the gateway/client side. Installs +use plain pinned `pip install pkg==version` — no `--no-deps`, no +`--ignore-requires-python` — so a real metadata conflict fails CI rather +than being bypassed. This island is chosen for least scope and is **not** +automatically approved for production. + +Base image: `docker.io/library/node:22-bookworm-slim@sha256:43ac6c60…` +(Debian 12; linux/amd64 manifest digest +`25330af3531fb5e23318554a0aa911125b6e91b1b777edf7655501d207c067a2`), +selected so the distro python3.11 is compatible with the legacy pyzmq pin. + +## Hermeticity and honest gaps + +- apt packages are installed at build time from current Debian mirrors; no + apt snapshot lock was supplied. The test image is **not** reproducible and + is not released or published anywhere. +- npm transitive dependencies of pptxgenjs are not lockfile-pinned; pip + transitives (jupyter-server 1.x, tornado, etc.) resolve freely within the + declared constraints. +- kubectl currently uses `KUBECTL_SOURCE=runner_preinstalled` with a + client/node skew gate; switch to `pinned_download` for hermetic runs. +- Group B pins were chosen for compatibility (parent registry-verified: + pycryptodomex 3.20.0, kubernetes 31.0.0, pptxgenjs 4.0.1, jinja2 3.1.6); + the remaining pins must be re-verified on PyPI/npm by the parent + immediately before publish. +- Static unit tests strip whole-line comments before content assertions so + rationale comments (which legitimately mention e.g. channel-connection + secrets in prose) are never confused with payload; comments are not + rewritten to satisfy checks. +- No historical/deck-authoring helpers are exercised in this stage; scope is + a simple one-slide deck, fresh-kernel render, and transport proof. + +## Parent verification checklist before publish + +1. Remaining Group B pins exist on PyPI/npm at the exact versions in + `pins.env` (jupyter-enterprise-gateway 3.3.0, jupyter-client 6.1.12, + ipykernel 6.29.5, pyzmq 24.0.1, pyyaml 6.0.3, requests 2.32.5, + websocket-client 1.8.0, Pillow 11.3.0). +2. `jupyter enterprisegateway` CLI accepts the flags used + (`--ip --port --log-level --KernelSpecManager.allowed_kernelspecs`). +3. pptxgenjs 4.0.1 API surface used in `build_deck.js`. +4. Optional: pinned kubectl, npm lockfile, apt snapshot lock. + +## Licensing + +Upstream sources are consumed only from the pinned checkout +(`jupyter-server/enterprise_gateway`, BSD-3-Clause, `LICENSE.md` verified +present) via COPY in CI builds; `LICENSE.md` is copied into both images both +next to the copied launchers and inside the kernelspec directory, and +`render_check.py` asserts the launchers copy. No upstream files are forked +into this repository. `runtime/k8s/kernel-pod.yaml.j2`, the kernelspec patch, +the client, the probes, the worker tasks, and all tests are fixture-authored. + +## How it runs + +PRs touching only this workflow and this directory trigger +`presentation-qualification` (permissions `contents: read`, 30-minute +timeout, pinned actions/checkout @ `3d3c42e5…` v7.0.1 and +actions/setup-python @ `5fda3b95…` v7.0.0 with host Python 3.11). The static +job runs stdlib-only unit tests; the runtime job runs +`runtime/run_runtime_suite.sh`, which fails closed on any pending pin, +inconsistent pin combination, or unresolved dependency. Nothing is executed +against any live cluster, and no local execution is claimed — CI is the only +validation environment. diff --git a/.github/tests/presentation-qualification/check_pins.py b/.github/tests/presentation-qualification/check_pins.py new file mode 100644 index 0000000..77af9a8 --- /dev/null +++ b/.github/tests/presentation-qualification/check_pins.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Fail-closed validator for pins.env. Exits 0 only when every pin is present, +non-pending, well formed, and internally consistent. Missing/pending pins exit +2, malformed or inconsistent pins exit 3.""" + +import re +import sys + +HEX64 = re.compile(r"^[0-9a-f]{64}$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +DIGEST_IMAGE = re.compile(r"^[a-z0-9./_:~-]+@sha256:[0-9a-f]{64}$") +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +PIPVER = re.compile(r"^\d+(\.\d+)+[a-zA-Z0-9.+-]*$") + +KEYS = { + "EG_REVISION": HEX40, + "NODE_BASE_IMAGE": DIGEST_IMAGE, + "ENTERPRISE_GATEWAY": PIPVER, + "JUPYTER_CLIENT": PIPVER, + "IPYKERNEL": PIPVER, + "PYZMQ": PIPVER, + "PYCRYPTODOMEX": PIPVER, + "KUBERNETES_PY": PIPVER, + "JINJA2": PIPVER, + "PYYAML": PIPVER, + "REQUESTS": PIPVER, + "WEBSOCKET_CLIENT": PIPVER, + "PILLOW": PIPVER, + "PPTXGENJS_VERSION": PIPVER, + "KIND_VERSION": SEMVER, + "KIND_SHA256": HEX64, + "KIND_NODE_IMAGE": DIGEST_IMAGE, + "KUBECTL_SOURCE": re.compile(r"^(pinned_download|runner_preinstalled)$"), + "KUBECTL_VERSION": re.compile(r"^(unpinned|\d+\.\d+\.\d+)$"), + "KUBECTL_SHA256": re.compile(r"^(unpinned|[0-9a-f]{64})$"), +} + +PENDING_PREFIX = "PENDING" + + +def load(path): + values = {} + with open(path, "r", encoding="utf-8") as handle: + for lineno, raw in enumerate(handle, 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise ValueError("line %d: not KEY=VALUE" % lineno) + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key not in KEYS: + raise ValueError("line %d: unknown key %s" % (lineno, key)) + if key in values: + raise ValueError("line %d: duplicate key %s" % (lineno, key)) + values[key] = value + return values + + +def cross_rules(values): + errors = [] + if values["KUBECTL_SOURCE"] == "runner_preinstalled": + if values["KUBECTL_VERSION"] != "unpinned" or values["KUBECTL_SHA256"] != "unpinned": + errors.append("runner_preinstalled kubectl must leave version/sha as unpinned") + if values["KUBECTL_SOURCE"] == "pinned_download": + if values["KUBECTL_VERSION"] == "unpinned" or values["KUBECTL_SHA256"] == "unpinned": + errors.append("pinned_download kubectl requires a real version and sha256") + return errors + + +def validate(path): + try: + values = load(path) + except (OSError, ValueError) as exc: + return 3, ["unreadable or malformed pins file: %s" % exc] + + missing = [key for key in KEYS if key not in values] + pending = [ + key + for key, value in values.items() + if not value or value.startswith(PENDING_PREFIX) + ] + if missing or pending: + return 2, [ + "pins incomplete (parent must supply verified values): %s" + % ", ".join(sorted(set(missing + pending))) + ] + + malformed = [ + key + for key, pattern in KEYS.items() + if not pattern.match(values[key]) + ] + if malformed: + return 3, ["malformed pins: %s" % ", ".join(sorted(malformed))] + + inconsistent = cross_rules(values) + if inconsistent: + return 3, inconsistent + return 0, [] + + +def main(argv): + if len(argv) != 2: + print("usage: check_pins.py pins.env", file=sys.stderr) + return 3 + code, errors = validate(argv[1]) + for error in errors: + print(error, file=sys.stderr) + if code == 0: + print("pins complete and consistent: %d pins" % len(KEYS)) + return code + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/tests/presentation-qualification/lib/__init__.py b/.github/tests/presentation-qualification/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/tests/presentation-qualification/lib/framing.py b/.github/tests/presentation-qualification/lib/framing.py new file mode 100644 index 0000000..e430f1e --- /dev/null +++ b/.github/tests/presentation-qualification/lib/framing.py @@ -0,0 +1,292 @@ +"""Bounded file transport framing for the qualification kernel channel. + +The worker prints framed base64 blocks on stdout; the Jupyter channel turns +them into stream messages that carry the client's execute_request msg_id as +their parent. The assembler accepts only lines matching that parent id and +enforces hard size/count/chunk limits, so a runaway kernel cannot exhaust the +client. Digests are verified over the reassembled bytes. +""" + +import base64 +import hashlib +import re + +VERSION = "PQB1" +BUILD_PREFIX = "PQBUILD" +DONE_PREFIX = "PQBDONE" +KV_PREFIX = "PQKV" +FILE_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") +SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") +KV_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$") +KV_VALUE_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") + + +class FramingError(Exception): + pass + + +class ProtocolError(FramingError): + pass + + +class ChannelLimitError(FramingError): + pass + + +class BuildError(FramingError): + pass + + +class DigestError(FramingError): + pass + + +class IncompleteError(FramingError): + pass + + +class Limits: + def __init__( + self, + max_files=2, + max_chunks=512, + max_chunk_bytes=32768, + max_file_bytes=8 * 1024 * 1024, + max_total_bytes=12 * 1024 * 1024, + max_lines=8192, + max_noise_lines=4096, + max_line_chars=100000, + max_kv_entries=16, + ): + self.max_files = max_files + self.max_chunks = max_chunks + self.max_chunk_bytes = max_chunk_bytes + self.max_file_bytes = max_file_bytes + self.max_total_bytes = max_total_bytes + self.max_lines = max_lines + self.max_noise_lines = max_noise_lines + self.max_line_chars = max_line_chars + self.max_kv_entries = max_kv_entries + + +DEFAULT_LIMITS = Limits() + + +def _check_file_id(file_id): + if not FILE_ID_RE.match(file_id or ""): + raise ProtocolError("invalid file id") + + +def encode_file(file_id, data, chunk_bytes=None, limits=DEFAULT_LIMITS): + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must be bytes") + data = bytes(data) + _check_file_id(file_id) + if chunk_bytes is None: + chunk_bytes = limits.max_chunk_bytes + if chunk_bytes <= 0 or chunk_bytes > limits.max_chunk_bytes: + raise ChannelLimitError("chunk size out of bounds") + if len(data) > limits.max_file_bytes: + raise ChannelLimitError("file exceeds size limit") + digest = hashlib.sha256(data).hexdigest() + chunks = [data[i : i + chunk_bytes] for i in range(0, len(data), chunk_bytes)] or [b""] + if len(chunks) > limits.max_chunks: + raise ChannelLimitError("chunk count exceeds limit") + lines = [] + for idx, chunk in enumerate(chunks): + lines.append( + "|".join( + [ + VERSION, + file_id, + str(len(chunks)), + str(len(data)), + digest, + str(idx), + base64.b64encode(chunk).decode("ascii"), + ] + ) + ) + return lines + + +def encode_build_status(code): + return "%s|%d" % (BUILD_PREFIX, int(code)) + + +def encode_kv(key, value): + if not KV_KEY_RE.match(key or ""): + raise ProtocolError("invalid kv key") + if not KV_VALUE_RE.match(value or ""): + raise ProtocolError("invalid kv value") + return "%s|%s|%s" % (KV_PREFIX, key, value) + + +def encode_done(file_id): + _check_file_id(file_id) + return "%s|%s" % (DONE_PREFIX, file_id) + + +class Artifact: + def __init__(self, file_id, total_chunks, total_bytes, expected_digest): + self.file_id = file_id + self.total_chunks = total_chunks + self.total_bytes = total_bytes + self.expected_digest = expected_digest + self.chunks = {} + self.done = False + + @property + def data(self): + return b"".join(self.chunks[i] for i in range(self.total_chunks)) + + @property + def sha256(self): + return hashlib.sha256(self.data).hexdigest() + + +class Assembler: + def __init__(self, parent_msg_id, limits=None): + self.parent_msg_id = parent_msg_id + self.limits = limits or DEFAULT_LIMITS + self._buffer = "" + self._started = {} + self._order = [] + self._bytes_seen = 0 + self.kv = {} + self.build_exit_code = None + self.total_lines = 0 + self.noise_lines = 0 + self.ignored_parent = 0 + self._finished = False + + def feed(self, parent_msg_id, text): + if self._finished: + raise ProtocolError("feed after finish") + if parent_msg_id != self.parent_msg_id: + self.ignored_parent += 1 + return + if isinstance(text, (bytes, bytearray)): + text = bytes(text).decode("utf-8") + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._handle_line(line) + if len(self._buffer) > self.limits.max_line_chars: + raise ChannelLimitError("unterminated line exceeds length limit") + + def _handle_line(self, line): + self.total_lines += 1 + if self.total_lines > self.limits.max_lines: + raise ChannelLimitError("line count limit") + if len(line) > self.limits.max_line_chars: + raise ChannelLimitError("line length limit") + if not line.startswith((VERSION + "|", BUILD_PREFIX + "|", DONE_PREFIX + "|", KV_PREFIX + "|")): + self.noise_lines += 1 + if self.noise_lines > self.limits.max_noise_lines: + raise ChannelLimitError("noise line limit") + return + parts = line.split("|") + if line.startswith(VERSION + "|"): + self._handle_block(parts) + elif line.startswith(BUILD_PREFIX + "|"): + self._handle_build(parts) + elif line.startswith(KV_PREFIX + "|"): + self._handle_kv(parts) + else: + self._handle_done(parts) + + def _handle_block(self, parts): + if len(parts) != 7: + raise ProtocolError("block arity") + _, file_id, total_chunks, total_bytes, digest, idx, b64 = parts + _check_file_id(file_id) + if not (total_chunks.isdigit() and total_bytes.isdigit() and idx.isdigit()): + raise ProtocolError("non-numeric block fields") + if not SHA256_HEX_RE.match(digest): + raise ProtocolError("digest format") + if file_id not in self._started: + if len(self._started) >= self.limits.max_files: + raise ChannelLimitError("file count limit") + total_chunks_i = int(total_chunks) + total_bytes_i = int(total_bytes) + if total_chunks_i < 1 or total_chunks_i > self.limits.max_chunks: + raise ChannelLimitError("declared chunk count out of bounds") + if total_bytes_i > self.limits.max_file_bytes: + raise ChannelLimitError("declared file size out of bounds") + self._started[file_id] = Artifact(file_id, total_chunks_i, total_bytes_i, digest) + self._order.append(file_id) + art = self._started[file_id] + if str(art.total_chunks) != total_chunks or str(art.total_bytes) != total_bytes or art.expected_digest != digest: + raise ProtocolError("inconsistent redeclared header") + if art.done: + raise ProtocolError("chunk after done") + idx_i = int(idx) + if not 0 <= idx_i < art.total_chunks: + raise ProtocolError("chunk index out of bounds") + if idx_i in art.chunks: + raise ProtocolError("duplicate chunk") + try: + payload = base64.b64decode(b64, validate=True) + except Exception as exc: + raise ProtocolError("invalid base64 payload") from exc + if len(payload) > self.limits.max_chunk_bytes: + raise ChannelLimitError("chunk payload exceeds limit") + art.chunks[idx_i] = payload + self._bytes_seen += len(payload) + if self._bytes_seen > self.limits.max_total_bytes: + raise ChannelLimitError("total transported bytes limit") + + def _handle_build(self, parts): + if len(parts) != 2 or not parts[1].lstrip("-").isdigit(): + raise ProtocolError("build status arity") + if self.build_exit_code is not None: + raise ProtocolError("duplicate build status") + self.build_exit_code = int(parts[1]) + + def _handle_kv(self, parts): + if len(parts) != 3: + raise ProtocolError("kv arity") + key, value = parts[1], parts[2] + if not KV_KEY_RE.match(key): + raise ProtocolError("kv key format") + if not KV_VALUE_RE.match(value): + raise ProtocolError("kv value format") + if key in self.kv: + raise ProtocolError("duplicate kv key") + if len(self.kv) >= self.limits.max_kv_entries: + raise ChannelLimitError("kv entry limit") + self.kv[key] = value + + def _handle_done(self, parts): + if len(parts) != 2: + raise ProtocolError("done arity") + file_id = parts[1] + _check_file_id(file_id) + if file_id not in self._started: + raise ProtocolError("done before header") + if self._started[file_id].done: + raise ProtocolError("duplicate done") + self._started[file_id].done = True + + def finish(self): + self._finished = True + if self.build_exit_code is None: + raise IncompleteError("no build status received") + if self.build_exit_code != 0: + raise BuildError("worker build exited with code %d" % self.build_exit_code) + if not self._started: + raise IncompleteError("no files received") + artifacts = {} + for file_id in self._order: + art = self._started[file_id] + if not art.done: + raise IncompleteError("%s missing done marker" % file_id) + if len(art.chunks) != art.total_chunks: + raise IncompleteError("%s missing chunks" % file_id) + if len(art.data) != art.total_bytes: + raise DigestError("%s reassembled size mismatch" % file_id) + if art.sha256 != art.expected_digest: + raise DigestError("%s digest mismatch" % file_id) + artifacts[file_id] = art + return artifacts diff --git a/.github/tests/presentation-qualification/lib/lifecycle.py b/.github/tests/presentation-qualification/lib/lifecycle.py new file mode 100644 index 0000000..b304ed6 --- /dev/null +++ b/.github/tests/presentation-qualification/lib/lifecycle.py @@ -0,0 +1,77 @@ +"""Pure kernel-lifecycle state machine used by the client cancellation path. + +Events are fed with a monotonic timestamp; deadlines are measured from +construction. The machine is deliberately strict: any unexpected event is an +error rather than a silent skip, and kernel death without a prior kill request +is a failure, not a success. +""" + + +class LifecycleError(Exception): + pass + + +class KernelLifecycle: + STARTING = "STARTING" + READY = "READY" + EXECUTING = "EXECUTING" + KILL_PENDING = "KILL_PENDING" + TERMINATED = "TERMINATED" + FAILED = "FAILED" + + _TRANSITIONS = { + ("STARTING", "INFO_READY"): "READY", + ("READY", "EXECUTE_BEGIN"): "EXECUTING", + ("EXECUTING", "EXECUTE_IDLE"): "READY", + ("STARTING", "KILL_REQUEST"): "KILL_PENDING", + ("READY", "KILL_REQUEST"): "KILL_PENDING", + ("EXECUTING", "KILL_REQUEST"): "KILL_PENDING", + ("KILL_PENDING", "KERNEL_DEAD"): "TERMINATED", + ("STARTING", "KERNEL_DEAD"): "FAILED", + ("READY", "KERNEL_DEAD"): "FAILED", + ("EXECUTING", "KERNEL_DEAD"): "FAILED", + } + + def __init__(self, start_deadline=120.0, execute_deadline=300.0, kill_deadline=60.0): + self.start_deadline = float(start_deadline) + self.execute_deadline = float(execute_deadline) + self.kill_deadline = float(kill_deadline) + self.state = self.STARTING + self.kill_requested = False + self.failure_reason = None + self._kill_at = None + + def _deadline_for_state(self): + if self.state == self.STARTING: + return self.start_deadline + if self.state in (self.READY, self.EXECUTING): + return self.execute_deadline + return None + + def feed(self, event, now=0.0): + if self.state in (self.TERMINATED, self.FAILED): + raise LifecycleError("event %s after terminal state %s" % (event, self.state)) + if self.state == self.KILL_PENDING and now - self._kill_at > self.kill_deadline: + self.state = self.FAILED + self.failure_reason = "kill deadline exceeded" + return "failed" + deadline = self._deadline_for_state() + if deadline is not None and now > deadline: + self.state = self.FAILED + self.failure_reason = "deadline exceeded in %s" % self.state + return "failed" + key = (self.state, event) + if key not in self._TRANSITIONS: + raise LifecycleError("illegal event %s in state %s" % (event, self.state)) + self.state = self._TRANSITIONS[key] + if event == "KILL_REQUEST": + self.kill_requested = True + self._kill_at = now + if self.state == self.TERMINATED: + return "succeed" if self.kill_requested else "failed" + if self.state == self.FAILED: + self.failure_reason = "kernel died unexpectedly" + return "failed" + if self.state == self.KILL_PENDING: + return "kill" + return "wait" diff --git a/.github/tests/presentation-qualification/lib/template_rules.py b/.github/tests/presentation-qualification/lib/template_rules.py new file mode 100644 index 0000000..ced8a1b --- /dev/null +++ b/.github/tests/presentation-qualification/lib/template_rules.py @@ -0,0 +1,138 @@ +"""Shared static-rule constants for fixture files. The unit tests assert these +rules against the checked-in fixture sources; the runtime build re-asserts the +rendered equivalents inside the gateway image (render_check.py).""" + +import re + +WORKER_IMAGE_PLACEHOLDER = "__PPTX_QUAL_WORKER_IMAGE__" +GATEWAY_IMAGE_PLACEHOLDER = "__GATEWAY_IMAGE__" +TOKEN_PLACEHOLDER = "__PQ_TOKEN__" + +KERNEL_NAMESPACE = "pptx-jobs" +KERNEL_SERVICE_ACCOUNT = "pptx-worker" +GATEWAY_SERVICE_ACCOUNT = "pptx-gateway" + +# Upstream launch_kubernetes.py (EG 0344929cbca688440ba1bc8f5faa074fe54bd595) +# maps only KERNEL_* env vars to Jinja keywords; PORT_RANGE/PUBLIC_KEY/ +# RESPONSE_ADDRESS reach the pod via extend_pod_env after rendering, so the +# template deliberately uses only kernel_id. +ALLOWED_TEMPLATE_VARS = {"kernel_id"} + +KERNEL_ENV_EXPECTED = { + "KERNEL_ID", + "LOG_LEVEL", + "EG_LOG_LEVEL", + "HOME", + "TMPDIR", +} + +GATEWAY_ENV_EXPECTED = { + "EG_AUTH_TOKEN", + "EG_NAMESPACE", + "EG_DEFAULT_KERNEL_SERVICE_ACCOUNT_NAME", + "HOME", + "JUPYTER_RUNTIME_DIR", +} + +KERNEL_REQUIRED_LITERALS = [ + "namespace: pptx-jobs", + "automountServiceAccountToken: false", + "serviceAccountName: pptx-worker", + "restartPolicy: Never", + "component: kernel", + "runAsUser: 1000", + "runAsGroup: 1000", + "fsGroup: 1000", + "type: RuntimeDefault", + "allowPrivilegeEscalation: false", + "readOnlyRootFilesystem: true", + "imagePullPolicy: IfNotPresent", + "image: " + WORKER_IMAGE_PLACEHOLDER, + "launch_ipykernel.py", + "--cluster-type none", + '"$KERNEL_ID"', + '"$PUBLIC_KEY"', + '"$RESPONSE_ADDRESS"', + '"$PORT_RANGE"', +] + +KERNEL_FORBIDDEN_SUBSTRINGS = [ + "hostPath", + "valueFrom", + "secretKeyRef", + "envFrom", + "persistentVolumeClaim", + "configMap", + "privileged: true", +] + +ENV_NAME_LINE_RE = re.compile(r"^\s*-\s*name:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$") +ENV_SECTION_HEADER_RE = re.compile(r"^(\s*)env:\s*$") +JINJA_VAR_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") +VERBS_RE = re.compile(r"verbs:\s*\[(.*?)\]") +RESOURCES_RE = re.compile(r"resources:\s*\[(.*?)\]") + +# Applied to kernel pod env NAMES only (never values). PUBLIC_KEY is the +# launcher's RSA public half and is name-allowed; nothing token/secret-shaped +# may appear among kernel pod env names. +FORBIDDEN_ENV_NAME_RE = re.compile( + r"(?i)(token|secret|password|passphrase|credential|privatekey|private_key|apikey|api_key)" +) + + +def env_section_names(text): + """Return the list of env-name lists, one per `env:` block, scoped by + indentation so container/volume `- name:` lines are not conflated with + environment entries.""" + sections = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + header = ENV_SECTION_HEADER_RE.match(lines[i]) + if not header: + i += 1 + continue + indent = len(header.group(1)) + names = [] + j = i + 1 + while j < len(lines): + line = lines[j] + if not line.strip(): + j += 1 + continue + line_indent = len(line) - len(line.lstrip()) + if line_indent <= indent: + break + match = ENV_NAME_LINE_RE.match(line) + if match: + names.append(match.group(1)) + j += 1 + sections.append(names) + i = j + return sections + + +def env_names(text): + return [name for section in env_section_names(text) for name in section] + + +def jinja_vars(text): + return set(JINJA_VAR_RE.findall(text)) + + +def strip_comments(text): + """Drop whole-line comments so content assertions check real YAML/JSON + payload rather than rationale comments (which legitimately mention words + like secrets).""" + return "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("#") + ) + + +def replace_worker_image(text, image): + if WORKER_IMAGE_PLACEHOLDER not in text: + raise ValueError("worker image placeholder missing") + rendered = text.replace(WORKER_IMAGE_PLACEHOLDER, image) + if WORKER_IMAGE_PLACEHOLDER in rendered: + raise ValueError("worker image placeholder not fully replaced") + return rendered diff --git a/.github/tests/presentation-qualification/lib/verify_artifacts.py b/.github/tests/presentation-qualification/lib/verify_artifacts.py new file mode 100644 index 0000000..21c61d8 --- /dev/null +++ b/.github/tests/presentation-qualification/lib/verify_artifacts.py @@ -0,0 +1,53 @@ +"""Stdlib artifact signature checks: PPTX zip magic and PNG signature + IHDR.""" + +import struct +import zlib + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +PPTX_MAGIC = b"PK\x03\x04" + + +class ArtifactVerifyError(Exception): + pass + + +def pptx_signature_ok(data): + return bytes(data[:4]) == PPTX_MAGIC + + +def png_signature_ok(data): + return bytes(data[:8]) == PNG_SIGNATURE + + +def parse_ihdr(data): + data = bytes(data) + if not png_signature_ok(data): + raise ArtifactVerifyError("png signature mismatch") + if len(data) < 8 + 4 + 4 + 13 + 4: + raise ArtifactVerifyError("png truncated before IHDR") + length = struct.unpack(">I", data[8:12])[0] + chunk_type = data[12:16] + if length != 13 or chunk_type != b"IHDR": + raise ArtifactVerifyError("first chunk is not IHDR") + width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack( + ">IIBBBBB", data[16:29] + ) + stored_crc = struct.unpack(">I", data[29:33])[0] + if zlib.crc32(data[12:29]) & 0xFFFFFFFF != stored_crc: + raise ArtifactVerifyError("IHDR crc mismatch") + if not 0 < width <= 100000: + raise ArtifactVerifyError("IHDR width out of range") + if not 0 < height <= 100000: + raise ArtifactVerifyError("IHDR height out of range") + if bit_depth not in (1, 2, 4, 8, 16): + raise ArtifactVerifyError("IHDR bit depth invalid") + if color_type not in (0, 2, 3, 4, 6): + raise ArtifactVerifyError("IHDR color type invalid") + if compression != 0 or filter_method != 0 or interlace not in (0, 1): + raise ArtifactVerifyError("IHDR coding fields invalid") + return { + "width": width, + "height": height, + "bit_depth": bit_depth, + "color_type": color_type, + } diff --git a/.github/tests/presentation-qualification/pins.env b/.github/tests/presentation-qualification/pins.env new file mode 100644 index 0000000..93c97c1 --- /dev/null +++ b/.github/tests/presentation-qualification/pins.env @@ -0,0 +1,46 @@ +# Presentation qualification fixture pins. +# check_pins.py rejects pending values and inconsistent combinations, so the +# runtime job fails closed instead of silently skipping dependencies. +# +# Network note: this FIRST fixture always runs kind's default kindnet CNI, +# which does not enforce NetworkPolicy. Network isolation is therefore +# structurally INCOMPLETE for this fixture; no CNI pin exists here and none +# may be inferred. + +# --- Group A: supplied and verified by parent session ------------------------ +# jupyter-server/enterprise_gateway revision (3.3.0 lineage) +EG_REVISION=0344929cbca688440ba1bc8f5faa074fe54bd595 +# Debian 12 (bookworm) Node base, index digest; linux/amd64 manifest digest +# 25330af3531fb5e23318554a0aa911125b6e91b1b777edf7655501d207c067a2. +NODE_BASE_IMAGE=docker.io/library/node:22-bookworm-slim@sha256:43ac6c60b8f89723f746e8a92ce91abd5017e627ce1ddfe4238355d3a30b772c +# kind v0.30.0 official release (checksum-verified binary install). +KIND_VERSION=0.30.0 +KIND_SHA256=517ab7fc89ddeed5fa65abf71530d90648d9638ef0c4cde22c2c11f8097b8889 +KIND_NODE_IMAGE=kindest/node:v1.33.4@sha256:25a6018e48dfcaee478f4a59af81157a437f15e6e140bf103f85a2e7cd0cbbf2 + +# --- Group B: exact versions. Registry presence parent-verified for --- +# --- pycryptodomex 3.20.0, kubernetes 31.0.0, pptxgenjs 4.0.1, and --- +# --- jinja2 3.1.6 (3.1.6 replaces vulnerable 3.1.4). EG 3.3.0 constrains --- +# --- jupyter-client<7 and pyzmq<25, so this is one shared legacy venv --- +# --- island: EG 3.3.0 + ipykernel 6.29.5 + jupyter-client 6.1.12 + --- +# --- pyzmq 24.0.1. Fixture-only; NOT automatically production approved. --- +ENTERPRISE_GATEWAY=3.3.0 +JUPYTER_CLIENT=6.1.12 +IPYKERNEL=6.29.5 +PYZMQ=24.0.1 +PYCRYPTODOMEX=3.20.0 +KUBERNETES_PY=31.0.0 +JINJA2=3.1.6 +PYYAML=6.0.3 +REQUESTS=2.32.5 +WEBSOCKET_CLIENT=1.8.0 +PILLOW=11.3.0 +PPTXGENJS_VERSION=4.0.1 + +# --- Group C: kubectl acquisition. pinned_download with a verified --- +# --- dl.k8s.io version+sha256 for hermetic runs; runner_preinstalled uses --- +# --- the ubuntu-latest kubectl behind a client/server skew gate --- +# --- (documented non-hermetic input, same category as apt). --- +KUBECTL_SOURCE=runner_preinstalled +KUBECTL_VERSION=unpinned +KUBECTL_SHA256=unpinned diff --git a/.github/tests/presentation-qualification/runtime/client/client.py b/.github/tests/presentation-qualification/runtime/client/client.py new file mode 100644 index 0000000..ad295c4 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/client/client.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""Fixture client for the presentation qualification core proof. + +Speaks the authenticated Jupyter kernel-gateway REST + websocket API directly +from outside the cluster, runs a BUILD kernel (PptxGenJS authoring) and then a +fresh RENDER kernel (receives the bounded deck, renders via LibreOffice + +Poppler), reassembles the bounded framed transport, and verifies PPTX/PNG +artifacts. Results carry only booleans, counts, digests, and dimensions -- +never environment values or credentials. + +Wire protocol: jupyter-server 1.24.0 services/kernels/handlers.py on_message +does json.loads(frame) and then frame.pop('channel', None) / frame['header'], +so messages are JSON dicts {"header", "parent_header", "metadata", "content", +"channel", "buffers"} — not ZMQ-style frame arrays. Received text is length- +bounded before json.loads and unexpected binary frames are rejected. Known +limitation, documented in the fixture README: websocket-client allocates each +incoming frame internally before this bound can be applied, so the bound +limits parsing and reassembly, not the initial frame allocation. +""" + +import argparse +import base64 +import io +import json +import os +import sys +import time +import uuid + +for _candidate in ("/fixture", "/opt/qual"): + if os.path.isdir(_candidate) and _candidate not in sys.path: + sys.path.insert(0, _candidate) + +from lib import framing, verify_artifacts # noqa: E402 + +import urllib.error # noqa: E402 +import urllib.request # noqa: E402 + +import websocket # noqa: E402 +from PIL import Image # noqa: E402 + +BASE_URL = os.environ.get("PQ_BASE_URL", "http://127.0.0.1:8888").rstrip("/") +KERNEL_NAME = os.environ.get("PQ_KERNEL_NAME", "presentation") +WORKER_IMAGE = os.environ.get("PQ_WORKER_IMAGE", "") +BASE_KERNEL_ENV = { + "KERNEL_NAMESPACE": "pptx-jobs", + "KERNEL_SERVICE_ACCOUNT_NAME": "pptx-worker", + "KERNEL_USERNAME": "presentation", +} +BUILD_TASK_CODE = ( + "import runpy; runpy.run_path('/opt/presentation/build_task.py', run_name='__main__')" +) +MAX_WS_TEXT_CHARS = 2 * 1024 * 1024 +MAX_UPLOAD_BYTES = 4 * 1024 * 1024 + + +class QualificationError(RuntimeError): + pass + + +def rest(method, path, body=None, timeout=30): + token = os.environ.get("PQ_TOKEN", "") + data = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request(BASE_URL + path, data=data, method=method) + if token: + request.add_header("Authorization", "token " + token) + if data is not None: + request.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read() + return response.status, (json.loads(raw) if raw else {}) + except urllib.error.HTTPError as exc: + return exc.code, {} + except urllib.error.URLError as exc: + raise QualificationError("gateway unreachable: %s" % exc.reason) + + +def start_body(extra_env): + env = dict(BASE_KERNEL_ENV) + if WORKER_IMAGE: + env["KERNEL_IMAGE"] = WORKER_IMAGE + env.update(extra_env) + return {"name": KERNEL_NAME, "env": env} + + +def start_kernel(extra_env): + status, model = rest("POST", "/api/kernels", start_body(extra_env)) + if status != 201: + raise QualificationError("kernel start rejected with http %d" % status) + kernel_id = model.get("id") if isinstance(model, dict) else None + if not kernel_id: + raise QualificationError("kernel start response missing id") + return kernel_id + + +def kill_kernel(kernel_id): + return rest("DELETE", "/api/kernels/%s" % kernel_id)[0] + + +def kernel_gone(kernel_id, timeout=60): + deadline = time.time() + timeout + while time.time() < deadline: + status, _ = rest("GET", "/api/kernels/%s" % kernel_id) + if status == 404: + return True + time.sleep(2) + return False + + +def ws_connect(kernel_id): + token = os.environ.get("PQ_TOKEN", "") + url = "%s/api/kernels/%s/channels?token=%s" % ( + BASE_URL.replace("http://", "ws://", 1), + kernel_id, + token, + ) + return websocket.create_connection(url, timeout=60) + + +def recv(ws, deadline): + while True: + remaining = deadline - time.time() + if remaining <= 0: + raise QualificationError("websocket deadline exceeded") + try: + return ws.recv() + except websocket.WebSocketTimeoutException: + continue + + +def receive(ws, deadline): + raw = recv(ws, deadline) + if isinstance(raw, (bytes, bytearray)): + raise QualificationError("unexpected binary websocket frame") + if len(raw) > MAX_WS_TEXT_CHARS: + raise QualificationError("websocket text frame exceeds bound") + frame = json.loads(raw) + if not isinstance(frame, dict) or "header" not in frame: + raise QualificationError("malformed websocket message") + return frame.get("header") or {}, frame.get("parent_header") or {}, frame.get("content") or {} + + +def send_request(ws, msg_type, content, session, parent=None): + header = { + "msg_id": uuid.uuid4().hex, + "username": "pptx-qual-client", + "session": session, + "date": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "msg_type": msg_type, + "version": "5.3", + } + message = { + "header": header, + "parent_header": {"msg_id": parent} if parent is not None else {}, + "metadata": {}, + "content": content, + "channel": "shell", + "buffers": [], + } + ws.send(json.dumps(message)) + return header["msg_id"] + + +def await_ready(ws, session, deadline): + parent_id = send_request(ws, "kernel_info_request", {}, session) + while time.time() < deadline: + header, parent, _content = receive(ws, deadline) + if header.get("msg_type") == "kernel_info_reply" and parent.get("msg_id") == parent_id: + return True + raise QualificationError("kernel never became ready") + + +def execute_task(ws, session, code, deadline): + parent_id = send_request( + ws, + "execute_request", + { + "code": code, + "silent": False, + "store_history": False, + "user_expressions": {}, + "allow_stdin": False, + }, + session, + ) + assembler = framing.Assembler(parent_id) + reply = None + idle = False + while time.time() < deadline: + header, parent, content = receive(ws, deadline) + if parent.get("msg_id") != parent_id: + assembler.feed(parent.get("msg_id") or "", "") + continue + msg_type = header.get("msg_type") + if msg_type == "stream": + assembler.feed(parent_id, content.get("text", "")) + elif msg_type == "status" and content.get("execution_state") == "idle": + idle = True + elif msg_type == "execute_reply": + reply = {"status": content.get("status")} + elif msg_type == "error": + reply = {"status": "error", "ename": content.get("ename")} + if idle and reply is not None: + break + if reply is None or not idle: + raise QualificationError("execute did not complete before deadline") + if reply.get("status") != "ok": + raise QualificationError("execute_reply status: %s" % reply.get("status")) + return assembler + + +def compose_upload_code(artifact): + data = artifact.data + if len(data) > MAX_UPLOAD_BYTES: + raise QualificationError("deck exceeds inline upload bound") + payload = base64.b64encode(data).decode("ascii") + digest = artifact.sha256 + return ( + "import os, runpy\n" + "os.makedirs('/job/in', exist_ok=True)\n" + "with open('/job/in/deck.b64', 'w') as _fh:\n" + " _fh.write(%r)\n" + "with open('/job/in/deck.sha256', 'w') as _fh:\n" + " _fh.write(%r)\n" + "runpy.run_path('/opt/presentation/render_task.py', run_name='__main__')\n" + % (payload, digest) + ) + + +def parse_env_args(pairs): + env = {} + for item in pairs or []: + if "=" not in item: + raise QualificationError("bad --set-env item: %s" % item.split("=", 1)[0]) + key, value = item.split("=", 1) + env[key] = value + return env + + +def write_result(path, payload): + if not path: + return + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def mode_full(args): + extra = parse_env_args(args.set_env) + result = {"ok": False, "mode": "full"} + + build_id = start_kernel(extra) + result["build_kernel_id"] = build_id + try: + ws = ws_connect(build_id) + try: + session = uuid.uuid4().hex + await_ready(ws, session, time.time() + 120) + build_assembler = execute_task(ws, session, BUILD_TASK_CODE, time.time() + args.timeout) + finally: + try: + ws.close() + except Exception: + pass + finally: + try: + kill_kernel(build_id) + except Exception: + pass + result["build_kernel_terminated"] = kernel_gone(build_id) + if not result["build_kernel_terminated"]: + raise QualificationError("build kernel not removed before render phase") + + build_artifacts = build_assembler.finish() + pptx = build_artifacts["deckpptx"] + if not verify_artifacts.pptx_signature_ok(pptx.data): + raise QualificationError("pptx signature mismatch") + + render_id = start_kernel(dict(extra)) + result["render_kernel_id"] = render_id + result["distinct_kernel_ids"] = render_id != build_id + try: + ws = ws_connect(render_id) + try: + session = uuid.uuid4().hex + await_ready(ws, session, time.time() + 120) + render_assembler = execute_task( + ws, session, compose_upload_code(pptx), time.time() + args.timeout + ) + finally: + try: + ws.close() + except Exception: + pass + finally: + try: + kill_kernel(render_id) + except Exception: + pass + + render_artifacts = render_assembler.finish() + png = render_artifacts["deckpng"] + kv = render_assembler.kv + if kv.get("preexisting_deckpptx") != "false": + raise QualificationError("render kernel was not fresh") + if kv.get("render_digest_ok") != "true": + raise QualificationError("render input digest mismatch") + ihdr = verify_artifacts.parse_ihdr(png.data) + image = Image.open(io.BytesIO(png.data)) + image.verify() + image = Image.open(io.BytesIO(png.data)) + image.load() + if list(image.size) != [ihdr["width"], ihdr["height"]]: + raise QualificationError("decoded png size disagrees with IHDR") + + result.update( + { + "pptx_bytes": pptx.total_bytes, + "pptx_chunks": pptx.total_chunks, + "pptx_sha256": pptx.sha256, + "pptx_signature_ok": True, + "png_bytes": png.total_bytes, + "png_chunks": png.total_chunks, + "png_sha256": png.sha256, + "png_ihdr": ihdr, + "png_decoded": True, + "png_decoded_size": list(image.size), + "framing_lines": build_assembler.total_lines + render_assembler.total_lines, + "noise_lines": build_assembler.noise_lines + render_assembler.noise_lines, + "ignored_other_parent": build_assembler.ignored_parent + render_assembler.ignored_parent, + "build_exit_code": build_assembler.build_exit_code, + "render_exit_code": render_assembler.build_exit_code, + "preexisting_deck": False, + "render_digest_ok": True, + "ok": True, + } + ) + write_result(args.out, result) + return 0 + + +def mode_start_wait_kill(args): + kernel_id = start_kernel(parse_env_args(args.set_env)) + result = {"ok": False, "mode": "start-wait-kill", "kernel_id": kernel_id} + try: + ws = ws_connect(kernel_id) + try: + await_ready(ws, uuid.uuid4().hex, time.time() + args.timeout) + time.sleep(args.hold) + result["started"] = True + result["ok"] = True + finally: + try: + ws.close() + except Exception: + pass + finally: + try: + kill_kernel(kernel_id) + except Exception: + pass + write_result(args.out, result) + return 0 + + +def mode_start_expect_failure(args): + # Truthfulness contract (security review): "rejected" may ONLY be reported + # for an explicit non-201 kernel POST status, recorded below, after an + # auth-positive control. A POST that is accepted and then fails (or even + # starts) is a test failure, never laundered into a rejection. + result = { + "ok": False, + "mode": "start-expect-failure", + "rejected": False, + "accepted": False, + "http_status": None, + } + control_status, _ = rest("GET", "/api/kernelspecs") + if control_status != 200: + raise QualificationError("auth-positive control failed with http %d" % control_status) + + status, model = rest("POST", "/api/kernels", start_body(parse_env_args(args.set_env))) + result["http_status"] = status + if status != 201: + result["rejected"] = True + result["ok"] = True + write_result(args.out, result) + return 0 + result["accepted"] = True + + kernel_id = model.get("id") if isinstance(model, dict) else None + try: + if not kernel_id: + raise QualificationError("accepted start returned no kernel id") + ws = ws_connect(kernel_id) + try: + await_ready(ws, uuid.uuid4().hex, time.time() + args.timeout) + result["started_unexpectedly"] = True + finally: + try: + ws.close() + except Exception: + pass + except Exception as exc: + result["failure"] = type(exc).__name__ + finally: + if kernel_id: + try: + kill_kernel(kernel_id) + except Exception: + pass + write_result(args.out, result) + return 1 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--mode", required=True, choices=("full", "start-wait-kill", "start-expect-failure")) + parser.add_argument("--out", required=True) + parser.add_argument("--set-env", action="append", default=[], metavar="KEY=VALUE") + parser.add_argument("--hold", type=int, default=15) + parser.add_argument("--timeout", type=int, default=300) + args = parser.parse_args() + try: + if args.mode == "full": + return mode_full(args) + if args.mode == "start-wait-kill": + return mode_start_wait_kill(args) + return mode_start_expect_failure(args) + except QualificationError as exc: + write_result(args.out, {"ok": False, "mode": args.mode, "reason": str(exc)}) + print("qualification client failure: %s" % exc, file=sys.stderr) + return 1 + except Exception as exc: + # Unexpected exceptions may embed the token-bearing websocket URL in + # their message; report the class name only. + write_result(args.out, {"ok": False, "mode": args.mode, "reason": type(exc).__name__}) + print("qualification client failure: %s" % type(exc).__name__, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/tests/presentation-qualification/runtime/client/probe_pod_invariants.py b/.github/tests/presentation-qualification/runtime/client/probe_pod_invariants.py new file mode 100644 index 0000000..2fe417d --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/client/probe_pod_invariants.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Runtime pod invariants for live kernel pods. Reads `kubectl get -o json` +from stdin and asserts security properties by NAME and BOOLEAN only -- env +values, rendered manifests, and logs are never printed or stored.""" + +import json +import re +import sys + +FORBIDDEN_ENV_NAME = re.compile( + r"(?i)(token|secret|password|passphrase|credential|privatekey|private_key|apikey|api_key)" +) +FORBIDDEN_VOLUME_KEYS = ("hostPath", "secret", "configMap", "persistentVolumeClaim", "projected") + + +def fail(message): + print("POD_INVARIANTS_FAIL: %s" % message, file=sys.stderr) + sys.exit(5) + + +def main(): + argv = sys.argv[1:] + all_namespaces = "--all-namespaces" in argv + positional = [arg for arg in argv if not arg.startswith("--")] + if len(positional) != 2: + fail("usage: probe_pod_invariants.py [--all-namespaces]") + namespace, worker_image = positional + + doc = json.load(sys.stdin) + items = doc.get("items") or [] + if not items: + if all_namespaces: + print("POD_INVARIANTS_OK pods=0") + return + fail("no kernel pods found") + + for pod in items: + meta = pod.get("metadata", {}) + if meta.get("namespace") != namespace: + fail("kernel pod outside fixed namespace: %s" % meta.get("namespace")) + labels = meta.get("labels") or {} + if labels.get("component") != "kernel": + fail("kernel pod missing component=kernel label (k8s.py proxy selector)") + spec = pod.get("spec", {}) + if spec.get("automountServiceAccountToken") is not False: + fail("automountServiceAccountToken is not false") + if spec.get("serviceAccountName") != "pptx-worker": + fail("kernel pod service account is not pptx-worker") + for volume in spec.get("volumes") or []: + if any(key in volume for key in FORBIDDEN_VOLUME_KEYS): + fail("forbidden volume type present") + + containers = spec.get("containers") or [] + if not containers: + fail("no containers") + container = containers[0] + if container.get("image") != worker_image: + fail("kernel image is not the pinned worker image") + env_entries = container.get("env") or [] + if any("valueFrom" in entry for entry in env_entries): + fail("env valueFrom present") + names = [entry["name"] for entry in env_entries] + offenders = sorted({n for n in names if FORBIDDEN_ENV_NAME.search(n)}) + if offenders: + fail("credential-shaped env names present: %s" % ",".join(offenders)) + values = {entry["name"]: entry.get("value") for entry in env_entries} + if values.get("LOG_LEVEL") != "30" or values.get("EG_LOG_LEVEL") != "30": + fail("kernel launcher log level not pinned to 30") + + container_sc = container.get("securityContext") or {} + if container_sc.get("allowPrivilegeEscalation") is not False: + fail("privilege escalation not disabled") + if container_sc.get("readOnlyRootFilesystem") is not True: + fail("root filesystem not read-only") + drops = (container_sc.get("capabilities") or {}).get("drop") or [] + if "ALL" not in drops: + fail("capabilities not fully dropped") + + pod_sc = spec.get("securityContext") or {} + if (pod_sc.get("runAsUser"), pod_sc.get("runAsGroup"), pod_sc.get("fsGroup")) != (1000, 1000, 1000): + fail("not pinned to uid/gid/fsGroup 1000") + if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault": + fail("seccomp not RuntimeDefault") + + print("POD_INVARIANTS_OK pods=%d" % len(items)) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/client/snapshot_diff.py b/.github/tests/presentation-qualification/runtime/client/snapshot_diff.py new file mode 100644 index 0000000..d7f435b --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/client/snapshot_diff.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Compare projected namespace/name/UID snapshots without retaining pod specs. + +Zero mode rejects all endpoint differences; scope mode rejects differences +outside the fixture namespace. Neither mode observes between-snapshot churn. +""" + +import re +import sys + + +def load(path): + result = set() + with open(path, "r", encoding="utf-8") as handle: + for row in handle: + fields = row.rstrip("\n").split("\t") + if len(fields) != 3 or not all(re.fullmatch(r"[a-z0-9.-]+", field) for field in fields): + raise ValueError("malformed pod metadata snapshot") + pod = tuple(fields) + if pod in result: + raise ValueError("duplicate pod metadata snapshot row") + result.add(pod) + return result + + +def violations(before, after, fixed_namespace, mode): + delta = before ^ after + if mode == "zero": + return delta + if mode == "scope": + return {pod for pod in delta if pod[0] != fixed_namespace} + raise ValueError("invalid snapshot comparison mode") + + +def main(): + if len(sys.argv) != 5: + print("usage: snapshot_diff.py ", file=sys.stderr) + sys.exit(6) + before_path, after_path, fixed_namespace, mode = sys.argv[1:5] + before = load(before_path) + after = load(after_path) + delta = (after - before) | (before - after) + for pod in sorted(delta): + print("POD_DELTA %s/%s" % (pod[0], pod[1])) + invalid = sorted(violations(before, after, fixed_namespace, mode)) + if invalid: + print( + "POD_SNAPSHOT_VIOLATION count=%d first=%s/%s" + % (len(invalid), invalid[0][0], invalid[0][1]), + file=sys.stderr, + ) + sys.exit(6) + print("POD_SNAPSHOT_OK delta=%d mode=%s" % (len(delta), mode)) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile b/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile new file mode 100644 index 0000000..6d06c2c --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile @@ -0,0 +1,35 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +ARG WORKER_IMAGE=__PPTX_QUAL_WORKER_IMAGE__ +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY .github/tests/presentation-qualification/pins.env /tmp/pins.env +RUN set -a && . /tmp/pins.env && set +a \ + && python3 -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir \ + jupyter-enterprise-gateway=="$ENTERPRISE_GATEWAY" jupyter-client=="$JUPYTER_CLIENT" \ + pyzmq=="$PYZMQ" kubernetes=="$KUBERNETES_PY" jinja2=="$JINJA2" pyyaml=="$PYYAML" \ + requests=="$REQUESTS" websocket-client=="$WEBSOCKET_CLIENT" Pillow=="$PILLOW" \ + && rm /tmp/pins.env +COPY upstream-eg/etc/kernel-launchers /usr/local/bin/kernel-launchers +COPY upstream-eg/LICENSE.md /usr/local/bin/kernel-launchers/LICENSE.md +COPY upstream-eg/etc/kernelspecs/python_kubernetes /usr/local/share/jupyter/kernels/presentation +COPY upstream-eg/LICENSE.md /usr/local/share/jupyter/kernels/presentation/LICENSE.md +COPY .github/tests/presentation-qualification/runtime/k8s/kernel-pod.yaml.j2 /usr/local/bin/kernel-launchers/kubernetes/scripts/kernel-pod.yaml.j2 +RUN sed -i "s|__PPTX_QUAL_WORKER_IMAGE__|${WORKER_IMAGE}|" /usr/local/bin/kernel-launchers/kubernetes/scripts/kernel-pod.yaml.j2 \ + && ! grep -q __PPTX_QUAL_WORKER_IMAGE__ /usr/local/bin/kernel-launchers/kubernetes/scripts/kernel-pod.yaml.j2 +COPY .github/tests/presentation-qualification/runtime/images/gateway_files/kernelspec_patch.py /tmp/kernelspec_patch.py +RUN /opt/venv/bin/python /tmp/kernelspec_patch.py \ + --kernelspec /usr/local/share/jupyter/kernels/presentation \ + --launcher /usr/local/bin/kernel-launchers/kubernetes/scripts/launch_kubernetes.py \ + --interpreter /opt/venv/bin/python \ + --worker-image "${WORKER_IMAGE}" +COPY .github/tests/presentation-qualification/runtime/images/gateway_files/render_check.py /tmp/render_check.py +RUN /opt/venv/bin/python /tmp/render_check.py \ + --template /usr/local/bin/kernel-launchers/kubernetes/scripts/kernel-pod.yaml.j2 \ + --worker-image "${WORKER_IMAGE}" +COPY .github/tests/presentation-qualification/runtime/client /opt/qual/client +COPY .github/tests/presentation-qualification/lib /opt/qual/lib +ENV PATH=/opt/venv/bin:${PATH} diff --git a/.github/tests/presentation-qualification/runtime/images/gateway_files/kernelspec_patch.py b/.github/tests/presentation-qualification/runtime/images/gateway_files/kernelspec_patch.py new file mode 100644 index 0000000..55e596a --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/gateway_files/kernelspec_patch.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Build-time, deliberate rewrite of the upstream python_kubernetes kernelspec +for this fixture's layout. Parent-verified upstream argv at EG +0344929cbca688440ba1bc8f5faa074fe54bd595: + [, + /usr/local/share/jupyter/kernels/python_kubernetes/scripts/launch_kubernetes.py, + --RemoteProcessProxy.kernel-id {kernel_id}, + --RemoteProcessProxy.port-range {port_range}, + --RemoteProcessProxy.response-address {response_address}, + --RemoteProcessProxy.public-key {public_key}] +metadata.process_proxy.class_name is +enterprise_gateway.services.processproxies.k8s.KubernetesProcessProxy and +metadata.config.image_name defaults to elyra/kernel-py:VERSION. argv[0] and +argv[1] are replaced with the fixture venv interpreter and copied launcher +path, and config.image_name is replaced with the fixture worker image; no +launcher environment context is inferred here. Fails the image build on any +structural surprise and never prints argv or env values.""" + +import argparse +import json +import os +import re +import sys + +EXPECTED_PROXY_CLASS = "enterprise_gateway.services.processproxies.k8s.KubernetesProcessProxy" +REMOTE_ARG_PREFIXES = ( + "--RemoteProcessProxy.kernel-id", + "--RemoteProcessProxy.port-range", + "--RemoteProcessProxy.response-address", + "--RemoteProcessProxy.public-key", +) +FORBIDDEN_ENV_NAME = re.compile( + r"(?i)(token|secret|password|passphrase|credential|privatekey|private_key|apikey|api_key)" +) + + +def check(condition, message): + if not condition: + print("KERNELSPEC_PATCH_FAIL: %s" % message, file=sys.stderr) + sys.exit(1) + + +def patch_shebang(path, interpreter): + with open(path, "r", encoding="utf-8") as handle: + lines = handle.readlines() + if lines and lines[0].startswith("#!"): + lines[0] = "#!%s\n" % interpreter + with open(path, "w", encoding="utf-8") as handle: + handle.writelines(lines) + os.chmod(path, 0o755) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--kernelspec", required=True) + parser.add_argument("--launcher", required=True) + parser.add_argument("--interpreter", required=True) + parser.add_argument("--worker-image", required=True) + args = parser.parse_args() + + spec_path = os.path.join(args.kernelspec, "kernel.json") + check(os.path.isfile(spec_path), "kernel.json missing") + with open(spec_path, "r", encoding="utf-8") as handle: + doc = json.load(handle) + + argv = doc.get("argv") + check(isinstance(argv, list) and len(argv) >= 2 + len(REMOTE_ARG_PREFIXES), "argv shape") + + patched = list(argv) + patched[0] = args.interpreter + patched[1] = args.launcher + check(os.path.isfile(patched[0]), "interpreter missing in image") + check(os.path.isfile(patched[1]), "launcher missing in image") + remote_args = [ + element + for element in patched[2:] + if isinstance(element, str) and any(element.startswith(prefix) for prefix in REMOTE_ARG_PREFIXES) + ] + check(len(remote_args) >= len(REMOTE_ARG_PREFIXES), "RemoteProcessProxy args missing") + for element in patched: + check(isinstance(element, str) and "\n" not in element, "argv element malformed") + + metadata = doc.get("metadata") or {} + proxy = metadata.get("process_proxy") or {} + check(proxy.get("class_name") == EXPECTED_PROXY_CLASS, "process proxy class mismatch") + metadata.setdefault("config", {})["image_name"] = args.worker_image + + env_names = doc.get("env") or {} + offenders = sorted(name for name in env_names if FORBIDDEN_ENV_NAME.search(name)) + check(not offenders, "credential-shaped env names in kernelspec") + + doc["argv"] = patched + doc["metadata"] = metadata + with open(spec_path, "w", encoding="utf-8") as handle: + json.dump(doc, handle, indent=2, sort_keys=True) + handle.write("\n") + + patch_shebang(args.launcher, args.interpreter) + print("KERNELSPEC_PATCH_OK") + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/images/gateway_files/render_check.py b/.github/tests/presentation-qualification/runtime/images/gateway_files/render_check.py new file mode 100644 index 0000000..6ffd8ab --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/gateway_files/render_check.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Build-time assertion of the rendered kernel pod template inside the gateway +image. Runs with the image's pinned jinja2/pyyaml, so the template variable +contract and every literal security pin are verified before the image can even +finish building.""" + +import argparse +import os +import sys + +import yaml +from jinja2 import Environment + +EXPECTED_ENV = { + "KERNEL_ID": None, + "LOG_LEVEL": "30", + "EG_LOG_LEVEL": "30", + "HOME": "/job/home", + "TMPDIR": "/job/tmp", +} +LAUNCHER = "/usr/local/bin/kernel-launchers/python/scripts/launch_ipykernel.py" +LICENSE = "/usr/local/bin/kernel-launchers/LICENSE.md" + + +def check(condition, message): + if not condition: + print("RENDER_CHECK_FAIL: %s" % message, file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--template", required=True) + parser.add_argument("--worker-image", required=True) + args = parser.parse_args() + + with open(args.template, "r", encoding="utf-8") as handle: + source = handle.read() + context = {"kernel_id": "11111111-2222-3333-4444-555555555555"} + rendered = Environment().from_string(source).render(context) + doc = yaml.safe_load(rendered) + + check(doc["apiVersion"] == "v1" and doc["kind"] == "Pod", "not a Pod") + meta = doc["metadata"] + check(meta["namespace"] == "pptx-jobs", "namespace not pinned to pptx-jobs") + check(meta["name"] == "kernel-" + context["kernel_id"], "pod name does not track kernel_id") + labels = meta["labels"] + check(labels.get("kernel_id") == context["kernel_id"], "kernel_id label missing") + check(labels.get("component") == "kernel", "component=kernel label missing (k8s.py selects by kernel_id,component=kernel)") + check(labels.get("app") == "pptx-qualification-kernel", "app label missing") + + spec = doc["spec"] + check(spec["automountServiceAccountToken"] is False, "SA token automount not disabled") + check(spec["serviceAccountName"] == "pptx-worker", "wrong service account") + check(spec["restartPolicy"] == "Never", "restart policy not Never") + + pod_sc = spec["securityContext"] + check( + (pod_sc["runAsUser"], pod_sc["runAsGroup"], pod_sc["fsGroup"]) == (1000, 1000, 1000), + "not pinned to uid/gid 1000", + ) + check(pod_sc["seccompProfile"]["type"] == "RuntimeDefault", "seccomp not RuntimeDefault") + + container = spec["containers"][0] + check(container["image"] == args.worker_image, "image not pinned to worker image") + check(container["imagePullPolicy"] == "IfNotPresent", "pull policy not IfNotPresent") + check(container["command"][0] == "/bin/sh" and container["command"][1] == "-c", "command is not shell-forwarded") + script = container["command"][2] + check(LAUNCHER in script, "launcher path missing from command") + check(os.path.isfile(LAUNCHER), "upstream launcher not present at pinned path") + check("--cluster-type none" in script, "--cluster-type none missing") + for var in ("KERNEL_ID", "PUBLIC_KEY", "RESPONSE_ADDRESS", "PORT_RANGE"): + check('"$%s"' % var in script, "env %s not forwarded as quoted argument" % var) + + env = {entry["name"]: entry.get("value") for entry in container["env"]} + check(set(env) == set(EXPECTED_ENV), "env names deviate from allowlist: %s" % sorted(set(env) ^ set(EXPECTED_ENV))) + for name, value in EXPECTED_ENV.items(): + if value is not None: + check(env.get(name) == value, "env %s != %s" % (name, value)) + check(all("valueFrom" not in entry for entry in container["env"]), "valueFrom present in env") + + container_sc = container["securityContext"] + check(container_sc["allowPrivilegeEscalation"] is False, "privilege escalation allowed") + check(container_sc["readOnlyRootFilesystem"] is True, "root filesystem writable") + check(container_sc["capabilities"]["drop"] == ["ALL"], "capabilities not fully dropped") + + volumes = spec.get("volumes") or [] + check(bool(volumes) and all("emptyDir" in volume for volume in volumes), "non-emptyDir volume present") + check(set(container["resources"]) == {"requests", "limits"}, "resource bounds missing") + + check(os.path.isfile(LICENSE), "upstream LICENSE.md not shipped with launchers") + print("RENDER_CHECK_OK") + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/images/worker.Dockerfile b/.github/tests/presentation-qualification/runtime/images/worker.Dockerfile new file mode 100644 index 0000000..b8c7212 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/worker.Dockerfile @@ -0,0 +1,24 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 python3-venv ca-certificates \ + libreoffice-impress poppler-utils fonts-liberation fontconfig \ + && rm -rf /var/lib/apt/lists/* +COPY .github/tests/presentation-qualification/pins.env /tmp/pins.env +RUN set -a && . /tmp/pins.env && set +a \ + && python3 -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir \ + jupyter-client=="$JUPYTER_CLIENT" ipykernel=="$IPYKERNEL" \ + pyzmq=="$PYZMQ" pycryptodomex=="$PYCRYPTODOMEX" \ + && rm /tmp/pins.env +COPY upstream-eg/etc/kernel-launchers /usr/local/bin/kernel-launchers +COPY upstream-eg/LICENSE.md /usr/local/bin/kernel-launchers/LICENSE.md +WORKDIR /opt/presentation +COPY .github/tests/presentation-qualification/runtime/images/worker_files/package.json /opt/presentation/package.json +RUN npm install --omit=dev && npm cache clean --force +COPY .github/tests/presentation-qualification/runtime/images/worker_files/build_deck.js \ + .github/tests/presentation-qualification/runtime/images/worker_files/build_task.py \ + .github/tests/presentation-qualification/runtime/images/worker_files/render_task.py /opt/presentation/ +COPY .github/tests/presentation-qualification/lib/framing.py /opt/presentation/framing.py diff --git a/.github/tests/presentation-qualification/runtime/images/worker_files/build_deck.js b/.github/tests/presentation-qualification/runtime/images/worker_files/build_deck.js new file mode 100644 index 0000000..8e8b91f --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/worker_files/build_deck.js @@ -0,0 +1,27 @@ +const PptxGenJS = require("pptxgenjs"); + +const out = process.argv[2]; +if (!out) { + process.stderr.write("usage: node build_deck.js \n"); + process.exit(2); +} + +const pptx = new PptxGenJS(); +pptx.layout = "LAYOUT_16x9"; +const slide = pptx.addSlide(); +slide.addText("Presentation qualification core proof", { + x: 0.5, y: 0.8, w: 9, h: 0.8, fontSize: 28 +}); +slide.addText( + "PptxGenJS-authored slide rendered by LibreOffice and Poppler inside a locked-down kernel pod", + { x: 0.5, y: 1.8, w: 9, h: 1.2, fontSize: 14 } +); +slide.addText(new Date().toISOString(), { x: 0.5, y: 3.4, w: 6, h: 0.5, fontSize: 12 }); + +pptx + .writeFile({ fileName: out }) + .then(() => process.exit(0)) + .catch((err) => { + process.stderr.write(String((err && err.message) || err) + "\n"); + process.exit(1); + }); diff --git a/.github/tests/presentation-qualification/runtime/images/worker_files/build_task.py b/.github/tests/presentation-qualification/runtime/images/worker_files/build_task.py new file mode 100644 index 0000000..f00a6c7 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/worker_files/build_task.py @@ -0,0 +1,42 @@ +import os +import subprocess +import sys + +sys.path.insert(0, "/opt/presentation") +import framing # noqa: E402 + +OUT_DIR = "/job/out" +DECK = OUT_DIR + "/deck.pptx" + + +def child_env(): + env = dict(os.environ) + # extend_pod_env (EG 0344929cbca688440ba1bc8f5faa074fe54bd595) overwrites + # templated HOME with the gateway's HOME; re-pin writable dirs. + env["HOME"] = "/job/home" + env["TMPDIR"] = "/job/tmp" + return env + + +def main(): + for path in ("/job/home", "/job/tmp", OUT_DIR): + os.makedirs(path, exist_ok=True) + + code = subprocess.run( + ["node", "/opt/presentation/build_deck.js", DECK], + env=child_env(), + cwd="/opt/presentation", + timeout=180, + ).returncode + + print(framing.encode_build_status(code), flush=True) + + if code == 0: + with open(DECK, "rb") as handle: + for line in framing.encode_file("deckpptx", handle.read()): + print(line, flush=True) + print(framing.encode_done("deckpptx"), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/images/worker_files/package.json b/.github/tests/presentation-qualification/runtime/images/worker_files/package.json new file mode 100644 index 0000000..2561bfc --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/worker_files/package.json @@ -0,0 +1,10 @@ +{ + "name": "pptx-qualification-worker", + "private": true, + "version": "0.0.0", + "description": "CI-only qualification fixture; authors the proof deck inside the locked-down kernel pod", + "license": "UNLICENSED", + "dependencies": { + "pptxgenjs": "4.0.1" + } +} diff --git a/.github/tests/presentation-qualification/runtime/images/worker_files/render_task.py b/.github/tests/presentation-qualification/runtime/images/worker_files/render_task.py new file mode 100644 index 0000000..dd13225 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/worker_files/render_task.py @@ -0,0 +1,109 @@ +import base64 +import binascii +import hashlib +import os +import subprocess +import sys + +sys.path.insert(0, "/opt/presentation") +import framing # noqa: E402 + +IN_DIR = "/job/in" +OUT_DIR = "/job/out" +B64_PATH = IN_DIR + "/deck.b64" +DIGEST_PATH = IN_DIR + "/deck.sha256" +DECK = OUT_DIR + "/deck.pptx" +PDF = OUT_DIR + "/deck.pdf" +PNG = OUT_DIR + "/deck.png" +MAX_B64_CHARS = 6 * 1024 * 1024 +MAX_DECK_BYTES = 4 * 1024 * 1024 +PPTX_MAGIC = b"PK\x03\x04" + + +def child_env(): + env = dict(os.environ) + env["HOME"] = "/job/home" + env["TMPDIR"] = "/job/tmp" + return env + + +def run_stage(argv, timeout): + return subprocess.run(argv, env=child_env(), timeout=timeout).returncode + + +def load_upload(): + with open(B64_PATH, "r", encoding="ascii") as handle: + b64 = handle.read().strip() + with open(DIGEST_PATH, "r", encoding="ascii") as handle: + expected = handle.read().strip() + if not 64 == len(expected): + raise ValueError("digest length") + if len(b64) > MAX_B64_CHARS: + raise ValueError("upload bound") + try: + data = base64.b64decode(b64, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError("base64") from exc + if len(data) > MAX_DECK_BYTES or not data.startswith(PPTX_MAGIC): + raise ValueError("deck bound or signature") + return data, expected + + +def main(): + for path in ("/job/home", "/job/tmp", IN_DIR, OUT_DIR): + os.makedirs(path, exist_ok=True) + + # Freshness evidence: reported before any write of DECK in this pod. + print( + framing.encode_kv("preexisting_deckpptx", "true" if os.path.exists(DECK) else "false"), + flush=True, + ) + + code = 3 + try: + data, expected = load_upload() + digest_ok = hashlib.sha256(data).hexdigest() == expected + print(framing.encode_kv("render_digest_ok", "true" if digest_ok else "false"), flush=True) + if not digest_ok: + raise ValueError("digest mismatch") + with open(DECK, "wb") as handle: + handle.write(data) + for stale in (PDF, PNG): + try: + os.remove(stale) + except FileNotFoundError: + pass + code = run_stage( + [ + "soffice", + "--headless", + "--norestore", + "--nolockcheck", + "-env:UserInstallation=file:///job/home/loprofile", + "--convert-to", + "pdf", + "--outdir", + OUT_DIR, + DECK, + ], + 300, + ) + if code == 0: + code = run_stage( + ["pdftoppm", "-png", "-r", "96", "-singlefile", PDF, PNG[:-4]], + 120, + ) + except (OSError, ValueError): + code = code or 3 + + print(framing.encode_build_status(code), flush=True) + + if code == 0: + with open(PNG, "rb") as handle: + for line in framing.encode_file("deckpng", handle.read()): + print(line, flush=True) + print(framing.encode_done("deckpng"), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in b/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in new file mode 100644 index 0000000..6a25900 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in @@ -0,0 +1,87 @@ +# Security rationale (source-backed, EG 0344929cbca688440ba1bc8f5faa074fe54bd595): +# launch_kubernetes.py's extend_pod_env runs after template rendering, overwrites +# every templated env name found in the gateway process environment, and then +# appends EVERY remaining gateway env var into the kernel pod. remotemanager.py +# _launch_kernel deletes only EG_AUTH_TOKEN and KG_AUTH_TOKEN after applying user +# overrides. The env list below is therefore the kernel-isolation boundary: it +# must stay minimal and must never gain provider, broker, or MCP credentials. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pptx-qual-gateway + namespace: pptx-jobs + labels: + app: pptx-qual-gateway +spec: + replicas: 1 + selector: + matchLabels: + app: pptx-qual-gateway + template: + metadata: + labels: + app: pptx-qual-gateway + spec: + serviceAccountName: pptx-gateway + terminationGracePeriodSeconds: 30 + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: gateway + image: __GATEWAY_IMAGE__ + imagePullPolicy: IfNotPresent + command: ["/opt/venv/bin/jupyter"] + args: + - enterprisegateway + - --ip=0.0.0.0 + - --port=8888 + - --log-level=WARNING + - --KernelSpecManager.allowed_kernelspecs=presentation + env: + - name: EG_AUTH_TOKEN + value: __PQ_TOKEN__ + - name: EG_NAMESPACE + value: pptx-jobs + - name: EG_DEFAULT_KERNEL_SERVICE_ACCOUNT_NAME + value: pptx-worker + - name: HOME + value: /tmp + - name: JUPYTER_RUNTIME_DIR + value: /tmp + ports: + - containerPort: 8888 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + sizeLimit: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: pptx-qual-gateway + namespace: pptx-jobs +spec: + selector: + app: pptx-qual-gateway + ports: + - port: 8888 + targetPort: 8888 diff --git a/.github/tests/presentation-qualification/runtime/k8s/kernel-pod.yaml.j2 b/.github/tests/presentation-qualification/runtime/k8s/kernel-pod.yaml.j2 new file mode 100644 index 0000000..baace4a --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/k8s/kernel-pod.yaml.j2 @@ -0,0 +1,82 @@ +# Rendered by launch_kubernetes.py (EG 0344929cbca688440ba1bc8f5faa074fe54bd595), +# which loads the template adjacent to the script. Only KERNEL_ID maps to a +# Jinja keyword (kernel_id); PORT_RANGE/PUBLIC_KEY/RESPONSE_ADDRESS are placed +# in the launcher process environment by the gateway and appended to the pod +# AFTER rendering by extend_pod_env, so templating them here would be a fake +# context — the command forwards the appended real values instead. +# launch_ipykernel.py (verified 648 lines at this revision) does not read those +# vars itself, hence the explicit quoted argument forwarding. +# The component=kernel label is REQUIRED: k8s.py get_container_status selects +# pods by 'kernel_id=,component=kernel'; without it the proxy cannot find +# the worker pod. LOG_LEVEL/EG_LOG_LEVEL 30 (WARNING) is mandatory because the +# launcher defaults to DEBUG (10), which can print channel connection secrets. +apiVersion: v1 +kind: Pod +metadata: + name: kernel-{{ kernel_id }} + namespace: pptx-jobs + labels: + app: pptx-qualification-kernel + component: kernel + kernel_id: "{{ kernel_id }}" +spec: + automountServiceAccountToken: false + serviceAccountName: pptx-worker + restartPolicy: Never + terminationGracePeriodSeconds: 10 + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: kernel + image: __PPTX_QUAL_WORKER_IMAGE__ + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -c + - >- + exec /opt/venv/bin/python /usr/local/bin/kernel-launchers/python/scripts/launch_ipykernel.py + --kernel-id "$KERNEL_ID" --public-key "$PUBLIC_KEY" + --response-address "$RESPONSE_ADDRESS" --port-range "$PORT_RANGE" + --cluster-type none + env: + - name: KERNEL_ID + value: "{{ kernel_id }}" + - name: LOG_LEVEL + value: "30" + - name: EG_LOG_LEVEL + value: "30" + - name: HOME + value: /job/home + - name: TMPDIR + value: /job/tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 250m + memory: 512Mi + ephemeral-storage: 256Mi + limits: + cpu: "2" + memory: 2Gi + ephemeral-storage: 1Gi + volumeMounts: + - name: job + mountPath: /job + - name: tmp + mountPath: /tmp + volumes: + - name: job + emptyDir: + sizeLimit: 1Gi + - name: tmp + emptyDir: + sizeLimit: 256Mi diff --git a/.github/tests/presentation-qualification/runtime/k8s/namespace.yaml b/.github/tests/presentation-qualification/runtime/k8s/namespace.yaml new file mode 100644 index 0000000..5149e33 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: pptx-jobs + labels: + app.kubernetes.io/part-of: presentation-qualification + presentation-qualification/fixture: "true" diff --git a/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml b/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml new file mode 100644 index 0000000..5332d62 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml @@ -0,0 +1,42 @@ +# Static, UNQUALIFIED example — retained for reference shape only. +# This FIRST fixture always runs kind's default kindnet CNI, which does NOT +# enforce NetworkPolicy. This manifest is never applied by the runtime suite +# and no isolation is claimed from its text. A future fixture revision may pin +# a verified CNI and promote this to an enforced, negatively probed policy; +# until then network isolation is reported INCOMPLETE. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: pptx-qual-default-deny + namespace: pptx-jobs +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: pptx-qual-allow-within-namespace-and-dns + namespace: pptx-jobs +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: {} + egress: + - to: + - podSelector: {} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml b/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml new file mode 100644 index 0000000..2474fde --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: pptx-gateway + namespace: pptx-jobs +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: pptx-worker + namespace: pptx-jobs +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: pptx-gateway-kernel-pods + namespace: pptx-jobs +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: pptx-gateway-kernel-pods + namespace: pptx-jobs +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: pptx-gateway-kernel-pods +subjects: + - kind: ServiceAccount + name: pptx-gateway + namespace: pptx-jobs diff --git a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh new file mode 100644 index 0000000..56e88e2 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# Presentation qualification — FIRST stage, CI-only core proof. +# Runs entirely inside a disposable, checksum-pinned kind cluster on the +# hosted runner (kind's default kindnet CNI; NetworkPolicy is NOT enforced, +# so network isolation is reported INCOMPLETE — a static, unqualified +# networkpolicy.yaml example is kept but never applied or claimed). Gates +# CORE scope: fixed-namespace RBAC, pod hardening, two-kernel build/render +# freshness, bounded transport, auth, cancellation, and label-independent +# all-namespace pod snapshots around every negative probe. The suite never +# collects kernel or gateway logs, never echoes credentials (shell tracing is +# not enabled), and uploads nothing. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$FIXTURE_DIR/../../.." && pwd)" +UPSTREAM_DIR="$REPO_ROOT/upstream-eg" + +CLUSTER_NAME=pptx-qual +KIND_CONTEXT="kind-$CLUSTER_NAME" +NAMESPACE=pptx-jobs +WORKER_TAG=pptx-qual/worker:ci +GATEWAY_TAG=pptx-qual/gateway:ci +BASE_URL=http://127.0.0.1:8888 + +WORK_DIR="${RUNNER_TEMP:-/tmp/opencode/pptx-qualification/runner}/run" +umask 077 +mkdir -p "$WORK_DIR/bin" +export KUBECONFIG="$WORK_DIR/kubeconfig" +KUBECTL_BIN="" + +cleanup() { + local rc=$? + set +e + if [[ -f "$WORK_DIR/portforward.pid" ]]; then + kill "$(cat "$WORK_DIR/portforward.pid")" 2>/dev/null || true + fi + if [[ -n "$KUBECTL_BIN" && -x "$KUBECTL_BIN" && -f "$KUBECONFIG" ]]; then + "$KUBECTL_BIN" --context "$KIND_CONTEXT" delete namespace "$NAMESPACE" --ignore-not-found --wait=false >/dev/null 2>&1 || true + fi + if [[ -x "$WORK_DIR/bin/kind" ]]; then + "$WORK_DIR/bin/kind" delete cluster --name "$CLUSTER_NAME" >/dev/null 2>&1 || true + fi + docker rmi -f "$WORKER_TAG" "$GATEWAY_TAG" >/dev/null 2>&1 || true + rm -f "$KUBECONFIG" "$WORK_DIR/gateway.yaml" "$WORK_DIR/deny.err" "$WORK_DIR/pf.log" "$WORK_DIR"/pods-*.json + exit "$rc" +} +trap cleanup EXIT + +fail() { + echo "QUALIFICATION FAILURE: $*" >&2 + if [[ -n "$KUBECTL_BIN" && -x "$KUBECTL_BIN" && -f "$KUBECONFIG" ]]; then + "$KUBECTL_BIN" --context "$KIND_CONTEXT" get events -n "$NAMESPACE" \ + -o jsonpath='{range .items[*]}{.reason}{"\n"}{end}' 2>/dev/null | sort | uniq -c | tail -n 10 >&2 || true + fi + exit 1 +} + +pods_snapshot() { + local path=$1 + "$KUBECTL_BIN" --context "$KIND_CONTEXT" get pods -A \ + -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\t"}{.metadata.uid}{"\n"}{end}' \ + > "$path" 2>/dev/null || fail "all-namespace pod snapshot failed" +} + +assert_snapshots_clean() { + local before=$1 after=$2 label=$3 mode=${4:-scope} + python3 "$FIXTURE_DIR/runtime/client/snapshot_diff.py" "$before" "$after" "$NAMESPACE" "$mode" \ + || fail "pod scope escape detected by $label snapshot comparison" +} + +cd "$FIXTURE_DIR" +python3 check_pins.py pins.env +set -a +. ./pins.env +set +a +: "${GITHUB_STEP_SUMMARY:=/dev/null}" + +[[ -d "$UPSTREAM_DIR" ]] || fail "upstream checkout missing at $UPSTREAM_DIR" +for upstream_path in \ + etc/kernel-launchers/kubernetes/scripts/launch_kubernetes.py \ + etc/kernel-launchers/kubernetes/scripts/kernel-pod.yaml.j2 \ + etc/kernelspecs/python_kubernetes/kernel.json \ + etc/kernel-launchers/python/scripts/launch_ipykernel.py \ + LICENSE.md; do + [[ -f "$UPSTREAM_DIR/$upstream_path" ]] || fail "missing pinned upstream file: $upstream_path" +done + +curl -fsSL -o "$WORK_DIR/bin/kind" \ + "https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION#v}/kind-linux-amd64" +echo "$KIND_SHA256 $WORK_DIR/bin/kind" | sha256sum -c - >/dev/null +chmod 0755 "$WORK_DIR/bin/kind" + +if [[ "$KUBECTL_SOURCE" == "pinned_download" ]]; then + curl -fsSL -o "$WORK_DIR/bin/kubectl" \ + "https://dl.k8s.io/release/v${KUBECTL_VERSION#v}/bin/linux/amd64/kubectl" + echo "$KUBECTL_SHA256 $WORK_DIR/bin/kubectl" | sha256sum -c - >/dev/null + chmod 0755 "$WORK_DIR/bin/kubectl" + KUBECTL_BIN="$WORK_DIR/bin/kubectl" +else + command -v kubectl >/dev/null 2>&1 || fail "runner kubectl missing; set KUBECTL_SOURCE=pinned_download with verified pins" + KUBECTL_BIN="$(command -v kubectl)" + node_minor="$(printf '%s' "$KIND_NODE_IMAGE" | sed -E 's|.*:v1\.([0-9]+)\..*|\1|')" + client_json="$("$KUBECTL_BIN" version --client -o json || true)" + printf '%s' "$client_json" | python3 -c ' +import json +import sys + +node_minor = int(sys.argv[1]) +doc = json.load(sys.stdin) +version = doc["clientVersion"] +assert version["major"] == "1", "unexpected kubectl major %r" % version["major"] +skew = abs(int(version["minor"]) - node_minor) +assert skew <= 1, ( + "kubectl 1.%s skews more than one minor from kind node 1.%d; " + "set KUBECTL_SOURCE=pinned_download with verified pins" % (version["minor"], node_minor) +) +print("kubectl skew gate ok: client 1.%s vs node 1.%d" % (version["minor"], node_minor)) +' "$node_minor" +fi +export PATH="$WORK_DIR/bin:$PATH" + +docker build --build-arg BASE_IMAGE="$NODE_BASE_IMAGE" \ + -f "$FIXTURE_DIR/runtime/images/worker.Dockerfile" -t "$WORKER_TAG" "$REPO_ROOT" +docker build --build-arg BASE_IMAGE="$NODE_BASE_IMAGE" --build-arg WORKER_IMAGE="$WORKER_TAG" \ + -f "$FIXTURE_DIR/runtime/images/gateway.Dockerfile" -t "$GATEWAY_TAG" "$REPO_ROOT" + +"$WORK_DIR/bin/kind" create cluster --name "$CLUSTER_NAME" --image "$KIND_NODE_IMAGE" \ + --kubeconfig "$KUBECONFIG" --wait 180s +"$KUBECTL_BIN" --context "$KIND_CONTEXT" wait --for=condition=Ready node --all --timeout=300s +"$WORK_DIR/bin/kind" load docker-image "$WORKER_TAG" --name "$CLUSTER_NAME" +"$WORK_DIR/bin/kind" load docker-image "$GATEWAY_TAG" --name "$CLUSTER_NAME" + +"$KUBECTL_BIN" --context "$KIND_CONTEXT" apply \ + -f "$FIXTURE_DIR/runtime/k8s/namespace.yaml" \ + -f "$FIXTURE_DIR/runtime/k8s/rbac.yaml" +PQ_TOKEN="$(openssl rand -hex 32)" +export PQ_TOKEN +sed -e "s|__PQ_TOKEN__|$PQ_TOKEN|" -e "s|__GATEWAY_IMAGE__|$GATEWAY_TAG|" \ + "$FIXTURE_DIR/runtime/k8s/gateway.yaml.in" > "$WORK_DIR/gateway.yaml" +"$KUBECTL_BIN" --context "$KIND_CONTEXT" apply -f "$WORK_DIR/gateway.yaml" +"$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" rollout status deployment/pptx-qual-gateway --timeout=240s + +"$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" port-forward svc/pptx-qual-gateway \ + --address 127.0.0.1 8888:8888 >"$WORK_DIR/pf.log" 2>&1 & +echo $! > "$WORK_DIR/portforward.pid" + +ready="" +for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $PQ_TOKEN" "$BASE_URL/api/kernelspecs" || true) + if [[ "$code" == "200" ]]; then ready=1; break; fi + sleep 5 +done +[[ -n "$ready" ]] || fail "gateway API never became ready" + +pods_snapshot "$WORK_DIR/pods-auth-before.json" +unauth=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/api/kernelspecs" || true) +badtok=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token definitely-not-the-token" "$BASE_URL/api/kernelspecs" || true) +unauth_post=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ + --data '{"name":"presentation","env":{}}' "$BASE_URL/api/kernels" || true) +badtok_post=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ + -H "Authorization: token definitely-not-the-token" \ + --data '{"name":"presentation","env":{}}' "$BASE_URL/api/kernels" || true) +[[ "$unauth" =~ ^(401|403)$ ]] || fail "unauthenticated GET accepted (http $unauth)" +[[ "$badtok" =~ ^(401|403)$ ]] || fail "wrong token GET accepted (http $badtok)" +[[ "$unauth_post" =~ ^(401|403)$ ]] || fail "unauthenticated kernel POST accepted (http $unauth_post)" +[[ "$badtok_post" =~ ^(401|403)$ ]] || fail "wrong token kernel POST accepted (http $badtok_post)" +pods_snapshot "$WORK_DIR/pods-auth-after.json" +assert_snapshots_clean "$WORK_DIR/pods-auth-before.json" "$WORK_DIR/pods-auth-after.json" "auth-negative" zero + +run_client() { + local mode=$1 + shift + docker run --rm --network host \ + -e PQ_TOKEN -e PQ_BASE_URL="$BASE_URL" -e PQ_KERNEL_NAME=presentation -e PQ_WORKER_IMAGE="$WORKER_TAG" \ + -v "$FIXTURE_DIR":/fixture:ro -v "$WORK_DIR":/out \ + "$GATEWAY_TAG" python /fixture/runtime/client/client.py --mode "$mode" --out "/out/result-$mode.json" "$@" +} + +wait_for_kernel_pod_and_assert() { + local client_pid=$1 + local deadline=$(( SECONDS + 240 )) + while (( SECONDS < deadline )); do + if ! kill -0 "$client_pid" 2>/dev/null; then return 2; fi + if ("$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" get pods -l app=pptx-qualification-kernel --no-headers 2>/dev/null || true) | grep -q Running; then + ("$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" get pods -l app=pptx-qualification-kernel -o json 2>/dev/null || true) \ + | python3 "$FIXTURE_DIR/runtime/client/probe_pod_invariants.py" "$NAMESPACE" "$WORKER_TAG" \ + || fail "live kernel pod invariants violated" + return 0 + fi + sleep 3 + done + return 1 +} + +pods_snapshot "$WORK_DIR/pods-ns-before.json" +set +e +run_client start-expect-failure --set-env KERNEL_NAMESPACE=pptx-wrong --timeout 200 +client_rc=$? +set -e +[[ $client_rc -eq 0 ]] || fail "KERNEL_NAMESPACE probe failed (client rc=$client_rc)" +python3 - "$WORK_DIR/result-start-expect-failure.json" <<'PY' +import json +import sys + +with open(sys.argv[1]) as handle: + result = json.load(handle) +assert result.get("ok") is True, result +assert result.get("rejected") is True, result +status = result.get("http_status") +assert isinstance(status, int) and status != 201, status +print("NAMESPACE_OVERRIDE_REJECTED http=%d" % status) +PY +pods_snapshot "$WORK_DIR/pods-ns-after.json" +assert_snapshots_clean "$WORK_DIR/pods-ns-before.json" "$WORK_DIR/pods-ns-after.json" "namespace-tamper" +"$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" delete pods -l app=pptx-qualification-kernel \ + --ignore-not-found --wait=false >/dev/null 2>&1 || true + +set +e +run_client full --timeout 420 & +FULL_PID=$! +set -e +wait_for_kernel_pod_and_assert "$FULL_PID" || fail "no running kernel pod observed during full run" +wait "$FULL_PID" || fail "full qualification client run failed" +python3 - "$WORK_DIR/result-full.json" <<'PY' +import json +import sys + +with open(sys.argv[1]) as handle: + result = json.load(handle) +assert result.get("ok") is True, "client reported failure" +for key in ( + "pptx_signature_ok", + "png_decoded", + "distinct_kernel_ids", + "build_kernel_terminated", +): + assert result.get(key) is True, key +assert result.get("preexisting_deck") is False, result.get("preexisting_deck") +assert result.get("render_digest_ok") is True, result.get("render_digest_ok") +assert result["build_exit_code"] == 0, result["build_exit_code"] +assert result["render_exit_code"] == 0, result["render_exit_code"] +ihdr = result["png_ihdr"] +assert ihdr["width"] > 0 and ihdr["height"] > 0, ihdr +assert result["png_decoded_size"] == [ihdr["width"], ihdr["height"]], result["png_decoded_size"] +assert len(result["pptx_sha256"]) == 64 and len(result["png_sha256"]) == 64 +assert result["pptx_bytes"] > 1000, result["pptx_bytes"] +assert result["pptx_chunks"] >= 1 and result["png_chunks"] >= 1 +print("RESULT_FULL_OK") +PY +"$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" wait --for=delete pod -l app=pptx-qualification-kernel \ + --timeout=180s || fail "kernel pods were not removed after kernel kills" + +set +e +run_client start-wait-kill --set-env KERNEL_IMAGE=pptx-qual/evil:ci --hold 40 --timeout 300 & +TAMPER_PID=$! +set -e +wait_for_kernel_pod_and_assert "$TAMPER_PID" || fail "no kernel pod observed during image-tamper run" +wait "$TAMPER_PID" || fail "image-tamper client run failed" +python3 - "$WORK_DIR/result-start-wait-kill.json" <<'PY' +import json +import sys + +with open(sys.argv[1]) as handle: + result = json.load(handle) +assert result.get("ok") is True and result.get("started") is True, result +print("IMAGE_OVERRIDE_IGNORED_KERNEL_STARTED") +PY +"$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" wait --for=delete pod -l app=pptx-qualification-kernel \ + --timeout=180s || fail "kernel pod was not removed after tamper-run kill" + +PROBE_OVERRIDES='{"spec":{"automountServiceAccountToken":false}}' +set +e +"$KUBECTL_BIN" --context "$KIND_CONTEXT" --as=system:serviceaccount:pptx-jobs:pptx-gateway \ + -n default run pg-deny-probe --image="$WORKER_TAG" --restart=Never \ + --overrides="$PROBE_OVERRIDES" --command -- sleep 2 >/dev/null 2>"$WORK_DIR/deny.err" +deny_rc=$? +set -e +if [[ $deny_rc -eq 0 ]]; then + "$KUBECTL_BIN" --context "$KIND_CONTEXT" -n default delete pod pg-deny-probe --ignore-not-found >/dev/null 2>&1 || true + fail "gateway service account created a pod outside $NAMESPACE" +fi +grep -qi forbidden "$WORK_DIR/deny.err" || fail "RBAC denial probe failed for an unexpected reason" + +set +e +"$KUBECTL_BIN" --context "$KIND_CONTEXT" --as=system:serviceaccount:pptx-jobs:pptx-gateway \ + -n "$NAMESPACE" run pg-allow-probe --image="$WORKER_TAG" --restart=Never \ + --overrides="$PROBE_OVERRIDES" --command -- sleep 4 >/dev/null 2>&1 +allow_rc=$? +set -e +[[ $allow_rc -eq 0 ]] || fail "RBAC positive control failed: gateway SA cannot create a pod in $NAMESPACE" +"$KUBECTL_BIN" --context "$KIND_CONTEXT" --as=system:serviceaccount:pptx-jobs:pptx-gateway \ + -n "$NAMESPACE" delete pod pg-allow-probe --ignore-not-found --wait=true >/dev/null 2>&1 || true + +CORE_STATUS=PASS +NETWORK_STATUS=INCOMPLETE +NETWORK_REASON="kind default CNI (kindnet) does not enforce NetworkPolicy; runtime/k8s/networkpolicy.yaml is a static, unqualified example that is never applied by this fixture" +OVERALL_STATUS=INCOMPLETE +python3 - "$WORK_DIR/qualification-status.json" "$CORE_STATUS" "$NETWORK_STATUS" "$OVERALL_STATUS" "$NETWORK_REASON" <<'PY' +import json +import sys + +path, core, network, overall, reason = sys.argv[1:6] +doc = { + "core_rbac_render_transport": core, + "network_isolation": network, + "network_reason": reason, + "overall": overall, + "note": "CI disposable cluster only; production qualification remains a parent decision", +} +with open(path, "w") as handle: + json.dump(doc, handle, indent=2, sort_keys=True) +PY +{ + echo "### Presentation qualification (FIRST stage, CI-only)" + echo "- core (fixed-namespace RBAC, pod hardening, fresh build+render kernels, bounded transport, auth, cancellation): $CORE_STATUS" + echo "- network isolation: $NETWORK_STATUS — $NETWORK_REASON" + echo "- overall: $OVERALL_STATUS (this workflow gates core scope only)" + echo "- disposable kind cluster deleted; images never pushed; no artifacts uploaded" +} >> "$GITHUB_STEP_SUMMARY" + +echo "QUALIFICATION_CORE=$CORE_STATUS" +echo "QUALIFICATION_NETWORK_ISOLATION=$NETWORK_STATUS ($NETWORK_REASON)" +echo "QUALIFICATION_OVERALL=$OVERALL_STATUS" +[[ "$CORE_STATUS" == "PASS" ]] || fail "core scope failed" +exit 0 diff --git a/.github/tests/presentation-qualification/unit/__init__.py b/.github/tests/presentation-qualification/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/tests/presentation-qualification/unit/test_channel_assembler.py b/.github/tests/presentation-qualification/unit/test_channel_assembler.py new file mode 100644 index 0000000..221b7b1 --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_channel_assembler.py @@ -0,0 +1,389 @@ +import base64 +import hashlib +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from lib import framing # noqa: E402 + +PARENT = "exec-msg-id-1" +OTHER = "some-other-parent" + + +def feed_lines(asm, lines, parent=PARENT): + for line in lines: + asm.feed(parent, line + "\n") + + +class EncodeDecodeRoundtrip(unittest.TestCase): + def test_two_files_roundtrip(self): + pptx = b"PK\x03\x04" + os.urandom(70000) + png = b"\x89PNG\r\n\x1a\n" + os.urandom(40000) + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpptx", pptx)) + feed_lines(asm, [framing.encode_done("deckpptx")]) + feed_lines(asm, framing.encode_file("deckpng", png)) + feed_lines(asm, [framing.encode_done("deckpng")]) + artifacts = asm.finish() + self.assertEqual(artifacts["deckpptx"].data, pptx) + self.assertEqual(artifacts["deckpng"].data, png) + self.assertEqual(artifacts["deckpptx"].sha256, hashlib.sha256(pptx).hexdigest()) + self.assertEqual(asm.build_exit_code, 0) + self.assertEqual(asm.ignored_parent, 0) + + def test_empty_file_roundtrip(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpptx", b"")) + feed_lines(asm, [framing.encode_done("deckpptx")]) + artifacts = asm.finish() + self.assertEqual(artifacts["deckpptx"].data, b"") + self.assertEqual(artifacts["deckpptx"].total_chunks, 1) + + def test_split_stream_buffers_lines(self): + payload = b"PK\x03\x04" + b"x" * 100 + lines = framing.encode_file("deckpptx", payload) + asm = framing.Assembler(PARENT) + blob = framing.encode_build_status(0) + "\n" + "\n".join(lines) + "\n" + framing.encode_done("deckpptx") + "\n" + for i in range(0, len(blob), 7): + asm.feed(PARENT, blob[i : i + 7]) + self.assertEqual(asm.finish()["deckpptx"].data, payload) + + +class WrongParentFiltering(unittest.TestCase): + def test_other_parent_ignored(self): + payload = b"PK\x03\x04data" + asm = framing.Assembler(PARENT) + asm.feed(OTHER, framing.encode_build_status(0) + "\n") + feed_lines(asm, framing.encode_file("deckpptx", payload)) + asm.feed(OTHER, "PQB1|evil|1|4|" + "0" * 64 + "|0|AAAA\n") + feed_lines(asm, [framing.encode_done("deckpptx")]) + asm.feed(OTHER, framing.encode_done("evil") + "\n") + artifacts = asm.finish() + self.assertEqual(artifacts["deckpptx"].data, payload) + self.assertEqual(asm.ignored_parent, 3) + self.assertNotIn("evil", artifacts) + + +class ProtocolErrors(unittest.TestCase): + # Delivery order is not constrained by the protocol: chunks may arrive in + # any order as long as every index arrives exactly once. + def test_out_of_order_delivery_accepted(self): + payload = b"PK\x03\x04" + b"0123456789abcdef" + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + lines = framing.encode_file("deckpptx", payload, chunk_bytes=4) + for index in reversed(range(len(lines))): + feed_lines(asm, [lines[index]]) + feed_lines(asm, [framing.encode_done("deckpptx")]) + self.assertEqual(asm.finish()["deckpptx"].data, payload) + + def test_duplicate_chunk_rejected(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + lines = framing.encode_file("deckpptx", b"0123456789abcdef", chunk_bytes=4) + feed_lines(asm, [lines[0]]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [lines[0]]) + + def test_gap_rejected_at_finish(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + lines = framing.encode_file("deckpptx", b"0123456789abcdef", chunk_bytes=4) + feed_lines(asm, [lines[0]]) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.IncompleteError): + asm.finish() + + def test_done_before_header(self): + asm = framing.Assembler(PARENT) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [framing.encode_done("deckpptx")]) + + def test_duplicate_done(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpptx", b"abc")) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [framing.encode_done("deckpptx")]) + + def test_chunk_after_done(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + lines = framing.encode_file("deckpptx", b"0123456789abcdef", chunk_bytes=4) + feed_lines(asm, lines[:1]) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, lines[1:]) + + def test_inconsistent_redeclared_header(self): + import base64 + import hashlib + + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + good = framing.encode_file("deckpptx", b"0123456789", chunk_bytes=5) + feed_lines(asm, good[:1]) + forged = "|".join( + [ + framing.VERSION, + "deckpptx", + "9", + "10", + hashlib.sha256(b"other").hexdigest(), + "1", + base64.b64encode(b"56789").decode(), + ] + ) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [forged]) + + def test_duplicate_build_status(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [framing.encode_build_status(0)]) + + def test_bad_file_id(self): + asm = framing.Assembler(PARENT) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, ["PQB1|../etc/passwd|1|1|" + "0" * 64 + "|0|QQ=="]) + + def test_invalid_base64(self): + asm = framing.Assembler(PARENT) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, ["PQB1|deckpptx|1|1|" + "0" * 64 + "|0|!!!not-base64!!!"]) + + +class LimitEnforcement(unittest.TestCase): + def make(self, **kwargs): + limits = framing.Limits(**kwargs) + return framing.Assembler(PARENT, limits), limits + + def test_file_count_limit(self): + asm, limits = self.make(max_files=1) + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpptx", b"abc")) + with self.assertRaises(framing.ChannelLimitError): + feed_lines(asm, framing.encode_file("deckpng", b"def")) + + def test_file_size_limit_encode(self): + limits = framing.Limits(max_file_bytes=8) + with self.assertRaises(framing.ChannelLimitError): + framing.encode_file("deckpptx", b"123456789", limits=limits) + + def test_file_size_limit_declared(self): + asm, _ = self.make(max_file_bytes=8) + feed_lines(asm, [framing.encode_build_status(0)]) + with self.assertRaises(framing.ChannelLimitError): + feed_lines(asm, ["PQB1|deckpptx|1|9|" + "0" * 64 + "|0|QQ=="]) + + def test_chunk_count_limit(self): + limits = framing.Limits(max_chunks=2) + with self.assertRaises(framing.ChannelLimitError): + framing.encode_file("deckpptx", b"0123456789abcdef", chunk_bytes=4, limits=limits) + + def test_chunk_payload_limit(self): + asm, _ = self.make(max_chunk_bytes=4) + feed_lines(asm, [framing.encode_build_status(0)]) + import base64 + import hashlib + + data = b"0123456789" + line = "|".join( + [ + framing.VERSION, + "deckpptx", + "1", + str(len(data)), + hashlib.sha256(data).hexdigest(), + "0", + base64.b64encode(data).decode(), + ] + ) + with self.assertRaises(framing.ChannelLimitError): + feed_lines(asm, [line]) + + def test_total_bytes_limit(self): + asm, _ = self.make(max_total_bytes=4) + feed_lines(asm, [framing.encode_build_status(0)]) + with self.assertRaises(framing.ChannelLimitError): + feed_lines(asm, framing.encode_file("deckpptx", b"0123456789")) + + def test_line_count_limit(self): + asm, _ = self.make(max_lines=3) + for _ in range(3): + asm.feed(PARENT, "noise\n") + with self.assertRaises(framing.ChannelLimitError): + asm.feed(PARENT, "noise\n") + + def test_noise_limit(self): + asm, _ = self.make(max_noise_lines=2) + asm.feed(PARENT, "noise\n") + asm.feed(PARENT, "noise\n") + with self.assertRaises(framing.ChannelLimitError): + asm.feed(PARENT, "noise\n") + + def test_unterminated_line_length(self): + asm, _ = self.make(max_line_chars=16) + with self.assertRaises(framing.ChannelLimitError): + asm.feed(PARENT, "x" * 17) + + +class BuildAndDigest(unittest.TestCase): + def test_nonzero_build_fails_even_with_blocks(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(3)]) + feed_lines(asm, framing.encode_file("deckpptx", b"PK\x03\x04abc")) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.BuildError): + asm.finish() + + def test_missing_build_status(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, framing.encode_file("deckpptx", b"abc")) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.IncompleteError): + asm.finish() + + def test_digest_mismatch(self): + import base64 + import hashlib + + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + data = b"PK\x03\x04corrupt-me" + wrong = hashlib.sha256(b"different").hexdigest() + for idx, chunk in enumerate([data]): + feed_lines( + asm, + [ + "|".join( + [ + framing.VERSION, + "deckpptx", + "1", + str(len(data)), + wrong, + str(idx), + base64.b64encode(chunk).decode(), + ] + ) + ], + ) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.DigestError): + asm.finish() + + def test_size_mismatch_vs_digest_declared(self): + import base64 + import hashlib + + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + data = b"0123456789" + feed_lines( + asm, + [ + "|".join( + [ + framing.VERSION, + "deckpptx", + "1", + "4", + hashlib.sha256(data).hexdigest(), + "0", + base64.b64encode(data).decode(), + ] + ) + ], + ) + feed_lines(asm, [framing.encode_done("deckpptx")]) + with self.assertRaises(framing.DigestError): + asm.finish() + + def test_feed_after_finish(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpptx", b"abc")) + feed_lines(asm, [framing.encode_done("deckpptx")]) + asm.finish() + with self.assertRaises(framing.ProtocolError): + asm.feed(PARENT, "x\n") + + +class NoiseTolerance(unittest.TestCase): + def test_interleaved_noise_lines_ignored(self): + payload = b"PK\x03\x04payload" + asm = framing.Assembler(PARENT) + asm.feed(PARENT, "stdout noise\n") + feed_lines(asm, [framing.encode_build_status(0)]) + lines = framing.encode_file("deckpptx", payload) + for i, line in enumerate(lines): + asm.feed(PARENT, "interleaved %d\n" % i) + asm.feed(PARENT, line + "\n") + asm.feed(PARENT, "trailing noise\n") + feed_lines(asm, [framing.encode_done("deckpptx")]) + artifacts = asm.finish() + self.assertEqual(artifacts["deckpptx"].data, payload) + self.assertEqual(asm.noise_lines, len(lines) + 2) + + +class KeyValueFraming(unittest.TestCase): + def test_kv_recorded_and_not_noise(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_kv("preexisting_deckpptx", "false")]) + feed_lines(asm, [framing.encode_kv("render_digest_ok", "true")]) + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpng", b"\x89PNG\r\n\x1a\nabc")) + feed_lines(asm, [framing.encode_done("deckpng")]) + asm.finish() + self.assertEqual( + asm.kv, {"preexisting_deckpptx": "false", "render_digest_ok": "true"} + ) + self.assertEqual(asm.noise_lines, 0) + + def test_wrong_parent_kv_ignored(self): + asm = framing.Assembler(PARENT) + asm.feed(OTHER, framing.encode_kv("evil", "true") + "\n") + feed_lines(asm, [framing.encode_build_status(0)]) + feed_lines(asm, framing.encode_file("deckpng", b"abc")) + feed_lines(asm, [framing.encode_done("deckpng")]) + asm.finish() + self.assertEqual(asm.kv, {}) + self.assertEqual(asm.ignored_parent, 1) + + def test_duplicate_kv_rejected(self): + asm = framing.Assembler(PARENT) + feed_lines(asm, [framing.encode_kv("preexisting_deckpptx", "false")]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [framing.encode_kv("preexisting_deckpptx", "true")]) + + def test_kv_bad_key_and_value_rejected(self): + asm = framing.Assembler(PARENT) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [framing.encode_kv("UPPER", "false")]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, [framing.encode_kv("ok", "has spaces")]) + + def test_kv_bad_arity_rejected(self): + asm = framing.Assembler(PARENT) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, ["PQKV|onlykey"]) + with self.assertRaises(framing.ProtocolError): + feed_lines(asm, ["PQKV|key|value|extra"]) + + def test_kv_entry_limit(self): + asm = framing.Assembler(PARENT, framing.Limits(max_kv_entries=2)) + feed_lines(asm, [framing.encode_kv("k1", "v1")]) + feed_lines(asm, [framing.encode_kv("k2", "v2")]) + with self.assertRaises(framing.ChannelLimitError): + feed_lines(asm, [framing.encode_kv("k3", "v3")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/presentation-qualification/unit/test_lifecycle.py b/.github/tests/presentation-qualification/unit/test_lifecycle.py new file mode 100644 index 0000000..8d0ff83 --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_lifecycle.py @@ -0,0 +1,109 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from lib.lifecycle import KernelLifecycle, LifecycleError # noqa: E402 + + +class HappyPaths(unittest.TestCase): + def test_full_session(self): + machine = KernelLifecycle(start_deadline=100, execute_deadline=300, kill_deadline=60) + self.assertEqual(machine.feed("INFO_READY", 5), "wait") + self.assertEqual(machine.state, KernelLifecycle.READY) + self.assertEqual(machine.feed("EXECUTE_BEGIN", 10), "wait") + self.assertEqual(machine.feed("EXECUTE_IDLE", 120), "wait") + self.assertEqual(machine.feed("KILL_REQUEST", 130), "kill") + self.assertEqual(machine.feed("KERNEL_DEAD", 140), "succeed") + self.assertEqual(machine.state, KernelLifecycle.TERMINATED) + self.assertTrue(machine.kill_requested) + self.assertIsNone(machine.failure_reason) + + def test_kill_before_ready(self): + machine = KernelLifecycle() + self.assertEqual(machine.feed("KILL_REQUEST", 3), "kill") + self.assertEqual(machine.feed("KERNEL_DEAD", 4), "succeed") + + def test_execute_kill_midflight(self): + machine = KernelLifecycle() + machine.feed("INFO_READY", 1) + machine.feed("EXECUTE_BEGIN", 2) + self.assertEqual(machine.feed("KILL_REQUEST", 50), "kill") + self.assertEqual(machine.feed("KERNEL_DEAD", 51), "succeed") + + +class FailurePaths(unittest.TestCase): + def test_kernel_death_without_kill_is_failure(self): + machine = KernelLifecycle() + machine.feed("INFO_READY", 1) + self.assertEqual(machine.feed("KERNEL_DEAD", 2), "failed") + self.assertEqual(machine.state, KernelLifecycle.FAILED) + self.assertIsNotNone(machine.failure_reason) + + def test_death_while_starting_is_failure(self): + machine = KernelLifecycle() + self.assertEqual(machine.feed("KERNEL_DEAD", 1), "failed") + + def test_start_deadline(self): + machine = KernelLifecycle(start_deadline=100) + self.assertEqual(machine.feed("INFO_READY", 150), "failed") + self.assertIn("deadline", machine.failure_reason) + + def test_execute_deadline(self): + machine = KernelLifecycle(execute_deadline=300) + machine.feed("INFO_READY", 10) + machine.feed("EXECUTE_BEGIN", 20) + self.assertEqual(machine.feed("EXECUTE_IDLE", 400), "failed") + + def test_kill_deadline(self): + machine = KernelLifecycle(kill_deadline=60) + machine.feed("INFO_READY", 1) + machine.feed("KILL_REQUEST", 10) + self.assertEqual(machine.feed("KERNEL_DEAD", 100), "failed") + + def test_terminated_kernel_without_kill_is_failure(self): + machine = KernelLifecycle() + machine.feed("KILL_REQUEST", 1) + machine.feed("KERNEL_DEAD", 2) + self.assertEqual(machine.kill_requested, True) + + def test_unexpected_death_marks_not_kill_requested(self): + machine = KernelLifecycle() + machine.feed("INFO_READY", 1) + machine.feed("KERNEL_DEAD", 2) + self.assertFalse(machine.kill_requested) + + +class Strictness(unittest.TestCase): + def test_illegal_event(self): + machine = KernelLifecycle() + with self.assertRaises(LifecycleError): + machine.feed("EXECUTE_IDLE", 1) + + def test_event_after_terminal(self): + machine = KernelLifecycle() + machine.feed("KILL_REQUEST", 1) + machine.feed("KERNEL_DEAD", 2) + with self.assertRaises(LifecycleError): + machine.feed("INFO_READY", 3) + with self.assertRaises(LifecycleError): + machine.feed("KERNEL_DEAD", 4) + + def test_double_kill_request(self): + machine = KernelLifecycle() + machine.feed("INFO_READY", 1) + machine.feed("KILL_REQUEST", 2) + with self.assertRaises(LifecycleError): + machine.feed("KILL_REQUEST", 3) + + def test_reexecute_after_idle(self): + machine = KernelLifecycle() + machine.feed("INFO_READY", 1) + machine.feed("EXECUTE_BEGIN", 2) + machine.feed("EXECUTE_IDLE", 3) + self.assertEqual(machine.feed("EXECUTE_BEGIN", 4), "wait") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/presentation-qualification/unit/test_pins.py b/.github/tests/presentation-qualification/unit/test_pins.py new file mode 100644 index 0000000..84c475c --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_pins.py @@ -0,0 +1,137 @@ +import os +import subprocess +import sys +import tempfile +import unittest + +CHECK_PINS = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "check_pins.py") +) + +VALID = """\ +EG_REVISION=0344929cbca688440ba1bc8f5faa074fe54bd595 +NODE_BASE_IMAGE=docker.io/library/node:22-bookworm-slim@sha256:43ac6c60b8f89723f746e8a92ce91abd5017e627ce1ddfe4238355d3a30b772c +ENTERPRISE_GATEWAY=3.3.0 +JUPYTER_CLIENT=6.1.12 +IPYKERNEL=6.29.5 +PYZMQ=24.0.1 +PYCRYPTODOMEX=3.20.0 +KUBERNETES_PY=31.0.0 +JINJA2=3.1.6 +PYYAML=6.0.3 +REQUESTS=2.32.5 +WEBSOCKET_CLIENT=1.8.0 +PILLOW=11.3.0 +PPTXGENJS_VERSION=4.0.1 +KIND_VERSION=0.30.0 +KIND_SHA256=517ab7fc89ddeed5fa65abf71530d90648d9638ef0c4cde22c2c11f8097b8889 +KIND_NODE_IMAGE=kindest/node:v1.33.4@sha256:25a6018e48dfcaee478f4a59af81157a437f15e6e140bf103f85a2e7cd0cbbf2 +KUBECTL_SOURCE=runner_preinstalled +KUBECTL_VERSION=unpinned +KUBECTL_SHA256=unpinned +""" + + +def run_check(content): + with tempfile.NamedTemporaryFile("w", suffix=".env", delete=False) as handle: + handle.write(content) + path = handle.name + try: + proc = subprocess.run( + [sys.executable, CHECK_PINS, path], + capture_output=True, + text=True, + timeout=60, + ) + return proc.returncode, proc.stdout, proc.stderr + finally: + os.unlink(path) + + +def with_value(key, value): + lines = [] + for line in VALID.splitlines(): + if line.startswith(key + "="): + lines.append("%s=%s" % (key, value)) + else: + lines.append(line) + return "\n".join(lines) + "\n" + + +class ValidPins(unittest.TestCase): + def test_valid_configuration_passes(self): + code, out, err = run_check(VALID) + self.assertEqual(code, 0, err) + self.assertIn("pins complete", out) + + def test_pinned_download_kubectl_valid(self): + content = VALID.replace("KUBECTL_SOURCE=runner_preinstalled", "KUBECTL_SOURCE=pinned_download") + content = content.replace("KUBECTL_VERSION=unpinned", "KUBECTL_VERSION=1.33.0") + content = content.replace("KUBECTL_SHA256=unpinned", "KUBECTL_SHA256=" + "a" * 64) + code, _, err = run_check(content) + self.assertEqual(code, 0, err) + + def test_cni_keys_are_rejected(self): + code, _, err = run_check(VALID + "CNI_NP_ENFORCED=false\n") + self.assertEqual(code, 3, err) + + +class PendingOrMissing(unittest.TestCase): + def test_pending_value_fails_with_key_named(self): + code, _, err = run_check(with_value("KIND_VERSION", "PENDING_PARENT_PIN")) + self.assertEqual(code, 2) + self.assertIn("KIND_VERSION", err) + + def test_missing_key_fails(self): + content = "\n".join(line for line in VALID.splitlines() if not line.startswith("PILLOW=")) + code, _, err = run_check(content) + self.assertEqual(code, 2) + self.assertIn("PILLOW", err) + + def test_empty_value_fails(self): + code, _, err = run_check(with_value("EG_REVISION", "")) + self.assertEqual(code, 2) + + +class Malformed(unittest.TestCase): + def test_bad_sha256(self): + code, _, err = run_check(with_value("KIND_SHA256", "zz" * 32)) + self.assertEqual(code, 3) + + def test_bad_digest_image(self): + code, _, err = run_check(with_value("NODE_BASE_IMAGE", "node:22")) + self.assertEqual(code, 3) + + def test_unknown_key_rejected(self): + code, _, err = run_check(VALID + "TOTALLY_NEW_PIN=1\n") + self.assertEqual(code, 3) + + def test_duplicate_key_rejected(self): + code, _, err = run_check(VALID + "KIND_VERSION=0.30.0\n") + self.assertEqual(code, 3) + + def test_vulnerable_jinja2_not_pinned_in_fixture(self): + with open( + os.path.join(os.path.dirname(__file__), "..", "pins.env"), encoding="utf-8" + ) as handle: + pins = handle.read() + self.assertNotIn("JINJA2=3.1.4", pins) + self.assertIn("JINJA2=3.1.6", pins) + + +class CrossRules(unittest.TestCase): + def test_runner_kubectl_must_stay_unpinned(self): + content = with_value("KUBECTL_VERSION", "1.33.0") + code, _, err = run_check(content) + self.assertEqual(code, 3) + self.assertIn("runner_preinstalled", err) + + def test_pinned_download_requires_real_values(self): + content = with_value("KUBECTL_SOURCE", "pinned_download") + code, _, err = run_check(content) + self.assertEqual(code, 3) + self.assertIn("pinned_download", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/presentation-qualification/unit/test_snapshot_diff.py b/.github/tests/presentation-qualification/unit/test_snapshot_diff.py new file mode 100644 index 0000000..3b227ed --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_snapshot_diff.py @@ -0,0 +1,50 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +SOURCE = Path(__file__).resolve().parents[1] / "runtime/client/snapshot_diff.py" +SPEC = importlib.util.spec_from_file_location("snapshot_diff", SOURCE) +snapshot_diff = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(snapshot_diff) + + +class SnapshotTests(unittest.TestCase): + def load_text(self, text): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "pods.tsv" + path.write_text(text, encoding="utf-8") + return snapshot_diff.load(path) + + def test_empty_snapshot(self): + self.assertEqual(self.load_text(""), set()) + + def test_metadata_tuple(self): + self.assertEqual(self.load_text("pptx-jobs\tkernel-a\t1234-abcd\n"), + {("pptx-jobs", "kernel-a", "1234-abcd")}) + + def test_malformed_and_duplicate_rows(self): + for text in ("a\tb\n", "a\tb\t\n", '{"items":[]}\n', + "a\tb\t123\na\tb\t123\n"): + with self.subTest(text=text), self.assertRaises(ValueError): + self.load_text(text) + + def test_zero_mode_rejects_in_namespace_creation(self): + pod = ("pptx-jobs", "unlabelled", "123") + self.assertEqual(snapshot_diff.violations(set(), {pod}, "pptx-jobs", "zero"), {pod}) + + def test_scope_mode_allows_only_fixture_namespace(self): + inside = ("pptx-jobs", "kernel", "123") + outside = ("default", "kernel", "456") + self.assertEqual(snapshot_diff.violations(set(), {inside, outside}, "pptx-jobs", "scope"), + {outside}) + + def test_uid_replacement_and_removal_detected(self): + old = ("pptx-jobs", "kernel", "123") + new = ("pptx-jobs", "kernel", "456") + self.assertEqual(snapshot_diff.violations({old}, {new}, "pptx-jobs", "zero"), {old, new}) + + def test_invalid_mode(self): + with self.assertRaises(ValueError): + snapshot_diff.violations(set(), set(), "pptx-jobs", "invalid") diff --git a/.github/tests/presentation-qualification/unit/test_template_security.py b/.github/tests/presentation-qualification/unit/test_template_security.py new file mode 100644 index 0000000..5037ca9 --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_template_security.py @@ -0,0 +1,334 @@ +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from lib import template_rules as rules # noqa: E402 + +FIXTURE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +REPO_ROOT = os.path.abspath(os.path.join(FIXTURE_ROOT, "..", "..")) + + +def read(*parts): + with open(os.path.join(*parts), "r", encoding="utf-8") as handle: + return handle.read() + + +class KernelPodTemplate(unittest.TestCase): + def setUp(self): + self.text = read(FIXTURE_ROOT, "runtime", "k8s", "kernel-pod.yaml.j2") + self.content = rules.strip_comments(self.text) + + def test_security_literals(self): + for literal in rules.KERNEL_REQUIRED_LITERALS: + self.assertIn(literal, self.content, literal) + + def test_forbidden_constructs_absent(self): + for forbidden in rules.KERNEL_FORBIDDEN_SUBSTRINGS: + self.assertNotIn(forbidden, self.content, forbidden) + + def test_only_allowed_jinja_vars(self): + self.assertLessEqual(rules.jinja_vars(self.text), rules.ALLOWED_TEMPLATE_VARS) + + def test_env_names_exact(self): + sections = rules.env_section_names(self.text) + self.assertEqual(len(sections), 1) + self.assertEqual(set(sections[0]), rules.KERNEL_ENV_EXPECTED) + + def test_upstream_appended_env_not_templated(self): + for name in ("PORT_RANGE", "PUBLIC_KEY", "RESPONSE_ADDRESS"): + self.assertNotIn("name: %s" % name, self.content) + + def test_log_level_pinned_to_warning(self): + for name in ("LOG_LEVEL", "EG_LOG_LEVEL"): + self.assertRegex(self.content, r"name:\s*%s\s*\n\s*value:\s*\"30\"" % name) + + def test_component_kernel_label_rationale_kept_in_comment(self): + self.assertIn("component=kernel label is REQUIRED", self.text) + + def test_placeholder_replacement(self): + rendered = rules.replace_worker_image(self.text, "pptx-qual/worker:ci") + self.assertNotIn(rules.WORKER_IMAGE_PLACEHOLDER, rendered) + self.assertIn("image: pptx-qual/worker:ci", rendered) + + def test_placeholder_missing_raises(self): + with self.assertRaises(ValueError): + rules.replace_worker_image("no placeholder here", "img") + + def test_volumes_are_emptydir_only(self): + self.assertEqual(self.content.count("emptyDir:"), 2) + for forbidden in ("hostPath", "secret", "configMap", "persistentVolumeClaim", "projected"): + self.assertNotIn(forbidden, self.content) + + +class GatewayManifest(unittest.TestCase): + def setUp(self): + self.text = read(FIXTURE_ROOT, "runtime", "k8s", "gateway.yaml.in") + + def test_env_allowlist_exact(self): + sections = rules.env_section_names(self.text) + self.assertEqual(len(sections), 1) + self.assertEqual(set(sections[0]), rules.GATEWAY_ENV_EXPECTED) + + def test_placeholders_present(self): + self.assertIn(rules.TOKEN_PLACEHOLDER, self.text) + self.assertIn(rules.GATEWAY_IMAGE_PLACEHOLDER, self.text) + + def test_no_value_from(self): + self.assertNotIn("valueFrom", self.text) + self.assertNotIn("secretKeyRef", self.text) + + def test_gateway_log_level_warning(self): + self.assertIn("--log-level=WARNING", self.text) + + def test_gateway_hardening(self): + for literal in ( + "readOnlyRootFilesystem: true", + "allowPrivilegeEscalation: false", + "runAsUser: 1000", + "runAsGroup: 1000", + ): + self.assertIn(literal, self.text, literal) + + def test_only_authoritative_token_env(self): + tokenish = [n for n in rules.env_names(self.text) if rules.FORBIDDEN_ENV_NAME_RE.search(n)] + self.assertEqual(tokenish, ["EG_AUTH_TOKEN"]) + + +class RbacManifest(unittest.TestCase): + def setUp(self): + self.text = read(FIXTURE_ROOT, "runtime", "k8s", "rbac.yaml") + + def test_single_namespace_role_only(self): + top_level_kinds = re.findall(r"^kind: (\S+)$", self.text, re.M) + self.assertEqual(top_level_kinds.count("Role"), 1) + self.assertNotIn("ClusterRole", self.text) + + def test_verbs_exactly_pods_crud_watch(self): + verbs = sorted(v.strip().strip('"') for v in rules.VERBS_RE.search(self.text).group(1).split(",")) + self.assertEqual(verbs, ["create", "delete", "get", "list", "watch"]) + resources = sorted(r.strip().strip('"') for r in rules.RESOURCES_RE.search(self.text).group(1).split(",")) + self.assertEqual(resources, ["pods"]) + + def test_role_is_namespace_scoped(self): + self.assertIn("namespace: pptx-jobs", self.text) + + def test_worker_service_account_has_no_bindings(self): + head, sep, tail = self.text.partition("subjects:") + self.assertIn("pptx-worker", head) + self.assertTrue(sep) + self.assertNotIn("pptx-worker", tail) + + def test_worker_sa_token_not_mounted(self): + self.assertIn("automountServiceAccountToken: false", self.text) + + +class NamespaceManifest(unittest.TestCase): + def test_fixed_namespace(self): + text = read(FIXTURE_ROOT, "runtime", "k8s", "namespace.yaml") + self.assertIn("kind: Namespace", text) + self.assertIn("name: pptx-jobs", text) + + +class NetworkPolicyManifest(unittest.TestCase): + def test_static_unqualified_example(self): + text = read(FIXTURE_ROOT, "runtime", "k8s", "networkpolicy.yaml") + self.assertIn("never applied", text) + self.assertIn("does NOT enforce NetworkPolicy", text) + self.assertEqual(text.count("kind: NetworkPolicy"), 2) + self.assertIn("Ingress", text) + self.assertIn("Egress", text) + self.assertIn("kubernetes.io/metadata.name: kube-system", text) + self.assertIn("port: 53", text) + + +class WorkflowGovernance(unittest.TestCase): + def setUp(self): + self.text = read(REPO_ROOT, "workflows", "presentation-qualification.yml") + + def test_pr_only_and_path_scoped(self): + self.assertIn("pull_request:", self.text) + self.assertNotIn("workflow_dispatch", self.text) + self.assertNotIn("push:", self.text) + self.assertIn('".github/workflows/presentation-qualification.yml"', self.text) + self.assertIn('".github/tests/presentation-qualification/**"', self.text) + + def test_permissions_and_timeout(self): + self.assertIn("contents: read", self.text) + self.assertEqual(self.text.count("timeout-minutes: 30"), 2) + + def test_pinned_actions_only(self): + for action_sha in ( + "3d3c42e5aac5ba805825da76410c181273ba90b1", + "5fda3b95a4ea91299a34e894583c3862153e4b97", + ): + self.assertGreaterEqual(self.text.count(action_sha), 1) + self.assertNotIn("actions/checkout@v", self.text) + self.assertNotIn("actions/setup-python@v", self.text) + self.assertGreaterEqual(self.text.count("persist-credentials: false"), 3) + + def test_upstream_repository_is_verified_org(self): + self.assertIn("repository: jupyter-server/enterprise_gateway", self.text) + self.assertNotIn("repository: jupyter/enterprise_gateway", self.text) + + def test_no_secrets_or_artifacts(self): + self.assertNotIn("secrets.", self.text) + self.assertNotIn("upload-artifact", self.text) + + +class SuiteHygiene(unittest.TestCase): + def setUp(self): + self.text = read(FIXTURE_ROOT, "runtime", "run_runtime_suite.sh") + + def test_no_trace_or_secret_echo(self): + self.assertNotIn("set -x", self.text) + self.assertNotIn('echo "$PQ_TOKEN"', self.text) + self.assertNotIn("secrets.", self.text) + + def test_no_raw_log_collection(self): + self.assertNotIn("kubectl logs", self.text) + self.assertNotIn("$KUBECTL_BIN logs", self.text) + self.assertNotIn("--tail", self.text) + self.assertNotIn("kubectl describe", self.text) + + def test_scoped_kubeconfig_and_context(self): + self.assertIn("KUBECONFIG=", self.text) + self.assertIn("--context", self.text) + self.assertIn("trap cleanup EXIT", self.text) + + def test_port_forward_address_flag(self): + self.assertIn("--address 127.0.0.1", self.text) + self.assertNotIn('127.0.0.1:8888:8888"', self.text.replace("--address 127.0.0.1 8888:8888", "")) + + def test_private_work_dir_and_snapshots(self): + self.assertIn("umask 077", self.text) + self.assertIn("snapshot_diff.py", self.text) + self.assertIn("pods-auth-before.json", self.text) + self.assertIn("pods-ns-before.json", self.text) + self.assertIn("assert_snapshots_clean", self.text) + + def test_auth_negatives_include_kernel_post(self): + self.assertIn("--data '{\"name\":\"presentation\",\"env\":{}}'", self.text) + self.assertIn("unauth_post", self.text) + self.assertIn("badtok_post", self.text) + + def test_namespace_rejection_requires_http_status(self): + self.assertIn("http_status", self.text) + self.assertIn("status != 201", self.text) + + +class ImageAndClientSources(unittest.TestCase): + def setUp(self): + self.pins = read(FIXTURE_ROOT, "pins.env") + self.pins_values = dict( + line.split("=", 1) + for line in self.pins.splitlines() + if "=" in line and not line.strip().startswith("#") + ) + + def test_dockerfiles_use_arg_base_image_and_suite_pins_it(self): + worker = read(FIXTURE_ROOT, "runtime", "images", "worker.Dockerfile") + gateway = read(FIXTURE_ROOT, "runtime", "images", "gateway.Dockerfile") + suite = read(FIXTURE_ROOT, "runtime", "run_runtime_suite.sh") + self.assertIn("ARG BASE_IMAGE", worker) + self.assertIn("ARG BASE_IMAGE", gateway) + self.assertIn('--build-arg BASE_IMAGE="$NODE_BASE_IMAGE"', suite) + + def test_worker_runtime_packages(self): + worker = read(FIXTURE_ROOT, "runtime", "images", "worker.Dockerfile") + for package in ("python3", "python3-venv", "libreoffice-impress", "poppler-utils", "fonts-liberation", "fontconfig"): + self.assertIn(package, worker, package) + + def test_upstream_license_copied_alongside_launchers(self): + worker = read(FIXTURE_ROOT, "runtime", "images", "worker.Dockerfile") + gateway = read(FIXTURE_ROOT, "runtime", "images", "gateway.Dockerfile") + self.assertIn("COPY upstream-eg/LICENSE.md /usr/local/bin/kernel-launchers/LICENSE.md", worker) + self.assertIn("COPY upstream-eg/LICENSE.md /usr/local/bin/kernel-launchers/LICENSE.md", gateway) + + def test_license_copy_matches_render_check_assertion(self): + gateway = read(FIXTURE_ROOT, "runtime", "images", "gateway.Dockerfile") + render_check = read(FIXTURE_ROOT, "runtime", "images", "gateway_files", "render_check.py") + self.assertIn("/usr/local/bin/kernel-launchers/LICENSE.md", gateway) + self.assertIn('LICENSE = "/usr/local/bin/kernel-launchers/LICENSE.md"', render_check) + + def test_gateway_replaces_template_after_upstream_copy(self): + gateway = read(FIXTURE_ROOT, "runtime", "images", "gateway.Dockerfile") + upstream_copy = gateway.index("COPY upstream-eg/etc/kernel-launchers") + template_copy = gateway.index("kernel-pod.yaml.j2") + self.assertLess(upstream_copy, template_copy) + self.assertIn("render_check.py", gateway) + + def test_gateway_pip_distribution_and_kernelspec_patch(self): + gateway = read(FIXTURE_ROOT, "runtime", "images", "gateway.Dockerfile") + self.assertIn('jupyter-enterprise-gateway=="$ENTERPRISE_GATEWAY"', gateway) + self.assertNotIn("enterprise-gateway==", gateway.replace("jupyter-enterprise-gateway==", "")) + self.assertIn("kernelspec_patch.py", gateway) + + def test_pins_resolve_in_build_inputs(self): + worker = read(FIXTURE_ROOT, "runtime", "images", "worker.Dockerfile") + for key in ("JUPYTER_CLIENT", "IPYKERNEL", "PYZMQ", "PYCRYPTODOMEX"): + self.assertIn("$%s" % key, worker, key) + gateway = read(FIXTURE_ROOT, "runtime", "images", "gateway.Dockerfile") + for key in ( + "ENTERPRISE_GATEWAY", + "JUPYTER_CLIENT", + "PYZMQ", + "KUBERNETES_PY", + "JINJA2", + "PYYAML", + "REQUESTS", + "WEBSOCKET_CLIENT", + "PILLOW", + ): + self.assertIn("$%s" % key, gateway, key) + + def test_no_cryptodome_without_x(self): + worker = read(FIXTURE_ROOT, "runtime", "images", "worker.Dockerfile") + self.assertIn("pycryptodomex==", worker) + self.assertNotIn("pycryptodome==", worker) + self.assertEqual(self.pins_values["PYCRYPTODOMEX"].strip(), "3.20.0") + self.assertNotIn("PYCRYPTODOME=", self.pins) + + def test_jinja2_not_vulnerable_pin(self): + self.assertEqual(self.pins_values["JINJA2"].strip(), "3.1.6") + + def test_pptxgenjs_version_matches_pins(self): + package_json = read(FIXTURE_ROOT, "runtime", "images", "worker_files", "package.json") + pinned = self.pins_values["PPTXGENJS_VERSION"].strip() + self.assertIn('"pptxgenjs": "%s"' % pinned, package_json) + + def test_worker_tasks_split_for_fresh_kernels(self): + files = os.listdir(os.path.join(FIXTURE_ROOT, "runtime", "images", "worker_files")) + self.assertIn("build_task.py", files) + self.assertIn("render_task.py", files) + self.assertNotIn("worker_task.py", files) + + def test_client_never_dumps_environment(self): + client = read(FIXTURE_ROOT, "runtime", "client", "client.py") + self.assertNotIn("json.dumps(os.environ", client) + self.assertNotIn("print(os.environ", client) + self.assertIn('"channel": "shell"', client) + + def test_probe_print_allowlist(self): + probe = read(FIXTURE_ROOT, "runtime", "client", "probe_pod_invariants.py") + allowed_prints = ( + 'print("POD_INVARIANTS_FAIL: %s" % message, file=sys.stderr)', + 'print("POD_INVARIANTS_OK pods=%d" % len(items))', + 'print("POD_INVARIANTS_OK pods=0")', + ) + residual = probe + for allowed in allowed_prints: + residual = residual.replace(allowed, "") + self.assertNotIn("print(", residual) + + def test_snapshot_diff_name_uid_semantics(self): + differ = read(FIXTURE_ROOT, "runtime", "client", "snapshot_diff.py") + self.assertIn("projected namespace/name/UID", differ) + self.assertIn(".metadata.uid", read(FIXTURE_ROOT, "runtime", "run_runtime_suite.sh")) + self.assertIn("fixed_namespace", differ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/presentation-qualification/unit/test_transport_verify.py b/.github/tests/presentation-qualification/unit/test_transport_verify.py new file mode 100644 index 0000000..50460c7 --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_transport_verify.py @@ -0,0 +1,80 @@ +import os +import struct +import sys +import unittest +import zlib + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from lib import verify_artifacts # noqa: E402 + + +def make_png(width=640, height=480): + sig = verify_artifacts.PNG_SIGNATURE + ihdr_payload = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + ihdr = struct.pack(">I", len(ihdr_payload)) + b"IHDR" + ihdr_payload + ihdr += struct.pack(">I", zlib.crc32(b"IHDR" + ihdr_payload) & 0xFFFFFFFF) + return sig + ihdr + b"IDAT-body-not-parsed" + b"IEND-placeholder" + + +class PptxSignature(unittest.TestCase): + def test_valid(self): + self.assertTrue(verify_artifacts.pptx_signature_ok(b"PK\x03\x04rest-of-zip")) + + def test_invalid(self): + self.assertFalse(verify_artifacts.pptx_signature_ok(b"MZ\x90\x00")) + self.assertFalse(verify_artifacts.pptx_signature_ok(b"")) + self.assertFalse(verify_artifacts.pptx_signature_ok(b"PK\x05\x06empty")) + + +class PngChecks(unittest.TestCase): + def test_valid_ihdr(self): + info = verify_artifacts.parse_ihdr(make_png(1920, 1080)) + self.assertEqual(info["width"], 1920) + self.assertEqual(info["height"], 1080) + self.assertEqual(info["bit_depth"], 8) + self.assertEqual(info["color_type"], 2) + + def test_signature_ok_helper(self): + self.assertTrue(verify_artifacts.png_signature_ok(make_png())) + self.assertFalse(verify_artifacts.png_signature_ok(b"\x89PNX")) + + def test_bad_signature(self): + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(b"\x89PNX\r\n\x1a\n" + make_png()[8:]) + + def test_truncated(self): + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(make_png()[:20]) + + def test_crc_mismatch(self): + png = bytearray(make_png()) + png[29] ^= 0xFF + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(bytes(png)) + + def test_zero_dimensions(self): + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(make_png(0, 480)) + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(make_png(640, 0)) + + def test_bad_bit_depth_and_color_type(self): + bad = bytearray(make_png()) + bad[24] = 3 + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(bytes(bad)) + bad2 = bytearray(make_png()) + bad2[25] = 5 + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(bytes(bad2)) + + def test_first_chunk_not_ihdr(self): + png = bytearray(make_png()) + png[12:16] = b"IDAT" + with self.assertRaises(verify_artifacts.ArtifactVerifyError): + verify_artifacts.parse_ihdr(bytes(png)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/presentation-qualification.yml b/.github/workflows/presentation-qualification.yml new file mode 100644 index 0000000..313b990 --- /dev/null +++ b/.github/workflows/presentation-qualification.yml @@ -0,0 +1,54 @@ +name: presentation-qualification + +on: + pull_request: + branches: + - main + paths: + - ".github/workflows/presentation-qualification.yml" + - ".github/tests/presentation-qualification/**" + +permissions: + contents: read + +jobs: + static-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Use host Python 3.11 (parent-verified action pin) + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Fixture unit tests (stdlib only) + run: >- + python3 -m unittest discover + -s .github/tests/presentation-qualification/unit + -t .github/tests/presentation-qualification -v + + runtime-qualification: + needs: static-tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Use host Python 3.11 (parent-verified action pin) + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Check out pinned Enterprise Gateway sources (BSD-3-Clause, not forked) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: jupyter-server/enterprise_gateway + ref: 0344929cbca688440ba1bc8f5faa074fe54bd595 + path: upstream-eg + persist-credentials: false + - name: Runtime qualification (core gate; network gate reported separately) + run: bash .github/tests/presentation-qualification/runtime/run_runtime_suite.sh From ac30d4917c965b98f72387246569d0fc2dfd51c6 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 11:15:12 -0600 Subject: [PATCH 2/8] test(presentations): repair initial qualification CI failures --- .../runtime/k8s/networkpolicy.yaml | 78 ++++++++++--------- .../runtime/k8s/rbac.yaml | 70 ++++++++--------- .../runtime/run_runtime_suite.sh | 6 +- .../unit/test_channel_assembler.py | 1 + .../unit/test_template_security.py | 5 +- 5 files changed, 83 insertions(+), 77 deletions(-) diff --git a/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml b/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml index 5332d62..8900d09 100644 --- a/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml +++ b/.github/tests/presentation-qualification/runtime/k8s/networkpolicy.yaml @@ -1,42 +1,44 @@ # Static, UNQUALIFIED example — retained for reference shape only. -# This FIRST fixture always runs kind's default kindnet CNI, which does NOT -# enforce NetworkPolicy. This manifest is never applied by the runtime suite +# This FIRST fixture uses kind's default kindnet CNI, which does NOT enforce NetworkPolicy. +# This manifest is never applied by the runtime suite # and no isolation is claimed from its text. A future fixture revision may pin # a verified CNI and promote this to an enforced, negatively probed policy; # until then network isolation is reported INCOMPLETE. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: pptx-qual-default-deny - namespace: pptx-jobs -spec: - podSelector: {} - policyTypes: - - Ingress - - Egress ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: pptx-qual-allow-within-namespace-and-dns - namespace: pptx-jobs -spec: - podSelector: {} - policyTypes: - - Ingress - - Egress - ingress: - - from: - - podSelector: {} - egress: - - to: - - podSelector: {} - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - ports: - - protocol: UDP - port: 53 - - protocol: TCP - port: 53 +apiVersion: v1 +kind: List +items: + - apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: pptx-qual-default-deny + namespace: pptx-jobs + spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + - apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: pptx-qual-allow-within-namespace-and-dns + namespace: pptx-jobs + spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: {} + egress: + - to: + - podSelector: {} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml b/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml index 2474fde..a558dff 100644 --- a/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml +++ b/.github/tests/presentation-qualification/runtime/k8s/rbac.yaml @@ -1,36 +1,36 @@ apiVersion: v1 -kind: ServiceAccount -metadata: - name: pptx-gateway - namespace: pptx-jobs ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: pptx-worker - namespace: pptx-jobs -automountServiceAccountToken: false ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: pptx-gateway-kernel-pods - namespace: pptx-jobs -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch", "create", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: pptx-gateway-kernel-pods - namespace: pptx-jobs -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: pptx-gateway-kernel-pods -subjects: - - kind: ServiceAccount - name: pptx-gateway - namespace: pptx-jobs +kind: List +items: + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: pptx-gateway + namespace: pptx-jobs + - apiVersion: v1 + kind: ServiceAccount + metadata: + name: pptx-worker + namespace: pptx-jobs + automountServiceAccountToken: false + - apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: pptx-gateway-kernel-pods + namespace: pptx-jobs + rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "delete"] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: pptx-gateway-kernel-pods + namespace: pptx-jobs + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: pptx-gateway-kernel-pods + subjects: + - kind: ServiceAccount + name: pptx-gateway + namespace: pptx-jobs diff --git a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh index 56e88e2..f4b174f 100644 --- a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh +++ b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh @@ -135,6 +135,8 @@ docker build --build-arg BASE_IMAGE="$NODE_BASE_IMAGE" --build-arg WORKER_IMAGE= -f "$FIXTURE_DIR/runtime/k8s/namespace.yaml" \ -f "$FIXTURE_DIR/runtime/k8s/rbac.yaml" PQ_TOKEN="$(openssl rand -hex 32)" +PQ_WRONG_TOKEN="$(openssl rand -hex 32)" +[[ "$PQ_WRONG_TOKEN" != "$PQ_TOKEN" ]] || fail "generated auth-negative token unexpectedly matched" export PQ_TOKEN sed -e "s|__PQ_TOKEN__|$PQ_TOKEN|" -e "s|__GATEWAY_IMAGE__|$GATEWAY_TAG|" \ "$FIXTURE_DIR/runtime/k8s/gateway.yaml.in" > "$WORK_DIR/gateway.yaml" @@ -155,11 +157,11 @@ done pods_snapshot "$WORK_DIR/pods-auth-before.json" unauth=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/api/kernelspecs" || true) -badtok=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token definitely-not-the-token" "$BASE_URL/api/kernelspecs" || true) +badtok=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $PQ_WRONG_TOKEN" "$BASE_URL/api/kernelspecs" || true) unauth_post=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ --data '{"name":"presentation","env":{}}' "$BASE_URL/api/kernels" || true) badtok_post=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \ - -H "Authorization: token definitely-not-the-token" \ + -H "Authorization: token $PQ_WRONG_TOKEN" \ --data '{"name":"presentation","env":{}}' "$BASE_URL/api/kernels" || true) [[ "$unauth" =~ ^(401|403)$ ]] || fail "unauthenticated GET accepted (http $unauth)" [[ "$badtok" =~ ^(401|403)$ ]] || fail "wrong token GET accepted (http $badtok)" diff --git a/.github/tests/presentation-qualification/unit/test_channel_assembler.py b/.github/tests/presentation-qualification/unit/test_channel_assembler.py index 221b7b1..e5d0e1f 100644 --- a/.github/tests/presentation-qualification/unit/test_channel_assembler.py +++ b/.github/tests/presentation-qualification/unit/test_channel_assembler.py @@ -58,6 +58,7 @@ def test_other_parent_ignored(self): payload = b"PK\x03\x04data" asm = framing.Assembler(PARENT) asm.feed(OTHER, framing.encode_build_status(0) + "\n") + feed_lines(asm, [framing.encode_build_status(0)]) feed_lines(asm, framing.encode_file("deckpptx", payload)) asm.feed(OTHER, "PQB1|evil|1|4|" + "0" * 64 + "|0|AAAA\n") feed_lines(asm, [framing.encode_done("deckpptx")]) diff --git a/.github/tests/presentation-qualification/unit/test_template_security.py b/.github/tests/presentation-qualification/unit/test_template_security.py index 5037ca9..f7b6ad2 100644 --- a/.github/tests/presentation-qualification/unit/test_template_security.py +++ b/.github/tests/presentation-qualification/unit/test_template_security.py @@ -102,8 +102,9 @@ def setUp(self): self.text = read(FIXTURE_ROOT, "runtime", "k8s", "rbac.yaml") def test_single_namespace_role_only(self): - top_level_kinds = re.findall(r"^kind: (\S+)$", self.text, re.M) - self.assertEqual(top_level_kinds.count("Role"), 1) + self.assertIn("apiVersion: v1\nkind: List\nitems:\n", self.text) + item_kinds = re.findall(r"^ kind: (\S+)$", self.text, re.M) + self.assertEqual(item_kinds, ["ServiceAccount", "ServiceAccount", "Role", "RoleBinding"]) self.assertNotIn("ClusterRole", self.text) def test_verbs_exactly_pods_crud_watch(self): From a23b2a833c5cd44eab7e6cc9e4a443759630cb93 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 11:18:59 -0600 Subject: [PATCH 3/8] test(presentations): pin kubectl to the disposable cluster version --- .github/tests/presentation-qualification/README.md | 8 +++++--- .github/tests/presentation-qualification/pins.env | 11 ++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/tests/presentation-qualification/README.md b/.github/tests/presentation-qualification/README.md index 2fbbef5..427e0c7 100644 --- a/.github/tests/presentation-qualification/README.md +++ b/.github/tests/presentation-qualification/README.md @@ -201,8 +201,9 @@ selected so the distro python3.11 is compatible with the legacy pyzmq pin. - npm transitive dependencies of pptxgenjs are not lockfile-pinned; pip transitives (jupyter-server 1.x, tornado, etc.) resolve freely within the declared constraints. -- kubectl currently uses `KUBECTL_SOURCE=runner_preinstalled` with a - client/node skew gate; switch to `pinned_download` for hermetic runs. +- kubectl uses `KUBECTL_SOURCE=pinned_download`, version 1.33.4 to match the + fixture node, verified against its [official SHA-256 checksum](https://dl.k8s.io/release/v1.33.4/bin/linux/amd64/kubectl.sha256). + This avoids runner-image client/server skew; it does not lock apt/npm/pip inputs. - Group B pins were chosen for compatibility (parent registry-verified: pycryptodomex 3.20.0, kubernetes 31.0.0, pptxgenjs 4.0.1, jinja2 3.1.6); the remaining pins must be re-verified on PyPI/npm by the parent @@ -223,7 +224,8 @@ selected so the distro python3.11 is compatible with the legacy pyzmq pin. 2. `jupyter enterprisegateway` CLI accepts the flags used (`--ip --port --log-level --KernelSpecManager.allowed_kernelspecs`). 3. pptxgenjs 4.0.1 API surface used in `build_deck.js`. -4. Optional: pinned kubectl, npm lockfile, apt snapshot lock. +4. Recheck the kubectl checksum when changing its version; npm and apt locks + remain optional follow-up work for this unpublished fixture. ## Licensing diff --git a/.github/tests/presentation-qualification/pins.env b/.github/tests/presentation-qualification/pins.env index 93c97c1..39c3c92 100644 --- a/.github/tests/presentation-qualification/pins.env +++ b/.github/tests/presentation-qualification/pins.env @@ -37,10 +37,7 @@ WEBSOCKET_CLIENT=1.8.0 PILLOW=11.3.0 PPTXGENJS_VERSION=4.0.1 -# --- Group C: kubectl acquisition. pinned_download with a verified --- -# --- dl.k8s.io version+sha256 for hermetic runs; runner_preinstalled uses --- -# --- the ubuntu-latest kubectl behind a client/server skew gate --- -# --- (documented non-hermetic input, same category as apt). --- -KUBECTL_SOURCE=runner_preinstalled -KUBECTL_VERSION=unpinned -KUBECTL_SHA256=unpinned +# Official checksum: https://dl.k8s.io/release/v1.33.4/bin/linux/amd64/kubectl.sha256 +KUBECTL_SOURCE=pinned_download +KUBECTL_VERSION=1.33.4 +KUBECTL_SHA256=c2ba72c115d524b72aaee9aab8df8b876e1596889d2f3f27d68405262ce86ca1 From 1c5ebebe5d64d420283bcd5fe420f9a35e9ffd42 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 11:34:22 -0600 Subject: [PATCH 4/8] test(presentations): expose bounded gateway readiness diagnostics --- .../runtime/run_runtime_suite.sh | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh index f4b174f..4f96f7d 100644 --- a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh +++ b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh @@ -148,12 +148,49 @@ sed -e "s|__PQ_TOKEN__|$PQ_TOKEN|" -e "s|__GATEWAY_IMAGE__|$GATEWAY_TAG|" \ echo $! > "$WORK_DIR/portforward.pid" ready="" +last_ready_code="" for _ in $(seq 1 60); do - code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $PQ_TOKEN" "$BASE_URL/api/kernelspecs" || true) + code=$(curl -s --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $PQ_TOKEN" "$BASE_URL/api/kernelspecs" || true) + if [[ "$code" =~ ^[0-9]{3}$ ]]; then last_ready_code=$code; else last_ready_code=none; fi if [[ "$code" == "200" ]]; then ready=1; break; fi sleep 5 done -[[ -n "$ready" ]] || fail "gateway API never became ready" +if [[ -z "$ready" ]]; then + pf_alive=false + if [[ -f "$WORK_DIR/portforward.pid" ]] && kill -0 "$(cat "$WORK_DIR/portforward.pid")" 2>/dev/null; then + pf_alive=true + fi + # Deliberately project status fields only; never collect pod specs, messages, + # environment, logs, or raw port-forward/curl output for readiness diagnosis. + gateway_status=$("$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" \ + get pods -l app=pptx-qual-gateway \ + -o 'jsonpath={range .items[*]}{.status.phase}{"|"}{range .status.containerStatuses[*]}{.restartCount}{":"}{.state.waiting.reason}{":"}{.lastState.terminated.reason}{":"}{.lastState.terminated.exitCode}{","}{end}{"\n"}{end}' \ + 2>/dev/null || true) + python3 - "$last_ready_code" "$pf_alive" "$gateway_status" <<'PY' +import re +import sys + +http_code = sys.argv[1] if re.fullmatch(r"[0-9]{3}", sys.argv[1]) else "none" +print("READINESS_DIAGNOSTIC http_status=%s portforward_alive=%s" % (http_code, sys.argv[2])) +for row in sys.argv[3].splitlines(): + fields = row.split("|", 1) + phase = re.sub(r"[^A-Za-z0-9]", "", fields[0]) or "unknown" + print("READINESS_DIAGNOSTIC pod_phase=%s" % phase) + if len(fields) == 1: + continue + for status in fields[1].split(","): + if not status: + continue + restart, waiting, terminated, exit_code = (status.split(":") + [""] * 4)[:4] + restart = restart if restart.isdigit() else "unknown" + waiting = re.sub(r"[^A-Za-z0-9]", "", waiting) or "none" + terminated = re.sub(r"[^A-Za-z0-9]", "", terminated) or "none" + exit_code = exit_code if exit_code.isdigit() else "none" + print("READINESS_DIAGNOSTIC restart_count=%s waiting_reason=%s terminated_reason=%s terminated_exit_code=%s" % (restart, waiting, terminated, exit_code)) +PY + fail "gateway API never became ready (diagnostic only; no readiness cause inferred)" +fi pods_snapshot "$WORK_DIR/pods-auth-before.json" unauth=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/api/kernelspecs" || true) From a25af1b2b8150fc6cac605fdbbcc74300b10d054 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 12:22:10 -0600 Subject: [PATCH 5/8] test(presentations): capture bounded gateway startup exceptions --- .../presentation-qualification/README.md | 4 + .../runtime/client/parse_startup_capture.py | 32 ++++ .../runtime/images/gateway.Dockerfile | 1 + .../images/gateway_files/startup_capture.py | 139 ++++++++++++++++++ .../runtime/k8s/gateway.yaml.in | 5 +- .../runtime/run_runtime_suite.sh | 5 + .../unit/test_startup_capture.py | 71 +++++++++ 7 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 .github/tests/presentation-qualification/runtime/client/parse_startup_capture.py create mode 100644 .github/tests/presentation-qualification/runtime/images/gateway_files/startup_capture.py create mode 100644 .github/tests/presentation-qualification/unit/test_startup_capture.py diff --git a/.github/tests/presentation-qualification/README.md b/.github/tests/presentation-qualification/README.md index 427e0c7..ba6eebe 100644 --- a/.github/tests/presentation-qualification/README.md +++ b/.github/tests/presentation-qualification/README.md @@ -248,3 +248,7 @@ job runs stdlib-only unit tests; the runtime job runs inconsistent pin combination, or unresolved dependency. Nothing is executed against any live cluster, and no local execution is claimed — CI is the only validation environment. + +## Gateway startup exception diagnostics + +The disposable gateway runs its installed vendor entry point through a capture wrapper. On startup failure it writes a bounded JSON report to `/dev/termination-log` with `terminationMessagePolicy: File` (never log fallback). The readiness failure path validates that report before printing fixed-allowlist exception classes, public module roots, source basenames and line numbers. Unknown identifiers are replaced; exception messages, locals, environments, raw logs and absolute paths are never included. Successful exits are unchanged; capture is diagnostic, not qualification proof. diff --git a/.github/tests/presentation-qualification/runtime/client/parse_startup_capture.py b/.github/tests/presentation-qualification/runtime/client/parse_startup_capture.py new file mode 100644 index 0000000..5df4670 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/client/parse_startup_capture.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Print only validated fields from the gateway's termination report.""" + +import importlib.util +import json +import os +import sys + +fixture = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +capture_path = os.path.join(fixture, "runtime", "images", "gateway_files", "startup_capture.py") +spec = importlib.util.spec_from_file_location("startup_capture", capture_path) +capture = importlib.util.module_from_spec(spec) +spec.loader.exec_module(capture) + + +def main(): + raw = sys.stdin.read(4097) + report = capture.parse_report(raw) + if report is None or report.get("capture_error"): + print("GATEWAY_STARTUP_EXCEPTION CAPTURE_UNAVAILABLE") + return + print("GATEWAY_STARTUP_EXCEPTION type=%s" % report["exception_type"]) + for name in report["causes"]: + print("GATEWAY_STARTUP_EXCEPTION cause=%s" % name) + for module in report["modules"]: + print("GATEWAY_STARTUP_EXCEPTION module=%s" % module) + for frame in report["frames"]: + print("GATEWAY_STARTUP_EXCEPTION frame=%s:%d" % (frame["file"], frame["line"])) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile b/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile index 6d06c2c..5c5cfb5 100644 --- a/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile +++ b/.github/tests/presentation-qualification/runtime/images/gateway.Dockerfile @@ -32,4 +32,5 @@ RUN /opt/venv/bin/python /tmp/render_check.py \ --worker-image "${WORKER_IMAGE}" COPY .github/tests/presentation-qualification/runtime/client /opt/qual/client COPY .github/tests/presentation-qualification/lib /opt/qual/lib +COPY .github/tests/presentation-qualification/runtime/images/gateway_files/startup_capture.py /opt/qual/startup_capture.py ENV PATH=/opt/venv/bin:${PATH} diff --git a/.github/tests/presentation-qualification/runtime/images/gateway_files/startup_capture.py b/.github/tests/presentation-qualification/runtime/images/gateway_files/startup_capture.py new file mode 100644 index 0000000..e2d31e6 --- /dev/null +++ b/.github/tests/presentation-qualification/runtime/images/gateway_files/startup_capture.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Run the installed Enterprise Gateway entry point with bounded safe capture.""" + +import json +import os +import re +import runpy +import sys +import traceback +import builtins + +VENDOR_ENTRY = "/opt/venv/bin/jupyter-enterprisegateway" +REPORT_PATH = "/dev/termination-log" +PUBLIC_FILES = {"startup_capture.py", "jupyter-enterprisegateway", "enterprisegatewayapp.py", "mixins.py", "serverapp.py", "logging.py", "application.py", "configurable.py", "__init__.py", "runpy.py", "remotemanager.py", "web.py"} +PUBLIC_MODULE_ROOTS = {"enterprise_gateway", "jupyter_server", "jupyter_client", "jupyter_core", "traitlets", "tornado", "zmq", "kubernetes", "jinja2", "yaml", "requests", "websocket", "PIL", "Crypto", "Cryptodome"} +PUBLIC_TYPES = {name for name, value in vars(builtins).items() if isinstance(value, type) and issubclass(value, BaseException)} | {"unknown", "TraitError"} +MAX_REPORT_BYTES = 2048 +SAFE_ATOM = re.compile(r"\A[A-Za-z0-9_.-]{1,80}\Z", re.ASCII) +SAFE_MODULE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\Z", re.ASCII) + + +def _safe_atom(value, default="unknown"): + if isinstance(value, str) and value.isascii() and SAFE_ATOM.fullmatch(value): + return value + return default + + +def _report(exc, tb): + frames = [] + for frame in traceback.extract_tb(tb)[-8:]: + filename = os.path.basename(frame.filename) + frames.append({"file": filename if filename in PUBLIC_FILES else "other", "line": max(0, int(frame.lineno))}) + types = [] + modules = [] + seen = set() + current = exc + for _ in range(4): + if current is None or id(current) in seen: + break + seen.add(id(current)) + types.append(type(current).__name__ if type(current).__name__ in PUBLIC_TYPES else "unknown") + if isinstance(current, ModuleNotFoundError): + name = current.name + if isinstance(name, str) and name.isascii() and len(name) <= 80 and SAFE_MODULE.fullmatch(name): + modules.append(name.split(".")[0] if name.split(".")[0] in PUBLIC_MODULE_ROOTS else "other") + current = current.__cause__ if current.__cause__ is not None else current.__context__ + report = { + "version": 1, + "exception_type": types[0] if types else "unknown", + "causes": types[1:], + "modules": modules[:4], + "frames": frames, + } + encoded = json.dumps(report, separators=(",", ":"), ensure_ascii=True).encode("ascii") + if len(encoded) > MAX_REPORT_BYTES: + report["frames"] = report["frames"][-4:] + report["modules"] = [] + encoded = json.dumps(report, separators=(",", ":"), ensure_ascii=True).encode("ascii") + if len(encoded) > MAX_REPORT_BYTES: + return b'{"version":1,"capture_error":true}' + return encoded + + +def _capture(exc, tb): + try: + # Kubernetes v1.33.4 kubelet creates this termination file writable for + # non-root containers; File policy exposes this bounded report, not logs. + with open(REPORT_PATH, "w", encoding="ascii") as handle: + handle.write(_report(exc, tb).decode("ascii")) + except BaseException: + try: + with open(REPORT_PATH, "w", encoding="ascii") as handle: + handle.write('{"version":1,"capture_error":true}') + except BaseException: + pass + + +def should_capture_system_exit(code): + return code not in (None, 0) + + +def main(): + sys.argv[0] = VENDOR_ENTRY + try: + runpy.run_path(VENDOR_ENTRY, run_name="__main__") + except SystemExit as exc: + code = exc.code + if should_capture_system_exit(code): + _capture(exc, exc.__traceback__) + if code is None or code == 0: + raise SystemExit(0) + if isinstance(code, int): + raise SystemExit(code) + raise SystemExit(1) + except BaseException as exc: + _capture(exc, exc.__traceback__) + raise SystemExit(1) + + +def parse_report(raw): + """Validate and return only the source-authored, bounded report schema.""" + if not isinstance(raw, str) or len(raw.encode("utf-8", "ignore")) > MAX_REPORT_BYTES: + return None + try: + doc = json.loads(raw) + except (TypeError, ValueError): + return None + if not isinstance(doc, dict) or type(doc.get("version")) is not int or doc.get("version") != 1: + return None + if set(doc) == {"version", "capture_error"} and doc.get("capture_error") is True: + return {"capture_error": True} + if set(doc) != {"version", "exception_type", "causes", "modules", "frames"}: + return None + exception_type = doc["exception_type"] + causes, modules, frames = doc["causes"], doc["modules"], doc["frames"] + if not isinstance(exception_type, str) or exception_type not in PUBLIC_TYPES: + return None + if not isinstance(causes, list) or len(causes) > 3 or any(not isinstance(x, str) or x not in PUBLIC_TYPES for x in causes): + return None + if not isinstance(modules, list) or len(modules) > 4 or any(not isinstance(x, str) or x not in PUBLIC_MODULE_ROOTS | {"other"} for x in modules): + return None + if any(len(x) > 80 for x in modules): + return None + if not isinstance(frames, list) or len(frames) > 8: + return None + safe_frames = [] + for frame in frames: + if not isinstance(frame, dict) or set(frame) != {"file", "line"}: + return None + if not isinstance(frame["file"], str) or frame["file"] not in PUBLIC_FILES | {"other"}: + return None + if not isinstance(frame["line"], int) or isinstance(frame["line"], bool) or frame["line"] < 0: + return None + safe_frames.append(frame) + return {"exception_type": exception_type, "causes": causes, "modules": modules, "frames": safe_frames} + + +if __name__ == "__main__": + main() diff --git a/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in b/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in index 6a25900..d13f4d5 100644 --- a/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in +++ b/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in @@ -33,13 +33,14 @@ spec: - name: gateway image: __GATEWAY_IMAGE__ imagePullPolicy: IfNotPresent - command: ["/opt/venv/bin/jupyter"] + command: ["/opt/venv/bin/python", "/opt/qual/startup_capture.py"] args: - - enterprisegateway - --ip=0.0.0.0 - --port=8888 - --log-level=WARNING - --KernelSpecManager.allowed_kernelspecs=presentation + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File env: - name: EG_AUTH_TOKEN value: __PQ_TOKEN__ diff --git a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh index 4f96f7d..7b6f8b9 100644 --- a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh +++ b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh @@ -189,6 +189,11 @@ for row in sys.argv[3].splitlines(): exit_code = exit_code if exit_code.isdigit() else "none" print("READINESS_DIAGNOSTIC restart_count=%s waiting_reason=%s terminated_reason=%s terminated_exit_code=%s" % (restart, waiting, terminated, exit_code)) PY + "$KUBECTL_BIN" --context "$KIND_CONTEXT" -n "$NAMESPACE" \ + get pods -l app=pptx-qual-gateway \ + -o 'jsonpath={.items[0].status.containerStatuses[0].lastState.terminated.message}' \ + 2>/dev/null | python3 "$FIXTURE_DIR/runtime/client/parse_startup_capture.py" \ + || echo 'GATEWAY_STARTUP_EXCEPTION CAPTURE_UNAVAILABLE' fail "gateway API never became ready (diagnostic only; no readiness cause inferred)" fi diff --git a/.github/tests/presentation-qualification/unit/test_startup_capture.py b/.github/tests/presentation-qualification/unit/test_startup_capture.py new file mode 100644 index 0000000..efb531c --- /dev/null +++ b/.github/tests/presentation-qualification/unit/test_startup_capture.py @@ -0,0 +1,71 @@ +import importlib.util +import json +import os +import unittest +from unittest import mock + +CAPTURE_PATH = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "runtime", "images", "gateway_files", "startup_capture.py") +) +SPEC = importlib.util.spec_from_file_location("startup_capture", CAPTURE_PATH) +capture = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(capture) + + +class StartupCapture(unittest.TestCase): + def test_report_excludes_exception_text_and_absolute_paths(self): + try: + raise RuntimeError("must-not-leak /private/path or local values") + except RuntimeError as exc: + encoded = capture._report(exc, exc.__traceback__) + self.assertLessEqual(len(encoded), capture.MAX_REPORT_BYTES) + text = encoded.decode("ascii") + self.assertNotIn("must-not-leak", text) + self.assertNotIn("/private/path", text) + doc = capture.parse_report(text) + self.assertEqual(doc["exception_type"], "RuntimeError") + self.assertTrue(all("/" not in frame["file"] for frame in doc["frames"])) + + def test_module_not_found_reports_only_validated_public_module(self): + exc = ModuleNotFoundError("secret detail must-not-leak") + exc.name = "public.package" + doc = json.loads(capture._report(exc, None)) + self.assertEqual(doc["modules"], ["other"]) + self.assertNotIn("must-not-leak", json.dumps(doc)) + + def test_parser_rejects_unexpected_fields_and_unsafe_values(self): + self.assertIsNone(capture.parse_report('{"version":1,"message":"no"}')) + self.assertIsNone(capture.parse_report('{"version":1,"exception_type":"RuntimeError","causes":[],"modules":[],"frames":[{"file":"/tmp/x","line":1}]}')) + self.assertEqual(capture.parse_report('{"version":1,"capture_error":true}'), {"capture_error": True}) + + def test_zero_system_exit_does_not_capture(self): + self.assertFalse(capture.should_capture_system_exit(None)) + self.assertFalse(capture.should_capture_system_exit(0)) + self.assertTrue(capture.should_capture_system_exit(1)) + + def test_parser_requires_bounded_json_report(self): + self.assertIsNone(capture.parse_report("x" * (capture.MAX_REPORT_BYTES + 1))) + + def test_parser_rejects_overlong_module(self): + module = "a" * 81 + raw = json.dumps({"version": 1, "exception_type": "Error", "causes": [], "modules": [module], "frames": []}) + self.assertIsNone(capture.parse_report(raw)) + + def test_main_exit_behavior_is_quiet(self): + with mock.patch.object(capture, "runpy") as mocked_runpy, mock.patch.object(capture, "_capture"): + mocked_runpy.run_path.side_effect = SystemExit(0) + with self.assertRaises(SystemExit) as result: + capture.main() + self.assertEqual(result.exception.code, 0) + mocked_runpy.run_path.side_effect = SystemExit(7) + with self.assertRaises(SystemExit) as result: + capture.main() + self.assertEqual(result.exception.code, 7) + mocked_runpy.run_path.side_effect = RuntimeError("private message") + with self.assertRaises(SystemExit) as result: + capture.main() + self.assertEqual(result.exception.code, 1) + + +if __name__ == "__main__": + unittest.main() From 5a0883fa8575bf99cbc961891d7ed57a31af7f0c Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 12:38:42 -0600 Subject: [PATCH 6/8] test(presentations): use the supported Traitlets WARN log level --- .../presentation-qualification/runtime/k8s/gateway.yaml.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in b/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in index d13f4d5..820913a 100644 --- a/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in +++ b/.github/tests/presentation-qualification/runtime/k8s/gateway.yaml.in @@ -37,7 +37,7 @@ spec: args: - --ip=0.0.0.0 - --port=8888 - - --log-level=WARNING + - --log-level=WARN - --KernelSpecManager.allowed_kernelspecs=presentation terminationMessagePath: /dev/termination-log terminationMessagePolicy: File From 3f0243d7c584019873513fd4981e11c87bed1455 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 12:40:43 -0600 Subject: [PATCH 7/8] test(presentations): require the supported gateway log-level spelling --- .../unit/test_template_security.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/tests/presentation-qualification/unit/test_template_security.py b/.github/tests/presentation-qualification/unit/test_template_security.py index f7b6ad2..eed531e 100644 --- a/.github/tests/presentation-qualification/unit/test_template_security.py +++ b/.github/tests/presentation-qualification/unit/test_template_security.py @@ -80,8 +80,9 @@ def test_no_value_from(self): self.assertNotIn("valueFrom", self.text) self.assertNotIn("secretKeyRef", self.text) - def test_gateway_log_level_warning(self): - self.assertIn("--log-level=WARNING", self.text) + def test_gateway_log_level_warn(self): + self.assertIn("--log-level=WARN", self.text) + self.assertNotIn("--log-level=WARNING", self.text) def test_gateway_hardening(self): for literal in ( From ff946b6cb67e6ebe0cd812a6d8ce3023351794a1 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Mon, 5 Oct 2026 12:55:47 -0600 Subject: [PATCH 8/8] test(presentations): distinguish kernel launch observation failures --- .../runtime/client/client.py | 1 + .../runtime/run_runtime_suite.sh | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/tests/presentation-qualification/runtime/client/client.py b/.github/tests/presentation-qualification/runtime/client/client.py index ad295c4..c0b5aee 100644 --- a/.github/tests/presentation-qualification/runtime/client/client.py +++ b/.github/tests/presentation-qualification/runtime/client/client.py @@ -86,6 +86,7 @@ def start_body(extra_env): def start_kernel(extra_env): status, model = rest("POST", "/api/kernels", start_body(extra_env)) + print("KERNEL_START_DIAGNOSTIC http_status=%s" % (status if type(status) is int and 100 <= status <= 599 else "unknown"), flush=True) if status != 201: raise QualificationError("kernel start rejected with http %d" % status) kernel_id = model.get("id") if isinstance(model, dict) else None diff --git a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh index 7b6f8b9..b43f388 100644 --- a/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh +++ b/.github/tests/presentation-qualification/runtime/run_runtime_suite.sh @@ -264,7 +264,17 @@ set +e run_client full --timeout 420 & FULL_PID=$! set -e -wait_for_kernel_pod_and_assert "$FULL_PID" || fail "no running kernel pod observed during full run" +if wait_for_kernel_pod_and_assert "$FULL_PID"; then + : +else + observe_rc=$? + if [[ "$observe_rc" -eq 2 ]]; then + echo 'KERNEL_OBSERVATION_DIAGNOSTIC outcome=client_exited' + else + echo 'KERNEL_OBSERVATION_DIAGNOSTIC outcome=observation_deadline' + fi + fail "no running kernel pod observed during full run" +fi wait "$FULL_PID" || fail "full qualification client run failed" python3 - "$WORK_DIR/result-full.json" <<'PY' import json