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,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 }}
|
||||
Reference in New Issue
Block a user