AI overhaul

This commit is contained in:
2026-10-23 12:49:53 -04:00
parent b040ca26c3
commit b4db1a0182
21 changed files with 1226 additions and 189 deletions
Executable
+5
View File
@@ -0,0 +1,5 @@
.git
__pycache__
.env*
*.vault
*.retry
Executable
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.{yml,yaml,j2}]
indent_style = space
indent_size = 2
charset = utf-8
insert_final_newline = true
+197 -20
View File
@@ -4,35 +4,212 @@ Role Name
A brief description of the role goes here.
Requirements
------------
# ansible-role-www
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.
Deploy the eom.dev website and membership services to **Kubernetes**, not the
development Docker Compose stack.
Role Variables
--------------
## What is deployed
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.
- Existing namespace, `data` ReadWriteMany archive PVC, Apache, HTTP/Prometheus
ClusterIP services, TLS ingress and optional Tor OnionService.
- Website source from `www_repo_url` at `www_repo_version`. Static content is
copied without deleting the persistent archive.
- Public membership ConfigMaps, the five existing live Stripe tiers
($4/$8/$16/$32/$64 USD monthly), optional SHKeeper prepaid crypto and Secret
references. LDAP/Dex and both payment providers use the same application.
- Persistent PostgreSQL 17 StatefulSet with a separate ReadWriteOnce claim, or
an external PostgreSQL server using `verify-full`.
- A release/configuration-specific migration Job, completed **before** new web
and worker Deployments are applied. Failed migrations stop the role.
- Non-root, read-only membership web pods and one reconciliation worker, resource
limits, probes, optional CA mounts and no service-account token automounts.
NetworkPolicy allows membership web ingress only from Apache and database
ingress only from membership clients. Worker ingress is denied.
Dependencies
------------
The role does **not** provision LDAP accounts/groups/ACLs, Dex clients, Stripe
products/webhooks, SHKeeper wallets, image builds or database backups. Review the
application's deployment instructions before launch.
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.
## Requirements
Example Playbook
----------------
- Ansible Core 2.15+ on a Linux/WSL controller, the collections in
[requirements.yml](requirements.yml), Kubernetes Python client and authorized
kubeconfig. No application pod needs Kubernetes API permissions.
- Kubernetes with StatefulSets, Jobs, Ingress/NetworkPolicy, a CNI that enforces
NetworkPolicy, default persistent StorageClass, and archive RWX provisioner.
- Ingress-nginx, cert-manager (`ca-issuer` by default), and the Tor controller
with OnionService CRD when `www_onion_enabled` is true.
- A **built and pushed** image from the www repository's membership Dockerfile,
including Stripe and SHKeeper migrations. Set `www_membership_image` to a
versioned tag, ideally an immutable digest. No unpublished image is assumed
and nothing is built inside the cluster. The image contract is `/app`,
Python/manage.py, UID/GID 10001, port 8000 and the Gunicorn default command.
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
## Required configuration
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
Defaults deliberately refuse incomplete membership deployments. Configure:
License
-------
| Variable | Purpose / default |
| --- | --- |
| `www_namespace` | Namespace, default `www` |
| `www_hostname` | Canonical HTTPS hostname, default **www.eom.dev** |
| `www_membership_enabled` | Deploy membership/proxy, default true |
| `www_membership_image` | Required versioned application image/digest |
| `www_membership_secret_name` | Existing Secret, default `membership` |
| `www_membership_secret_data` | Optional Vault/lookup mapping to manage Secret; default empty |
| `www_membership_environment_overrides` | Partial public overrides merged with defaults |
| `www_membership_tiers` | Tier mapping with label, price_id, price_label, usd_amount, group_dn |
| `www_membership_replicas` | Web replicas, default 2 |
| `www_membership_worker_interval` | Seconds after full sweep, default 60 |
| `www_membership_ca_secret_name` | Optional CA Secret mounted at `/etc/membership/ca` |
| `www_membership_stripe_enabled` | Require Stripe settings/secrets, default true |
| `www_membership_shkeeper_enabled` | Require SHKeeper settings/key, default false |
| `www_membership_postgres_managed` | Provision local PostgreSQL, default true |
| `www_membership_postgres_storage_size` | Default 10Gi |
| `www_membership_postgres_storage_class` | Empty uses cluster default |
| `www_repo_url` / `www_repo_version` | Public source URL and ref/commit; default existing URL / HEAD |
| `www_rollout_revision` | Optional marker to force source/config rollout |
| `www_image_pull_secrets` | List of `{name: registry-secret}` objects |
| `www_onion_enabled` | Retain static Tor mirror, default true |
BSD
Public overrides must include nonempty `LDAP_BIND_DN`, `EMAIL_HOST`, verified
`LDAP_PASSWORD_SCHEME: '{ARGON2}'`, and the correct Dex issuer/client/connector.
Stripe needs `STRIPE_PORTAL_CONFIGURATION`. SMTP STARTTLS, production PostgreSQL
and HTTPS are required; production SQLite is rejected. A complete override of
`www_membership_environment` is possible but must preserve mandatory defaults.
Author Information
------------------
Payment flags validate operator intent; **actual application availability is
controlled by its environment, secrets and tiers**. Disabling Stripe validation
alone does not remove its button. Set an empty tier mapping to disable all paid
tiers. SHKeeper needs both its flag and URL/currencies plus API key in the Secret.
It prepays one calendar month per invoice, without automatic renewal.
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
Defaults reuse product `prod_Sc9N5J0vNAdLCn` and the same five live Price IDs as
the application. Provision dedicated `www-level-0` through `www-level-4`
groupOfUniqueNames groups, sentinel and ACLs separately. Override group DNs if
needed; sandbox testing requires separate test-mode prices.
### Secrets
Create an Opaque Secret using External Secrets, Sealed Secrets or your secret
manager, or provide `www_membership_secret_data` from **Ansible Vault**/an external
lookup. Role-managed values use base64 `data` for repeatable reconciliation.
Secret create/read/validation tasks use `no_log: true`, and diffs are disabled.
Base64 is not encryption: enable etcd encryption at rest and appropriate RBAC.
Never put credentials in public overrides or commit real values to git.
Only these keys are permitted:
| Secret key | Required |
| --- | --- |
| `MEMBERSHIP_SECRET_KEY` | Always; at least 32 random characters |
| `POSTGRES_PASSWORD` | Always |
| `DEX_CLIENT_SECRET` | Always |
| `LDAP_BIND_PASSWORD` | Always |
| `EMAIL_HOST_PASSWORD` | When SMTP authentication requires it |
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | With Stripe enabled |
| `SHKEEPER_API_KEY` | With SHKeeper enabled |
Do not use root/admin LDAP credentials. Restrict ACLs to the website account OU
and dedicated group attributes. Use SHKeeper's invoice API key, never wallet
private keys or UI/payout credentials.
Private trust roots belong in a separate CA Secret. Set paths such as
`LDAP_CA_CERT_FILE`, `SHKEEPER_CA_CERT_FILE`, `PGSSLROOTCERT` or
`REQUESTS_CA_BUNDLE` to `/etc/membership/ca/<secret-key>`. The read-only mount is
shared by web, worker and migrations. Verify certificate expiry, strong keys,
SHA-2 signatures and trust independently; do not disable TLS verification.
### Example inventory and hostname
[tests/example-vars.yml](tests/example-vars.yml) contains public configuration
only. Supply credentials separately via Vault or a pre-existing Secret. Run
through your existing deployment playbook after pushing matching website and
membership releases and installing the collection requirements.
**Hostname matters:** the old role serves **www.eom.dev**, whereas the standalone
app defaults to eom.dev. This role sets `MEMBERSHIP_PUBLIC_URL` to
`https://{{ www_hostname }}`. Register these exact callbacks, including slashes:
- Dex: `https://<www_hostname>/members/oidc/callback/`.
- Stripe: `https://<www_hostname>/members/stripe/webhook/`.
- SHKeeper: `https://<www_hostname>/members/shkeeper/callback/`.
To serve the apex instead, set `www_hostname: eom.dev` and align DNS,
certificates, callbacks and redirects. Membership requires one canonical HTTPS
origin for sessions, CSRF, email verification and OIDC.
## Upgrades and operations
- Pin source ref and images to the same reviewed release. Change
`www_rollout_revision` when deliberately tracking a moving branch. Git URL/ref
are passed as environment variables, not shell-interpolated. Never put deploy
tokens in the repository URL.
- Apache config/source ref changes replace its pod. Membership image, public
settings, tiers and Secret/CA **resource versions** trigger a new migration Job
and workload rollout without publishing secret content hashes. The worker
uses Recreate to avoid parallel release workers.
- Completed migration Jobs are retained for idempotent reruns. Prune superseded
Jobs manually after an upgrade; retain the current Job. Review/remove failed
Jobs or choose a new rollout revision to retry. Keep schema changes compatible
with old pods during rollout; coordinate downtime for incompatible changes.
- The archive PVC is not wiped. Repository files (including dotfiles) overlay
it; stale deleted static assets are not automatically pruned. Review archive
path collisions deliberately. Database storage remains separate.
- PostgreSQL initializes credentials only on an empty directory. Updating the
Secret **does not rotate an existing DB password**: coordinate SQL rotation,
application rollout and DB restart manually. Do not change the database
name/user or major PostgreSQL version on an existing claim without a migration
plan. Expand existing claims through your approved storage procedure rather
than editing immutable StatefulSet claim templates.
- Local PostgreSQL is network-isolated but its default connection is not TLS.
Use an encrypted private CNI/service mesh or an external database with
`POSTGRES_SSLMODE=verify-full` and a trusted CA when encrypted DB transport is
required. Use encrypted disks/backups, restrict access and test restores. The
single managed instance is not HA and has no automatic backup schedule.
- For external PostgreSQL set `www_membership_postgres_managed: false` and
override POSTGRES_HOST/PORT/DB/USER and SSLMODE. Existing managed resources and
claims are deliberately **not deleted** when switching.
- Monitor worker errors and stale `last_ldap_sync`, `last_billing_sync` and
`last_shkeeper_sync`. Process health is not proof of successful reconciliation.
Downstream apps must refresh cached Dex group/session claims promptly.
## Network and privacy
No direct membership/database ingress, NodePort or LoadBalancer is created.
Apache alone may assert the trusted headers. Do not create permissive policies
or give untrusted pods its label. NetworkPolicy enforcement is required. Egress
is left to cluster policy: allow DNS, PostgreSQL, LDAP TLS, Dex HTTPS, SMTP
STARTTLS, Stripe HTTPS and optional SHKeeper HTTPS to configured endpoints.
Private destinations are supported; the role cannot infer safe IP allowlists.
Ingress enforces TLS and disables access logging of email bearer tokens/OIDC
codes. Apache excludes member access logs; the app image disables Gunicorn
access logs. Redact URIs/bodies at upstream load balancers, WAFs, APM and error
tracing too. The default IP throttle sees the ingress peer; configure trusted
mod_remoteip ranges separately for per-client limits. Never trust arbitrary
X-Forwarded-For headers.
Tor remains a **static mirror**; membership is denied for noncanonical Host
headers. Disabling membership only disables its Apache route: existing workers,
database and history are not deleted. Keep workers alive to revoke expiries
until a reviewed shutdown. Turning off Tor likewise does not delete an existing
OnionService automatically.
## Validation
[tests/Dockerfile](tests/Dockerfile) runs real Ansible syntax checks and template
rendering without a cluster, then
[tests/validate_manifests.py](tests/validate_manifests.py) asserts Secret/config
separation, tiers, migrations, private services, network isolation, non-root
constraints, probes and archive preservation. Fixtures cover managed PostgreSQL,
external TLS DB/private CA/image pull Secrets, SHKeeper, and disabled membership/
Tor. Before production, validate in a disposable namespace, including storage
permissions, NetworkPolicy, migrations, LDAP/Dex and sandbox payment flows.
The editor's container scanner currently flags one high-severity finding in the
validation runner's upstream Python base image, even after updating/pulling it.
This runner is test-only and is not deployed by the role. Review that finding
with your authenticated image scanner and refresh approved base-image digests
before using it in CI; no scanner authentication was configured by this change.
+108
View File
@@ -10,3 +10,111 @@ www_ftp_user: changeme
www_ftp_password: changeme
www_ftp_pasv_address: example.com
www_repo_url: https://gitea.eom.dev/DevOps/www
# Kubernetes deployment (existing archive, Prometheus and Tor are retained).
www_namespace: www
www_hostname: www.eom.dev
www_ingress_class: nginx
www_cluster_issuer: ca-issuer
www_httpd_image: ericomeehan/www:latest
www_source_image: debian:trixie
www_repo_version: HEAD
www_rollout_revision: ""
www_archive_storage_size: 8Ti
www_onion_enabled: true
www_wait_timeout: 600
www_image_pull_secrets: []
www_membership_enabled: true
# Build/push www/membership/Dockerfile first. Use an immutable release/digest.
www_membership_image: ""
www_membership_image_pull_policy: IfNotPresent
www_membership_replicas: 2
www_membership_worker_interval: 60
www_membership_secret_name: membership
# Empty uses an existing Kubernetes Secret. Otherwise supply via Vault/lookup.
www_membership_secret_data: {}
www_membership_ca_secret_name: ""
www_membership_resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 512Mi}
www_membership_worker_resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 512Mi}
www_membership_postgres_managed: true
www_membership_postgres_image: postgres:17
www_membership_postgres_storage_size: 10Gi
# Empty uses the cluster default StorageClass. The claim is never auto-deleted.
www_membership_postgres_storage_class: ""
www_membership_postgres_resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "2", memory: 1Gi}
www_membership_stripe_enabled: true
www_membership_shkeeper_enabled: false
www_membership_environment_overrides: {}
# Public settings only. Credentials belong in the membership Secret.
www_membership_environment:
MEMBERSHIP_PUBLIC_URL: "https://{{ www_hostname }}"
MEMBERSHIP_ALLOW_HTTP: "0"
MEMBERSHIP_TRUST_PROXY: "1"
MEMBERSHIP_TIERS_FILE: /etc/membership/tiers/tiers.json
POSTGRES_HOST: membership-db
POSTGRES_PORT: "5432"
POSTGRES_DB: membership
POSTGRES_USER: membership
POSTGRES_SSLMODE: prefer
DEX_ISSUER: https://dex.eom.dev
DEX_CLIENT_ID: www-membership
DEX_CONNECTOR_ID: www-ldap
LDAP_URL: ldaps://ldap.eom.dev
LDAP_BIND_DN: ""
LDAP_ACCOUNT_BASE_DN: ou=www,ou=people,dc=eom,dc=dev
LDAP_SEARCH_BASE_DN: ou=people,dc=eom,dc=dev
LDAP_PASSWORD_SCHEME: ""
LDAP_GROUP_PLACEHOLDER_DN: cn=membership-sentinel,ou=system,dc=eom,dc=dev
LDAP_CA_CERT_FILE: ""
EMAIL_HOST: ""
EMAIL_PORT: "587"
EMAIL_HOST_USER: ""
EMAIL_USE_TLS: "1"
DEFAULT_FROM_EMAIL: accounts@eom.dev
STRIPE_LIVE_MODE: "1"
STRIPE_PORTAL_CONFIGURATION: ""
SHKEEPER_URL: ""
SHKEEPER_CURRENCIES: ""
SHKEEPER_CA_CERT_FILE: ""
# Existing LIVE Stripe prices. Provision the dedicated group DNs separately.
# Override with {} to disable paid tiers, or test-mode prices for a sandbox.
www_membership_tiers:
level-0:
label: Level 0
price_id: price_1Rgv4xC5KPXqTvVLfMd6z7wn
price_label: $4 USD / month
usd_amount: "4.00"
group_dn: cn=www-level-0,ou=groups,dc=eom,dc=dev
level-1:
label: Level 1
price_id: price_1Rgv5QC5KPXqTvVLv1PaHWb5
price_label: $8 USD / month
usd_amount: "8.00"
group_dn: cn=www-level-1,ou=groups,dc=eom,dc=dev
level-2:
label: Level 2
price_id: price_1Rgv5rC5KPXqTvVL8Ol8sMUf
price_label: $16 USD / month
usd_amount: "16.00"
group_dn: cn=www-level-2,ou=groups,dc=eom,dc=dev
level-3:
label: Level 3
price_id: price_1Rgv6FC5KPXqTvVL39mAxYjm
price_label: $32 USD / month
usd_amount: "32.00"
group_dn: cn=www-level-3,ou=groups,dc=eom,dc=dev
level-4:
label: Level 4
price_id: price_1Rgv6mC5KPXqTvVLkcmznWZa
price_label: $64 USD / month
usd_amount: "64.00"
group_dn: cn=www-level-4,ou=groups,dc=eom,dc=dev
+8 -3
View File
@@ -139,10 +139,10 @@ LoadModule headers_module modules/mod_headers.so
LoadModule setenvif_module modules/mod_setenvif.so
LoadModule version_module modules/mod_version.so
#LoadModule remoteip_module modules/mod_remoteip.so
#LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_module modules/mod_proxy.so
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
#LoadModule proxy_ftp_module modules/mod_proxy_ftp.so
#LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule proxy_http_module modules/mod_proxy_http.so
#LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so
#LoadModule proxy_scgi_module modules/mod_proxy_scgi.so
#LoadModule proxy_uwsgi_module modules/mod_proxy_uwsgi.so
@@ -240,6 +240,9 @@ ServerAdmin eric@eom.dev
#
ServerName eom.dev:443
# Rendered separately so the membership proxy can be disabled safely.
IncludeOptional conf/extra/membership.conf
#
# Deny access to the entirety of your server's filesystem. You must
# explicitly permit access to web content directories in other
@@ -357,7 +360,9 @@ LogLevel warn
# define per-<VirtualHost> access logfiles, transactions will be
# logged therein and *not* in this file.
#
CustomLog /proc/self/fd/1 common
# Never log email verification tokens or OIDC callback authorization codes.
SetEnvIf Request_URI "^/members/" membership_sensitive
CustomLog /proc/self/fd/1 common env=!membership_sensitive
#
# If you prefer a logfile with access, agent, and referer information
+4 -5
View File
@@ -1,8 +1,7 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: Eric Meehan
description: Kubernetes deployment for eom.dev Apache and LDAP/Dex memberships with Stripe and SHKeeper
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
@@ -15,9 +14,9 @@ galaxy_info:
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: license (GPL-2.0-or-later, MIT, etc)
license: MIT-0
min_ansible_version: 2.1
min_ansible_version: "2.15"
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
+2
View File
@@ -0,0 +1,2 @@
---
collections: [{name: kubernetes.core, version: ">=3.0.0,<7.0.0"}]
+34 -161
View File
@@ -1,171 +1,44 @@
#SPDX-License-Identifier: MIT-0
# SPDX-License-Identifier: MIT-0
---
# tasks file for ansible-role-www
- name: namespace
k8s:
- name: Validate public website configuration
ansible.builtin.assert:
that:
- www_hostname is match('^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$')
- www_namespace is match('^[a-z0-9]([a-z0-9-]*[a-z0-9])?$')
fail_msg: Use a DNS hostname and a Kubernetes namespace without URL paths or credentials.
- name: Ensure website namespace
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Namespace
metadata:
name: www
name: "{{ www_namespace }}"
- name: pvc
k8s:
- name: Deploy membership prerequisites and workloads
ansible.builtin.include_tasks: membership.yml
when: www_membership_enabled | bool
- name: Render Apache membership proxy configuration
ansible.builtin.set_fact:
_www_proxy_config: "{{ lookup('ansible.builtin.template', 'membership-httpd.conf.j2') }}"
- name: Deploy Apache, public services, storage and ingress
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
namespace: www
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 8Ti
definition: "{{ lookup('ansible.builtin.template', 'website.yml.j2') | from_yaml_all | list }}"
- name: configmap for httpd.conf
k8s:
state: present
definition:
apiVersion: v1
kind: ConfigMap
metadata:
name: httpd
namespace: www
data:
httpd.conf: "{{ lookup('file', 'httpd.conf') }}"
- name: deployment for www
k8s:
definition:
apiVersion: v1
kind: Deployment
metadata:
name: httpd
namespace: www
spec:
replicas: 1
selector:
matchLabels:
app: httpd
template:
metadata:
labels:
app: httpd
spec:
initContainers:
- name: init
image: debian:trixie
command:
- /bin/bash
- -c
- "apt update -y && apt install -y git && git clone {{ www_repo_url }} /tmp/www && rm -rf /data/* && cp -r /tmp/www/htdocs/* /data/"
volumeMounts:
- name: data
mountPath: /data
containers:
- name: httpd
image: ericomeehan/www:latest
imagePullPolicy: Always
ports:
- containerPort: 80
- containerPort: 9117
volumeMounts:
- name: data
mountPath: /usr/local/apache2/htdocs
- name: httpd
mountPath: /usr/local/apache2/conf/httpd.conf
subPath: httpd.conf
volumes:
- name: data
persistentVolumeClaim:
claimName: data
- name: httpd
configMap:
name: httpd
- name: service for prometheus
k8s:
definition:
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: www
spec:
selector:
app: httpd
ports:
- port: 9117
name: http
type: ClusterIP
- name: service for httpd
k8s:
definition:
apiVersion: v1
kind: Service
metadata:
name: httpd
namespace: www
spec:
selector:
app: httpd
ports:
- port: 80
name: http
type: ClusterIP
- name: onionservice
k8s:
definition:
apiVersion: tor.k8s.torproject.org/v1alpha2
kind: OnionService
metadata:
name: httpd
namespace: www
spec:
version: 3
rules:
- port:
number: 80
backend:
service:
name: httpd
port:
number: 80
- name: ingress
k8s:
state: present
definition:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: ca-issuer
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-headers: "X-Forwarded-For"
name: www
namespace: www
spec:
ingressClassName: nginx
rules:
- host: www.eom.dev
http:
paths:
- pathType: Prefix
path: /
backend:
service:
name: httpd
port:
number: 80
tls:
- hosts:
- www.eom.dev
secretName: www
- name: Wait for Apache rollout
kubernetes.core.k8s_info:
api_version: apps/v1
kind: Deployment
name: httpd
namespace: "{{ www_namespace }}"
wait: true
wait_timeout: "{{ www_wait_timeout }}"
wait_condition:
type: Available
status: "True"
when: not ansible_check_mode
+195
View File
@@ -0,0 +1,195 @@
---
- name: Merge public membership environment overrides
tags: [membership_validate]
no_log: true
ansible.builtin.set_fact:
www_membership_environment: "{{ www_membership_environment | combine(www_membership_environment_overrides) }}"
- name: Validate membership deployment settings
tags: [membership_validate]
ansible.builtin.assert:
that:
- www_membership_image | length > 0
- not www_membership_image.endswith(':latest')
- "www_membership_image is search('(:[^/]+$|@sha256:[a-f0-9]{64}$)')"
- www_membership_environment is mapping
- www_membership_tiers is mapping
- www_membership_environment.MEMBERSHIP_PUBLIC_URL == 'https://' ~ www_hostname
- www_membership_environment.MEMBERSHIP_ALLOW_HTTP | string == '0'
- www_membership_environment.MEMBERSHIP_TRUST_PROXY | string == '1'
- www_membership_environment.MEMBERSHIP_TIERS_FILE == '/etc/membership/tiers/tiers.json'
- "'MEMBERSHIP_SQLITE_PATH' not in www_membership_environment"
- "'MEMBERSHIP_TIERS_JSON' not in www_membership_environment"
- www_membership_replicas | int >= 1
- www_membership_worker_interval | int >= 10
- www_membership_environment.LDAP_PASSWORD_SCHEME == '{ARGON2}'
- www_membership_environment.LDAP_BIND_DN | length > 0
- www_membership_environment.EMAIL_HOST | length > 0
- www_membership_environment.EMAIL_USE_TLS | string == '1'
- www_membership_environment.DEX_ISSUER is match('^https://[^/@]+')
- www_membership_environment.LDAP_URL is match('^ldaps?://[^/@]+')
- not www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL is match('^https://[^/@]+') and www_membership_environment.SHKEEPER_CURRENCIES | length > 0)
- www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL | length == 0 and www_membership_environment.SHKEEPER_CURRENCIES | length == 0)
- not www_membership_stripe_enabled or www_membership_environment.STRIPE_PORTAL_CONFIGURATION | length > 0
- not www_membership_postgres_managed or www_membership_environment.POSTGRES_HOST == 'membership-db'
- www_membership_postgres_managed or www_membership_environment.POSTGRES_SSLMODE == 'verify-full'
- not www_membership_postgres_managed or www_membership_environment.POSTGRES_PORT | string == '5432'
fail_msg: Supply a versioned membership image and configure verified LDAP, SMTP, Dex, database and payment settings (see role README).
- name: Validate optional CA file mount paths
tags: [membership_validate]
ansible.builtin.assert:
that:
- item.value | length == 0 or (www_membership_ca_secret_name | length > 0 and item.value is match('^/etc/membership/ca/[A-Za-z0-9._-]+$'))
fail_msg: CA paths must refer to filenames in the configured Secret mounted at /etc/membership/ca/.
loop: "{{ www_membership_environment | dict2items | selectattr('key', 'in', ['LDAP_CA_CERT_FILE', 'SHKEEPER_CA_CERT_FILE', 'PGSSLROOTCERT', 'REQUESTS_CA_BUNDLE']) | list }}"
- name: Define secret-only settings
tags: [membership_validate]
ansible.builtin.set_fact:
_www_secret_keys: [MEMBERSHIP_SECRET_KEY, POSTGRES_PASSWORD, DEX_CLIENT_SECRET, LDAP_BIND_PASSWORD, EMAIL_HOST_PASSWORD, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SHKEEPER_API_KEY]
_www_required_secret_keys: "{{ ['MEMBERSHIP_SECRET_KEY', 'POSTGRES_PASSWORD', 'DEX_CLIENT_SECRET', 'LDAP_BIND_PASSWORD'] + (['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] if www_membership_stripe_enabled else []) + (['SHKEEPER_API_KEY'] if www_membership_shkeeper_enabled else []) }}"
- name: Prevent secrets in public ConfigMaps
tags: [membership_validate]
ansible.builtin.assert:
that:
- www_membership_environment.keys() | intersect(_www_secret_keys) | length == 0
fail_msg: Put credentials in the Kubernetes Secret, never www_membership_environment.
no_log: true
- name: Validate optional Vault-provided secret data
tags: [membership_validate]
ansible.builtin.assert:
that:
- www_membership_secret_data is mapping
- www_membership_secret_data.keys() | difference(_www_secret_keys) | length == 0
- _www_required_secret_keys | difference(www_membership_secret_data.keys()) | length == 0
- www_membership_secret_data.MEMBERSHIP_SECRET_KEY | length >= 32
fail_msg: Provide the documented secret keys using Vault or a pre-existing Kubernetes Secret.
when: www_membership_secret_data | length > 0
no_log: true
- name: Create membership Secret only when explicitly supplied
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Secret
metadata:
name: "{{ www_membership_secret_name }}"
namespace: "{{ www_namespace }}"
type: Opaque
data: "{{ dict(www_membership_secret_data.keys() | zip(www_membership_secret_data.values() | map('b64encode'))) }}"
when: www_membership_secret_data | length > 0
no_log: true
diff: false
- name: Read deployed Secret for validation and rollout tracking
kubernetes.core.k8s_info:
api_version: v1
kind: Secret
name: "{{ www_membership_secret_name }}"
namespace: "{{ www_namespace }}"
register: _www_membership_secret
no_log: true
when: not ansible_check_mode
- name: Validate deployed secret keys without exposing values
ansible.builtin.assert:
that:
- _www_membership_secret.resources | length == 1
- _www_required_secret_keys | difference(_www_membership_secret.resources[0].data.keys()) | length == 0
- _www_membership_secret.resources[0].data.keys() | difference(_www_secret_keys) | length == 0
- (_www_membership_secret.resources[0].data.MEMBERSHIP_SECRET_KEY | b64decode | length) >= 32
fail_msg: The membership Secret is missing or contains invalid keys. See the role README.
no_log: true
when: not ansible_check_mode
- name: Validate mandatory secret values are nonempty
ansible.builtin.assert:
that:
- _www_membership_secret.resources[0].data[item] | b64decode | length > 0
loop: "{{ _www_required_secret_keys }}"
no_log: true
when: not ansible_check_mode
- name: Read optional CA Secret revision
kubernetes.core.k8s_info:
api_version: v1
kind: Secret
name: "{{ www_membership_ca_secret_name }}"
namespace: "{{ www_namespace }}"
register: _www_membership_ca
no_log: true
when:
- not ansible_check_mode
- www_membership_ca_secret_name | length > 0
- name: Require the configured CA Secret to exist
ansible.builtin.assert:
that: _www_membership_ca.resources | length == 1
when:
- not ansible_check_mode
- www_membership_ca_secret_name | length > 0
no_log: true
- name: Compute configuration revision without secret contents
# resourceVersion changes trigger a rollout without exposing secret values.
ansible.builtin.set_fact:
_www_membership_revision: "{{ {'image': www_membership_image, 'environment': www_membership_environment, 'tiers': www_membership_tiers, 'secret_name': www_membership_secret_name, 'secret_version': (_www_membership_secret.resources[0].metadata.resourceVersion if not ansible_check_mode else 'check'), 'ca_name': www_membership_ca_secret_name, 'ca_version': (_www_membership_ca.resources[0].metadata.resourceVersion if www_membership_ca_secret_name | length > 0 and not ansible_check_mode else ''), 'rollout': www_rollout_revision} | to_json(sort_keys=True) | hash('sha256') }}"
no_log: true
- name: Deploy public membership configuration and network isolation
kubernetes.core.k8s:
state: present
definition: "{{ lookup('ansible.builtin.template', 'membership-config.yml.j2') | from_yaml_all | list }}"
- name: Deploy persistent membership PostgreSQL
kubernetes.core.k8s:
state: present
definition: "{{ lookup('ansible.builtin.template', 'membership-postgres.yml.j2') | from_yaml_all | list }}"
when: www_membership_postgres_managed | bool
- name: Wait for membership PostgreSQL
kubernetes.core.k8s_info:
api_version: apps/v1
kind: StatefulSet
name: membership-db
namespace: "{{ www_namespace }}"
register: _www_postgres
until: _www_postgres.resources | length == 1 and (_www_postgres.resources[0].status.readyReplicas | default(0) | int) == 1
retries: "{{ (www_wait_timeout | int / 5) | int }}"
delay: 5
when:
- www_membership_postgres_managed | bool
- not ansible_check_mode
- name: Apply versioned database migration Job and wait for completion
kubernetes.core.k8s:
state: present
definition: "{{ lookup('ansible.builtin.template', 'membership-migrate.yml.j2') | from_yaml }}"
wait: "{{ not ansible_check_mode }}"
wait_timeout: "{{ www_wait_timeout }}"
wait_condition:
type: Complete
status: "True"
- name: Deploy membership web service and reconciliation worker
kubernetes.core.k8s:
state: present
definition: "{{ lookup('ansible.builtin.template', 'membership-workloads.yml.j2') | from_yaml_all | list }}"
- name: Wait for membership web and worker rollouts
kubernetes.core.k8s_info:
api_version: apps/v1
kind: Deployment
name: "{{ item }}"
namespace: "{{ www_namespace }}"
wait: true
wait_timeout: "{{ www_wait_timeout }}"
wait_condition:
type: Available
status: "True"
loop: [membership, membership-worker]
when: not ansible_check_mode
+71
View File
@@ -0,0 +1,71 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: membership-config
namespace: {{ www_namespace }}
data:
{% for key, value in www_membership_environment | dictsort %}
{{ key }}: {{ value | string | to_json }}
{% endfor %}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: membership-tiers
namespace: {{ www_namespace }}
data:
tiers.json: {{ www_membership_tiers | to_json | to_json }}
---
# Only Apache may assert the trusted proxy headers to the membership service.
# Requires a CNI which actually enforces NetworkPolicy.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: membership-web
namespace: {{ www_namespace }}
spec:
podSelector:
matchLabels:
app: membership
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
app: httpd
ports:
- protocol: TCP
port: 8000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: membership-worker
namespace: {{ www_namespace }}
spec:
podSelector:
matchLabels:
app: membership-worker
policyTypes: [Ingress]
ingress: []
{% if www_membership_postgres_managed %}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: membership-db
namespace: {{ www_namespace }}
spec:
podSelector:
matchLabels:
app: membership-db
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
www-membership-client: "true"
ports:
- protocol: TCP
port: 5432
{% endif %}
+22
View File
@@ -0,0 +1,22 @@
{% if www_membership_enabled %}
ProxyRequests Off
ProxyPreserveHost On
ProxyPass "/members/" "http://membership:8000/members/" connectiontimeout=5 timeout=120
ProxyPassReverse "/members/" "http://membership:8000/members/"
<Location "/members/">
# NetworkPolicy permits only Apache to assert these headers.
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}"
LimitRequestBody 262144
# The Tor/public static mirror remains available, but account/billing flows
# use only the canonical HTTPS origin registered in Dex and Stripe.
<If "%{HTTP_HOST} != '{{ www_hostname }}'">
Require all denied
</If>
</Location>
{% else %}
# Membership proxy disabled. Existing stateful workloads are not deleted.
<Location "/members/">
Require all denied
</Location>
{% endif %}
+16
View File
@@ -0,0 +1,16 @@
{% set component = 'migrate' %}
apiVersion: batch/v1
kind: Job
metadata:
name: membership-migrate-{{ _www_membership_revision[:16] }}
namespace: {{ www_namespace }}
spec:
backoffLimit: 2
activeDeadlineSeconds: {{ www_wait_timeout }}
template:
metadata:
labels:
app: membership-migrate
www-membership-client: "true"
spec:
{% filter indent(6, true) %}{% include 'membership-pod.yml.j2' %}{% endfilter %}
+79
View File
@@ -0,0 +1,79 @@
# Shared pod spec for web, worker and migration Job.
automountServiceAccountToken: false
restartPolicy: {{ 'OnFailure' if component == 'migrate' else 'Always' }}
{% if www_image_pull_secrets | length %}
imagePullSecrets:
{{ www_image_pull_secrets | to_nice_yaml | indent(2, true) }}
{% endif %}
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: {{ component }}
image: {{ www_membership_image | to_json }}
imagePullPolicy: {{ www_membership_image_pull_policy }}
{% if component == 'migrate' %}
command: [python, manage.py, migrate, --noinput]
{% elif component == 'worker' %}
command: [python, manage.py, sync_memberships, --loop, --interval, {{ www_membership_worker_interval | string | to_json }}]
{% endif %}
envFrom:
- configMapRef:
name: membership-config
- secretRef:
name: {{ www_membership_secret_name }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
{{ (www_membership_worker_resources if component == 'worker' else www_membership_resources) | to_nice_yaml | indent(6, true) }}
{% if component == 'web' %}
ports:
- name: http
containerPort: 8000
startupProbe:
tcpSocket: {port: http}
periodSeconds: 5
failureThreshold: 24
readinessProbe:
exec:
command:
- python
- -c
- {{ ("import http.client; c=http.client.HTTPConnection('127.0.0.1',8000,timeout=3); c.request('GET','/members/',headers={'Host': '" ~ www_hostname ~ "', 'X-Forwarded-Proto': 'https'}); r=c.getresponse(); assert r.status==200; c.close()") | to_json }}
timeoutSeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket: {port: http}
periodSeconds: 20
{% endif %}
volumeMounts:
- name: tiers
mountPath: /etc/membership/tiers
readOnly: true
- name: tmp
mountPath: /tmp
{% if www_membership_ca_secret_name | length %}
- name: ca
mountPath: /etc/membership/ca
readOnly: true
{% endif %}
volumes:
- name: tiers
configMap:
name: membership-tiers
- name: tmp
emptyDir:
sizeLimit: 64Mi
{% if www_membership_ca_secret_name | length %}
- name: ca
secret:
secretName: {{ www_membership_ca_secret_name }}
defaultMode: 0444
{% endif %}
+90
View File
@@ -0,0 +1,90 @@
apiVersion: v1
kind: Service
metadata:
name: membership-db
namespace: {{ www_namespace }}
spec:
type: ClusterIP
clusterIP: None
selector:
app: membership-db
ports:
- name: postgres
port: 5432
targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: membership-db
namespace: {{ www_namespace }}
spec:
serviceName: membership-db
replicas: 1
selector:
matchLabels:
app: membership-db
template:
metadata:
labels:
app: membership-db
spec:
automountServiceAccountToken: false
{% if www_image_pull_secrets | length %}
imagePullSecrets:
{{ www_image_pull_secrets | to_nice_yaml | indent(8, true) }}
{% endif %}
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: postgres
image: {{ www_membership_postgres_image | to_json }}
env:
- name: POSTGRES_DB
value: {{ www_membership_environment.POSTGRES_DB | to_json }}
- name: POSTGRES_USER
value: {{ www_membership_environment.POSTGRES_USER | to_json }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ www_membership_secret_name }}
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- name: postgres
containerPort: 5432
readinessProbe:
exec:
command: [sh, -c, 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"']
periodSeconds: 5
startupProbe:
exec:
command: [sh, -c, 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"']
failureThreshold: 60
periodSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
{{ www_membership_postgres_resources | to_nice_yaml | indent(12, true) }}
volumeMounts:
- name: database
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: database
spec:
accessModes: [ReadWriteOnce]
{% if www_membership_postgres_storage_class | length %}
storageClassName: {{ www_membership_postgres_storage_class | to_json }}
{% endif %}
resources:
requests:
storage: {{ www_membership_postgres_storage_size }}
+41
View File
@@ -0,0 +1,41 @@
{% for component in ['web', 'worker'] %}
{% set deployment = 'membership' if component == 'web' else 'membership-worker' %}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ deployment }}
namespace: {{ www_namespace }}
spec:
replicas: {{ www_membership_replicas if component == 'web' else 1 }}
{% if component == 'worker' %}
strategy:
type: Recreate
{% endif %}
selector:
matchLabels:
app: {{ deployment }}
template:
metadata:
labels:
app: {{ deployment }}
www-membership-client: "true"
annotations:
membership/config-revision: {{ _www_membership_revision | to_json }}
spec:
{% filter indent(6, true) %}{% include 'membership-pod.yml.j2' %}{% endfilter %}
{% endfor %}
---
apiVersion: v1
kind: Service
metadata:
name: membership
namespace: {{ www_namespace }}
spec:
type: ClusterIP
selector:
app: membership
ports:
- name: http
port: 8000
targetPort: http
+172
View File
@@ -0,0 +1,172 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
namespace: {{ www_namespace }}
spec:
accessModes: [ReadWriteMany]
resources:
requests:
storage: {{ www_archive_storage_size }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: httpd
namespace: {{ www_namespace }}
data:
httpd.conf: {{ lookup('ansible.builtin.file', role_path ~ '/files/httpd.conf') | to_json }}
membership.conf: {{ _www_proxy_config | to_json }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: httpd
namespace: {{ www_namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: httpd
template:
metadata:
labels:
app: httpd
annotations:
www/config-revision: {{ (lookup('ansible.builtin.file', role_path ~ '/files/httpd.conf') ~ _www_proxy_config ~ www_rollout_revision ~ www_repo_url ~ www_repo_version) | hash('sha256') | to_json }}
spec:
automountServiceAccountToken: false
{% if www_image_pull_secrets | length %}
imagePullSecrets:
{{ www_image_pull_secrets | to_nice_yaml | indent(8, true) }}
{% endif %}
initContainers:
- name: init
image: {{ www_source_image | to_json }}
command:
- /bin/bash
- -ec
- |
apt-get update
apt-get install --no-install-recommends -y git ca-certificates
git clone --depth 1 -- "$WWW_REPO_URL" /tmp/www
git -C /tmp/www fetch --depth 1 origin "$WWW_REPO_VERSION"
git -C /tmp/www checkout --detach FETCH_HEAD
# Never delete /data: it contains persistent archives, not just
# this repository's static site. Copy hidden site assets too.
cp -a /tmp/www/htdocs/. /data/
env:
- name: WWW_REPO_URL
value: {{ www_repo_url | to_json }}
- name: WWW_REPO_VERSION
value: {{ www_repo_version | to_json }}
volumeMounts:
- name: data
mountPath: /data
containers:
- name: httpd
image: {{ www_httpd_image | to_json }}
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
- name: metrics
containerPort: 9117
readinessProbe:
tcpSocket: {port: http}
volumeMounts:
- name: data
mountPath: /usr/local/apache2/htdocs
- name: httpd
mountPath: /usr/local/apache2/conf/httpd.conf
subPath: httpd.conf
readOnly: true
- name: httpd
mountPath: /usr/local/apache2/conf/extra/membership.conf
subPath: membership.conf
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: data
- name: httpd
configMap:
name: httpd
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: {{ www_namespace }}
spec:
type: ClusterIP
selector:
app: httpd
ports:
- name: http
port: 9117
targetPort: metrics
---
apiVersion: v1
kind: Service
metadata:
name: httpd
namespace: {{ www_namespace }}
spec:
type: ClusterIP
selector:
app: httpd
ports:
- name: http
port: 80
targetPort: http
{% if www_onion_enabled %}
---
apiVersion: tor.k8s.torproject.org/v1alpha2
kind: OnionService
metadata:
name: httpd
namespace: {{ www_namespace }}
spec:
version: 3
rules:
- port:
number: 80
backend:
service:
name: httpd
port:
number: 80
{% endif %}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: www
namespace: {{ www_namespace }}
annotations:
cert-manager.io/cluster-issuer: {{ www_cluster_issuer | to_json }}
nginx.ingress.kubernetes.io/ssl-redirect: "true"
# Redact email verification tokens and OIDC codes at the edge, not only in
# Apache/Gunicorn. Do not enable request-body/error tracing for /members/.
nginx.ingress.kubernetes.io/enable-access-log: "false"
nginx.ingress.kubernetes.io/proxy-body-size: "256k"
nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec:
ingressClassName: {{ www_ingress_class }}
rules:
- host: {{ www_hostname }}
http:
paths:
- pathType: Prefix
path: /
backend:
service:
name: httpd
port:
number: 80
tls:
- hosts: [{{ www_hostname }}]
secretName: www
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.13-slim-trixie
RUN pip install --no-cache-dir 'ansible-core>=2.18,<2.19' 'kubernetes>=28,<34' PyYAML 'kubernetes-validate>=1.31,<1.35'
COPY requirements.yml /tmp/requirements.yml
RUN ansible-galaxy collection install -r /tmp/requirements.yml
WORKDIR /roles/ansible-role-www
COPY . ./
ENV ANSIBLE_ROLES_PATH=/roles ANSIBLE_LOCAL_TEMP=/tmp/ansible-local
CMD ["sh", "-ec", "ansible-playbook -i localhost, tests/test.yml --syntax-check; ansible-playbook -i localhost, tests/syntax.yml --syntax-check; ansible-playbook -i localhost, tests/syntax.yml --tags membership_validate; ansible-playbook -i localhost, tests/render.yml; python tests/validate_manifests.py"]
+14
View File
@@ -0,0 +1,14 @@
---
# Credentials must come from Vault or an existing Kubernetes Secret.
www_hostname: www.eom.dev
www_membership_image: registry.example.org/www-membership:2026-10-23
www_repo_version: refs/heads/main
www_membership_secret_name: www-membership-credentials
www_membership_shkeeper_enabled: true
www_membership_environment_overrides:
LDAP_BIND_DN: cn=www-membership,ou=system,dc=eom,dc=dev
LDAP_PASSWORD_SCHEME: "{ARGON2}"
EMAIL_HOST: smtp.eom.dev
STRIPE_PORTAL_CONFIGURATION: bpc_REPLACE
SHKEEPER_URL: https://shkeeper.example.org
SHKEEPER_CURRENCIES: BTC,XMR
+53
View File
@@ -0,0 +1,53 @@
---
- name: Render membership manifests without contacting a cluster
hosts: localhost
connection: local
gather_facts: false
vars:
role_path: /roles/ansible-role-www
tasks:
- name: Load role defaults
ansible.builtin.include_vars: ../defaults/main.yml
- name: Supply nonsecret fixture settings
ansible.builtin.set_fact:
www_membership_image: registry.example.org/www-membership:release-test
_www_membership_revision: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
www_membership_environment: "{{ www_membership_environment | combine({'MEMBERSHIP_PUBLIC_URL': 'https://' ~ www_hostname, 'LDAP_BIND_DN': 'cn=membership,dc=example,dc=org', 'LDAP_PASSWORD_SCHEME': '{ARGON2}', 'EMAIL_HOST': 'smtp.example.org', 'STRIPE_PORTAL_CONFIGURATION': 'bpc_test'}) }}"
- name: Render Apache proxy fixture
ansible.builtin.set_fact:
_www_proxy_config: "{{ lookup('ansible.builtin.template', role_path ~ '/templates/membership-httpd.conf.j2') }}"
- name: Write Apache fixture for container syntax tests
ansible.builtin.template:
src: ../templates/membership-httpd.conf.j2
dest: /tmp/www-test-membership.conf
mode: "0600"
- name: Render standard deployment variants
ansible.builtin.template:
src: "../templates/{{ item }}.yml.j2"
dest: "/tmp/www-test-{{ item }}.yml"
mode: "0600"
loop: [website, membership-config, membership-postgres, membership-migrate, membership-workloads]
- name: Enable optional external database and private CA fixture
ansible.builtin.set_fact:
www_membership_postgres_managed: false
www_membership_ca_secret_name: private-ca
www_image_pull_secrets: [{name: registry-pull}]
www_membership_environment: "{{ www_membership_environment | combine({'POSTGRES_HOST': 'postgres.example.org', 'POSTGRES_SSLMODE': 'verify-full', 'PGSSLROOTCERT': '/etc/membership/ca/db.pem', 'LDAP_CA_CERT_FILE': '/etc/membership/ca/ldap.pem', 'SHKEEPER_URL': 'https://crypto.example.org', 'SHKEEPER_CURRENCIES': 'BTC,XMR', 'SHKEEPER_CA_CERT_FILE': '/etc/membership/ca/shkeeper.pem'}) }}"
- name: Render external provider variants
ansible.builtin.template:
src: "../templates/{{ item }}.yml.j2"
dest: "/tmp/www-test-external-{{ item }}.yml"
mode: "0600"
loop: [membership-config, membership-workloads, membership-migrate]
- name: Render disabled membership proxy variant
ansible.builtin.set_fact:
www_membership_enabled: false
www_onion_enabled: false
- name: Render disabled Apache fixture
ansible.builtin.set_fact:
_www_proxy_config: "{{ lookup('ansible.builtin.template', role_path ~ '/templates/membership-httpd.conf.j2') }}"
- name: Render disabled website
ansible.builtin.template:
src: ../templates/website.yml.j2
dest: /tmp/www-test-disabled-website.yml
mode: "0600"
+11
View File
@@ -0,0 +1,11 @@
---
- name: Parse all deployment tasks without contacting a cluster
hosts: localhost
connection: local
gather_facts: false
vars_files:
- ../defaults/main.yml
- example-vars.yml
tasks:
- name: Parse membership deployment tasks
ansible.builtin.import_tasks: ../tasks/membership.yml
+89
View File
@@ -0,0 +1,89 @@
"""Offline invariants for manifests rendered by real Ansible/Jinja."""
import json
from pathlib import Path
import yaml
from kubernetes_validate import validate
def docs(name):
return [doc for doc in yaml.safe_load_all(Path(f"/tmp/www-test-{name}.yml").read_text()) if doc]
def find(objects, kind, name):
return next(obj for obj in objects if obj["kind"] == kind and obj["metadata"]["name"] == name)
def pod_checks(pod, external=False):
assert pod["automountServiceAccountToken"] is False
assert pod["securityContext"]["runAsNonRoot"] is True
container = pod["containers"][0]
assert container["securityContext"]["readOnlyRootFilesystem"] is True
assert container["securityContext"]["capabilities"]["drop"] == ["ALL"]
assert container["envFrom"] == [{"configMapRef": {"name": "membership-config"}}, {"secretRef": {"name": "membership"}}]
assert not container.get("env")
assert container["resources"]["limits"]["memory"]
assert any(mount["mountPath"] == "/etc/membership/tiers" and mount["readOnly"] for mount in container["volumeMounts"])
if external:
assert pod["imagePullSecrets"] == [{"name": "registry-pull"}]
assert any(volume.get("secret", {}).get("secretName") == "private-ca" for volume in pod["volumes"])
assert any(mount["mountPath"] == "/etc/membership/ca" and mount["readOnly"] for mount in container["volumeMounts"])
all_objects = []
for name in ["website", "membership-config", "membership-postgres", "membership-migrate", "membership-workloads"]:
all_objects.extend(docs(name))
for path in Path('/tmp').glob('www-test-*.yml'):
for obj in yaml.safe_load_all(path.read_text()):
if obj and obj['kind'] != 'OnionService':
validate(obj, desired_version='1.31.0', strict=True)
assert all(obj["metadata"]["namespace"] == "www" for obj in all_objects)
assert len({(obj["kind"], obj["metadata"]["name"]) for obj in all_objects}) == len(all_objects)
config = find(all_objects, "ConfigMap", "membership-config")["data"]
assert all(isinstance(value, str) for value in config.values())
assert config["MEMBERSHIP_PUBLIC_URL"] == "https://www.eom.dev"
assert "MEMBERSHIP_SECRET_KEY" not in config and "SHKEEPER_API_KEY" not in config
tiers = json.loads(find(all_objects, "ConfigMap", "membership-tiers")["data"]["tiers.json"])
assert len(tiers) == 5 and tiers["level-4"]["usd_amount"] == "64.00"
for name in ["membership", "membership-worker"]:
deployment = find(all_objects, "Deployment", name)
pod_checks(deployment["spec"]["template"]["spec"])
web = find(all_objects, "Deployment", "membership")["spec"]["template"]["spec"]["containers"][0]
assert "assert r.status==200" in web["readinessProbe"]["exec"]["command"][-1]
worker = find(all_objects, "Deployment", "membership-worker")
assert worker["spec"]["replicas"] == 1 and worker["spec"]["strategy"]["type"] == "Recreate"
assert "sync_memberships" in worker["spec"]["template"]["spec"]["containers"][0]["command"]
job = next(obj for obj in all_objects if obj["kind"] == "Job")
assert job["metadata"]["name"] == "membership-migrate-" + "a" * 16
pod_checks(job["spec"]["template"]["spec"])
assert job["spec"]["template"]["spec"]["containers"][0]["command"] == ["python", "manage.py", "migrate", "--noinput"]
db = find(all_objects, "StatefulSet", "membership-db")
assert db["spec"]["volumeClaimTemplates"][0]["spec"]["accessModes"] == ["ReadWriteOnce"]
assert "persistentVolumeClaimRetentionPolicy" not in db["spec"]
assert find(all_objects, "Service", "membership-db")["spec"]["type"] == "ClusterIP"
assert find(all_objects, "Service", "membership")["spec"]["type"] == "ClusterIP"
policy = find(all_objects, "NetworkPolicy", "membership-web")
assert policy["spec"]["ingress"][0]["from"] == [{"podSelector": {"matchLabels": {"app": "httpd"}}}]
apache = find(all_objects, "ConfigMap", "httpd")["data"]
assert "http://membership:8000/members/" in apache["membership.conf"]
assert "X-Forwarded-Proto" in apache["membership.conf"]
assert "Require all denied" in apache["membership.conf"]
assert "env=!membership_sensitive" in apache["httpd.conf"]
assert "LoadModule proxy_http_module" in apache["httpd.conf"]
source = find(all_objects, "Deployment", "httpd")["spec"]["template"]["spec"]["initContainers"][0]
assert "rm -rf" not in source["command"][-1]
assert 'cp -a /tmp/www/htdocs/. /data/' in source["command"][-1]
ingress = find(all_objects, "Ingress", "www")
assert ingress["metadata"]["annotations"]["nginx.ingress.kubernetes.io/enable-access-log"] == "false"
assert ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] == "httpd"
for obj in docs("external-membership-workloads"):
if obj["kind"] == "Deployment":
pod_checks(obj["spec"]["template"]["spec"], external=True)
external_config = docs("external-membership-config")
assert not any(obj["kind"] == "NetworkPolicy" and obj["metadata"]["name"] == "membership-db" for obj in external_config)
assert find(external_config, "ConfigMap", "membership-config")["data"]["POSTGRES_SSLMODE"] == "verify-full"
disabled = docs("disabled-website")
assert not any(obj["kind"] == "OnionService" for obj in disabled)
assert "ProxyPass" not in find(disabled, "ConfigMap", "httpd")["data"]["membership.conf"]
print(f"PASS: {len(all_objects)} Kubernetes objects; default, external/CA/SHKeeper and disabled variants")