From be7ce6b39509be062f91616f1058867ee6c6d90e Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 4 Jun 2026 07:51:14 +0000 Subject: [PATCH] feat(service-catalog): split printer-device access and LAN discovery lane (closes #67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the printer-device access and LAN mDNS discovery validation lane as a standalone split from the base print-service lane (#64). Makes the substrate dependencies (#54) explicit and keeps them out of the base lane. ## Dependency chain #54 substrate (USB device passthrough + LAN network policy on ghost) → #67 this lane (USB access + mDNS discovery contract proven) → #64 base print lane (already unblocked; this lane extends it) ## New files ### tests/service_catalog/print_device/test_print_device_discovery.py Two test classes with independent skip gates: TestPrinterDeviceAccessLane (gate: TEST_USB_PRINTER_DEVICE env var) - test_usb_device_node_exists_in_container - test_usb_device_node_is_character_device - test_cups_can_detect_local_usb_printer (lpinfo -v) - test_cups_accepts_test_print_job (lp + cancel) - test_usb_device_permissions_allow_cups_user - test_kubevirt_usb_passthrough_is_out_of_scope → pytest.skip → #54 TestLanDiscoveryLane (gate: TEST_AVAHI_ENABLED=true) - test_ipp_service_exposed_outside_cluster (NodePort/LB) - test_nodeport_is_reachable_on_node_ip - test_avahi_sidecar_container_is_running - test_avahi_daemon_process_is_active - test_avahi_daemon_advertises_ipp_service (avahi-browse _ipp._tcp) - test_avahi_service_file_is_present_in_config - test_auth_gated_cups_ui_is_out_of_scope → pytest.skip → deferred - test_split_horizon_dns_for_cups_is_out_of_scope → pytest.skip → bluespeed Both classes skip cleanly when their gate condition is not met so non-hardware CI is not broken. ### argo/workflow-templates/homelab-print-device.yaml WorkflowTemplate with three parameters controlling hardware paths: - usb-device-path: set to /dev/usb/lp0 to enable USB tests (hostPath volume) - avahi-enabled: set to 'true' to enable mDNS tests (NodePort svc + avahi sidecar) - ipp-nodeport: default 30631 check-prerequisites guard step emits informative messages about #54 requirements but does not fail — tests self-skip when conditions are absent. Fixture uses lscr.io/linuxserver/cups:latest (real CUPS image, not nginx stub) because USB tests require actual CUPS commands (lpinfo, lp, lpstat). Avahi sidecar uses ubuntu:22.04 + avahi-daemon + ConfigMap service definition for the _ipp._tcp record. ### argo/homelab-print-device.yaml Workflow submit trigger. ## Updated files ### docs/homelab-contracts.md - §6 known-blockers: #67 → ✅ defined - §7 Printer-Device Access and LAN Discovery Lane added: boundary table (base vs this lane), substrate assumptions tables, behavior tables for both test classes, WorkflowTemplate parameters table Closes #67 Child of #51 Depends on #54 (substrate), #64 (base print lane) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- argo/homelab-print-device.yaml | 11 + .../homelab-print-device.yaml | 329 ++++++++++++ docs/homelab-contracts.md | 79 +++ .../service_catalog/print_device/__init__.py | 0 .../test_print_device_discovery.py | 481 ++++++++++++++++++ 5 files changed, 900 insertions(+) create mode 100644 argo/homelab-print-device.yaml create mode 100644 argo/workflow-templates/homelab-print-device.yaml create mode 100644 tests/service_catalog/print_device/__init__.py create mode 100644 tests/service_catalog/print_device/test_print_device_discovery.py diff --git a/argo/homelab-print-device.yaml b/argo/homelab-print-device.yaml new file mode 100644 index 00000000..d49ffa0b --- /dev/null +++ b/argo/homelab-print-device.yaml @@ -0,0 +1,11 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Workflow +metadata: + generateName: homelab-print-device- + namespace: argo + labels: + app.kubernetes.io/component: homelab-print-device + app.kubernetes.io/part-of: bluefin-test-suite +spec: + workflowTemplateRef: + name: homelab-print-device diff --git a/argo/workflow-templates/homelab-print-device.yaml b/argo/workflow-templates/homelab-print-device.yaml new file mode 100644 index 00000000..108c072b --- /dev/null +++ b/argo/workflow-templates/homelab-print-device.yaml @@ -0,0 +1,329 @@ +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: homelab-print-device + namespace: argo + labels: + app.kubernetes.io/component: homelab-print-device + app.kubernetes.io/part-of: bluefin-test-suite +spec: + entrypoint: pipeline + onExit: cleanup + serviceAccountName: homelab-runner + arguments: + parameters: + - name: lane + value: "homelab-print-device" + - name: branch + value: "main" + - name: usb-device-path + # Set to the host USB printer device node, e.g. /dev/usb/lp0. + # Leave empty to skip USB device-access tests. + value: "" + - name: avahi-enabled + # Set to "true" to enable LAN mDNS discovery tests. + # Requires NodePort service and avahi sidecar in the fixture. + value: "false" + - name: ipp-nodeport + # NodePort number for the external IPP service (default 30631). + value: "30631" + templates: + - name: pipeline + dag: + tasks: + - name: check-prerequisites + template: check-prerequisites + arguments: + parameters: + - name: usb-device-path + value: "{{workflow.parameters.usb-device-path}}" + - name: avahi-enabled + value: "{{workflow.parameters.avahi-enabled}}" + - name: create-namespace + depends: "check-prerequisites.Succeeded" + template: create-namespace + arguments: + parameters: + - name: namespace + value: "homelab-print-device-{{workflow.uid}}" + - name: deploy-fixture + depends: "create-namespace.Succeeded" + template: deploy-fixture + arguments: + parameters: + - name: namespace + value: "homelab-print-device-{{workflow.uid}}" + - name: lane + value: "{{workflow.parameters.lane}}" + - name: usb-device-path + value: "{{workflow.parameters.usb-device-path}}" + - name: avahi-enabled + value: "{{workflow.parameters.avahi-enabled}}" + - name: ipp-nodeport + value: "{{workflow.parameters.ipp-nodeport}}" + - name: run-tests + depends: "deploy-fixture.Succeeded" + templateRef: + name: run-incluster-tests + template: run-pytest + arguments: + parameters: + - name: namespace + value: "homelab-print-device-{{workflow.uid}}" + - name: suite-path + value: "service_catalog/print_device" + - name: lane + value: "{{workflow.parameters.lane}}" + - name: app-label + value: "app=homelab-print-device" + - name: service-name + value: "homelab-print-device" + - name: branch + value: "{{workflow.parameters.branch}}" + - name: extra-env + value: | + TEST_USB_PRINTER_DEVICE={{workflow.parameters.usb-device-path}} + TEST_AVAHI_ENABLED={{workflow.parameters.avahi-enabled}} + TEST_IPP_NODEPORT={{workflow.parameters.ipp-nodeport}} + + - name: check-prerequisites + # Guard step: emit a clear message if neither USB nor avahi is enabled. + # Exits successfully (not a failure) so the workflow can proceed to + # run tests — those tests will individually skip with informative messages. + inputs: + parameters: + - name: usb-device-path + - name: avahi-enabled + script: + image: cgr.dev/chainguard/kubectl:latest-dev + command: [bash, -c] + source: | + set -euo pipefail + USB_DEV="{{inputs.parameters.usb-device-path}}" + AVAHI="{{inputs.parameters.avahi-enabled}}" + + if [ -z "${USB_DEV}" ] && [ "${AVAHI}" != "true" ]; then + echo "WARNING: Neither usb-device-path nor avahi-enabled is set." >&2 + echo "All tests in service_catalog/print_device will be skipped." >&2 + echo "" >&2 + echo "To enable USB tests: set usb-device-path to the host device" >&2 + echo " node (e.g. /dev/usb/lp0). Requires substrate from #54:" >&2 + echo " usblp kernel module + device node + container allow-list." >&2 + echo "" >&2 + echo "To enable mDNS tests: set avahi-enabled=true. Requires" >&2 + echo " substrate from #54: NodePort/LB service + avahi sidecar." >&2 + else + [ -n "${USB_DEV}" ] && echo "USB tests enabled (device: ${USB_DEV})" >&2 + [ "${AVAHI}" = "true" ] && echo "mDNS tests enabled (avahi sidecar)" >&2 + fi + + - name: create-namespace + inputs: + parameters: + - name: namespace + resource: + action: create + manifest: | + apiVersion: v1 + kind: Namespace + metadata: + name: "{{inputs.parameters.namespace}}" + labels: + app.kubernetes.io/part-of: bluefin-test-suite + bluefin.io/lane: homelab-print-device + + - name: deploy-fixture + inputs: + parameters: + - name: namespace + - name: lane + - name: usb-device-path + - name: avahi-enabled + - name: ipp-nodeport + script: + image: cgr.dev/chainguard/kubectl:latest-dev + command: [bash, -c] + source: | + set -euo pipefail + NS="{{inputs.parameters.namespace}}" + USB_DEV="{{inputs.parameters.usb-device-path}}" + AVAHI="{{inputs.parameters.avahi-enabled}}" + NODEPORT="{{inputs.parameters.ipp-nodeport}}" + + # Build the USB hostPath volume fragment only when a device path is set. + USB_VOLUME_MOUNT="" + USB_VOLUME="" + if [ -n "${USB_DEV}" ]; then + USB_VOLUME_MOUNT=" + - name: usb-printer + mountPath: ${USB_DEV}" + USB_VOLUME=" + - name: usb-printer + hostPath: + path: ${USB_DEV} + type: CharDevice" + fi + + # Build the avahi sidecar container fragment only when avahi is enabled. + AVAHI_CONTAINER="" + if [ "${AVAHI}" = "true" ]; then + AVAHI_CONTAINER=" + - name: avahi-sidecar + # avahi-daemon sidecar that advertises the CUPS IPP service + # on the LAN via mDNS (_ipp._tcp). The avahi-cups service + # definition is mounted from a ConfigMap. + image: docker.io/library/ubuntu:22.04 + command: + - sh + - -c + - | + apt-get update -qq && apt-get install -y -qq avahi-daemon avahi-utils + exec avahi-daemon --no-rlimits --no-drop-root + securityContext: + capabilities: + add: [NET_ADMIN] + volumeMounts: + - name: avahi-services + mountPath: /etc/avahi/services" + fi + + # Build the avahi ConfigMap + volume only when avahi is enabled. + AVAHI_CONFIGMAP="" + AVAHI_CM_VOLUME="" + if [ "${AVAHI}" = "true" ]; then + kubectl apply -n "${NS}" -f - < + + + CUPS @ homelab + + _ipp._tcp + 631 + txtvers=1 + qtotal=1 + pdl=application/pdf,image/jpeg,image/urf + Color=T + Duplex=T + + + CMEOF + AVAHI_CM_VOLUME=" + - name: avahi-services + configMap: + name: avahi-cups-service" + fi + + # Determine service type based on avahi-enabled flag. + if [ "${AVAHI}" = "true" ]; then + SVC_TYPE="NodePort" + NODEPORT_LINE=" nodePort: ${NODEPORT}" + else + SVC_TYPE="ClusterIP" + NODEPORT_LINE="" + fi + + kubectl apply -n "${NS}" -f - <&2 + + - name: cleanup + script: + image: cgr.dev/chainguard/kubectl:latest-dev + command: [bash, -c] + source: | + set -euo pipefail + NS="homelab-print-device-{{workflow.uid}}" + kubectl delete namespace "${NS}" --ignore-not-found=true >&2 || true + timeout 180 bash -c "until ! kubectl get namespace \"${NS}\" >/dev/null 2>&1; do sleep 5; done" >&2 || true + echo "cleanup-complete" diff --git a/docs/homelab-contracts.md b/docs/homelab-contracts.md index 98ab4f45..a4b6423b 100644 --- a/docs/homelab-contracts.md +++ b/docs/homelab-contracts.md @@ -197,3 +197,82 @@ The lab validates the following hostname/routing pattern for exposed in-cluster | #60 first restore drill | ✅ implemented | `homelab-restore-drill` WorkflowTemplate + `tests/homelab_backup/` | | #84 PVC restore drill with backup artifact | ✅ implemented | `homelab-restore-drill` WorkflowTemplate + `tests/homelab_backup/` | | Media service lane | ❌ deferred | #62 (shared mount) + #63 (GPU) | +| #67 printer-device access + LAN discovery | ✅ defined | Substrate work from #54 required before tests execute | + +--- + +## 7. Printer-Device Access and LAN Discovery Lane (#67) + +Split from the base non-media print-service lane (#64). Defines the +validation contracts for USB printer device access and LAN mDNS +auto-discovery — the two hardware/network-heavy paths that are explicitly +out of scope for the base lane. + +### Boundary between base lane and this lane + +| What | Base lane (#64) | This lane (#67) | +|---|---|---| +| CUPS deployment + PVC | ✅ validated | — | +| IPP port 631 in-cluster reachability | ✅ validated | — | +| Env injection (PUID/PGID/TZ) | ✅ validated | — | +| Config PVC rollout persistence | ✅ validated | — | +| USB device node in container | — | ✅ validated | +| CUPS lpinfo USB URI | — | ✅ validated | +| NodePort/LB service for LAN | — | ✅ validated | +| avahi sidecar running + advertising | — | ✅ validated | +| _ipp._tcp mDNS service record | — | ✅ validated | + +### Substrate assumptions from #54 + +#### USB device-access path +| Requirement | How to verify | +|---|---| +| USB printer attached to ghost node | `lsusb` on host reports printer VID/PID | +| `usblp` or `usbfs` kernel module loaded | `lsmod \| grep usblp` on host | +| Device node exists at expected path | `ls -la /dev/usb/lp0` on host | +| Container runtime device allow-list includes device node | Pod spec carries `securityContext` or device allow-list entry | + +#### LAN mDNS / discovery path +| Requirement | How to verify | +|---|---| +| NodePort or LoadBalancer service on port 30631 | `kubectl get svc -A \| grep 631` | +| avahi-daemon sidecar running | sidecar container Ready in pod status | +| avahi service file for _ipp._tcp present | `/etc/avahi/services/cups.service` in sidecar | +| Multicast not filtered on node NIC | `avahi-browse -t _ipp._tcp` from host returns result | + +### Behavior table + +#### Class 1: TestPrinterDeviceAccessLane (gate: `TEST_USB_PRINTER_DEVICE` env var) + +| Behavior | Test | Artifact | +|---|---|---| +| USB device node visible in container | `test_usb_device_node_exists_in_container` | `print-device-node-check.txt` | +| Device node is a character device | `test_usb_device_node_is_character_device` | `print-device-node-ls.txt` | +| CUPS lpinfo reports USB device URI | `test_cups_can_detect_local_usb_printer` | `print-lpinfo.txt` | +| CUPS accepts test print job | `test_cups_accepts_test_print_job` | `print-test-job.txt` | +| Device node r/w permissions allow CUPS | `test_usb_device_permissions_allow_cups_user` | `print-device-permissions.txt` | +| KubeVirt USB passthrough | `test_kubevirt_usb_passthrough_is_out_of_scope` | `pytest.skip` → #54 | + +#### Class 2: TestLanDiscoveryLane (gate: `TEST_AVAHI_ENABLED=true`) + +| Behavior | Test | Artifact | +|---|---|---| +| IPP service type is NodePort or LoadBalancer | `test_ipp_service_exposed_outside_cluster` | `print-discovery-service.json` | +| NodePort reachable on node IP | `test_nodeport_is_reachable_on_node_ip` | `print-discovery-nodeport-reach.txt` | +| avahi sidecar container is Ready | `test_avahi_sidecar_container_is_running` | `print-discovery-pods.json` | +| avahi-daemon process is active | `test_avahi_daemon_process_is_active` | `print-avahi-process.txt` | +| avahi advertises _ipp._tcp record | `test_avahi_daemon_advertises_ipp_service` | `print-avahi-browse.txt` | +| avahi service file present in /etc/avahi/services/ | `test_avahi_service_file_is_present_in_config` | `print-avahi-services.txt` | +| Auth-gated CUPS UI | `test_auth_gated_cups_ui_is_out_of_scope` | `pytest.skip` → deferred | +| Split-horizon DNS for CUPS | `test_split_horizon_dns_for_cups_is_out_of_scope` | `pytest.skip` → bluespeed | + +### WorkflowTemplate parameters + +| Parameter | Default | Purpose | +|---|---|---| +| `usb-device-path` | `""` | Set to host device path (e.g. `/dev/usb/lp0`) to enable USB tests | +| `avahi-enabled` | `"false"` | Set to `"true"` to enable mDNS discovery tests | +| `ipp-nodeport` | `"30631"` | NodePort number for external IPP service | + +When both parameters are at their defaults, all tests skip with informative messages +and the workflow exits successfully — non-hardware CI is not broken. diff --git a/tests/service_catalog/print_device/__init__.py b/tests/service_catalog/print_device/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/service_catalog/print_device/test_print_device_discovery.py b/tests/service_catalog/print_device/test_print_device_discovery.py new file mode 100644 index 00000000..4c9224c1 --- /dev/null +++ b/tests/service_catalog/print_device/test_print_device_discovery.py @@ -0,0 +1,481 @@ +""" +Printer-device access and LAN discovery lane — split from the base print lane. + +This module defines the validation contract for the hardware-heavy and +network-discovery-heavy aspects of the OpenPrinting/CUPS workload that are +explicitly out of scope for the base print-service lane (#64). Keeping +these requirements here makes the substrate dependencies visible and +independently trackable without blocking the base lane. + +## Why this split exists + +The base non-media lane (#64) validates the in-cluster deployment contract +for CUPS (PVC binding, IPP port reachability, env injection, rollout +persistence). It cannot and should not validate: + + - Attached USB printer device access — requires host device passthrough + that depends on substrate work in #54. + - LAN mDNS / avahi self-discovery — requires a NodePort or LoadBalancer + service plus an avahi sidecar, neither of which is part of the base + in-cluster validation contract. + +## Substrate assumptions this lane requires (both from epic #54) + + ### USB device-access path + A. The target node (ghost) has one or more USB printer device nodes + accessible at a path like /dev/usb/lp0 or /dev/bus/usb/XXX/YYY. + B. The host usbfs and usblp kernel modules are loaded. + C. The pod spec carries a hostPath volume for the device node, and the + container runtime allows it (privileged or specific device allow-list). + D. CUPS inside the container can open the device node — file permissions + and SELinux/AppArmor policy allow it. + + ### LAN mDNS / discovery path + E. A NodePort or LoadBalancer service exposes the CUPS IPP port (631) + on the host network interface so LAN clients can reach it. + F. An avahi-daemon sidecar is running in the pod and advertising the + _ipp._tcp service record on the LAN. + G. The cluster node is on the same Layer-2 segment as LAN clients so + mDNS multicast packets (224.0.0.251) are not filtered. + +## Dependency chain + + #54 substrate (host device passthrough + LAN network policy) + → #67 this lane (USB access + mDNS discovery contract proven) + → #64 base print lane (already unblocked; this lane extends it) + +## All tests are gated per class + + TestPrinterDeviceAccessLane: + Requires TEST_USB_PRINTER_DEVICE env var (e.g. /dev/usb/lp0). + Skips entire class if the env var is not set. + + TestLanDiscoveryLane: + Requires TEST_AVAHI_ENABLED=true env var. + Skips entire class if the env var is not set. + + Both classes can be skipped in non-hardware CI without any failures. + +Source idea: projectbluefin/bluespeed#11 +Child of: #67, #51 +Depends on: #54, #64 +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +from tests.service_catalog.shared.kube import ( + NAMESPACE, + get_pods_json, + run_kubectl, + write_artifact, +) + + +# ── Gate helpers ────────────────────────────────────────────────────────────── + +_USB_DEVICE = os.environ.get("TEST_USB_PRINTER_DEVICE", "") +_AVAHI_ENABLED = os.environ.get("TEST_AVAHI_ENABLED", "").lower() in ("1", "true", "yes") + +_NO_USB = not _USB_DEVICE +_NO_AVAHI = not _AVAHI_ENABLED + +_USB_SKIP_REASON = ( + "TEST_USB_PRINTER_DEVICE is not set. " + "Set it to the host device path (e.g. /dev/usb/lp0) to enable USB printer tests. " + "Requires substrate work from #54: usblp kernel module, host device node, " + "and container runtime device allow-list configured on ghost." +) + +_AVAHI_SKIP_REASON = ( + "TEST_AVAHI_ENABLED is not set to 'true'. " + "Set TEST_AVAHI_ENABLED=true to enable LAN discovery tests. " + "Requires substrate work from #54: NodePort/LoadBalancer service on port 631 " + "and avahi-daemon sidecar advertising _ipp._tcp on the LAN segment." +) + + +# ── Lane constants ──────────────────────────────────────────────────────────── + +DEPLOYMENT_NAME = "homelab-print-device" +APP_LABEL = os.environ.get("TEST_APP_LABEL", f"app={DEPLOYMENT_NAME}") +SERVICE_NAME = os.environ.get("TEST_SERVICE_NAME", DEPLOYMENT_NAME) + +CONFIG_PVC_NAME = "homelab-print-device-config" +CONFIG_MOUNT_PATH = "/config" + +IPP_PORT = 631 # IPP standard +IPP_NODEPORT = int(os.environ.get("TEST_IPP_NODEPORT", "30631")) # NodePort for LAN exposure + +# Device node path on the host; set via TEST_USB_PRINTER_DEVICE +USB_DEVICE_PATH = _USB_DEVICE or "/dev/usb/lp0" + +CUPS_CONTAINER_NAME = "cups-server" +AVAHI_CONTAINER_NAME = "avahi-sidecar" + +EXPECTED_ENV_VARS = {"PUID": "1000", "PGID": "1000", "TZ": "UTC"} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _exec_in_pod(*command: str, container: str | None = None) -> subprocess.CompletedProcess[str]: + args = ["exec", "-n", NAMESPACE, _first_pod_name()] + if container: + args += ["-c", container] + args += ["--", *command] + return run_kubectl(*args) + + +def _first_pod_name() -> str: + result = run_kubectl( + "get", "pods", "-n", NAMESPACE, "-l", APP_LABEL, + "--field-selector=status.phase=Running", + "-o", "jsonpath={.items[0].metadata.name}", + ) + assert result.returncode == 0 and result.stdout.strip(), ( + f"No Running pod found: {result.stdout}{result.stderr}" + ) + return result.stdout.strip() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Class 1 — USB printer device access +# ══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.skipif(_NO_USB, reason=_USB_SKIP_REASON) +class TestPrinterDeviceAccessLane: + """ + USB printer device-access lane. + + All tests in this class are skipped unless TEST_USB_PRINTER_DEVICE is + set to a real device path on the host node. See the module docstring + for the full substrate dependency list (epic #54). + + The class validates that the container runtime correctly passes the USB + device node through to the CUPS container so that CUPS can open and + communicate with the attached printer. + """ + + # ── 1. Substrate — host device prerequisites ────────────────────────────── + + def test_usb_device_node_exists_in_container(self): + """ + The USB printer device node is visible inside the CUPS container. + + This confirms the hostPath volume was mounted correctly by the + container runtime and the device node is present (not just the + directory placeholder). + """ + result = _exec_in_pod( + "sh", "-c", + f"test -e {USB_DEVICE_PATH} && echo PRESENT || echo MISSING", + container=CUPS_CONTAINER_NAME, + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-device-node-check.txt", result.stdout) + assert "PRESENT" in result.stdout, ( + f"USB device node {USB_DEVICE_PATH} not found inside container. " + "Check hostPath volume mount and container runtime device allow-list (see #54)." + ) + + def test_usb_device_node_is_character_device(self): + """ + The device node is a character device, not a directory or regular file. + + lp0 is a character device (c in ls -l output). A directory or regular + file at the same path means the hostPath mount silently failed to bind + the device node. + """ + result = _exec_in_pod( + "sh", "-c", + f"ls -la {USB_DEVICE_PATH} 2>&1", + container=CUPS_CONTAINER_NAME, + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-device-node-ls.txt", result.stdout) + assert result.stdout.lstrip().startswith("c"), ( + f"Device node {USB_DEVICE_PATH} is not a character device:\n{result.stdout}" + ) + + def test_cups_can_detect_local_usb_printer(self): + """ + CUPS (lpstat or lpinfo) detects the locally attached USB printer. + + Uses lpinfo -v to enumerate device URIs. A URI containing 'usb://' + or 'direct:usb' confirms CUPS has discovered the device node and + can communicate with it over the USB backend. + """ + result = _exec_in_pod( + "sh", "-c", + "lpinfo -v 2>&1 || true", + container=CUPS_CONTAINER_NAME, + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-lpinfo.txt", result.stdout) + output = result.stdout.lower() + assert "usb" in output or "direct" in output, ( + "CUPS lpinfo -v did not report any USB device URI. " + f"Output:\n{result.stdout}\n" + "Verify the USB device is attached, device node is accessible, " + "and the usblp kernel module is loaded on the host (see #54)." + ) + + def test_cups_accepts_test_print_job(self): + """ + CUPS accepts a test job for the USB printer and reports it queued. + + Uses lp to submit a minimal test print job (cancel immediately after). + A successful lp return code proves the CUPS backend can communicate + with the attached device well enough to accept and queue the job. + """ + result = _exec_in_pod( + "sh", "-c", + # List printers, pick the first, submit a test-page job, cancel it + "PRINTER=$(lpstat -p 2>/dev/null | awk 'NR==1{print $2}');" + " if [ -z \"${PRINTER}\" ]; then echo NO_PRINTER_CONFIGURED; exit 1; fi;" + " JOB=$(echo 'test' | lp -d \"${PRINTER}\" 2>&1);" + " echo \"${JOB}\";" + " JOB_ID=$(echo \"${JOB}\" | grep -oP '(?<=job-id )[0-9]+' || true);" + " [ -n \"${JOB_ID}\" ] && cancel \"${JOB_ID}\" 2>/dev/null || true", + container=CUPS_CONTAINER_NAME, + ) + write_artifact("print-test-job.txt", result.stdout + result.stderr) + assert result.returncode == 0, result.stdout + result.stderr + assert "NO_PRINTER_CONFIGURED" not in result.stdout, ( + "No printer configured in CUPS. " + "The lane fixture must add the USB printer to CUPS before this test runs." + ) + + def test_usb_device_permissions_allow_cups_user(self): + """ + The CUPS process user (or the PUID/PGID user) can read and write to + the USB device node. Insufficient permissions produce silent failures + in CUPS that are hard to diagnose without this explicit check. + """ + result = _exec_in_pod( + "sh", "-c", + f"test -r {USB_DEVICE_PATH} && test -w {USB_DEVICE_PATH}" + " && echo RW_OK || echo PERMISSION_DENIED", + container=CUPS_CONTAINER_NAME, + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-device-permissions.txt", result.stdout) + assert "RW_OK" in result.stdout, ( + f"CUPS user cannot read/write {USB_DEVICE_PATH}. " + "Check device node group ownership and container security context (see #54)." + ) + + # ── 2. Explicitly deferred paths ───────────────────────────────────────── + + def test_kubevirt_usb_passthrough_is_out_of_scope(self): + """ + KubeVirt USB device passthrough (USB host-device assignment through + the KubeVirt device API) is out of scope for this lane. This lane + validates the simpler hostPath approach only. VM-backed USB + passthrough requires additional substrate work in #54. + """ + pytest.skip( + "KubeVirt USB device passthrough deferred to #54; " + "requires KubeVirt USB host-device assignment — " + "out of scope for hostPath-based USB lane" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Class 2 — LAN mDNS / avahi discovery +# ══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.skipif(_NO_AVAHI, reason=_AVAHI_SKIP_REASON) +class TestLanDiscoveryLane: + """ + LAN mDNS / avahi printer-discovery lane. + + All tests in this class are skipped unless TEST_AVAHI_ENABLED=true is set. + See the module docstring for the full substrate dependency list (epic #54). + + The class validates that: + - The CUPS service is exposed outside the cluster on port 631 (NodePort + or LoadBalancer) so LAN clients can reach it. + - An avahi-daemon sidecar is running inside the pod and advertising the + _ipp._tcp mDNS service record on the LAN. + """ + + # ── 1. Service exposure ─────────────────────────────────────────────────── + + def test_ipp_service_exposed_outside_cluster(self): + """ + The IPP service is exposed via NodePort or LoadBalancer so LAN clients + can print without a VPN or ingress controller. + + Checks that the service type is not ClusterIP and that an external + port mapping exists for port 631. + """ + result = run_kubectl( + "get", "service", SERVICE_NAME, "-n", NAMESPACE, "-o", "json" + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-discovery-service.json", result.stdout) + svc = json.loads(result.stdout) + svc_type = svc.get("spec", {}).get("type", "ClusterIP") + assert svc_type in ("NodePort", "LoadBalancer"), ( + f"Service type is {svc_type!r}; expected NodePort or LoadBalancer " + "for LAN-accessible printing. Update the Deployment fixture to expose " + "the service externally (see the WorkflowTemplate for this lane)." + ) + + def test_nodeport_is_reachable_on_node_ip(self): + """ + The NodePort is reachable from within the cluster using the node's + internal IP, simulating a LAN client on the same L2 segment. + + Uses wget from inside the workload pod targeting the node IP rather + than the ClusterIP so this test exercises the actual NodePort path. + """ + # Retrieve node IP of the pod's host node + node_result = run_kubectl( + "get", "pod", _first_pod_name(), "-n", NAMESPACE, + "-o", "jsonpath={.status.hostIP}", + ) + assert node_result.returncode == 0 and node_result.stdout.strip(), ( + f"Could not determine host IP: {node_result.stdout}{node_result.stderr}" + ) + node_ip = node_result.stdout.strip() + write_artifact("print-discovery-node-ip.txt", node_ip) + + result = _exec_in_pod( + "sh", "-c", + f"wget -q -S -O /dev/null http://{node_ip}:{IPP_NODEPORT}/ 2>&1 || true", + container=CUPS_CONTAINER_NAME, + ) + output = result.stdout + result.stderr + write_artifact("print-discovery-nodeport-reach.txt", output) + assert "Connection refused" not in output, ( + f"NodePort {IPP_NODEPORT} refused connection on node IP {node_ip}. " + "Verify the NodePort service is correctly configured." + ) + assert "Name or service not known" not in output, output + + # ── 2. Avahi sidecar ────────────────────────────────────────────────────── + + def test_avahi_sidecar_container_is_running(self): + """ + The avahi-daemon sidecar container is in a Running/Ready state inside + the pod. A crashed or missing avahi sidecar will silently prevent + mDNS advertisement without producing any CUPS error. + """ + pods = get_pods_json() + write_artifact("print-discovery-pods.json", json.dumps(pods, indent=2)) + items = pods.get("items", []) + assert items, "No print-device pod found" + pod = items[0] + container_statuses = pod.get("status", {}).get("containerStatuses", []) + avahi_status = next( + (cs for cs in container_statuses if cs.get("name") == AVAHI_CONTAINER_NAME), + None, + ) + assert avahi_status is not None, ( + f"No container named '{AVAHI_CONTAINER_NAME}' found in pod. " + "Verify the Deployment fixture includes the avahi sidecar container." + ) + assert avahi_status.get("ready"), ( + f"avahi sidecar container is not Ready: {avahi_status}" + ) + + def test_avahi_daemon_process_is_active(self): + """ + avahi-daemon is running as a process inside the sidecar container. + + A container that started successfully but whose init system failed to + launch avahi-daemon will still show as Ready from Kubernetes' perspective + (as long as the entry-point process is alive). This test confirms + avahi-daemon itself is running. + """ + result = _exec_in_pod( + "sh", "-c", + "pgrep -x avahi-daemon >/dev/null && echo RUNNING || echo NOT_RUNNING", + container=AVAHI_CONTAINER_NAME, + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-avahi-process.txt", result.stdout) + assert "RUNNING" in result.stdout, ( + "avahi-daemon process not found in sidecar container. " + "Check avahi-daemon startup command and sidecar image." + ) + + def test_avahi_daemon_advertises_ipp_service(self): + """ + avahi-daemon is advertising an _ipp._tcp service record. + + Uses avahi-browse -t to do a one-shot lookup for _ipp._tcp. A result + proves the service record is on the wire and LAN clients using mDNS + (e.g. macOS Print Center, Linux system-config-printer) can auto-discover + the printer without manual configuration. + """ + result = _exec_in_pod( + "sh", "-c", + "avahi-browse -t -r _ipp._tcp 2>&1 || true", + container=AVAHI_CONTAINER_NAME, + ) + write_artifact("print-avahi-browse.txt", result.stdout + result.stderr) + output = result.stdout + result.stderr + assert "_ipp._tcp" in output or "IPP" in output, ( + "avahi-browse did not find any _ipp._tcp service record. " + "Verify avahi-daemon is advertising the CUPS IPP service " + "and that multicast is not filtered on the node network interface." + ) + + def test_avahi_service_file_is_present_in_config(self): + """ + The avahi service definition file for CUPS IPP is present in + /etc/avahi/services/ inside the sidecar. + + avahi-daemon reads static service files from this directory at + startup. A missing file means the sidecar was launched without the + correct configuration and will not advertise the printer. + """ + result = _exec_in_pod( + "sh", "-c", + "ls /etc/avahi/services/*.service 2>/dev/null | grep -q . " + "&& echo PRESENT || echo MISSING", + container=AVAHI_CONTAINER_NAME, + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("print-avahi-services.txt", result.stdout) + assert "PRESENT" in result.stdout, ( + "No .service files found in /etc/avahi/services/ in the sidecar. " + "Mount the avahi CUPS service definition file via a ConfigMap." + ) + + # ── 3. Explicitly deferred paths ───────────────────────────────────────── + + def test_auth_gated_cups_ui_is_out_of_scope(self): + """ + Auth-gating the CUPS administration web UI (Authelia, OAuth proxy, + or similar) is out of scope for this discovery lane. The lane + validates only the _ipp._tcp service advertisement and NodePort + reachability, not authenticated access to the admin interface. + """ + pytest.skip( + "Auth-gated CUPS administration UI deferred beyond #67; " + "requires auth-gating infrastructure — out of scope for discovery lane" + ) + + def test_split_horizon_dns_for_cups_is_out_of_scope(self): + """ + Split-horizon DNS / LAN hostname routing for CUPS + (e.g. cups.home.local → NodePort) is out of scope for this lane. + The lane proves only raw mDNS advertisement, not DNS-based routing. + DNS-based routing patterns are tracked in the bluespeed repo. + """ + pytest.skip( + "Split-horizon DNS and LAN hostname routing deferred; " + "tracked in projectbluefin/bluespeed — out of scope for mDNS lane" + )