Initial commit

This commit is contained in:
2026-10-24 16:21:16 -04:00
commit b894fa148a
21 changed files with 719 additions and 0 deletions
Executable
+5
View File
@@ -0,0 +1,5 @@
.git
*.retry
__pycache__
.venv
*.vault.yml
Executable
+4
View File
@@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
*.j2 text eol=lf
Dockerfile text eol=lf
Executable
+3
View File
@@ -0,0 +1,3 @@
*.retry
__pycache__/
.venv/
Executable
+27
View File
@@ -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.
Executable
+167
View File
@@ -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).
+38
View File
@@ -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
Executable
+8
View File
@@ -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: []
+30
View File
@@ -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
+37
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
ansible-core>=2.16,<2.20
kubernetes>=24.2.0,<35
PyYAML>=6.0,<7
jsonpatch>=1.32,<2
+4
View File
@@ -0,0 +1,4 @@
---
collections:
- name: kubernetes.core
version: 6.0.0
Executable
+38
View File
@@ -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
+31
View File
@@ -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
+48
View File
@@ -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 }}
+27
View File
@@ -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 %}
+15
View File
@@ -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
+14
View File
@@ -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 }}
+25
View File
@@ -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
+10
View File
@@ -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"]
Executable
+20
View File
@@ -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'
Executable
+164
View File
@@ -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'