mbox series
Message ID6cbd321133bc5cbd130ac582fdf684d3b87ea816.1780583783.git.pw@patches.jarry.cc
StateNew
Delegate
ArchivedNo
Headers
show
Message-ID: 
 <6cbd321133bc5cbd130ac582fdf684d3b87ea816.1780583783.git.pw@patches.jarry.cc>
In-Reply-To: <cover.1780583783.git.pw@patches.jarry.cc>
References: <cover.1780583783.git.pw@patches.jarry.cc>
From: Robin Jarry <robin@jarry.cc>
Date: Tue, 2 Jun 2026 15:32:45 +0200
Subject: [PATCH patchwork v4 15/15] deploy
Sender: pw@patches.jarry.cc
Reply-To: pw@patches.jarry.cc
List-ID: <pw.jarry.cc>
X-Patchwork-Hint: ignore
To: pw@patches.jarry.cc
Cc: Robin Jarry <rjarry@redhat.com>,
    Robin Jarry <robin@jarry.cc>
Series
Forge ml sync
github_prhttps://github.com/rjarry/patchwork/pull/3
github_branchforge

Commit Message

Robin JarryJun. 2, 2026, 15:32. UTC
[v4,15/15] deploy

Signed-off-by: Robin Jarry <robin@jarry.cc>
---

Notes:
    https://github.com/rjarry/patchwork/pull/3/commits/6cbd321133bc5cbd130ac582fdf684d3b87ea816

 Makefile                         | 147 +++++++++++++++++++++++++++++++
 nginx.conf                       |  62 +++++++++++++
 parsemail.sh                     |  14 +++
 patchwork.service                |  22 +++++
 patchwork/settings/production.py |  67 ++++++++++++++
 postfix/client_access            |   2 +
 postfix/header_checks            |   2 +
 postfix/main.cf                  |  53 +++++++++++
 postfix/master.cf                |  28 ++++++
 postfix/recipient_bcc            |   1 +
 postfix/transport                |   2 +
 uwsgi.ini                        |  10 +++
 12 files changed, 410 insertions(+)
 create mode 100644 Makefile
 create mode 100644 nginx.conf
 create mode 100755 parsemail.sh
 create mode 100644 patchwork.service
 create mode 100644 patchwork/settings/production.py
 create mode 100644 postfix/client_access
 create mode 100644 postfix/header_checks
 create mode 100644 postfix/main.cf
 create mode 100644 postfix/master.cf
 create mode 100644 postfix/recipient_bcc
 create mode 100644 postfix/transport
 create mode 100644 uwsgi.ini

Patch

