From b894fa148a73b635fe17dd29b4a424b99095db93 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Sat, 24 Oct 2026 16:21:16 -0400 Subject: [PATCH] Initial commit --- .dockerignore | 5 ++ .gitattributes | 4 + .gitignore | 3 + LICENSE | 27 ++++++ README.md | 167 ++++++++++++++++++++++++++++++++++++ defaults/main.yml | 38 ++++++++ meta/main.yml | 8 ++ playbooks/deploy-eom.yml | 30 +++++++ playbooks/deploy-friend.yml | 37 ++++++++ requirements.txt | 4 + requirements.yml | 4 + tasks/main.yml | 38 ++++++++ tasks/validate.yml | 31 +++++++ templates/deployment.yml.j2 | 48 +++++++++++ templates/ingress.yml.j2 | 27 ++++++ templates/onion.yml.j2 | 15 ++++ templates/pvc.yml.j2 | 14 +++ templates/service.yml.j2 | 25 ++++++ tests/Dockerfile | 10 +++ tests/run.sh | 20 +++++ tests/test.yml | 164 +++++++++++++++++++++++++++++++++++ 21 files changed, 719 insertions(+) create mode 100755 .dockerignore create mode 100755 .gitattributes create mode 100755 .gitignore create mode 100755 LICENSE create mode 100755 README.md create mode 100755 defaults/main.yml create mode 100755 meta/main.yml create mode 100755 playbooks/deploy-eom.yml create mode 100755 playbooks/deploy-friend.yml create mode 100755 requirements.txt create mode 100755 requirements.yml create mode 100755 tasks/main.yml create mode 100755 tasks/validate.yml create mode 100755 templates/deployment.yml.j2 create mode 100755 templates/ingress.yml.j2 create mode 100755 templates/onion.yml.j2 create mode 100755 templates/pvc.yml.j2 create mode 100755 templates/service.yml.j2 create mode 100755 tests/Dockerfile create mode 100755 tests/run.sh create mode 100755 tests/test.yml diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..d075ac8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +*.retry +__pycache__ +.venv +*.vault.yml \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000..9efee9b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.sh text eol=lf +*.j2 text eol=lf +Dockerfile text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..56e806a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.retry +__pycache__/ +.venv/ diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..177e328 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +BSD 3-Clause License + +Copyright (c) 2026, Eric Meehan +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..38de457 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# ansible-role-owncast + +Reusable Ansible role for deploying [Owncast](https://owncast.online/) on an +existing Kubernetes cluster. Extracted from ansible-role-eom; no EOM services, +domains, certificate issuers, or Tor installation are assumed by role defaults. + +## Requirements + +- Ansible Core 2.16–2.19 on a Linux/macOS controller, WSL, or a Linux container. +- Install [requirements.txt](requirements.txt) with pip and the collection with + `ansible-galaxy collection install -r requirements.yml`. Dependencies belong + in the interpreter Ansible uses on localhost. +- An authorized kubeconfig or the usual `K8S_AUTH_*` authentication environment + settings supported by `kubernetes.core.k8s`. +- A default StorageClass supporting ReadWriteOnce, an explicit StorageClass, or + an existing PVC in the instance namespace. Use encrypted backing storage and + arrange encrypted backups separately. +- For optional ingress: DNS, an ingress controller, and a valid TLS secret or + cert-manager with a configured ClusterIssuer. For LoadBalancer services: a + working cluster load balancer implementation. +- Optional onion access requires the Tor operator and its + `tor.k8s.torproject.org/v1alpha2` OnionService CRD already installed. + +Check out the repository as `ansible-role-owncast` under a directory in +`ANSIBLE_ROLES_PATH`. For sibling role repositories, set that environment +variable to their parent directory. The supplied playbooks run on the controller +using its current Kubernetes context, with no SSH target required. Deployment +commands below run from this repository on the controller. + +## Your existing eom.dev instance + +Use [playbooks/deploy-eom.yml](playbooks/deploy-eom.yml). It explicitly preserves: + +- Namespace, Deployment, Service, PVC, and TLS secret named `owncast`. +- Image `owncast/owncast:0.3.0`, one replica, 128Gi ReadWriteOnce storage at + `/app/data`, CPU request `1.5`, HTTP 8080 and RTMP 1935. +- LoadBalancer service with `externalTrafficPolicy: Local`. +- NGINX ingress for `owncast.eom.dev`, with ClusterIssuer `ca-issuer`. +- OnionService `http` in namespace `owncast`, port 80 → HTTP 8080, formerly + part of the shared EOM onion task. + +First run `ansible-playbook -i localhost, playbooks/deploy-eom.yml --check --diff`, +review the changes, then run `ansible-playbook -i localhost, playbooks/deploy-eom.yml`. +If Tor was never installed, add `-e owncast_onion_enabled=false` to both commands. + +### Migration safety + +1. Back up the data volume securely and record the live image, resource names, + StorageClass, selector, and any provider annotations before deploying. +2. Check the Kubernetes context. Keep the same namespace, PVC, and selector + `app: owncast`. A different live immutable Deployment selector needs manual + reconciliation; the role does not force-replace resources. +3. Stop invoking the retired EOM Owncast task. Its deployment code, + full-deployment entry, and Owncast-specific onion task have been removed. + The old task path only fails with migration guidance; it does not delegate + or deploy anything. No automatic dependency is added; use this playbook. +4. Do not delete the existing PVC or TLS secret. Resources are adopted by name; + administrator settings, stream keys, and the data directory are not reset. + Leave `owncast_storage_class` blank to preserve the existing claim's class. +5. Verify the rollout, HTTPS, administrator login, RTMP ingest, and optional + onion URL after deployment. + +The Deployment API is corrected from the old invalid `v1` to `apps/v1`. +Updates use `Recreate` rather than overlapping replicas so the SQLite database +and RWO volume have a single writer; an explicit null removes inherited +`rollingUpdate` settings when patching the existing Deployment. Expect brief +downtime during an update. +Removing Ansible tasks does not delete any live cluster resources. + +## Your friend's instance + +Use [playbooks/deploy-friend.yml](playbooks/deploy-friend.yml). It uses separate +namespace, resource names, PVC, and TLS secret `owncast-friend`, with the same +image, storage sizing, resource request, and network layout as your instance. +Tor is disabled. Two required inputs deliberately have no invented defaults: + +- `friend_owncast_hostname`: your friend's real streaming hostname. +- `friend_owncast_cluster_issuer`: a ClusterIssuer valid for that hostname. + +Define these in inventory variables for localhost or an external YAML vars file. +Run `ansible-playbook -i localhost, playbooks/deploy-friend.yml -e @/secure/friend-vars.yml`. +Use `--check --diff` first. Add `--ask-vault-pass` for a Vault-encrypted vars file; +do not commit secrets or pass passwords on the command line. Extra variables +can also override the `owncast_*` settings when different sizing is needed. + +The playbook rejects missing inputs, the eom.dev hostname, and the existing +namespace before changing the cluster. For deployment on another cluster, use +a separate kubeconfig/context. On a shared cluster, plan for another load +balancer address and volume. Namespace separation is not a tenant security +boundary by itself. + +## Generic usage and variables + +Invoke `ansible-role-owncast` from your own playbook or inventory; neither +site-specific playbook is required. All defaults are in +[defaults/main.yml](defaults/main.yml). + +| Variable | Default / meaning | +| --- | --- | +| `owncast_namespace`, `owncast_name` | `owncast`; change for independent instances | +| `owncast_image` | `owncast/owncast:0.3.0`; override with a tested tag or digest | +| `owncast_image_pull_policy`, `owncast_image_pull_secrets` | `IfNotPresent`, `[]`; Secret references for private registries | +| `owncast_resources` | Requests of 250m CPU and 256Mi memory; configure requests/limits for your transcoding workload | +| `owncast_node_selector`, `owncast_tolerations` | `{}`, `[]` | +| `owncast_pvc_name` | Resource name | +| `owncast_existing_claim` | Empty; nonempty skips PVC creation and mounts that existing claim in this namespace | +| `owncast_storage_size`, `owncast_storage_class` | `10Gi`, empty (default StorageClass) | +| `owncast_service_type` | `ClusterIP`; also supports NodePort and LoadBalancer | +| `owncast_service_annotations` | `{}`; provider-specific configuration | +| `owncast_external_traffic_policy` | `Local`; emitted only for NodePort/LoadBalancer | +| `owncast_load_balancer_source_ranges` | `[]`; optional provider-supported CIDR restrictions on both service ports | +| `owncast_ingress_enabled`, `owncast_hostname` | `false`, empty; public HTTP exposure is opt-in | +| `owncast_ingress_class`, `owncast_ingress_annotations` | Empty (controller default), `{}` | +| `owncast_tls_enabled`, `owncast_tls_secret_name` | `true`, resource name; applies when ingress is enabled | +| `owncast_cluster_issuer` | Empty; provide a name to use cert-manager, otherwise provision the TLS secret yourself | +| `owncast_onion_enabled`, `owncast_onion_name` | `false`, `http` | + +HTTP and RTMP container/service ports remain fixed at 8080 and 1935; configure +external TCP routing separately if not using a LoadBalancer/NodePort service. +One Owncast process per data volume is supported; horizontal scaling is not. +Storage expansion depends on the StorageClass; never shrink an existing claim +or change its immutable StorageClass. Changing namespaces/names creates another +instance, not a data migration. + +Disabling ingress or Tor stops managing it; it **does not delete a previously +created resource**. Remove stale resources manually when revoking public access. +The role uses only `state: present` and provides no destructive purge task. + +## Security and operations + +- No secrets are embedded, generated, or rotated. For a fresh instance, first + deploy privately (`owncast_service_type=ClusterIP`, ingress disabled) and use + Owncast's supported admin workflow to set unique administrator credentials + and stream key **before exposing it publicly**. Existing credentials remain + in the persistent database and are not altered by this role. +- The site playbooks intentionally preserve existing LoadBalancer behavior: + **unencrypted HTTP 8080 is exposed alongside RTMP 1935**, bypassing ingress + TLS on that port. Restrict access with provider/firewall rules or source + ranges, or use ClusterIP plus separately configured RTMP routing. RTMP itself + is unencrypted; use trusted networking/tunnels when confidentiality matters. +- TLS terminates at ingress. Use a trusted certificate/issuer; Kubernetes API + certificate verification is not disabled. Storage encryption, backups, RBAC, + NetworkPolicies, ingress admin restrictions, and renewal are operator duties. +- Pods receive no Kubernetes API token, cannot escalate privileges, drop Linux + capabilities, and use runtime-default seccomp. UID and data ownership are + intentionally not changed during migration. +- The old image version is retained for migration compatibility, not presented + as the latest release. Review and test image upgrades separately after a + backup; a digest-pinned image can be supplied via `owncast_image`. + +## Tests (no cluster needed) + +[tests/test.yml](tests/test.yml) renders templates with actual playbook vars, +checks migration identity/settings and friend isolation, exercises optional +fields, and checks invalid-input rejection. [tests/run.sh](tests/run.sh) also +syntax-checks both playbooks and tests friend preflight failures before any +cluster access. + +Run `sh tests/run.sh` on a configured controller, or build +[tests/Dockerfile](tests/Dockerfile) with +`docker build -f tests/Dockerfile -t owncast-role-tests .` and run +`docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges owncast-role-tests`. +The image runs tests as a non-root user and requires no kubeconfig or secrets. + +## License + +BSD-3-Clause; see [LICENSE](LICENSE). \ No newline at end of file diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100755 index 0000000..e3b0273 --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,38 @@ +--- +# Kubernetes authentication uses the controller's kubeconfig/K8S_AUTH_* settings. +owncast_namespace: owncast +owncast_name: owncast +owncast_image: owncast/owncast:0.3.0 +owncast_image_pull_policy: IfNotPresent +owncast_image_pull_secrets: [] +owncast_resources: + requests: + cpu: 250m + memory: 256Mi +owncast_node_selector: {} +owncast_tolerations: [] + +owncast_pvc_name: "{{ owncast_name }}" +owncast_existing_claim: "" +owncast_storage_size: 10Gi +# Empty uses the default StorageClass; do not change an existing claim's class. +owncast_storage_class: "" + +owncast_service_type: ClusterIP +owncast_service_annotations: {} +owncast_external_traffic_policy: Local +owncast_load_balancer_source_ranges: [] + +# Public exposure is opt-in. Container ports are HTTP 8080 and RTMP 1935. +owncast_ingress_enabled: false +owncast_hostname: "" +owncast_ingress_class: "" +owncast_ingress_annotations: {} +owncast_tls_enabled: true +owncast_tls_secret_name: "{{ owncast_name }}" +# Empty means an externally managed TLS secret, without cert-manager. +owncast_cluster_issuer: "" + +# Requires the Tor operator and OnionService CRD. +owncast_onion_enabled: false +owncast_onion_name: http \ No newline at end of file diff --git a/meta/main.yml b/meta/main.yml new file mode 100755 index 0000000..bc142bc --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,8 @@ +--- +galaxy_info: + role_name: owncast + author: Eric Meehan + description: Deploy a persistent Owncast instance on Kubernetes + license: BSD-3-Clause + min_ansible_version: "2.16" +dependencies: [] \ No newline at end of file diff --git a/playbooks/deploy-eom.yml b/playbooks/deploy-eom.yml new file mode 100755 index 0000000..4c23c16 --- /dev/null +++ b/playbooks/deploy-eom.yml @@ -0,0 +1,30 @@ +--- +# Runs on the controller using its existing kubeconfig; no SSH target required. +# Preserve names to adopt existing resources without migrating persistent data. +- name: Deploy eom.dev Owncast + hosts: localhost + connection: local + gather_facts: false + vars: + owncast_namespace: owncast + owncast_name: owncast + owncast_pvc_name: owncast + owncast_image: owncast/owncast:0.3.0 + owncast_storage_size: 128Gi + owncast_resources: + requests: + cpu: "1.5" + owncast_service_type: LoadBalancer + owncast_external_traffic_policy: Local + owncast_ingress_enabled: true + owncast_hostname: owncast.eom.dev + owncast_ingress_class: nginx + owncast_tls_enabled: true + owncast_cluster_issuer: ca-issuer + owncast_tls_secret_name: owncast + # Formerly managed by the shared EOM onion task. Requires the Tor operator; + # override to false if that optional integration was never installed. + owncast_onion_enabled: true + owncast_onion_name: http + roles: + - ansible-role-owncast \ No newline at end of file diff --git a/playbooks/deploy-friend.yml b/playbooks/deploy-friend.yml new file mode 100755 index 0000000..8bb676e --- /dev/null +++ b/playbooks/deploy-friend.yml @@ -0,0 +1,37 @@ +--- +# Supply friend_owncast_hostname and friend_owncast_cluster_issuer in inventory +# or an external vars file. Do not guess a domain or reuse eom.dev's ingress. +- name: Deploy an isolated Owncast instance for a friend + hosts: localhost + connection: local + gather_facts: false + vars: + owncast_namespace: owncast-friend + owncast_name: owncast-friend + owncast_pvc_name: owncast-friend + owncast_image: owncast/owncast:0.3.0 + owncast_storage_size: 128Gi + owncast_resources: + requests: + cpu: "1.5" + owncast_service_type: LoadBalancer + owncast_external_traffic_policy: Local + owncast_ingress_enabled: true + owncast_hostname: "{{ friend_owncast_hostname | default('') }}" + owncast_ingress_class: nginx + owncast_tls_enabled: true + owncast_cluster_issuer: "{{ friend_owncast_cluster_issuer | default('') }}" + owncast_tls_secret_name: owncast-friend + owncast_onion_enabled: false + pre_tasks: + - name: Require friend-specific settings before changing the cluster + ansible.builtin.assert: + that: + - friend_owncast_hostname | default('') | length > 0 + - friend_owncast_hostname | default('') != 'owncast.eom.dev' + - friend_owncast_cluster_issuer | default('') | length > 0 + - owncast_namespace != 'owncast' + - owncast_hostname != 'owncast.eom.dev' + fail_msg: Supply friend_owncast_hostname and friend_owncast_cluster_issuer for a separate instance. + roles: + - ansible-role-owncast \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..341c32c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +ansible-core>=2.16,<2.20 +kubernetes>=24.2.0,<35 +PyYAML>=6.0,<7 +jsonpatch>=1.32,<2 \ No newline at end of file diff --git a/requirements.yml b/requirements.yml new file mode 100755 index 0000000..65ade0f --- /dev/null +++ b/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: kubernetes.core + version: 6.0.0 \ No newline at end of file diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100755 index 0000000..1f739f5 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,38 @@ +--- +- name: Validate Owncast configuration + ansible.builtin.import_tasks: validate.yml + +- name: Create Owncast namespace + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ owncast_namespace }}" + +- name: Create Owncast persistent volume claim + kubernetes.core.k8s: + state: present + definition: "{{ lookup('ansible.builtin.template', 'pvc.yml.j2') | from_yaml }}" + when: owncast_existing_claim | length == 0 + +- name: Deploy Owncast workload and service + kubernetes.core.k8s: + state: present + definition: "{{ lookup('ansible.builtin.template', item) | from_yaml }}" + loop: + - deployment.yml.j2 + - service.yml.j2 + +- name: Deploy Owncast ingress + kubernetes.core.k8s: + state: present + definition: "{{ lookup('ansible.builtin.template', 'ingress.yml.j2') | from_yaml }}" + when: owncast_ingress_enabled | bool + +- name: Deploy Owncast onion service + kubernetes.core.k8s: + state: present + definition: "{{ lookup('ansible.builtin.template', 'onion.yml.j2') | from_yaml }}" + when: owncast_onion_enabled | bool \ No newline at end of file diff --git a/tasks/validate.yml b/tasks/validate.yml new file mode 100755 index 0000000..035897f --- /dev/null +++ b/tasks/validate.yml @@ -0,0 +1,31 @@ +--- +- name: Validate Owncast identity and service settings + ansible.builtin.assert: + that: + - owncast_namespace is match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + - owncast_namespace | length <= 63 + - owncast_name is match('^[a-z]([-a-z0-9]*[a-z0-9])?$') + - owncast_name | length <= 63 + - owncast_pvc_name is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$') + - owncast_pvc_name | length <= 253 + - owncast_image | length > 0 + - owncast_service_type in ['ClusterIP', 'NodePort', 'LoadBalancer'] + - owncast_external_traffic_policy in ['Local', 'Cluster'] + - owncast_image_pull_policy in ['Always', 'IfNotPresent', 'Never'] + - owncast_resources is mapping + - owncast_service_annotations is mapping + - owncast_ingress_annotations is mapping + - owncast_load_balancer_source_ranges is sequence + - owncast_load_balancer_source_ranges is not string + fail_msg: Invalid Owncast resource identity, image, resources, or service settings. + +- name: Validate Owncast ingress settings + ansible.builtin.assert: + that: + - owncast_hostname | length > 0 + - owncast_hostname | length <= 253 + - owncast_hostname is match('^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$') + - not (owncast_tls_enabled | bool) or owncast_tls_secret_name | length > 0 + - owncast_cluster_issuer | length == 0 or owncast_tls_enabled | bool + fail_msg: Ingress requires a DNS hostname and, for TLS, a secret name. An issuer requires TLS. + when: owncast_ingress_enabled | bool \ No newline at end of file diff --git a/templates/deployment.yml.j2 b/templates/deployment.yml.j2 new file mode 100755 index 0000000..b580407 --- /dev/null +++ b/templates/deployment.yml.j2 @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ owncast_name | to_json }} + namespace: {{ owncast_namespace | to_json }} + labels: + app: {{ owncast_name | to_json }} +spec: + replicas: 1 + # The SQLite database and RWO volume must have only one writer. + strategy: + type: Recreate + rollingUpdate: null + selector: + matchLabels: + app: {{ owncast_name | to_json }} + template: + metadata: + labels: + app: {{ owncast_name | to_json }} + spec: + automountServiceAccountToken: false + imagePullSecrets: {{ owncast_image_pull_secrets | to_json }} + nodeSelector: {{ owncast_node_selector | to_json }} + tolerations: {{ owncast_tolerations | to_json }} + containers: + - name: owncast + image: {{ owncast_image | to_json }} + imagePullPolicy: {{ owncast_image_pull_policy | to_json }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + resources: {{ owncast_resources | to_json }} + volumeMounts: + - name: data + mountPath: /app/data + ports: + - name: http + containerPort: 8080 + - name: rtmp + containerPort: 1935 + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ (owncast_existing_claim if owncast_existing_claim | length > 0 else owncast_pvc_name) | to_json }} \ No newline at end of file diff --git a/templates/ingress.yml.j2 b/templates/ingress.yml.j2 new file mode 100755 index 0000000..eb21433 --- /dev/null +++ b/templates/ingress.yml.j2 @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ owncast_name | to_json }} + namespace: {{ owncast_namespace | to_json }} + annotations: {{ (owncast_ingress_annotations | combine({'cert-manager.io/cluster-issuer': owncast_cluster_issuer} if owncast_cluster_issuer | length > 0 else {})) | to_json }} +spec: +{% if owncast_ingress_class | length > 0 %} + ingressClassName: {{ owncast_ingress_class | to_json }} +{% endif %} + rules: + - host: {{ owncast_hostname | to_json }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ owncast_name | to_json }} + port: + number: 8080 +{% if owncast_tls_enabled | bool %} + tls: + - hosts: + - {{ owncast_hostname | to_json }} + secretName: {{ owncast_tls_secret_name | to_json }} +{% endif %} \ No newline at end of file diff --git a/templates/onion.yml.j2 b/templates/onion.yml.j2 new file mode 100755 index 0000000..d9410bd --- /dev/null +++ b/templates/onion.yml.j2 @@ -0,0 +1,15 @@ +apiVersion: tor.k8s.torproject.org/v1alpha2 +kind: OnionService +metadata: + name: {{ owncast_onion_name | to_json }} + namespace: {{ owncast_namespace | to_json }} +spec: + version: 3 + rules: + - port: + number: 80 + backend: + service: + name: {{ owncast_name | to_json }} + port: + number: 8080 \ No newline at end of file diff --git a/templates/pvc.yml.j2 b/templates/pvc.yml.j2 new file mode 100755 index 0000000..93f7986 --- /dev/null +++ b/templates/pvc.yml.j2 @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ owncast_pvc_name | to_json }} + namespace: {{ owncast_namespace | to_json }} +spec: + accessModes: + - ReadWriteOnce +{% if owncast_storage_class | length > 0 %} + storageClassName: {{ owncast_storage_class | to_json }} +{% endif %} + resources: + requests: + storage: {{ owncast_storage_size | to_json }} \ No newline at end of file diff --git a/templates/service.yml.j2 b/templates/service.yml.j2 new file mode 100755 index 0000000..b6df4eb --- /dev/null +++ b/templates/service.yml.j2 @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ owncast_name | to_json }} + namespace: {{ owncast_namespace | to_json }} + annotations: {{ owncast_service_annotations | to_json }} +spec: + selector: + app: {{ owncast_name | to_json }} + type: {{ owncast_service_type | to_json }} +{% if owncast_service_type in ['LoadBalancer', 'NodePort'] %} + externalTrafficPolicy: {{ owncast_external_traffic_policy | to_json }} +{% endif %} +{% if owncast_service_type == 'LoadBalancer' and owncast_load_balancer_source_ranges | length > 0 %} + loadBalancerSourceRanges: {{ owncast_load_balancer_source_ranges | to_json }} +{% endif %} + ports: + - name: rtmp + port: 1935 + targetPort: rtmp + protocol: TCP + - name: http + port: 8080 + targetPort: http + protocol: TCP \ No newline at end of file diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100755 index 0000000..de104a7 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim-trixie +COPY requirements.txt requirements.yml /tmp/ +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && ansible-galaxy collection install -r /tmp/requirements.yml -p /usr/share/ansible/collections \ + && useradd --create-home tester +WORKDIR /roles/ansible-role-owncast +COPY . ./ +ENV ANSIBLE_ROLES_PATH=/roles +USER tester +CMD ["sh", "tests/run.sh"] \ No newline at end of file diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..1635b63 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu +ansible-playbook -i localhost, playbooks/deploy-eom.yml --syntax-check +ansible-playbook -i localhost, playbooks/deploy-friend.yml --syntax-check +ansible-playbook -i localhost, tests/test.yml + +# These must fail at the preflight assertion, before trying to access a cluster. +if ansible-playbook -i localhost, playbooks/deploy-friend.yml > /tmp/owncast-friend-missing.log 2>&1; then + echo 'Friend deployment accepted missing settings' >&2 + exit 1 +fi +grep -q 'Supply friend_owncast_hostname and friend_owncast_cluster_issuer' /tmp/owncast-friend-missing.log +if ansible-playbook -i localhost, playbooks/deploy-friend.yml \ + -e friend_owncast_hostname=owncast.eom.dev \ + -e friend_owncast_cluster_issuer=fixture > /tmp/owncast-friend-collision.log 2>&1; then + echo 'Friend deployment accepted the existing hostname' >&2 + exit 1 +fi +grep -q 'Supply friend_owncast_hostname and friend_owncast_cluster_issuer' /tmp/owncast-friend-collision.log +echo 'Owncast offline checks passed' \ No newline at end of file diff --git a/tests/test.yml b/tests/test.yml new file mode 100755 index 0000000..5bdaf28 --- /dev/null +++ b/tests/test.yml @@ -0,0 +1,164 @@ +--- +- name: Validate Owncast templates without a cluster + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Load role defaults and validate them without deploying + ansible.builtin.include_role: + name: ansible-role-owncast + tasks_from: validate.yml + public: true + + - name: Render generic default resources + ansible.builtin.set_fact: + deployment: "{{ lookup('template', '../templates/deployment.yml.j2') | from_yaml }}" + service: "{{ lookup('template', '../templates/service.yml.j2') | from_yaml }}" + pvc: "{{ lookup('template', '../templates/pvc.yml.j2') | from_yaml }}" + + - name: Check private defaults and single-writer safeguards + ansible.builtin.assert: + that: + - deployment.apiVersion == 'apps/v1' + - deployment.spec.strategy.type == 'Recreate' + - deployment.spec.replicas == 1 + - not deployment.spec.template.spec.automountServiceAccountToken + - not deployment.spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation + - service.spec.type == 'ClusterIP' + - "'externalTrafficPolicy' not in service.spec" + - "'storageClassName' not in pvc.spec" + - pvc.spec.resources.requests.storage == '10Gi' + - not owncast_ingress_enabled + - not owncast_onion_enabled + + - name: Load the actual eom playbook variables + ansible.builtin.set_fact: + "{{ item.key }}": "{{ item.value }}" + loop: "{{ (lookup('file', '../playbooks/deploy-eom.yml') | from_yaml)[0].vars | dict2items }}" + + - name: Validate eom settings + ansible.builtin.include_role: + name: ansible-role-owncast + tasks_from: validate.yml + + - name: Render eom resources + ansible.builtin.set_fact: + deployment: "{{ lookup('template', '../templates/deployment.yml.j2') | from_yaml }}" + service: "{{ lookup('template', '../templates/service.yml.j2') | from_yaml }}" + pvc: "{{ lookup('template', '../templates/pvc.yml.j2') | from_yaml }}" + ingress: "{{ lookup('template', '../templates/ingress.yml.j2') | from_yaml }}" + onion: "{{ lookup('template', '../templates/onion.yml.j2') | from_yaml }}" + + - name: Check preservation of existing resource identities and settings + ansible.builtin.assert: + that: + - deployment.metadata.name == 'owncast' + - deployment.metadata.namespace == 'owncast' + - deployment.spec.selector.matchLabels == {'app':'owncast'} + - deployment.spec.template.spec.volumes[0].persistentVolumeClaim.claimName == 'owncast' + - deployment.spec.template.spec.containers[0].image == 'owncast/owncast:0.3.0' + - deployment.spec.template.spec.containers[0].resources.requests.cpu == '1.5' + - deployment.spec.template.spec.containers[0].volumeMounts[0].mountPath == '/app/data' + - service.spec.type == 'LoadBalancer' + - service.spec.externalTrafficPolicy == 'Local' + - service.spec.ports | map(attribute='port') | list == [1935, 8080] + - pvc.spec.resources.requests.storage == '128Gi' + - ingress.spec.ingressClassName == 'nginx' + - ingress.spec.rules[0].host == 'owncast.eom.dev' + - ingress.spec.tls[0].secretName == 'owncast' + - ingress.metadata.annotations['cert-manager.io/cluster-issuer'] == 'ca-issuer' + - onion.metadata.name == 'http' + - onion.metadata.namespace == 'owncast' + - onion.spec.rules[0].backend.service.name == 'owncast' + - onion.spec.rules[0].backend.service.port.number == 8080 + + - name: Set fake friend settings for validation + ansible.builtin.set_fact: + friend_owncast_hostname: stream.friend.example.org + friend_owncast_cluster_issuer: friend-issuer + + - name: Load the actual friend playbook variables + ansible.builtin.set_fact: + "{{ item.key }}": "{{ item.value }}" + loop: "{{ (lookup('template', '../playbooks/deploy-friend.yml') | from_yaml)[0].vars | dict2items }}" + + - name: Validate friend settings + ansible.builtin.include_role: + name: ansible-role-owncast + tasks_from: validate.yml + + - name: Render friend resources + ansible.builtin.set_fact: + deployment: "{{ lookup('template', '../templates/deployment.yml.j2') | from_yaml }}" + pvc: "{{ lookup('template', '../templates/pvc.yml.j2') | from_yaml }}" + ingress: "{{ lookup('template', '../templates/ingress.yml.j2') | from_yaml }}" + + - name: Check friend isolation + ansible.builtin.assert: + that: + - deployment.metadata.namespace == 'owncast-friend' + - deployment.spec.selector.matchLabels == {'app':'owncast-friend'} + - deployment.spec.template.spec.volumes[0].persistentVolumeClaim.claimName == 'owncast-friend' + - pvc.metadata.name == 'owncast-friend' + - ingress.spec.rules[0].host == friend_owncast_hostname + - ingress.spec.tls[0].secretName == 'owncast-friend' + - ingress.metadata.annotations['cert-manager.io/cluster-issuer'] == friend_owncast_cluster_issuer + - not owncast_onion_enabled + + - name: Exercise existing storage and optional non-TLS ingress + ansible.builtin.set_fact: + owncast_existing_claim: external-data + owncast_storage_class: encrypted-storage + owncast_service_type: NodePort + owncast_tls_enabled: false + owncast_cluster_issuer: "" + owncast_ingress_class: "" + owncast_ingress_annotations: + example.org/test: "true" + + - name: Render optional configuration + ansible.builtin.set_fact: + deployment: "{{ lookup('template', '../templates/deployment.yml.j2') | from_yaml }}" + pvc: "{{ lookup('template', '../templates/pvc.yml.j2') | from_yaml }}" + service: "{{ lookup('template', '../templates/service.yml.j2') | from_yaml }}" + ingress: "{{ lookup('template', '../templates/ingress.yml.j2') | from_yaml }}" + + - name: Check optional configuration + ansible.builtin.assert: + that: + - deployment.spec.template.spec.volumes[0].persistentVolumeClaim.claimName == 'external-data' + - pvc.spec.storageClassName == 'encrypted-storage' + - service.spec.type == 'NodePort' + - service.spec.externalTrafficPolicy == 'Local' + - "'tls' not in ingress.spec" + - "'ingressClassName' not in ingress.spec" + - ingress.metadata.annotations == {'example.org/test':'true'} + + - name: Set restricted load balancer settings + ansible.builtin.set_fact: + owncast_service_type: LoadBalancer + owncast_load_balancer_source_ranges: [192.0.2.0/24] + + - name: Check load balancer restrictions + ansible.builtin.assert: + that: + - (lookup('template', '../templates/service.yml.j2') | from_yaml).spec.loadBalancerSourceRanges == ['192.0.2.0/24'] + + - name: Set invalid DNS hostname + ansible.builtin.set_fact: + owncast_hostname: https://invalid.example.org/ + + - name: Check invalid-input rejection + block: + - name: Run validation with an invalid hostname + ansible.builtin.include_role: + name: ansible-role-owncast + tasks_from: validate.yml + - name: Fail if invalid configuration was accepted + ansible.builtin.fail: + msg: Invalid hostname was accepted + rescue: + - name: Confirm the expected validation failure + ansible.builtin.assert: + that: + - ansible_failed_task.name == 'Validate Owncast ingress settings' \ No newline at end of file