diff --git a/argo/homelab-media-service.yaml b/argo/homelab-media-service.yaml new file mode 100644 index 000000000..298942501 --- /dev/null +++ b/argo/homelab-media-service.yaml @@ -0,0 +1,15 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Workflow +metadata: + generateName: homelab-media-service- + namespace: argo +spec: + serviceAccountName: argo + workflowTemplateRef: + name: homelab-media-service + arguments: + parameters: + - name: lane + value: "homelab-media-service" + - name: branch + value: "main" diff --git a/argo/workflow-templates/homelab-media-service.yaml b/argo/workflow-templates/homelab-media-service.yaml new file mode 100644 index 000000000..9fe4cfb86 --- /dev/null +++ b/argo/workflow-templates/homelab-media-service.yaml @@ -0,0 +1,206 @@ +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: homelab-media-service + namespace: argo + labels: + app.kubernetes.io/component: homelab-media-service + app.kubernetes.io/part-of: bluefin-test-suite +spec: + entrypoint: pipeline + onExit: cleanup + serviceAccountName: homelab-runner + arguments: + parameters: + - name: lane + value: "homelab-media-service" + - name: branch + value: "main" + templates: + - name: pipeline + dag: + tasks: + - name: create-namespace + template: create-namespace + arguments: + parameters: + - name: namespace + value: "homelab-media-{{workflow.uid}}" + - name: deploy-fixture + depends: "create-namespace.Succeeded" + template: deploy-fixture + arguments: + parameters: + - name: namespace + value: "homelab-media-{{workflow.uid}}" + - name: lane + value: "{{workflow.parameters.lane}}" + - name: run-tests + depends: "deploy-fixture.Succeeded" + templateRef: + name: run-incluster-tests + template: run-pytest + arguments: + parameters: + - name: namespace + value: "homelab-media-{{workflow.uid}}" + - name: suite-path + value: "service_catalog/media" + - name: lane + value: "{{workflow.parameters.lane}}" + - name: app-label + value: "app=homelab-media-service" + - name: service-name + value: "homelab-media-service" + - name: branch + value: "{{workflow.parameters.branch}}" + + - 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-media-service + + - name: deploy-fixture + inputs: + parameters: + - name: namespace + - name: lane + script: + image: cgr.dev/chainguard/kubectl:latest-dev + command: [bash, -c] + source: | + set -euo pipefail + NS="{{inputs.parameters.namespace}}" + 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-media-{{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 98ab4f45a..f1cfa1f11 100644 --- a/docs/homelab-contracts.md +++ b/docs/homelab-contracts.md @@ -18,6 +18,7 @@ access guarantees it must prove. | **General-purpose** | `homelab-substrate` | `tests/homelab_substrate/` | k3s scheduling, pod lifecycle, in-cluster HTTP/TCP reachability, `local-path` PVC allocation | | **NAS / storage** | `homelab-storage` | `tests/homelab_storage/` | PVC bound on `local-path`, data survives `rollout restart`, `findmnt`/`df`/`lsblk`/ZFS artifacts captured | | **Service access** | `homelab-access-probe` | `tests/homelab_access/` | Cluster-DNS resolution, TLS handshake, expected-host routing via SNI | +| **Media service** | `homelab-media-service` | `tests/service_catalog/media/` | Dual-PVC deployment (config + data), PUID/PGID/TZ env injection, port 8096 reachability, state survives `rollout restart` | ### Minimum persistence contract per class @@ -38,9 +39,18 @@ access guarantees it must prove. - TLS handshake reports a certificate with `Protocol version` in the output. - Health endpoint returns `access-ok` with `Host:` header routing. +#### Media service +- Config PVC (1Gi `local-path` ReadWriteOnce) binds and is writable at `/config`. +- Media-data PVC (10Gi `local-path` ReadWriteOnce) binds and is writable at `/data`. +- Both PVCs survive a `rollout restart` (sentinel files checked after pod replacement). +- `PUID`, `PGID`, and `TZ` environment variables are present in the container env. +- Web UI port 8096 is reachable within the cluster namespace (TCP connect succeeds). +- Cluster DNS resolves `homelab-media-service..svc.cluster.local`. +- **GPU transcoding deferred to #63** — requires GPU passthrough feature gate. +- **RWX shared-media mount blocked until #62** — current `local-path` is RWO only. + ### Gaps surfaced explicitly -- Media streaming / transcoding lane: not yet defined. GPU passthrough is a - known blocker; filed as a follow-up under the service-catalog epic. +- GPU transcoding / hardware passthrough: split into #63. - ReadWriteMany / shared-media access: blocked by #62. - Service-to-service auth: deferred to service-catalog auth-gating lane (#61). @@ -196,4 +206,58 @@ The lab validates the following hostname/routing pattern for exposed in-cluster | #61 auth-gated service UI | ❌ deferred | service-catalog baseline lane first | | #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) | +| #59 Media service lane (base) | ✅ implemented | `homelab-media-service` WorkflowTemplate + `tests/service_catalog/media/` | + +--- + +## 7. Media-Service Workload Lane (#59) + +### Lane definition + +The first representative media-service lane validates the **in-cluster +Plex/Jellyfin-class workload contract** for homelab services following the +linuxserver.io pattern. It is the base lane under the service-catalog epic +(#51) and is intentionally scoped to what can run today without GPU hardware +or RWX storage. + +### Minimum workload behaviors this lane must prove + +| Behavior | Test | Evidence artifact | +|---|---|---| +| Deployment reaches Running | `test_deployment_becomes_ready` | `media-deployment.json` | +| Pod is fully ready | `test_pod_reaches_running_state` | `media-pods.json` | +| ClusterIP service has endpoints on port 8096 | `test_service_has_endpoints` | `media-service.json`, `media-endpoints.json` | +| Config PVC (1Gi) binds on `local-path` | `test_config_pvc_is_bound` | `media-pvc-homelab-media-config.json` | +| Config mount writable at `/config` | `test_config_mount_is_writable` | — | +| Media-data PVC (10Gi) binds on `local-path` | `test_data_pvc_is_bound` | `media-pvc-homelab-media-data.json` | +| Data mount writable at `/data` | `test_data_mount_is_writable` | — | +| Port 8096 reachable in-cluster | `test_media_port_is_reachable_in_cluster` | `media-reachability.txt` | +| Cluster DNS resolves service FQDN | `test_cluster_dns_resolves_media_service` | `media-dns.txt` | +| PUID / PGID / TZ env vars injected | `test_puid_pgid_tz_env_vars_are_present` | `media-env.txt` | +| Config + data state survive `rollout restart` | `test_config_and_data_state_survive_rollout_restart` | `media-pods-before/after-restart.json`, `media-rollout-status.txt` | +| Storage observability artifacts captured | `test_collects_storage_observability_artifacts` | `media-{config,data}-{df,findmnt,stat}.txt` | + +### Out-of-scope for base lane + +| Path | Issue | Reason | +|---|---|---| +| GPU transcoding / hardware passthrough | #63 | Requires `DevicePlugin` + KubeVirt GPU feature gate | +| ReadWriteMany / shared-media mount | #62 | `local-path` is RWO-only; needs NFS CSI or Longhorn | +| Auth-gated service UI | #61 | Deferred until base lane is proven | +| External / LAN hostname routing | bluespeed repo | Outside cluster-internal contract scope | + +### Dependencies + +This lane requires the following contracts to be proven first: + +- **#54 (homelab substrate)** — in-cluster workload scheduling and pod lifecycle +- **#52 (homelab storage)** — `local-path` PVC binding and persistence contract +- **#53 (homelab access)** — cluster-DNS and in-cluster reachability contract + +### Fixture image note + +The validation fixture uses `nginx:1.27.5-alpine` on port 8096 to represent +the HTTP service interface. The contract validated (dual PVC, env injection, +port reachability, rollout persistence) is fully representative of the +linuxserver.io pattern. Replace the fixture with `linuxserver/jellyfin` or +equivalent when the lane graduates to integration testing. diff --git a/tests/service_catalog/media/__init__.py b/tests/service_catalog/media/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/service_catalog/media/test_media_service.py b/tests/service_catalog/media/test_media_service.py new file mode 100644 index 000000000..27dc773a2 --- /dev/null +++ b/tests/service_catalog/media/test_media_service.py @@ -0,0 +1,326 @@ +""" +Representative media-service workload lane. + +Proves the minimum in-cluster validation contract for a linuxserver.io-style +media service (Plex/Jellyfin class) running directly in Kubernetes: + + 1. Deployment — workload reaches Running state on the cluster node. + 2. Persistence — config PVC and media-data PVC bind on local-path and + survive a rollout restart. + 3. Reachability — web UI port (8096) is reachable within the cluster + namespace. + 4. Secret/env — PUID, PGID, and TZ environment variables are injected + into the container and visible in the process environment. + 5. Teardown — namespace cleanup is handled by the workflow onExit handler; + this lane verifies the pod and PVCs are present before any + cleanup runs. + +Lane dependencies: + - #52 (homelab storage epic) — local-path PVC contract proven first + - #53 (homelab access epic) — in-cluster DNS and TLS contracts proven first + - #54 (homelab substrate epic) — in-cluster workload scheduling proven first + +Out-of-scope for this base lane (tracked separately): + - GPU transcoding and hardware passthrough → issue #63 + - ReadWriteMany / shared-media mount → blocked by #62 + - Auth-gated service UI (Authelia / OAuth proxy) → issue #61 + - External / LAN hostname routing → bluespeed repo +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +from tests.service_catalog.shared.kube import ( + TEST_NAMESPACE, + first_pod_name, + get_pods_json, + run_kubectl, + write_artifact, +) + + +# ── Lane constants ──────────────────────────────────────────────────────────── + +DEPLOYMENT_NAME = "homelab-media-service" +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-media-config" +DATA_PVC_NAME = "homelab-media-data" +CONFIG_MOUNT_PATH = "/config" +DATA_MOUNT_PATH = "/data" + +MEDIA_SERVICE_PORT = 8096 # Jellyfin/linuxserver.io standard HTTP port + +EXPECTED_ENV_VARS = {"PUID": "1000", "PGID": "1000", "TZ": "UTC"} + +EXPECTED_STORAGE_CLASS = "local-path" +EXPECTED_CONFIG_CAPACITY = "1Gi" +EXPECTED_DATA_CAPACITY = "10Gi" +EXPECTED_ACCESS_MODES = ["ReadWriteOnce"] + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _exec_in_pod(*command: str) -> subprocess.CompletedProcess[str]: + return run_kubectl("exec", "-n", TEST_NAMESPACE, first_pod_name(), "--", *command) + + +def _get_pvc(pvc_name: str) -> dict: + result = run_kubectl("get", "pvc", pvc_name, "-n", TEST_NAMESPACE, "-o", "json") + assert result.returncode == 0, result.stdout + result.stderr + write_artifact(f"media-pvc-{pvc_name}.json", result.stdout) + return json.loads(result.stdout) + + +def _pvc_debug(data: dict) -> str: + return json.dumps(data, indent=2) + + +# ── Test class ──────────────────────────────────────────────────────────────── + + +class TestMediaServiceLane: + """ + Base media-service workload lane. + + Validates deployment, dual-PVC persistence, port reachability, env + injection, and rollout-restart survival. GPU transcoding and RWX/ + shared-storage paths are skipped with explicit issue references. + """ + + # ── 1. Deployment ───────────────────────────────────────────────────────── + + def test_deployment_becomes_ready(self): + """Workload reaches availableReplicas >= 1 on the cluster node.""" + result = run_kubectl( + "get", "deployment", DEPLOYMENT_NAME, "-n", TEST_NAMESPACE, "-o", "json" + ) + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("media-deployment.json", result.stdout) + data = json.loads(result.stdout) + status = data.get("status", {}) + assert status.get("availableReplicas", 0) >= 1, json.dumps(status, indent=2) + assert status.get("readyReplicas", 0) >= 1, json.dumps(status, indent=2) + + def test_pod_reaches_running_state(self): + """At least one pod is Running and its containers are all Ready.""" + pods = get_pods_json() + write_artifact("media-pods.json", json.dumps(pods, indent=2)) + items = pods.get("items", []) + assert items, "No media-service pod found" + pod = items[0] + phase = pod.get("status", {}).get("phase") + assert phase == "Running", json.dumps(pod.get("status", {}), indent=2) + container_statuses = pod.get("status", {}).get("containerStatuses", []) + assert container_statuses, "No containerStatuses found" + for cs in container_statuses: + assert cs.get("ready"), f"Container {cs.get('name')} not ready: {cs}" + + def test_service_has_endpoints(self): + """ClusterIP service exposes at least one endpoint on MEDIA_SERVICE_PORT.""" + svc = run_kubectl("get", "service", SERVICE_NAME, "-n", TEST_NAMESPACE, "-o", "json") + eps = run_kubectl("get", "endpoints", SERVICE_NAME, "-n", TEST_NAMESPACE, "-o", "json") + assert svc.returncode == 0, svc.stdout + svc.stderr + assert eps.returncode == 0, eps.stdout + eps.stderr + write_artifact("media-service.json", svc.stdout) + write_artifact("media-endpoints.json", eps.stdout) + eps_data = json.loads(eps.stdout) + subsets = eps_data.get("subsets", []) + assert subsets, "No endpoint subsets found" + all_addresses = [addr for s in subsets for addr in s.get("addresses", [])] + assert all_addresses, "No endpoint addresses found" + + # ── 2. Persistence — config PVC ─────────────────────────────────────────── + + def test_config_pvc_is_bound(self): + """Config PVC (1Gi ReadWriteOnce local-path) binds within the namespace.""" + data = _get_pvc(CONFIG_PVC_NAME) + assert data.get("status", {}).get("phase") == "Bound", _pvc_debug(data) + + def test_config_pvc_capacity_and_access_modes(self): + data = _get_pvc(CONFIG_PVC_NAME) + assert ( + data.get("status", {}).get("capacity", {}).get("storage") + == EXPECTED_CONFIG_CAPACITY + ), _pvc_debug(data) + assert data.get("spec", {}).get("accessModes") == EXPECTED_ACCESS_MODES, _pvc_debug(data) + + def test_config_pvc_storage_class(self): + data = _get_pvc(CONFIG_PVC_NAME) + assert ( + data.get("spec", {}).get("storageClassName") == EXPECTED_STORAGE_CLASS + ), _pvc_debug(data) + + def test_config_mount_is_writable(self): + """Container can write to /config — required for service configuration state.""" + result = _exec_in_pod( + "sh", "-c", + f"test -d {CONFIG_MOUNT_PATH} && touch {CONFIG_MOUNT_PATH}/.writetest" + f" && rm -f {CONFIG_MOUNT_PATH}/.writetest", + ) + assert result.returncode == 0, result.stdout + result.stderr + + # ── 3. Persistence — media data PVC ────────────────────────────────────── + + def test_data_pvc_is_bound(self): + """Media-data PVC (10Gi ReadWriteOnce local-path) binds within the namespace.""" + data = _get_pvc(DATA_PVC_NAME) + assert data.get("status", {}).get("phase") == "Bound", _pvc_debug(data) + + def test_data_pvc_capacity_and_access_modes(self): + data = _get_pvc(DATA_PVC_NAME) + assert ( + data.get("status", {}).get("capacity", {}).get("storage") + == EXPECTED_DATA_CAPACITY + ), _pvc_debug(data) + assert data.get("spec", {}).get("accessModes") == EXPECTED_ACCESS_MODES, _pvc_debug(data) + + def test_data_mount_is_writable(self): + """Container can write to /data — required for media library state.""" + result = _exec_in_pod( + "sh", "-c", + f"test -d {DATA_MOUNT_PATH} && touch {DATA_MOUNT_PATH}/.writetest" + f" && rm -f {DATA_MOUNT_PATH}/.writetest", + ) + assert result.returncode == 0, result.stdout + result.stderr + + # ── 4. Reachability ─────────────────────────────────────────────────────── + + def test_media_port_is_reachable_in_cluster(self): + """ + HTTP GET to the media service on port 8096 succeeds within the cluster. + + Uses wget from inside the workload pod because the test runner pod may + not be on the same node or network segment. A non-200 response that + is not a connection error (e.g. 401 Unauthorized) still proves the + port is open and the service is running. + """ + fqdn = f"{SERVICE_NAME}.{TEST_NAMESPACE}.svc.cluster.local" + result = _exec_in_pod( + "sh", "-c", + f"wget -q -S -O /dev/null http://{fqdn}:{MEDIA_SERVICE_PORT}/ 2>&1 || true", + ) + # We tolerate any HTTP response code; a TCP connection refusal is the only failure. + output = result.stdout + result.stderr + write_artifact("media-reachability.txt", output) + # "Connection refused" or "Name or service not known" = service is down + assert "Connection refused" not in output, output + assert "Name or service not known" not in output, output + + def test_cluster_dns_resolves_media_service(self): + """Cluster DNS resolves the media service FQDN.""" + fqdn = f"{SERVICE_NAME}.{TEST_NAMESPACE}.svc.cluster.local" + result = run_kubectl( + "exec", "-n", TEST_NAMESPACE, first_pod_name(), + "--", "getent", "hosts", fqdn, + ) + write_artifact("media-dns.txt", result.stdout + result.stderr) + assert result.returncode == 0, result.stdout + result.stderr + + # ── 5. Secret / env injection ───────────────────────────────────────────── + + def test_puid_pgid_tz_env_vars_are_present(self): + """ + PUID, PGID, and TZ env vars are visible in the container environment. + + linuxserver.io containers rely on these to set file ownership and + timezone at startup. This check proves the env values are injected + by the Deployment spec and not silently dropped. + """ + result = _exec_in_pod("env") + assert result.returncode == 0, result.stdout + result.stderr + write_artifact("media-env.txt", result.stdout) + env = dict( + line.split("=", 1) for line in result.stdout.splitlines() if "=" in line + ) + for var, expected in EXPECTED_ENV_VARS.items(): + assert var in env, f"{var} not found in container env:\n{result.stdout}" + assert env[var] == expected, ( + f"{var}={env[var]!r} but expected {expected!r}" + ) + + # ── 6. Rollout-restart persistence ──────────────────────────────────────── + + def test_config_and_data_state_survive_rollout_restart(self): + """ + A sentinel file written to both /config and /data survives a + rollout restart, proving both PVCs persist through pod replacement. + """ + before = get_pods_json() + write_artifact("media-pods-before-restart.json", json.dumps(before, indent=2)) + + for mount, label in [(CONFIG_MOUNT_PATH, "config"), (DATA_MOUNT_PATH, "data")]: + seed = _exec_in_pod( + "sh", "-c", f"echo media-sentinel-{label} >{mount}/media-sentinel-{label}.txt", + ) + assert seed.returncode == 0, seed.stdout + seed.stderr + + restart = run_kubectl( + "rollout", "restart", f"deployment/{DEPLOYMENT_NAME}", "-n", TEST_NAMESPACE + ) + assert restart.returncode == 0, restart.stdout + restart.stderr + write_artifact("media-restart.txt", restart.stdout + restart.stderr) + + status = run_kubectl( + "rollout", "status", f"deployment/{DEPLOYMENT_NAME}", + "-n", TEST_NAMESPACE, "--timeout=300s", + ) + assert status.returncode == 0, status.stdout + status.stderr + write_artifact("media-rollout-status.txt", status.stdout + status.stderr) + + after = get_pods_json() + write_artifact("media-pods-after-restart.json", json.dumps(after, indent=2)) + + for mount, label in [(CONFIG_MOUNT_PATH, "config"), (DATA_MOUNT_PATH, "data")]: + verify = _exec_in_pod("cat", f"{mount}/media-sentinel-{label}.txt") + assert verify.returncode == 0, verify.stdout + verify.stderr + assert verify.stdout.strip() == f"media-sentinel-{label}", ( + f"Sentinel missing from {mount} after restart: {verify.stdout!r}" + ) + + # ── 7. Storage observability artifacts ──────────────────────────────────── + + def test_collects_storage_observability_artifacts(self): + """Capture disk/mount evidence for both PVC mount paths.""" + for mount, label in [(CONFIG_MOUNT_PATH, "config"), (DATA_MOUNT_PATH, "data")]: + commands = { + f"media-{label}-df.txt": ["df", "-h", mount], + f"media-{label}-findmnt.txt": ["findmnt", mount], + f"media-{label}-stat.txt": ["stat", mount], + } + for artifact, cmd in commands.items(): + result = _exec_in_pod(*cmd) + assert result.returncode == 0, ( + f"{' '.join(cmd)} failed: {result.stdout}{result.stderr}" + ) + write_artifact(artifact, result.stdout + result.stderr) + + # ── 8. Explicitly deferred paths ───────────────────────────────────────── + + def test_gpu_transcoding_is_out_of_scope_for_base_lane(self): + """ + GPU transcoding and hardware passthrough are out of scope for this + base lane. Filed as follow-up in issue #63. + """ + pytest.skip( + "GPU transcoding and KubeVirt device passthrough deferred to #63; " + "requires GPU feature gate and device-plugin configuration" + ) + + def test_rwx_shared_media_mount_is_blocked(self): + """ + ReadWriteMany / shared-media mount between multiple pods is blocked + until a RWX-capable storage class (NFS CSI, Longhorn, etc.) is + available on the cluster. Tracked in #62. + """ + pytest.skip( + "RWX/shared-storage scenarios blocked by #62 until " + "ReadWriteMany storage class is available" + )