mbox series
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..32a8167
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,147 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) 2026 Robin Jarry
+#
+# Makefile for deploying pwforge + patchwork on a single host.
+# Run as root or with sudo.
+
+DOMAIN ?= patches.jarry.cc
+DB_NAME ?= patchwork
+DB_USER ?= patchwork
+DB_PASSWORD ?= $(shell openssl rand -base64 24)
+DJANGO_SECRET ?= $(shell openssl rand -base64 48)
+WEBHOOK_SECRET ?= $(shell openssl rand -base64 48)
+
+.PHONY: help
+help:
+	@echo "usage: make <target> [VAR=value ...]"
+	@echo ""
+	@echo "targets:"
+	@echo "  deps       install system packages"
+	@echo "  database   create postgresql database"
+	@echo "  patchwork  install patchwork"
+	@echo "  uwsgi      install uwsgi config and service"
+	@echo "  postfix    install postfix config"
+	@echo "  nginx      install nginx config and obtain TLS cert"
+	@echo "  install    install everything (idempotent)"
+	@echo ""
+	@echo "variables:"
+	@echo "  DOMAIN            $(DOMAIN)"
+	@echo "  DB_NAME           $(DB_NAME)"
+	@echo "  DB_USER           $(DB_USER)"
+	@echo "  DB_PASSWORD       (auto-generated if unset)"
+	@echo "  DJANGO_SECRET     (auto-generated if unset)"
+
+DEB := git nginx certbot python3-certbot-nginx postfix opendkim opendkim-tools
+DEB += uwsgi uwsgi-plugin-python3
+DEB += postgresql python3-psycopg2
+DEB += python3-django python3-djangorestframework python3-django-filters
+
+.PHONY: deps
+deps:
+	apt-get update -q
+	apt-get install -qy --no-install-recommends $(DEB)
+	usermod -a -G opendkim postfix
+
+/etc/default/patchwork:
+	@echo "DJANGO_SETTINGS_MODULE=patchwork.settings.production" > $@
+	@echo "DJANGO_SECRET_KEY=$(DJANGO_SECRET)" >> $@
+	@echo "DATABASE_NAME=$(DB_NAME)" >> $@
+	@echo "DATABASE_USER=$(DB_USER)" >> $@
+	@echo "DATABASE_PASSWORD=$(DB_PASSWORD)" >> $@
+	@echo "DATABASE_HOST=localhost" >> $@
+	@echo "DATABASE_PORT=" >> $@
+	@echo "STATIC_ROOT=/var/www/patchwork" >> $@
+	@echo "ALLOWED_HOSTS=$(DOMAIN)" >> $@
+	@echo "FROM_EMAIL='Patchwork <no-reply@$(DOMAIN)>'" >> $@
+	@echo "GH_WEBHOOK_SECRET=$(WEBHOOK_SECRET)" >> $@
+	@echo "GH_TOKEN=replace_me" >> $@
+	chmod 640 $@
+	chgrp www-data $@
+
+.PHONY: database
+database: /etc/default/patchwork
+	@set -e; . $<; \
+	user=`sudo -u postgres psql -tXAc "SELECT 1 FROM pg_roles WHERE rolname='$$DATABASE_USER';"`; \
+	if [ -z "$$user" ]; then \
+		sudo -u postgres psql -c "CREATE USER $$DATABASE_USER PASSWORD '$$DATABASE_PASSWORD';"; \
+		sudo -u postgres createdb -O $$DATABASE_USER $$DATABASE_NAME; \
+	fi
+
+.PHONY: patchwork
+patchwork: database
+	mkdir -p /var/cache/patchwork/mirrors
+	chmod 770 /var/cache/patchwork/mirrors
+	chgrp -R www-data /var/cache/patchwork/mirrors
+	mkdir -p /var/www/patchwork
+	set -a && . /etc/default/patchwork && set +a -xe && \
+		python3 manage.py migrate --noinput && \
+		python3 manage.py collectstatic --noinput && \
+		python3 manage.py loaddata default_tags default_states
+
+/etc/uwsgi/apps-available/patchwork.ini: uwsgi.ini
+	install -Dm 0644 $< $@
+	sed -i 's,^chdir.*,chdir = $(CURDIR),' $@
+
+/etc/uwsgi/apps-enabled/patchwork.ini: /etc/uwsgi/apps-available/patchwork.ini
+	ln -sfr $< $@
+
+/etc/systemd/system/%.service: %.service
+	install -Dm 0644 $< $@
+
+.PHONY: uwsgi
+uwsgi: /etc/uwsgi/apps-enabled/patchwork.ini /etc/systemd/system/patchwork.service
+
+/etc/postfix/%: postfix/%
+	install -D -m 0644 $< $@
+	sed -i 's/patches.example.com/$(DOMAIN)/g' $@
+	sed -i 's,/opt/patchwork,$(CURDIR),' $@
+
+/etc/dkimkeys/$(DOMAIN)/mail.private:
+	mkdir -p $(@D)
+	opendkim-genkey -d $(DOMAIN) -s mail -D $(@D)
+	chown -R opendkim:opendkim $(@D)
+	sed -Ei '/^(Domain|Selector|KeyFile)/d' /etc/opendkim.conf
+	echo 'Domain     $(DOMAIN)' >> /etc/opendkim.conf
+	echo 'Selector   mail' >> /etc/opendkim.conf
+	echo 'KeyFile    $@' >> /etc/opendkim.conf
+
+.PHONY: postfix
+postfix: $(addprefix /etc/,$(wildcard postfix/*)) /etc/dkimkeys/$(DOMAIN)/mail.private
+	postmap /etc/postfix/transport
+	postmap /etc/postfix/recipient_bcc
+
+/etc/nginx/sites-enabled/$(DOMAIN): nginx.conf
+	@if ! [ -r "/etc/letsencrypt/live/$(DOMAIN)/cert.pem" ]; then \
+		set -xe; \
+		rm -f $@ /etc/nginx/sites-enabled/pw_; \
+		awk '/^server / {count++} count<2' $< | \
+			sed 's/patches.example.com/$(DOMAIN)/g' > \
+			/etc/nginx/sites-enabled/pw_; \
+		systemctl reload nginx; \
+		mkdir -p /var/www/acme; \
+		certbot certonly --webroot -w /var/www/acme -d $(DOMAIN); \
+		rm -f /etc/nginx/sites-enabled/pw_; \
+	fi
+	install -Dm 644 $< /etc/nginx/sites-available/$(DOMAIN)
+	sed -i 's/patches.example.com/$(DOMAIN)/g' /etc/nginx/sites-available/$(DOMAIN)
+	ln -sfr /etc/nginx/sites-available/$(DOMAIN) $@
+	nginx -t
+
+.PHONY: nginx
+nginx: /etc/nginx/sites-enabled/$(DOMAIN)
+
+.PHONY: install
+install: database patchwork uwsgi postfix nginx
+	systemctl daemon-reload
+	systemctl enable --now patchwork postfix nginx opendkim
+	systemctl restart patchwork postfix nginx opendkim
+	@echo
+	@echo "Update your DNS zone with the following entries:"
+	@echo
+	@ip -j addr | jq -r '.[].addr_info[] | select(.scope == "global" and .family == "inet") | .local' | \
+		sed 's/^/$(DOMAIN). 10800 IN A /'
+	@ip -j addr | jq -r '.[].addr_info[] | select(.scope == "global" and .family == "inet6") | .local' | \
+		sed 's/^/$(DOMAIN). 10800 IN AAAA /'
+	@echo "$(DOMAIN). 10800 IN MX 10 $(DOMAIN)."
+	@echo '$(DOMAIN). 10800 IN TXT "v=spf1 a -all"'
+	@sed -E 's/^(mail._domainkey)/\1.$(DOMAIN)./' /etc/dkimkeys/$(DOMAIN)/mail.txt
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..1be8d5c
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,62 @@
+# /etc/nginx/sites-available/pwforge
+#
+# Reverse proxy for patchwork and pwforge behind nginx.
+# Use certbot to obtain TLS certificates:
+#   certbot certonly --webroot -w /var/www/acme -d patches.example.com
+
+limit_req_zone $pw_limit zone=pw:10m rate=20r/s;
+
+# Only rate-limit write requests (POST, PUT, DELETE)
+map $request_method $pw_limit {
+	GET "";
+	HEAD "";
+	default $binary_remote_addr;
+}
+
+server {
+	listen 80;
+	listen [::]:80;
+	server_name patches.example.com;
+
+	location /.well-known/acme-challenge/ {
+		root /var/www/acme;
+	}
+	location / {
+		return 301 https://patches.example.com$request_uri;
+	}
+}
+
+server {
+	listen 443 ssl;
+	listen [::]:443 ssl;
+	http2 on;
+	server_name patches.example.com;
+
+	ssl_certificate /etc/letsencrypt/live/patches.example.com/fullchain.pem;
+	ssl_certificate_key /etc/letsencrypt/live/patches.example.com/privkey.pem;
+	ssl_protocols TLSv1.2 TLSv1.3;
+
+	limit_req zone=pw burst=10 nodelay;
+
+	server_tokens off;
+
+	location = favicon.ico {
+		access_log off;
+		log_not_found off;
+	}
+
+	location /static {
+		alias /var/www/patchwork;
+		expires 3h;
+		gzip on;
+		gzip_types text/css application/javascript;
+		gzip_min_length 500;
+		gzip_disable msie6;
+	}
+
+	# patchwork web UI and API
+	location / {
+		include uwsgi_params;
+		uwsgi_pass unix:/run/patchwork/uwsgi.sock;
+	}
+}
diff --git a/parsemail.sh b/parsemail.sh
new file mode 100755
index 0000000..a597b4e
--- /dev/null
+++ b/parsemail.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+
+# Called by postfix pipe transport to feed incoming mail to patchwork.
+# Exit 75 (EX_TEMPFAIL) on failure so postfix retries later.
+
+set -a
+. /etc/default/patchwork
+set +a
+
+cd $(dirname $0)
+systemd-cat --identifier=parsemail --priority=debug --stderr-priority=err \
+	python3 manage.py parsemail || exit 75
diff --git a/patchwork.service b/patchwork.service
new file mode 100644
index 0000000..3b8c310
--- /dev/null
+++ b/patchwork.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=Patchwork uWSGI
+After=postgresql.service
+
+[Service]
+ExecStart=/usr/bin/uwsgi --ini /etc/uwsgi/apps-enabled/patchwork.ini
+Restart=always
+KillSignal=SIGQUIT
+Type=notify
+NotifyAccess=all
+User=www-data
+Group=www-data
+EnvironmentFile=/etc/default/patchwork
+ReadWritePaths=/var/cache/patchwork/mirrors
+RuntimeDirectory=patchwork
+ProtectSystem=strict
+ProtectHome=true
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/patchwork/settings/production.py b/patchwork/settings/production.py
new file mode 100644
index 0000000..647a258
--- /dev/null
+++ b/patchwork/settings/production.py
@@ -0,0 +1,67 @@
+"""
+Patchwork production settings for forge sync deployment.
+"""
+
+import os
+
+from .base import *  # noqa
+
+DEFAULT_FROM_EMAIL = os.environ.get("FROM_EMAIL", DEFAULT_FROM_EMAIL)
+SERVER_EMAIL = DEFAULT_FROM_EMAIL
+NOTIFICATION_FROM_EMAIL = DEFAULT_FROM_EMAIL
+EMAIL_SUBJECT_PREFIX = "[patchwork] "
+
+SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
+
+ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost").split(",")
+
+DATABASES = {
+    "default": {
+        "ENGINE": "django.db.backends.postgresql_psycopg2",
+        "HOST": os.environ.get("DATABASE_HOST", "localhost"),
+        "PORT": os.environ.get("DATABASE_PORT", ""),
+        "NAME": os.environ.get("DATABASE_NAME", "patchwork"),
+        "USER": os.environ.get("DATABASE_USER", "patchwork"),
+        "PASSWORD": os.environ.get("DATABASE_PASSWORD", ""),
+    },
+}
+
+STATIC_ROOT = os.environ.get("STATIC_ROOT", "/var/www/patchwork")
+
+STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
+
+TIME_ZONE = "UTC"
+
+LOGGING = {
+    "version": 1,
+    "disable_existing_loggers": False,
+    "formatters": {
+        "syslog": {
+            "format": "%(name)s: %(message)s",
+        },
+    },
+    "handlers": {
+        "syslog": {
+            "class": "logging.handlers.SysLogHandler",
+            "address": "/dev/log",
+            "facility": "daemon",
+            "formatter": "syslog",
+        },
+    },
+    "root": {
+        "handlers": ["syslog"],
+        "level": "INFO",
+    },
+}
+
+ENABLE_FORGE = True
+FORGE_BACKENDS = ["patchwork.forge.github"]
+FORGE_WEBHOOK_SECRETS = {
+    "github": os.environ.get("GH_WEBHOOK_SECRET", ""),
+}
+FORGE_GIT_MIRROR_PATH = "/var/cache/patchwork/mirrors"
+FORGE_AUTH = {
+    "github": {
+        "token": os.environ.get("GH_TOKEN", ""),
+    },
+}
diff --git a/postfix/client_access b/postfix/client_access
new file mode 100644
index 0000000..9190745
--- /dev/null
+++ b/postfix/client_access
@@ -0,0 +1,2 @@
+# mails.dpdk.org
+217.70.189.124/32	OK
diff --git a/postfix/header_checks b/postfix/header_checks
new file mode 100644
index 0000000..7516786
--- /dev/null
+++ b/postfix/header_checks
@@ -0,0 +1,2 @@
+/^From:.*pw@patches\.jarry\.cc/ PREPEND List-Id: <pwforge.jarry.cc>
+/^To:.*pw@patches\.jarry\.cc/ PREPEND List-Id: <pwforge.jarry.cc>
diff --git a/postfix/main.cf b/postfix/main.cf
new file mode 100644
index 0000000..d516098
--- /dev/null
+++ b/postfix/main.cf
@@ -0,0 +1,53 @@
+# Postfix configuration for pwforge patchwork mail ingestion.
+#
+# This instance only accepts mail from a designated upstream MTA and
+# delivers it to patchwork via a pipe transport. It does not relay
+# or deliver mail to local users.
+
+compatibility_level = 3.6
+
+# Replace with your actual hostname and domain.
+myhostname = patches.jarry.cc
+mydomain = patches.jarry.cc
+myorigin = $mydomain
+mydestination = $myhostname, localhost
+
+inet_interfaces = all
+inet_protocols = all
+
+# Only accept mail from the upstream MTA and localhost (for pwforge's
+# git send-email). Replace CHANGE_ME with the upstream MTA address.
+mynetworks = 127.0.0.0/8, [::1]/128
+smtpd_client_restrictions =
+	permit_mynetworks,
+	check_client_access cidr:/etc/postfix/client_access,
+	reject
+
+# Route list mail to patchwork parsemail pipe.
+header_checks = regexp:/etc/postfix/header_checks
+recipient_bcc_maps = hash:/etc/postfix/recipient_bcc
+transport_maps = hash:/etc/postfix/transport
+
+# Discard mail to unknown local users silently.
+local_recipient_maps =
+fallback_transport = discard:
+
+# Do not relay email, only allow local send
+smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
+default_transport = smtp:
+relay_domains =
+relayhost =
+
+# Generous limits for patch series.
+message_size_limit = 52428800
+mailbox_size_limit = 0
+
+# Retry quickly on transient failures (e.g. database locks).
+minimal_backoff_time = 30s
+maximal_backoff_time = 300s
+queue_run_delay = 30s
+
+# DKIM
+milter_default_action = accept
+smtpd_milters = unix:/run/opendkim/opendkim.sock
+non_smtpd_milters = unix:/run/opendkim/opendkim.sock
diff --git a/postfix/master.cf b/postfix/master.cf
new file mode 100644
index 0000000..54e8e0b
--- /dev/null
+++ b/postfix/master.cf
@@ -0,0 +1,28 @@
+# Postfix master process configuration for pwforge patchwork ingestion.
+
+smtp       inet  n  -  n  -  -  smtpd
+pickup     unix  n  -  n  60  1  pickup
+cleanup    unix  n  -  n  -  0  cleanup
+qmgr       unix  n  -  n  300 1  qmgr
+tlsmgr     unix  -  -  n  1000? 1  tlsmgr
+rewrite    unix  -  -  n  -  -  trivial-rewrite
+bounce     unix  -  -  n  -  0  bounce
+defer      unix  -  -  n  -  0  bounce
+trace      unix  -  -  n  -  0  bounce
+verify     unix  -  -  n  -  1  verify
+flush      unix  n  -  n  1000? 0  flush
+proxymap   unix  -  -  n  -  -  proxymap
+proxywrite unix  -  -  n  -  1  proxymap
+smtp       unix  -  -  n  -  -  smtp
+relay      unix  -  -  n  -  -  smtp -o syslog_name=postfix/$service_name
+showq      unix  n  -  n  -  -  showq
+error      unix  -  -  n  -  -  error
+retry      unix  -  -  n  -  -  error
+discard    unix  -  -  n  -  -  discard
+local      unix  -  n  n  -  -  local
+virtual    unix  -  n  n  -  -  virtual
+lmtp       unix  -  -  n  -  -  lmtp
+anvil      unix  -  -  n  -  1  anvil
+scache     unix  -  -  n  -  1  scache
+postlog    unix-dgram n  -  n  -  1  postlogd
+patchwork  unix  -  n  n  -  1  pipe flags=FR user=www-data argv=/opt/patchwork/parsemail.sh
diff --git a/postfix/recipient_bcc b/postfix/recipient_bcc
new file mode 100644
index 0000000..7c6c223
--- /dev/null
+++ b/postfix/recipient_bcc
@@ -0,0 +1 @@
+pw@patches.jarry.cc    robin@jarry.cc
diff --git a/postfix/transport b/postfix/transport
new file mode 100644
index 0000000..742b403
--- /dev/null
+++ b/postfix/transport
@@ -0,0 +1,2 @@
+# Route mail for the subscribed address to patchwork
+patches.jarry.cc	patchwork:
diff --git a/uwsgi.ini b/uwsgi.ini
new file mode 100644
index 0000000..12811e2
--- /dev/null
+++ b/uwsgi.ini
@@ -0,0 +1,10 @@
+[uwsgi]
+plugins = python3
+chdir = /opt/patchwork
+module = patchwork.wsgi:application
+master = true
+processes = 4
+socket = /run/patchwork/uwsgi.sock
+chmod-socket = 660
+buffer-size = 16384
+vacuum = true