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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.render/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
<form method="post" action="https://listmonk.eom.dev/subscription/form">
|
||||||
|
<input type="hidden" name="nonce">
|
||||||
|
<input type="email" name="email" required placeholder="you@example.com">
|
||||||
|
<input type="hidden" name="l" value="LIST-UUID">
|
||||||
|
<button type="submit">Subscribe</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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 <news@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
|
||||||
@@ -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; }
|
||||||
@@ -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); }
|
||||||
@@ -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: []
|
||||||
@@ -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 }}"
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
localhost
|
||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user