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