commit 22a7ffe67676173cc2c946afe587474380a348ac Author: Claude Date: Fri Nov 6 20:17:30 2026 -0500 Initial listmonk role: eom.dev newsletter on Kubernetes listmonk v6.2.0 with PostgreSQL 17, Dex login for listed admins, delivery through postfix.eom.dev, Catppuccin public pages, Altcha captcha and a double opt-in "eom.dev" list. Database-held settings are converged by an idempotent SQL script, tested against v6.2.0. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f02c61 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.render/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96a9117 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..614a0c9 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# ansible-role-listmonk + +Deploys [listmonk](https://listmonk.app) v6.2.0, the eom.dev newsletter, to Kubernetes. + +It fits in with the rest of eom.dev: + +- **Login** goes through Dex (`listmonk` static client) with the same "eom.dev account" button as the other services. Only e-mail addresses listed in `listmonk_oidc_admins` can sign in, so being a member is not enough to get into the admin UI. A password login for the `admin` user is kept as a fallback. +- **Mail** goes out through `postfix.eom.dev:587` as the LDAP account `news`, the same way Gitea, Nextcloud and PeerTube send mail. +- **Look**: the public pages (subscribe, archive, preferences, unsubscribe) use the Catppuccin Mocha/Latte palette from `dex.eom.dev`, along with the eom.dev logo and favicon. +- **Cluster conventions**: `ca-issuer` TLS, `nfs-client` volumes, and anti-affinity that keeps the pods off the ingress-nginx node. + +## What it creates + +| Object | Purpose | +| --- | --- | +| `Secret/listmonk` | DB password and bootstrap admin credentials | +| `StatefulSet/listmonk-db` | PostgreSQL 17 (alpine, uid 70) on `nfs-client` | +| `Deployment/listmonk` | Runs `--install --idempotent`, then `--upgrade`, then serves on :9000 | +| `PVC/listmonk-uploads` | Media uploaded for campaigns | +| `Ingress/listmonk` | `https://listmonk.eom.dev` | + +listmonk keeps most of its configuration in the `settings` table, not in a config file. The role writes those settings with an idempotent SQL script (`templates/configure.sql.j2`) that it runs in the database pod. The script covers SMTP, Dex, theme, captcha, privacy, the Dex admin users and the lists in `listmonk_lists`. On the first run it also removes listmonk's sample data. listmonk is restarted only when a row actually changes. + +Settings the role manages are reset on every run. Change them in `defaults/main.yml` or `listmonk_settings_extra`, not in the UI. Everything else, including templates, campaigns and subscribers, stays under the UI's control. + +## Required variables (Vault) + +| Variable | Notes | +| --- | --- | +| `listmonk_db_password` | 16+ chars. Only used when the volume is first initialised | +| `listmonk_admin_password` | 8+ chars, for the `admin` fallback login | +| `listmonk_smtp_password` | Password of the `news` LDAP account | +| `listmonk_oidc_client_secret` | Must match the `listmonk` client in Dex | + +The eom.dev playbook maps them from the Vault names `listmonk_postgresql_password`, `listmonk_admin_password`, `listmonk_smtp_password` and `listmonk_dex_client_secret`. + +## One-time setup outside this role + +1. In LDAP, create the posix account `news` with `mail: news@eom.dev` and add it to `cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev`. Its password is `listmonk_smtp_password`. +2. Add the `listmonk` client to Dex (`ericomeehan.dex/playbooks/deploy-eom.yml`) and run that play. + +## Embedding a signup form + +listmonk accepts a plain HTML POST, so any eom.dev page can include a form: + +```html +
+ + + + +
+``` + +Copy the list UUID from **Lists → eom.dev → Forms** in the admin UI. The form uses double opt-in, so subscribers have to confirm by e-mail. The page at `https://listmonk.eom.dev/subscription/form` adds an Altcha proof-of-work captcha. + +## Testing + +```sh +ansible-playbook -i tests/inventory tests/render.yml # renders into .render/ +``` + +This version was tested against `listmonk/listmonk:v6.2.0` and `postgres:17.11-alpine`. The SQL changes 12 settings, 1 user, 1 list and the sample data on the first run, and nothing on the second. + +## License + +MIT-0 diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..d0bd3ee --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: MIT-0 +--- +listmonk_name: listmonk +listmonk_namespace: listmonk +listmonk_create_namespace: true + +# null uses normal kubeconfig discovery, including KUBECONFIG. +listmonk_kubeconfig: null +listmonk_kube_context: null +listmonk_wait_timeout: 600 + +listmonk_image: listmonk/listmonk:v6.2.0 +listmonk_postgres_image: postgres:17.11-alpine +listmonk_image_pull_policy: IfNotPresent + +listmonk_host: listmonk.eom.dev +listmonk_root_url: "https://{{ listmonk_host }}" +listmonk_ingress_class_name: nginx +listmonk_ingress_annotations: + cert-manager.io/cluster-issuer: ca-issuer + nginx.ingress.kubernetes.io/proxy-body-size: 16m +listmonk_tls_secret_name: "{{ listmonk_name }}-tls" + +listmonk_storage_class: nfs-client +listmonk_db_storage_size: 2Ti +listmonk_uploads_storage_size: 2Ti + +# Required secrets (Vault). The database password is only read on first +# initialisation of the volume; change it in Postgres before changing it here. +listmonk_db_password: "" +# Break-glass password login for the "admin" super admin. Day-to-day login is Dex. +listmonk_admin_user: admin +listmonk_admin_password: "" + +# Mail goes out through postfix.eom.dev as an LDAP account in "Postfix Users", +# the same pattern Gitea, Nextcloud and PeerTube use. SPOOF_PROTECTION requires +# the From address to belong to that account. +listmonk_smtp_host: postfix.eom.dev +listmonk_smtp_port: 587 +listmonk_smtp_tls_type: STARTTLS +listmonk_smtp_username: news +listmonk_smtp_password: "" +listmonk_smtp_max_conns: 4 +listmonk_from_email: "eom.dev " + +# Log in with Dex. Users are matched by e-mail and must already exist in +# listmonk, so being an eom.dev member alone grants nothing. +listmonk_oidc_enabled: true +listmonk_oidc_provider_url: https://dex.eom.dev/ +listmonk_oidc_provider_name: eom.dev account +listmonk_oidc_client_id: listmonk +listmonk_oidc_client_secret: "" +# Accounts allowed to log in through Dex as Super Admin. +listmonk_oidc_admins: + - username: eric + name: Eric Meehan + email: eric@eom.dev + +listmonk_site_name: eom.dev +listmonk_logo_url: https://dex.eom.dev/theme/logo.svg +listmonk_favicon_url: https://www.eom.dev/static/favicon.svg +listmonk_notify_emails: + - eric@eom.dev + +# Lists are created by name if missing and never deleted. Existing lists keep +# any edits made in the UI except for type, optin and description. +listmonk_lists: + - name: eom.dev + type: public + optin: double + tags: [newsletter] + description: >- + One short email per premiere, plus the monthly essay. Nothing else. + +# Drop the sample lists, subscribers and campaign created by --install. +listmonk_remove_sample_data: true + +# Extra settings rows (key: JSON value) merged over the role's own. +listmonk_settings_extra: {} + +listmonk_resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi +listmonk_postgres_resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + +# Keep off the node that runs the ingress controller, like the other services. +listmonk_affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - topologyKey: kubernetes.io/hostname + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + labelSelector: + matchLabels: + app.kubernetes.io/component: controller + app.kubernetes.io/name: ingress-nginx diff --git a/files/admin.css b/files/admin.css new file mode 100644 index 0000000..9d79415 --- /dev/null +++ b/files/admin.css @@ -0,0 +1,9 @@ +/* eom.dev: Catppuccin accent in the admin UI, light theme kept for readability. */ +:root { + --primary: #8839ef; + --link: #8839ef; +} +.navbar.is-dark, .navbar { border-bottom: 3px solid #8839ef; } +.button.is-primary, .tag.is-primary { background-color: #8839ef; } +.button.is-primary:hover { background-color: #7287fd; } +a { color: #8839ef; } diff --git a/files/public.css b/files/public.css new file mode 100644 index 0000000..b5838dc --- /dev/null +++ b/files/public.css @@ -0,0 +1,73 @@ +/* eom.dev: Catppuccin Mocha / Latte, matching dex.eom.dev. */ +:root { + color-scheme: dark; + --base: #1e1e2e; + --mantle: #181825; + --surface: #313244; + --border: #585b70; + --text: #cdd6f4; + --muted: #a6adc8; + --accent: #cba6f7; + --accent-hover: #b4befe; + --on-accent: #11111b; + --error: #f38ba8; + --focus: #fab387; +} +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + --base: #eff1f5; + --mantle: #e6e9ef; + --surface: #fff; + --border: #9ca0b0; + --text: #4c4f69; + --muted: #5c5f77; + --accent: #8839ef; + --accent-hover: #7287fd; + --on-accent: #fff; + --error: #d20f39; + --focus: #fe640b; + } +} +html, body { + background: var(--base); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.6; +} +a { color: var(--accent); } +a:hover { color: var(--accent-hover); } +h1, h2, h3, h4, label { color: var(--text); } +.small, .lists .description, .archive .date, footer.container, footer a { color: var(--muted); } +.wrap { + background: var(--mantle); + border: 1px solid var(--border); + border-radius: 1.25rem; + box-shadow: 0 12px 40px rgb(0 0 0 / 10%); +} +.header { border-bottom: 1px solid var(--border); } +.header .logo img { max-width: 240px; max-height: 72px; } +input[type="text"], input[type="email"], input[type="password"], select { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 0.6rem; +} +input:focus, select:focus, .button:focus-visible { + border-color: var(--focus); + outline: 2px solid var(--focus); + outline-offset: 1px; +} +input::placeholder { color: var(--muted); } +.button { + background: var(--accent); + color: var(--on-accent); + border: 1px solid var(--accent); + border-radius: 0.6rem; + font-weight: 650; +} +.button:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); } +.button.button-outline { background: transparent; color: var(--accent); } +.button.button-outline:hover { background: var(--surface); color: var(--accent-hover); } +.error { color: var(--error); } +.pg-page.pg-selected { background: var(--accent); color: var(--on-accent); } diff --git a/meta/main.yml b/meta/main.yml new file mode 100644 index 0000000..c72bb2f --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MIT-0 +--- +galaxy_info: + role_name: listmonk + author: Eric Meehan + description: Deploy the eom.dev newsletter (listmonk) to Kubernetes with Dex login and postfix.eom.dev delivery. + license: MIT-0 + min_ansible_version: "2.16" + galaxy_tags: + - kubernetes + - listmonk + - newsletter + - email +dependencies: [] diff --git a/playbooks/listmonk.yaml b/playbooks/listmonk.yaml new file mode 100644 index 0000000..003468b --- /dev/null +++ b/playbooks/listmonk.yaml @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MIT-0 +# eom.dev deployment. Copy of software-infrastructure/listmonk.yaml. +- name: Deploy listmonk + hosts: alpha-control-plane + become: true + vars_files: + - ../secrets.yaml + roles: + - role: ericomeehan.listmonk + # listmonk_admin_password and listmonk_smtp_password come from Vault as-is. + vars: + listmonk_db_password: "{{ listmonk_postgresql_password }}" + listmonk_oidc_client_secret: "{{ listmonk_dex_client_secret }}" diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..2086063 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Validate required secrets + ansible.builtin.assert: + that: + - listmonk_db_password | length >= 16 + - listmonk_admin_password | length >= 8 + - listmonk_smtp_password | length > 0 + - not listmonk_oidc_enabled | bool or listmonk_oidc_client_secret | length > 0 + fail_msg: >- + Set listmonk_db_password (16+ chars), listmonk_admin_password (8+), + listmonk_smtp_password and listmonk_oidc_client_secret in Vault. + quiet: true + +- name: Create namespace + kubernetes.core.k8s: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ listmonk_namespace }}" + when: listmonk_create_namespace | bool + +- name: Store credentials + kubernetes.core.k8s: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + state: present + apply: true + definition: "{{ lookup('ansible.builtin.template', 'secret.yml.j2') | from_yaml }}" + no_log: true + +- name: Deploy PostgreSQL + kubernetes.core.k8s: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + state: present + apply: true + definition: "{{ lookup('ansible.builtin.template', 'postgres.yml.j2') | from_yaml_all | list }}" + wait: true + wait_timeout: "{{ listmonk_wait_timeout }}" + +- name: Deploy listmonk + kubernetes.core.k8s: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + state: present + apply: true + definition: "{{ lookup('ansible.builtin.template', 'listmonk.yml.j2') | from_yaml_all | list }}" + wait: true + wait_timeout: "{{ listmonk_wait_timeout }}" + +# Settings, Dex admins and lists live in listmonk's database, not its config. +- name: Converge database-held configuration + kubernetes.core.k8s_exec: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + namespace: "{{ listmonk_namespace }}" + pod: "{{ listmonk_name }}-db-0" + container: postgres + command: >- + sh -c 'echo {{ lookup('ansible.builtin.template', 'configure.sql.j2') | b64encode }} + | base64 -d | psql -U listmonk -d listmonk -qtA' + register: listmonk_configure + changed_when: listmonk_configure.stdout_lines | map('int') | sum > 0 + no_log: true + +- name: Restart listmonk to load the new settings + kubernetes.core.k8s: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + state: patched + kind: Deployment + namespace: "{{ listmonk_namespace }}" + name: "{{ listmonk_name }}" + definition: + spec: + template: + metadata: + annotations: + kubectl.kubernetes.io/restartedAt: "{{ now(utc=true).isoformat() }}" + wait: true + wait_condition: + type: Available + status: "True" + wait_timeout: "{{ listmonk_wait_timeout }}" + when: listmonk_configure is changed diff --git a/templates/configure.sql.j2 b/templates/configure.sql.j2 new file mode 100644 index 0000000..0144e0f --- /dev/null +++ b/templates/configure.sql.j2 @@ -0,0 +1,126 @@ +{#- Converges listmonk's database-held configuration. Every statement prints + the number of rows it changed, so the play can report "changed" and only + restart listmonk when something actually moved. -#} +{%- macro q(s) -%}$lmv${{ s }}$lmv${%- endmacro -%} +{%- set settings = { + 'app.site_name': listmonk_site_name, + 'app.root_url': listmonk_root_url, + 'app.from_email': listmonk_from_email, + 'app.logo_url': listmonk_logo_url, + 'app.favicon_url': listmonk_favicon_url, + 'app.notify_emails': listmonk_notify_emails, + 'app.enable_public_subscription_page': true, + 'app.enable_public_archive': true, + 'app.send_optin_confirmation': true, + 'privacy.individual_tracking': false, + 'privacy.unsubscribe_header': true, + 'privacy.record_optin_ip': false, + 'security.captcha': { + 'altcha': {'enabled': true, 'complexity': 300000}, + 'hcaptcha': {'enabled': false, 'key': '', 'secret': ''}, + }, + 'security.oidc': { + 'enabled': listmonk_oidc_enabled | bool, + 'provider_url': listmonk_oidc_provider_url, + 'provider_name': listmonk_oidc_provider_name, + 'client_id': listmonk_oidc_client_id, + 'client_secret': listmonk_oidc_client_secret, + 'auto_create_users': false, + 'default_user_role_id': none, + 'default_list_role_id': none, + }, + 'upload.provider': 'filesystem', + 'upload.filesystem.upload_path': '/listmonk/uploads', + 'upload.filesystem.upload_uri': '/uploads', + 'smtp': [{ + 'enabled': true, + 'host': listmonk_smtp_host, + 'port': listmonk_smtp_port | int, + 'auth_protocol': 'login', + 'username': listmonk_smtp_username, + 'password': listmonk_smtp_password, + 'hello_hostname': listmonk_host, + 'max_conns': listmonk_smtp_max_conns | int, + 'idle_timeout': '15s', + 'wait_timeout': '5s', + 'max_msg_retries': 2, + 'msg_retry_delay': '10ms', + 'tls_type': listmonk_smtp_tls_type, + 'tls_skip_verify': false, + 'email_headers': [], + 'from_addresses': [], + }], + 'appearance.public.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/public.css'), + 'appearance.admin.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/admin.css'), +} | combine(listmonk_settings_extra) -%} +\set ON_ERROR_STOP on +BEGIN; + +WITH desired(key, value) AS (VALUES +{% for k, v in settings.items() %} + ({{ q(k) }}, {{ q(v | to_json) }}::jsonb){{ ',' if not loop.last }} +{% endfor %} +), changed AS ( + UPDATE settings s SET value = d.value, updated_at = NOW() + FROM desired d + WHERE s.key = d.key AND s.value IS DISTINCT FROM d.value + RETURNING s.key +) +SELECT count(*) FROM changed; + +{% for u in listmonk_oidc_admins %} +WITH ins AS ( + INSERT INTO users (username, password_login, email, name, type, user_role_id, status) + SELECT {{ q(u.username) }}, false, {{ q(u.email | lower) }}, {{ q(u.name) }}, 'user', r.id, 'enabled' + FROM roles r WHERE r.name = 'Super Admin' AND r.type = 'user' + ON CONFLICT DO NOTHING + RETURNING id +) +SELECT count(*) FROM ins; +{% endfor %} + +{% for l in listmonk_lists %} +WITH ins AS ( + INSERT INTO lists (uuid, name, type, optin, tags, description) + SELECT gen_random_uuid(), {{ q(l.name) }}, {{ q(l.type | default('public')) }}::list_type, + {{ q(l.optin | default('double')) }}::list_optin, + ARRAY[{% for t in l.tags | default([]) %}{{ q(t) }}{{ ',' if not loop.last }}{% endfor %}]::varchar(100)[], + {{ q(l.description | default('') | trim) }} + WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {{ q(l.name) }}) + RETURNING id +) +SELECT count(*) FROM ins; +WITH upd AS ( + UPDATE lists SET type = {{ q(l.type | default('public')) }}::list_type, + optin = {{ q(l.optin | default('double')) }}::list_optin, + description = {{ q(l.description | default('') | trim) }}, + updated_at = NOW() + WHERE name = {{ q(l.name) }} + AND (type, optin, description) IS DISTINCT FROM + ({{ q(l.type | default('public')) }}::list_type, {{ q(l.optin | default('double')) }}::list_optin, {{ q(l.description | default('') | trim) }}) + RETURNING id +) +SELECT count(*) FROM upd; +{% endfor %} + +{% if listmonk_remove_sample_data | bool %} +WITH del AS ( + DELETE FROM campaigns + WHERE archive_slug = 'welcome-to-listmonk' AND status = 'draft' AND 'test-campaign' = ANY(tags) + RETURNING id +) +SELECT count(*) FROM del; +WITH del AS ( + DELETE FROM subscribers WHERE email IN ('john@example.com', 'anon@example.com') RETURNING id +) +SELECT count(*) FROM del; +WITH del AS ( + DELETE FROM lists l + WHERE l.name IN ('Default list', 'Opt-in list') AND l.tags = ARRAY['test']::varchar(100)[] + AND NOT EXISTS (SELECT 1 FROM subscriber_lists sl WHERE sl.list_id = l.id) + RETURNING id +) +SELECT count(*) FROM del; +{% endif %} + +COMMIT; diff --git a/templates/listmonk.yml.j2 b/templates/listmonk.yml.j2 new file mode 100644 index 0000000..17f7f6f --- /dev/null +++ b/templates/listmonk.yml.j2 @@ -0,0 +1,147 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ listmonk_name }}-uploads + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} +spec: + accessModes: [ReadWriteOnce] + storageClassName: {{ listmonk_storage_class }} + resources: + requests: + storage: {{ listmonk_uploads_storage_size }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ listmonk_name }} + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: app +spec: + replicas: 1 + # One writer: listmonk's campaign manager is not safe to run twice. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: app + template: + metadata: + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: app + annotations: + checksum/secret: {{ lookup('ansible.builtin.template', 'secret.yml.j2') | hash('sha256') }} + spec: + affinity: {{ listmonk_affinity | to_json }} + containers: + - name: listmonk + image: {{ listmonk_image }} + imagePullPolicy: {{ listmonk_image_pull_policy }} + # --config '' : configuration comes from the environment only. + # --install --idempotent only installs into an empty database; + # --upgrade runs migrations after an image bump. + command: + - sh + - -c + - >- + ./listmonk --install --idempotent --yes --config '' && + ./listmonk --upgrade --yes --config '' && + exec ./listmonk --config '' + env: + - name: TZ + value: America/New_York + - name: LISTMONK_app__address + value: 0.0.0.0:9000 + - name: LISTMONK_db__host + value: {{ listmonk_name }}-db + - name: LISTMONK_db__port + value: "5432" + - name: LISTMONK_db__user + value: listmonk + - name: LISTMONK_db__database + value: listmonk + - name: LISTMONK_db__ssl_mode + value: disable + envFrom: + - secretRef: + name: {{ listmonk_name }} + ports: + - name: http + containerPort: 9000 + startupProbe: + httpGet: + path: /health + port: http + periodSeconds: 5 + failureThreshold: 60 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 30 + timeoutSeconds: 5 + resources: {{ listmonk_resources | to_json }} + volumeMounts: + - name: uploads + mountPath: /listmonk/uploads + volumes: + - name: uploads + persistentVolumeClaim: + claimName: {{ listmonk_name }}-uploads +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ listmonk_name }} + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} +spec: + selector: + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: app + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ listmonk_name }} + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} + annotations: {{ listmonk_ingress_annotations | to_json }} +spec: + ingressClassName: {{ listmonk_ingress_class_name }} + tls: + - hosts: + - {{ listmonk_host }} + secretName: {{ listmonk_tls_secret_name }} + rules: + - host: {{ listmonk_host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ listmonk_name }} + port: + name: http diff --git a/templates/postgres.yml.j2 b/templates/postgres.yml.j2 new file mode 100644 index 0000000..33ad4ac --- /dev/null +++ b/templates/postgres.yml.j2 @@ -0,0 +1,90 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ listmonk_name }}-db + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: database +spec: + clusterIP: None + selector: + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: database + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ listmonk_name }}-db + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: database +spec: + serviceName: {{ listmonk_name }}-db + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: database + template: + metadata: + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} + app.kubernetes.io/component: database + spec: + # postgres:*-alpine runs as uid/gid 70. Running unprivileged skips the + # entrypoint's chown, which NFS with root_squash would refuse. + securityContext: + runAsUser: 70 + runAsGroup: 70 + fsGroup: 70 + affinity: {{ listmonk_affinity | to_json }} + containers: + - name: postgres + image: {{ listmonk_postgres_image }} + imagePullPolicy: {{ listmonk_image_pull_policy }} + env: + - name: POSTGRES_USER + value: listmonk + - name: POSTGRES_DB + value: listmonk + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ listmonk_name }} + key: POSTGRES_PASSWORD + ports: + - name: postgres + containerPort: 5432 + readinessProbe: + exec: + command: [pg_isready, -U, listmonk, -d, listmonk] + periodSeconds: 10 + livenessProbe: + exec: + command: [pg_isready, -U, listmonk, -d, listmonk] + initialDelaySeconds: 30 + periodSeconds: 30 + resources: {{ listmonk_postgres_resources | to_json }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ReadWriteOnce] + storageClassName: {{ listmonk_storage_class }} + resources: + requests: + storage: {{ listmonk_db_storage_size }} diff --git a/templates/secret.yml.j2 b/templates/secret.yml.j2 new file mode 100644 index 0000000..518e688 --- /dev/null +++ b/templates/secret.yml.j2 @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ listmonk_name }} + namespace: {{ listmonk_namespace }} + labels: + app.kubernetes.io/name: listmonk + app.kubernetes.io/instance: {{ listmonk_name }} +type: Opaque +stringData: + POSTGRES_PASSWORD: {{ listmonk_db_password | to_json }} + LISTMONK_db__password: {{ listmonk_db_password | to_json }} + LISTMONK_ADMIN_USER: {{ listmonk_admin_user | to_json }} + LISTMONK_ADMIN_PASSWORD: {{ listmonk_admin_password | to_json }} diff --git a/tests/inventory b/tests/inventory new file mode 100644 index 0000000..2fbb50c --- /dev/null +++ b/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/tests/render.yml b/tests/render.yml new file mode 100644 index 0000000..950dc17 --- /dev/null +++ b/tests/render.yml @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: MIT-0 +# Renders every template with throwaway values; no cluster access. +# ansible-playbook tests/render.yml -e out=/tmp/listmonk-render +- name: Render listmonk role templates + hosts: localhost + connection: local + gather_facts: false + vars: + role_path: "{{ playbook_dir }}/.." + out: "{{ playbook_dir }}/../.render" + tasks: + - name: Load role defaults + ansible.builtin.include_vars: "{{ role_path }}/defaults/main.yml" + - name: Throwaway secrets + ansible.builtin.set_fact: + listmonk_db_password: render-only-db-password + listmonk_admin_password: render-only + listmonk_smtp_password: render-only + listmonk_oidc_client_secret: render-only + - name: Output directory + ansible.builtin.file: + path: "{{ out }}" + state: directory + mode: "0700" + - name: Render + ansible.builtin.template: + src: "{{ role_path }}/templates/{{ item }}.j2" + dest: "{{ out }}/{{ item }}" + mode: "0600" + loop: [secret.yml, postgres.yml, listmonk.yml, configure.sql] + - name: Manifests parse as YAML + ansible.builtin.assert: + that: lookup('ansible.builtin.file', out ~ '/' ~ item) | from_yaml_all | list | length > 0 + quiet: true + loop: [secret.yml, postgres.yml, listmonk.yml]