This commit is contained in:
2026-10-19 12:51:18 -04:00
parent 1bbb5b9898
commit 321f7d8c00
22 changed files with 922 additions and 302 deletions
+16
View File
@@ -0,0 +1,16 @@
MIT No Attribution
Copyright 2026 Eric Meehan
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+187 -38
View File
@@ -1,38 +1,187 @@
Role Name
=========
A brief description of the role goes here.
Requirements
------------
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
Role Variables
--------------
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
Dependencies
------------
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
# Ansible role: Wikideck
Deploy the Wikideck production container to an existing Kubernetes cluster using
`kubernetes.core.k8s`. The role creates a single-replica Deployment, a persistent
volume claim, a dedicated ServiceAccount, a ClusterIP Service, and a NetworkPolicy.
An HTTPS Ingress is optional and disabled by default.
**Wikideck is a prototype, not a hardened public service.** This role does not add
application authentication, distributed consensus, a database, or wallet recovery.
Use test wallets and restrict access. Mining happens in the browser; the server
coordinates the chain and verifies proofs.
## Requirements
- A Linux/macOS/WSL controller with Python 3.10+ and Ansible Core 2.16–2.19
(the selected Ansible release may require a newer Python).
- Install controller packages from [requirements.txt](requirements.txt) and the
collection from [requirements.yml](requirements.yml).
- A supported Kubernetes cluster with `apps/v1` and `networking.k8s.io/v1` APIs,
a storage provisioner or an existing PVC, and a NetworkPolicy-capable CNI.
- Kubeconfig access to create/update/read the resources in the target namespace,
delete the role's Ingress/NetworkPolicy when disabled, and optionally create a
Namespace. No cluster-admin access or in-cluster workload RBAC is needed.
- **A published image built from Wikideck's production Dockerfile.** There is no
assumed official registry image. Set `wikideck_image` to your actual tagged or
digest-pinned image. The role does not build/push images or install a cluster.
- For ingress: an ingress controller, DNS, and a trusted TLS Secret in the app
namespace. Alternatively, configure cert-manager annotations and an existing
issuer. The role does not install cert-manager or create an issuer.
Install dependencies on the controller with `pip install -r requirements.txt`
and `ansible-galaxy collection install -r requirements.yml`. Use an isolated Python
environment. Windows controllers must run Ansible through WSL or Linux containers.
## Usage
Install this checkout into your playbook's role search path as `roles/wikideck`,
or install it using an Ansible Galaxy Git role requirement named `wikideck`.
[examples/playbook.yml](examples/playbook.yml) shows an HTTPS deployment with
ingress-nginx and cert-manager. Replace the image, hostname, class, issuer, and
controller namespace with real values before running it.
```yaml
- name: Deploy a cluster-internal Wikideck instance
hosts: localhost
connection: local
gather_facts: false
roles:
- role: wikideck
vars:
wikideck_image: registry.example.com/games/wikideck:1.0.0
```
Run the playbook on the controller, using its normal kubeconfig discovery or
`wikideck_kubeconfig` / `wikideck_kube_context` overrides. Do not target every
Kubernetes node: the role operates on the Kubernetes API, not over node SSH.
For a private local preview, forward the Service with
`kubectl -n wikideck port-forward service/wikideck 3001:80` and open
<http://127.0.0.1:3001>. Keep the forwarding bind address at its loopback default.
Wikideck already allows this local origin. Different hosts, ports, schemes, and
browser profiles use different browser wallets.
For remote browsers, enable ingress and set `wikideck_public_origin` to exactly
`https://` followed by `wikideck_ingress_host` (no slash or path). HTTPS is needed
for browser wallet cryptography. Configure HTTP-to-HTTPS redirect at your ingress
controller; the example includes the ingress-nginx annotation. Ingress creation
does not wait for DNS, certificate issuance, or an externally reachable endpoint.
## Variables
All defaults are in [defaults/main.yml](defaults/main.yml).
| Variable | Default | Description |
| --- | --- | --- |
| `wikideck_image` | empty, required | Published image with explicit tag or SHA-256 digest; prefer a verified digest. |
| `wikideck_name` | `wikideck` | Resource name and instance label; at most 50 characters. |
| `wikideck_namespace` | `wikideck` | Namespace for all namespaced resources. |
| `wikideck_create_namespace` | `true` | Create the namespace; disable for a pre-provisioned namespace. |
| `wikideck_kubeconfig` / `wikideck_kube_context` | `null` | Override controller authentication/context. API TLS verification remains enabled. |
| `wikideck_wait` / `wikideck_wait_timeout` | `true` / `300` | Wait for Deployment readiness, timeout in seconds. |
| `wikideck_image_pull_policy` | `IfNotPresent` | `Always`, `IfNotPresent`, or `Never`. |
| `wikideck_image_pull_secrets` | `[]` | Names of existing registry Secrets in this namespace. |
| `wikideck_offline` | `false` | Starter articles without live Wikipedia requests. |
| `wikideck_public_origin` | empty | Additional allowed HTTPS browser origin; required with ingress. |
| `wikideck_ingress_enabled` | `false` | Create TLS ingress; disabling removes the role's named ingress. |
| `wikideck_ingress_host` | empty | DNS hostname, without scheme, port, or path. |
| `wikideck_ingress_class_name` | empty | Omit to use the cluster default IngressClass. |
| `wikideck_ingress_annotations` | `{}` | Controller/cert-manager-specific configuration. |
| `wikideck_tls_secret_name` | `{{ wikideck_name }}-tls` | Existing or externally provisioned TLS Secret. |
| `wikideck_existing_claim` | empty | Use an existing PVC instead of creating one. |
| `wikideck_storage_size` | `1Gi` | Requested PVC capacity; resizing requires StorageClass support. |
| `wikideck_storage_class` | `null` | `null`: cluster default; empty string: no class; otherwise a class name. |
| `wikideck_storage_access_modes` | `[ReadWriteOnce]` | `ReadWriteOnce` or `ReadWriteOncePod`; never `ReadWriteMany`. |
| `wikideck_resources` | see defaults | CPU/memory requests `100m`/`128Mi`, limits `1`/`512Mi`. |
| `wikideck_node_selector` / `wikideck_tolerations` / `wikideck_affinity` | `{}` / `[]` / `{}` | Pod placement settings. |
| `wikideck_network_policy_enabled` | `true` | Manage network isolation; disabling deletes this role's policy. |
| `wikideck_network_policy_ingress_from` | same-namespace pods | List of NetworkPolicy peers allowed to reach TCP 3001; empty list denies all ingress. |
| `wikideck_network_policy_dns_to` | `kube-system` namespace | DNS peers allowed on UDP/TCP 53; adapt for custom/NodeLocal DNS. |
| `wikideck_network_policy_https_to` | public IPv4/IPv6 ranges, private/link-local excluded | TCP 443 egress peers; omitted in offline mode. |
## Storage and safe updates
- Exactly **one replica** and **Recreate** updates are deliberate and cannot be
changed through role variables. Updates briefly interrupt service. Do not add
an HPA, manually scale, or attach the same chain PVC to another writer.
- `ReadWriteOnce` alone does not enforce a single pod on the same node. Prefer
`ReadWriteOncePod` if supported by your CSI driver. Do not force-delete an old
pod on a partitioned node until it has been fenced; Recreate is not a
distributed storage lock.
- `/app/data` contains the chain and signed transfer journal. The process runs
as UID/GID 1000 with `fsGroup: 1000`. Your storage driver must honor ownership
settings, or the existing volume must already be writable by this identity.
No privileged/root ownership-fixing init container is used.
- The PVC is created before the Deployment without waiting, supporting
`WaitForFirstConsumer` storage classes. Existing claims are not modified.
- The role never deletes PVCs or Namespaces, and does not assign ownerReferences
that would garbage-collect storage with the Deployment. Renaming resources or
switching claims does not migrate data or remove previous resources.
- Back up the chain before upgrades. Stop the Deployment for a consistent file
backup or use your CSI provider's documented snapshot procedure. Encrypt disks
and backups, define retention, and test restores. Deleting a namespace or PVC
can still destroy data depending on the underlying reclaim policy.
- Chain verification runs at startup. For large chains, review resources,
startup probe budget (300 seconds), and readiness wait timeout. JSON storage
is unbounded; this role cannot solve Wikideck's persistence scalability limits.
## Security and networking
The workload uses a read-only root filesystem, drops all Linux capabilities,
disallows privilege escalation, uses runtime-default seccomp, and has no mounted
service account token or RBAC grants. Only persistent data and a 32 MiB memory
temporary directory are writable. All three HTTP probes target `/healthz` on port
3001; Kubernetes does not use the image's Docker HEALTHCHECK. A 30-second grace
period accommodates the application's 25-second shutdown deadline.
NetworkPolicy defaults allow same-namespace clients, cluster DNS in `kube-system`,
and public HTTPS for Wikipedia. With ingress, explicitly allow the ingress
controller's namespace/pods as shown in the example. Peer entries are additive
OR alternatives: put a namespaceSelector and podSelector in the **same entry** to
restrict pods within that namespace. Empty peer lists deny that traffic; empty
peer objects are rejected to avoid accidentally allowing every source/destination.
Standard NetworkPolicy cannot allow Wikipedia by hostname; the default public
TCP 443 egress is broader than a domain allowlist. Use CNI FQDN policies or an
egress gateway if required. Policies are additive with any other policies in the
namespace and have no effect without an enforcing CNI. NodeLocal DNS, host-network
ingress controllers, service meshes, and private proxies may need peer changes.
TLS terminates at ingress; backend HTTP is private but **not encrypted by this
role**. Use a properly configured service mesh if in-cluster encryption is
required. TLS keys and registry credentials must be provisioned separately, not
committed to playbooks. Use trusted, rotated certificates and retain Kubernetes
API certificate verification. The role does not embed secrets or implement its
own cryptography; image signature verification and encrypted storage are cluster
operator responsibilities. Scan and rebuild the application image regularly.
Wikideck does not currently configure Express `trust proxy`. Clients behind one
proxy can therefore share an IP-based application rate limit. Review the
application before public exposure; do not try to fix this by blindly trusting
forwarded headers. The role adds no authentication, WAF, or abuse protection.
## Validation
[tests/render.yml](tests/render.yml) runs entirely offline: it validates inputs,
renders/parses every template across default, ingress, existing-claim, storage
class, offline, deny-all, and digest-image variants, and checks wiring/security
invariants. It also checks that unsafe/invalid settings are rejected. Run it with
`ansible-playbook -i tests/inventory tests/render.yml`.
[tests/test.yml](tests/test.yml) is a real-cluster smoke test using the local
checkout. Syntax-check it with `ansible-playbook -i tests/inventory tests/test.yml --syntax-check`.
To deploy to a disposable test namespace, run it with
`ansible-playbook -i tests/inventory tests/test.yml -e wikideck_image=YOUR_IMAGE -e wikideck_namespace=wikideck-test`.
Run it a second time to verify idempotence. Check Pod readiness, `/healthz`, PVC
retention across updates, DNS/HTTPS egress, TLS, and browser mining/transfer flows
on your chosen cluster. Offline tests do not prove CSI/CNI/controller behavior.
Ansible check mode (`--check --diff`) is supported by the Kubernetes module, but
still needs API access; creating a new namespace in check mode may require a
separate namespace provisioning pass. No uninstall or automatic data deletion is
provided.
## License
MIT-0. See [LICENSE](LICENSE). The application and Wikipedia/Wikimedia content
retain their own upstream licenses and attribution requirements.
+73 -2
View File
@@ -1,2 +1,73 @@
---
# defaults file for ansible-role-wikideck
# SPDX-License-Identifier: MIT-0
---
# Required: publish an image built from Wikideck's production Dockerfile.
wikideck_image: ""
wikideck_name: wikideck
wikideck_namespace: wikideck
wikideck_create_namespace: true
# null uses normal kubeconfig discovery, including KUBECONFIG.
wikideck_kubeconfig: null
wikideck_kube_context: null
wikideck_wait: true
wikideck_wait_timeout: 300
wikideck_image_pull_policy: IfNotPresent
# Existing registry Secret names in the application namespace.
wikideck_image_pull_secrets: []
wikideck_offline: false
# Exact browser origin, without a trailing slash. Required when using ingress.
wikideck_public_origin: ""
wikideck_ingress_enabled: false
wikideck_ingress_host: ""
wikideck_ingress_class_name: ""
wikideck_ingress_annotations: {}
# Existing TLS Secret, or one provisioned by cert-manager via annotations.
wikideck_tls_secret_name: "{{ wikideck_name }}-tls"
# Persistence is mandatory; existing claims are never modified by this role.
wikideck_existing_claim: ""
wikideck_storage_size: 1Gi
# null: cluster default; "": no StorageClass; otherwise: named class.
wikideck_storage_class: null
wikideck_storage_access_modes:
- ReadWriteOnce
wikideck_resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
wikideck_node_selector: {}
wikideck_tolerations: []
wikideck_affinity: {}
# Requires a NetworkPolicy-capable CNI. Same-namespace clients only by default.
wikideck_network_policy_enabled: true
# Kubernetes NetworkPolicyPeer objects; controller namespaces must be explicit.
wikideck_network_policy_ingress_from:
- podSelector: {}
# DNS is allowed to kube-system on UDP/TCP 53. Adapt for NodeLocal/custom DNS.
wikideck_network_policy_dns_to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
# Live article discovery needs HTTPS. Standard NetworkPolicy cannot select FQDNs.
wikideck_network_policy_https_to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 100.64.0.0/10
- 127.0.0.0/8
- 169.254.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
- ipBlock:
cidr: "::/0"
except:
- "::1/128"
- "fc00::/7"
- "fe80::/10"
+21
View File
@@ -0,0 +1,21 @@
---
- name: Deploy Wikideck
hosts: localhost
connection: local
gather_facts: false
roles:
- role: wikideck
vars:
# Replace with your published, scanned image; preferably use @sha256:...
wikideck_image: registry.example.com/games/wikideck:1.0.0
wikideck_ingress_enabled: true
wikideck_ingress_host: wikideck.example.com
wikideck_public_origin: https://wikideck.example.com
wikideck_ingress_class_name: nginx
wikideck_ingress_annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
wikideck_network_policy_ingress_from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
-2
View File
@@ -1,2 +0,0 @@
---
# handlers file for ansible-role-wikideck
+14 -52
View File
@@ -1,52 +1,14 @@
galaxy_info:
author: your name
description: your role description
company: your company (optional)
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
# Choose a valid license ID from https://spdx.org - some suggested licenses:
# - BSD-3-Clause (default)
# - MIT
# - GPL-2.0-or-later
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: license (GPL-2.0-or-later, MIT, etc)
min_ansible_version: 2.1
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
#
# Provide a list of supported platforms, and for each platform a list of versions.
# If you don't wish to enumerate all versions for a particular platform, use 'all'.
# To view available platforms and versions (or releases), visit:
# https://galaxy.ansible.com/api/v1/platforms/
#
# platforms:
# - name: Fedora
# versions:
# - all
# - 25
# - name: SomePlatform
# versions:
# - all
# - 1.0
# - 7
# - 99.99
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.
# SPDX-License-Identifier: MIT-0
---
galaxy_info:
role_name: wikideck
author: Eric Meehan
description: Deploy Wikideck to Kubernetes with persistent storage and optional TLS ingress.
license: MIT-0
min_ansible_version: "2.16"
galaxy_tags:
- kubernetes
- wikideck
- ingress
- games
dependencies: []
+5
View File
@@ -0,0 +1,5 @@
# Install on a Linux/macOS/WSL Ansible controller, not on Kubernetes nodes.
ansible-core>=2.16,<2.20
kubernetes>=24.2.0,<35.0.0
PyYAML>=6.0.2,<7.0.0
jsonpatch>=1.33,<2.0.0
+4
View File
@@ -0,0 +1,4 @@
---
collections:
- name: kubernetes.core
version: ">=3.0.0,<7.0.0"
+76 -199
View File
@@ -1,199 +1,76 @@
---
# tasks file for ansible-role-wikideck
- name: Create WikiDeck namespace
k8s:
state: present
definition:
apiVersion: v1
kind: Namespace
metadata:
name: wikideck
- name: Create PVC for MariaDB
k8s:
state: present
definition:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb
namespace: wikideck
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Ti
- name: Create Deployment for MariaDB
k8s:
state: present
definition:
apiVersion: v1
kind: Deployment
metadata:
name: mariadb
namespace: wikideck
labels:
app: mariadb
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb
volumeMounts:
- name: data
mountPath: /var/lib/mysql
ports:
- containerPort: 3306
env:
- name: MARIADB_ROOT_PASSWORD
value: "{{ mariadb_root_password }}"
- name: MARIADB_DATABASE
value: wikideck
- name: MARIADB_USER
value: wikideck
- name: MARIADB_PASSWORD
value: "{{ wikideck_mariadb_password }}"
volumes:
- name: data
persistentVolumeClaim:
claimName: mariadb
- name: Create Service for MariaDB
k8s:
state: present
definition:
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: wikideck
spec:
selector:
app: mariadb
ports:
- port: 3306
name: mariadb
type: ClusterIP
- name: Create PVC for WikiDeck Mine
k8s:
state: present
definition:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wikideck-mine
namespace: wikideck
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Ti
- name: Create Deployment for WikiDeck Mine
k8s:
state: present
definition:
apiVersion: v1
kind: Deployment
metadata:
name: wikideck-mine
namespace: wikideck
labels:
app: wikideck-mine
spec:
replicas: 1
selector:
matchLabels:
app: wikideck-mine
template:
metadata:
labels:
app: wikideck-mine
spec:
containers:
- name: wikideck-mine
image: ericomeehan/wikideck-mine
volumeMounts:
- name: data
mountPath: /data
ports:
- containerPort: 80
env:
- name: ROLE
value: mine
- name: DATA_PATH
value: /data
- name: DIFFICULTY_REQUIREMENT
value: 10
- name: MINE_LOG_FILE
value: /tmp/mine.log
- name: MARIADB_HOST
value: mariadb
- name: MARIADB_DATABASE
value: mine
- name: MARIADB_USER
value: mine
- name: MARIADB_PASSWORD
value: "{{ wikideck_mine_mariadb_password }}"
volumes:
- name: data
persistentVolumeClaim:
claimName: wikideck-mine
- name: Create Service for WikiDeck Mine
k8s:
state: present
definition:
apiVersion: v1
kind: Service
metadata:
name: wikideck-mine
namespace: wikideck
spec:
selector:
app: wikideck-mine
ports:
- port: 80
name: http
type: ClusterIP
- name: Create Ingress
k8s:
state: present
definition:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: ca-issuer
name: wikideck-mine
namespace: wikideck
spec:
ingressClassName: nginx
rules:
- host: wikideck.eom.dev
http:
paths:
- pathType: Prefix
path: /mine
backend:
service:
name: wikideck-mine
port:
number: 80
tls:
- hosts:
- wikideck.eom.dev
secretName: wikideck
# SPDX-License-Identifier: MIT-0
---
- name: Validate role configuration
ansible.builtin.import_tasks: validate.yml
- name: Create application namespace
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: present
apply: true
definition:
apiVersion: v1
kind: Namespace
metadata:
name: "{{ wikideck_namespace }}"
when: wikideck_create_namespace | bool
- name: Create persistent chain storage
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', 'pvc.yml.j2') | from_yaml }}"
# WaitForFirstConsumer claims cannot bind until the pod is scheduled.
wait: false
when: wikideck_existing_claim | length == 0
- name: Create workload identity and internal service
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', item) | from_yaml }}"
loop:
- serviceaccount.yml.j2
- service.yml.j2
- name: Configure network isolation before starting the application
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: "{{ 'present' if wikideck_network_policy_enabled else 'absent' }}"
definition: "{{ lookup('ansible.builtin.template', 'networkpolicy.yml.j2') | from_yaml }}"
- name: Deploy the single-writer application
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', 'deployment.yml.j2') | from_yaml }}"
wait: "{{ wikideck_wait | bool }}"
wait_timeout: "{{ wikideck_wait_timeout | int }}"
- name: Configure optional TLS ingress
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', 'ingress.yml.j2') | from_yaml }}"
when: wikideck_ingress_enabled | bool
- name: Remove role ingress when disabled
kubernetes.core.k8s:
kubeconfig: "{{ wikideck_kubeconfig | default(omit, true) }}"
context: "{{ wikideck_kube_context | default(omit, true) }}"
state: absent
api_version: networking.k8s.io/v1
kind: Ingress
namespace: "{{ wikideck_namespace }}"
name: "{{ wikideck_name }}"
when: not wikideck_ingress_enabled | bool
+77
View File
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: MIT-0
---
- name: Validate deployment settings
ansible.builtin.assert:
that:
- wikideck_image is string and wikideck_image | length > 0
- wikideck_image is match('^[^\s@]+(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}|@sha256:[a-f0-9]{64})$')
- wikideck_name is string and wikideck_name is match('^[a-z]([a-z0-9-]*[a-z0-9])?$')
- wikideck_name | length <= 50
- wikideck_namespace is string and wikideck_namespace is match('^[a-z0-9]([a-z0-9-]*[a-z0-9])?$')
- wikideck_namespace | length <= 63
- wikideck_image_pull_policy in ['Always', 'IfNotPresent', 'Never']
- wikideck_wait_timeout | string is match('^[1-9][0-9]*$')
- wikideck_resources is mapping
- wikideck_resources.requests is defined and wikideck_resources.requests is mapping
- wikideck_resources.limits is defined and wikideck_resources.limits is mapping
- wikideck_resources.requests.cpu is defined and wikideck_resources.requests.memory is defined
- wikideck_resources.limits.cpu is defined and wikideck_resources.limits.memory is defined
- wikideck_node_selector is mapping
- wikideck_affinity is mapping
- wikideck_ingress_annotations is mapping
- wikideck_storage_size is string and wikideck_storage_size | length > 0
- wikideck_storage_access_modes | length > 0
- wikideck_storage_access_modes | difference(['ReadWriteOnce', 'ReadWriteOncePod']) | length == 0
- wikideck_storage_class is none or wikideck_storage_class is string
- wikideck_existing_claim is string
- wikideck_existing_claim | length == 0 or wikideck_existing_claim is match('^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$')
- wikideck_public_origin is string
- wikideck_public_origin | length == 0 or wikideck_public_origin is match('^https://[a-z0-9.-]+(:[0-9]+)?$')
- wikideck_ingress_enabled is boolean
- wikideck_network_policy_enabled is boolean
- wikideck_offline is boolean
- wikideck_create_namespace is boolean
- wikideck_wait is boolean
fail_msg: >-
Provide a tagged or digest-pinned wikideck_image and valid Kubernetes names.
Resources need CPU/memory requests and limits; storage must be single-writer.
PUBLIC_ORIGIN must be an HTTPS origin without a path or trailing slash.
- name: Validate TLS ingress settings
ansible.builtin.assert:
that:
- wikideck_ingress_host is string and wikideck_ingress_host | length > 0
- wikideck_ingress_host | length <= 253
- wikideck_ingress_host is match('^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$')
- wikideck_tls_secret_name is string and wikideck_tls_secret_name | length > 0
- wikideck_public_origin == 'https://' ~ wikideck_ingress_host
fail_msg: >-
Ingress requires a DNS hostname, a TLS Secret name, and wikideck_public_origin
set to https:// followed by exactly wikideck_ingress_host.
when: wikideck_ingress_enabled | bool
- name: Validate list settings
ansible.builtin.assert:
that:
- lookup('ansible.builtin.vars', item) is sequence
- lookup('ansible.builtin.vars', item) is not string
- lookup('ansible.builtin.vars', item) is not mapping
fail_msg: "{{ item }} must be a list."
loop:
- wikideck_image_pull_secrets
- wikideck_tolerations
- wikideck_storage_access_modes
- wikideck_network_policy_ingress_from
- wikideck_network_policy_dns_to
- wikideck_network_policy_https_to
- name: Require explicit network policy peers
ansible.builtin.assert:
that:
- item is mapping
- item | length > 0
fail_msg: Use an explicit podSelector, namespaceSelector, or ipBlock rather than an empty peer.
loop: >-
{{ wikideck_network_policy_ingress_from + wikideck_network_policy_dns_to
+ wikideck_network_policy_https_to }}
when: wikideck_network_policy_enabled | bool
+110
View File
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: MIT-0
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ wikideck_name | to_json }}
namespace: {{ wikideck_namespace | to_json }}
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible
spec:
# Never expose replicas as a variable: chain.json has no multi-writer locking.
replicas: 1
strategy:
type: Recreate
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
template:
metadata:
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
spec:
serviceAccountName: {{ wikideck_name | to_json }}
automountServiceAccountToken: false
enableServiceLinks: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
{% if wikideck_image_pull_secrets | length > 0 %}
imagePullSecrets:
{% for secret in wikideck_image_pull_secrets %}
- name: {{ secret | to_json }}
{% endfor %}
{% endif %}
nodeSelector: {{ wikideck_node_selector | to_json }}
tolerations: {{ wikideck_tolerations | to_json }}
affinity: {{ wikideck_affinity | to_json }}
containers:
- name: wikideck
image: {{ wikideck_image | to_json }}
imagePullPolicy: {{ wikideck_image_pull_policy | to_json }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: production
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "3001"
- name: WIKIDECK_DATA_DIR
value: /app/data
- name: WIKIDECK_OFFLINE
value: {{ ('1' if wikideck_offline else '0') | to_json }}
{% if wikideck_public_origin | length > 0 %}
- name: PUBLIC_ORIGIN
value: {{ wikideck_public_origin | to_json }}
{% endif %}
resources: {{ wikideck_resources | to_json }}
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumeMounts:
- name: data
mountPath: /app/data
- name: tmp
mountPath: /tmp
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ (wikideck_existing_claim if wikideck_existing_claim | length > 0 else wikideck_name ~ '-data') | to_json }}
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 32Mi
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: MIT-0
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ wikideck_name | to_json }}
namespace: {{ wikideck_namespace | to_json }}
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible
annotations: {{ wikideck_ingress_annotations | to_json }}
spec:
{% if wikideck_ingress_class_name | length > 0 %}
ingressClassName: {{ wikideck_ingress_class_name | to_json }}
{% endif %}
tls:
- hosts:
- {{ wikideck_ingress_host | to_json }}
secretName: {{ wikideck_tls_secret_name | to_json }}
rules:
- host: {{ wikideck_ingress_host | to_json }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ wikideck_name | to_json }}
port:
name: http
+44
View File
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: MIT-0
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ wikideck_name | to_json }}
namespace: {{ wikideck_namespace | to_json }}
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
policyTypes: [Ingress, Egress]
{% if wikideck_network_policy_ingress_from | length > 0 %}
ingress:
- from: {{ wikideck_network_policy_ingress_from | to_json }}
ports:
- protocol: TCP
port: 3001
{% else %}
ingress: []
{% endif %}
{% if wikideck_network_policy_dns_to | length == 0 and (wikideck_offline or wikideck_network_policy_https_to | length == 0) %}
egress: []
{% else %}
egress:
{% if wikideck_network_policy_dns_to | length > 0 %}
- to: {{ wikideck_network_policy_dns_to | to_json }}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{% endif %}
{% if not wikideck_offline and wikideck_network_policy_https_to | length > 0 %}
- to: {{ wikideck_network_policy_https_to | to_json }}
ports:
- protocol: TCP
port: 443
{% endif %}
{% endif %}
+18
View File
@@ -0,0 +1,18 @@
# SPDX-License-Identifier: MIT-0
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ (wikideck_name ~ '-data') | to_json }}
namespace: {{ wikideck_namespace | to_json }}
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible
spec:
accessModes: {{ wikideck_storage_access_modes | to_json }}
{% if wikideck_storage_class is not none %}
storageClassName: {{ wikideck_storage_class | to_json }}
{% endif %}
resources:
requests:
storage: {{ wikideck_storage_size | to_json }}
+20
View File
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: MIT-0
apiVersion: v1
kind: Service
metadata:
name: {{ wikideck_name | to_json }}
namespace: {{ wikideck_namespace | to_json }}
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
+11
View File
@@ -0,0 +1,11 @@
# SPDX-License-Identifier: MIT-0
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ wikideck_name | to_json }}
namespace: {{ wikideck_namespace | to_json }}
labels:
app.kubernetes.io/name: wikideck
app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible
automountServiceAccountToken: false
+33
View File
@@ -0,0 +1,33 @@
---
- name: Reset validation result
ansible.builtin.set_fact:
wikideck_test_invalid_accepted: false
- name: Try invalid deployment settings
vars:
wikideck_image: "{{ invalid_case.image | default('registry.example.com/wikideck:1.0.0') }}"
wikideck_name: "{{ invalid_case.name | default('wikideck') }}"
wikideck_storage_access_modes: "{{ invalid_case.access_modes | default(['ReadWriteOnce']) }}"
wikideck_ingress_enabled: "{{ invalid_case.ingress | default(false) }}"
wikideck_ingress_host: "{{ invalid_case.host | default('wikideck.example.com') }}"
wikideck_public_origin: "{{ invalid_case.origin | default('') }}"
wikideck_resources: "{{ invalid_case.resources | default({'requests': {'cpu': '100m', 'memory': '128Mi'}, 'limits': {'cpu': '1', 'memory': '512Mi'}}) }}"
wikideck_offline: "{{ invalid_case.offline | default(false) }}"
wikideck_image_pull_secrets: "{{ invalid_case.pull_secrets | default([]) }}"
wikideck_network_policy_ingress_from: "{{ invalid_case.ingress_from | default([{'podSelector': {}}]) }}"
block:
- name: Run role input validation
ansible.builtin.include_tasks: ../tasks/validate.yml
- name: Record unexpectedly accepted inputs
ansible.builtin.set_fact:
wikideck_test_invalid_accepted: true
rescue:
- name: Confirm invalid inputs failed validation
ansible.builtin.debug:
msg: "Rejected invalid settings: {{ invalid_case.label }}"
- name: Assert invalid configuration was rejected
ansible.builtin.assert:
that: not wikideck_test_invalid_accepted
fail_msg: "Validation incorrectly accepted {{ invalid_case.label }}"
+2 -2
View File
@@ -1,2 +1,2 @@
localhost
[local]
localhost ansible_connection=local
+94
View File
@@ -0,0 +1,94 @@
---
# Offline tests: no Kubernetes credentials or API calls.
- name: Validate role configuration and rendered Kubernetes resources
hosts: localhost
connection: local
gather_facts: false
vars_files:
- ../defaults/main.yml
tasks:
- name: Test rendering variants
ansible.builtin.include_tasks: render_case.yml
vars:
wikideck_image: "{{ render_case.image | default('registry.example.com/wikideck:1.0.0') }}"
wikideck_name: "{{ render_case.name | default('wikideck') }}"
wikideck_namespace: "{{ render_case.namespace | default('wikideck') }}"
wikideck_offline: "{{ render_case.offline | default(false) }}"
wikideck_ingress_enabled: "{{ render_case.ingress | default(false) }}"
wikideck_ingress_host: wikideck.example.com
wikideck_public_origin: "{{ 'https://wikideck.example.com' if render_case.ingress | default(false) else '' }}"
wikideck_ingress_class_name: "{{ render_case.ingress_class | default('') }}"
wikideck_ingress_annotations: "{{ render_case.annotations | default({}) }}"
wikideck_storage_class: "{{ render_case.storage_class | default(none) }}"
wikideck_existing_claim: "{{ render_case.existing_claim | default('') }}"
wikideck_image_pull_secrets: "{{ render_case.pull_secrets | default([]) }}"
wikideck_network_policy_ingress_from: "{{ render_case.ingress_from | default([{'podSelector': {}}]) }}"
wikideck_network_policy_dns_to: "{{ render_case.dns_to | default([{'namespaceSelector': {}}]) }}"
wikideck_network_policy_https_to: "{{ render_case.https_to | default([{'ipBlock': {'cidr': '0.0.0.0/0'}}]) }}"
loop:
- label: defaults
- label: TLS ingress and existing storage
ingress: true
ingress_class: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
existing_claim: existing-chain
storage_class: fast-encrypted
pull_secrets: [registry-credentials]
name: custom-deck
namespace: games
- label: offline and no storage class
offline: true
storage_class: ""
- label: default-deny all traffic
ingress_from: []
dns_to: []
https_to: []
- label: DNS-only offline mode
offline: true
- label: digest-pinned image
image: registry.example.com/wikideck@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
loop_control:
loop_var: render_case
label: "{{ render_case.label }}"
- name: Test invalid settings are rejected
ansible.builtin.include_tasks: invalid_case.yml
loop:
- label: missing image
image: ""
- label: untagged image
image: registry.example.com/wikideck
- label: registry port is not an image tag
image: registry.example.com:5000/wikideck
- label: malformed image digest
image: registry.example.com/wikideck@sha256:abcd
- label: invalid resource name
name: Invalid_Name
- label: unsafe multi-writer storage
access_modes: [ReadWriteMany]
- label: missing ingress origin
ingress: true
- label: mismatched ingress origin
ingress: true
origin: https://another.example.com
- label: insecure public origin
origin: http://wikideck.example.com
- label: origin with path
origin: https://wikideck.example.com/path
- label: invalid ingress hostname
ingress: true
host: invalid_host
origin: https://wikideck.example.com
- label: missing resource limits
resources:
requests: {cpu: 100m, memory: 128Mi}
- label: ambiguous boolean
offline: "false"
- label: wrong list type
pull_secrets: not-a-list
- label: empty peer allows all traffic
ingress_from: [{}]
loop_control:
loop_var: invalid_case
label: "{{ invalid_case.label }}"
+77
View File
@@ -0,0 +1,77 @@
---
- name: Validate rendering case inputs
ansible.builtin.include_tasks: ../tasks/validate.yml
- name: Reset rendered resources
ansible.builtin.set_fact:
wikideck_test_manifests: {}
- name: Render and parse all Kubernetes templates
ansible.builtin.set_fact:
wikideck_test_manifests: >-
{{ wikideck_test_manifests | combine({item: lookup('ansible.builtin.template',
playbook_dir ~ '/../templates/' ~ item ~ '.yml.j2') | from_yaml}) }}
loop: [deployment, service, serviceaccount, pvc, ingress, networkpolicy]
- name: Check safety and wiring invariants
ansible.builtin.assert:
that:
- deployment.spec.replicas == 1
- deployment.spec.strategy.type == 'Recreate'
- deployment.spec.selector.matchLabels == deployment.spec.template.metadata.labels
- wikideck_test_manifests.service.spec.selector == deployment.spec.selector.matchLabels
- wikideck_test_manifests.networkpolicy.spec.podSelector.matchLabels == deployment.spec.selector.matchLabels
- wikideck_test_manifests.service.spec.type == 'ClusterIP'
- wikideck_test_manifests.service.spec.ports[0].targetPort == 'http'
- pod.serviceAccountName == wikideck_name
- not pod.automountServiceAccountToken
- not wikideck_test_manifests.serviceaccount.automountServiceAccountToken
- pod.securityContext.runAsNonRoot and pod.securityContext.runAsUser == 1000
- pod.securityContext.fsGroup == 1000
- pod.securityContext.seccompProfile.type == 'RuntimeDefault'
- container.securityContext.readOnlyRootFilesystem
- not container.securityContext.allowPrivilegeEscalation
- container.securityContext.capabilities.drop == ['ALL']
- container.image == wikideck_image
- container.resources == wikideck_resources
- container.ports[0].containerPort == 3001
- container.startupProbe.httpGet.path == '/healthz'
- container.readinessProbe.httpGet.path == '/healthz'
- container.livenessProbe.httpGet.path == '/healthz'
- pod.terminationGracePeriodSeconds >= 30
- pod.volumes[0].persistentVolumeClaim.claimName == expected_claim
- pod.volumes[1].emptyDir.medium == 'Memory'
- container.volumeMounts[0].mountPath == '/app/data'
- env.HOST == '0.0.0.0' and env.PORT == '3001'
- env.WIKIDECK_DATA_DIR == '/app/data' and env.NODE_ENV == 'production'
- env.WIKIDECK_OFFLINE == ('1' if wikideck_offline else '0')
- (env.PUBLIC_ORIGIN | default('')) == wikideck_public_origin
- wikideck_test_manifests.pvc.spec.accessModes == wikideck_storage_access_modes
- wikideck_test_manifests.ingress.spec.tls[0].secretName == wikideck_tls_secret_name
- wikideck_test_manifests.ingress.spec.rules[0].host == wikideck_ingress_host
- wikideck_test_manifests.ingress.spec.rules[0].http.paths[0].backend.service.name == wikideck_name
- wikideck_test_manifests.ingress.metadata.annotations == wikideck_ingress_annotations
- (wikideck_test_manifests.ingress.spec.ingressClassName | default('')) == wikideck_ingress_class_name
- (pod.imagePullSecrets | default([]) | map(attribute='name') | list) == wikideck_image_pull_secrets
- wikideck_test_manifests.networkpolicy.spec.policyTypes == ['Ingress', 'Egress']
- wikideck_test_manifests.networkpolicy.spec.ingress | length == (1 if wikideck_network_policy_ingress_from | length > 0 else 0)
- wikideck_test_manifests.networkpolicy.spec.egress | length == expected_egress_count | int
vars:
deployment: "{{ wikideck_test_manifests.deployment }}"
pod: "{{ deployment.spec.template.spec }}"
container: "{{ pod.containers[0] }}"
env: "{{ dict(container.env | map(attribute='name') | zip(container.env | map(attribute='value'))) }}"
expected_claim: "{{ wikideck_existing_claim if wikideck_existing_claim | length > 0 else wikideck_name ~ '-data' }}"
expected_egress_count: >-
{{ (1 if wikideck_network_policy_dns_to | length > 0 else 0)
+ (1 if not wikideck_offline and wikideck_network_policy_https_to | length > 0 else 0) }}
- name: Check omitted default StorageClass
ansible.builtin.assert:
that: wikideck_test_manifests.pvc.spec.storageClassName is not defined
when: wikideck_storage_class is none
- name: Check explicit StorageClass including empty string
ansible.builtin.assert:
that: wikideck_test_manifests.pvc.spec.storageClassName == wikideck_storage_class
when: wikideck_storage_class is not none
+10 -5
View File
@@ -1,5 +1,10 @@
---
- hosts: localhost
remote_user: root
roles:
- ansible-role-wikideck
---
# Live-cluster smoke test. Supply wikideck_image with --extra-vars.
- name: Deploy Wikideck to the selected test cluster
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Apply the role from this checkout
ansible.builtin.include_role:
name: "{{ playbook_dir }}/.." # noqa: role-name[path] -- smoke-test the checkout without installation
-2
View File
@@ -1,2 +0,0 @@
---
# vars file for ansible-role-wikideck