mirror of
https://github.com/chatmail/relay.git
synced 2026-05-16 03:24:48 +00:00
Compare commits
15 Commits
expire-ind
...
j4n/initen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd542ada40 | ||
|
|
df4ff92133 | ||
|
|
825831ee81 | ||
|
|
0aa08b7413 | ||
|
|
14dfabf2ff | ||
|
|
0a77b3339b | ||
|
|
001d8c80fc | ||
|
|
1e376f7945 | ||
|
|
1ae92e0639 | ||
|
|
56386c231b | ||
|
|
2bdfecff72 | ||
|
|
cef739e3b3 | ||
|
|
3d128d3c64 | ||
|
|
79f68342f4 | ||
|
|
54863453c2 |
43
.github/workflows/ci.yaml
vendored
43
.github/workflows/ci.yaml
vendored
@@ -1,15 +1,26 @@
|
|||||||
name: CI
|
name: Run unit-tests and container-based deploy+test verification
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
# Triggers when a PR is merged into main or a direct push occurs
|
||||||
push:
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
# Triggers for any PR (and its subsequent commits) targeting the main branch
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tox:
|
tox:
|
||||||
name: isolated chatmaild tests
|
name: isolated chatmaild tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
# Checkout pull request HEAD commit instead of merge commit
|
# Checkout pull request HEAD commit instead of merge commit
|
||||||
# Otherwise `test_deployed_state` will be unhappy.
|
# Otherwise `test_deployed_state` will be unhappy.
|
||||||
with:
|
with:
|
||||||
@@ -24,7 +35,9 @@ jobs:
|
|||||||
name: deploy-chatmail tests
|
name: deploy-chatmail tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: initenv
|
- name: initenv
|
||||||
run: scripts/initenv.sh
|
run: scripts/initenv.sh
|
||||||
@@ -38,5 +51,23 @@ jobs:
|
|||||||
- name: run deploy-chatmail offline tests
|
- name: run deploy-chatmail offline tests
|
||||||
run: pytest --pyargs cmdeploy
|
run: pytest --pyargs cmdeploy
|
||||||
|
|
||||||
# all other cmdeploy commands require a staging server
|
lxc-test:
|
||||||
# see https://github.com/deltachat/chatmail/issues/100
|
name: LXC deploy and test
|
||||||
|
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0
|
||||||
|
with:
|
||||||
|
cmlxc_commands: |
|
||||||
|
cmlxc init
|
||||||
|
# single cmdeploy relay test
|
||||||
|
cmlxc -v deploy-cmdeploy --source ./repo cm0
|
||||||
|
cmlxc -v test-mini cm0
|
||||||
|
cmlxc -v test-cmdeploy cm0
|
||||||
|
|
||||||
|
# cross cmdeploy relay test
|
||||||
|
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only cm1
|
||||||
|
cmlxc -v test-cmdeploy cm0 cm1
|
||||||
|
|
||||||
|
# cross cmdeploy/madmail relay tests
|
||||||
|
cmlxc -v deploy-madmail mad0
|
||||||
|
cmlxc -v test-cmdeploy cm0 mad0
|
||||||
|
cmlxc -v test-mini cm0 mad0
|
||||||
|
cmlxc -v test-mini mad0 cm0
|
||||||
|
|||||||
104
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
104
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
@@ -1,104 +0,0 @@
|
|||||||
name: deploy on staging-ipv4.testrun.org, and run tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
paths-ignore:
|
|
||||||
- 'scripts/**'
|
|
||||||
- '**/README.md'
|
|
||||||
- 'CHANGELOG.md'
|
|
||||||
- 'LICENSE'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: deploy on staging-ipv4.testrun.org, and run tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
environment:
|
|
||||||
name: staging-ipv4.testrun.org
|
|
||||||
url: https://staging-ipv4.testrun.org/
|
|
||||||
concurrency: staging-ipv4.testrun.org
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: prepare SSH
|
|
||||||
run: |
|
|
||||||
mkdir ~/.ssh
|
|
||||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan staging-ipv4.testrun.org > ~/.ssh/known_hosts
|
|
||||||
# save previous acme & dkim state
|
|
||||||
rsync -avz root@staging-ipv4.testrun.org:/var/lib/acme acme-ipv4 || true
|
|
||||||
rsync -avz root@staging-ipv4.testrun.org:/etc/dkimkeys dkimkeys-ipv4 || true
|
|
||||||
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
|
|
||||||
if [ -f dkimkeys-ipv4/dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys-ipv4 root@ns.testrun.org:/tmp/ || true; fi
|
|
||||||
if [ "$(ls -A acme-ipv4/acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme-ipv4 root@ns.testrun.org:/tmp/ || true; fi
|
|
||||||
# make sure CAA record isn't set
|
|
||||||
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging-ipv4.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org systemctl reload nsd
|
|
||||||
|
|
||||||
- name: rebuild staging-ipv4.testrun.org to have a clean VPS
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"image":"debian-12"}' \
|
|
||||||
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_IPV4_SERVER_ID }}/actions/rebuild"
|
|
||||||
|
|
||||||
- run: scripts/initenv.sh
|
|
||||||
|
|
||||||
- name: append venv/bin to PATH
|
|
||||||
run: echo venv/bin >>$GITHUB_PATH
|
|
||||||
|
|
||||||
- name: upload TLS cert after rebuilding
|
|
||||||
run: |
|
|
||||||
echo " --- wait until staging-ipv4.testrun.org VPS is rebuilt --- "
|
|
||||||
rm ~/.ssh/known_hosts
|
|
||||||
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u ; do sleep 1 ; done
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u
|
|
||||||
# download acme & dkim state from ns.testrun.org
|
|
||||||
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme-ipv4/acme acme-restore || true
|
|
||||||
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
|
|
||||||
# restore acme & dkim state to staging2.testrun.org
|
|
||||||
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
|
|
||||||
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
|
|
||||||
|
|
||||||
- name: run deploy-chatmail offline tests
|
|
||||||
run: pytest --pyargs cmdeploy
|
|
||||||
|
|
||||||
- name: setup dependencies
|
|
||||||
run: |
|
|
||||||
ssh root@staging-ipv4.testrun.org apt update
|
|
||||||
ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
|
|
||||||
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
|
|
||||||
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
|
|
||||||
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
|
|
||||||
|
|
||||||
- name: initialize config
|
|
||||||
run: |
|
|
||||||
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
|
|
||||||
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
|
|
||||||
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
|
|
||||||
|
|
||||||
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
|
|
||||||
|
|
||||||
- name: set DNS entries
|
|
||||||
run: |
|
|
||||||
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
|
|
||||||
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
|
|
||||||
cat .github/workflows/staging-ipv4.testrun.org-default.zone
|
|
||||||
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org systemctl reload nsd
|
|
||||||
|
|
||||||
- name: cmdeploy test
|
|
||||||
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
|
|
||||||
|
|
||||||
- name: cmdeploy dns
|
|
||||||
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
|
|
||||||
|
|
||||||
97
.github/workflows/test-and-deploy.yaml
vendored
97
.github/workflows/test-and-deploy.yaml
vendored
@@ -1,97 +0,0 @@
|
|||||||
name: deploy on staging2.testrun.org, and run tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
paths-ignore:
|
|
||||||
- 'scripts/**'
|
|
||||||
- '**/README.md'
|
|
||||||
- 'CHANGELOG.md'
|
|
||||||
- 'LICENSE'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: deploy on staging2.testrun.org, and run tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
environment:
|
|
||||||
name: staging2.testrun.org
|
|
||||||
url: https://staging2.testrun.org/
|
|
||||||
concurrency: staging2.testrun.org
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: prepare SSH
|
|
||||||
run: |
|
|
||||||
mkdir ~/.ssh
|
|
||||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
|
|
||||||
# save previous acme & dkim state
|
|
||||||
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
|
|
||||||
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
|
|
||||||
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
|
|
||||||
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
|
|
||||||
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
|
|
||||||
# make sure CAA record isn't set
|
|
||||||
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org systemctl reload nsd
|
|
||||||
|
|
||||||
- name: rebuild staging2.testrun.org to have a clean VPS
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"image":"debian-12"}' \
|
|
||||||
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
|
|
||||||
|
|
||||||
- run: scripts/initenv.sh
|
|
||||||
|
|
||||||
- name: append venv/bin to PATH
|
|
||||||
run: echo venv/bin >>$GITHUB_PATH
|
|
||||||
|
|
||||||
- name: upload TLS cert after rebuilding
|
|
||||||
run: |
|
|
||||||
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
|
|
||||||
rm ~/.ssh/known_hosts
|
|
||||||
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
|
|
||||||
# download acme & dkim state from ns.testrun.org
|
|
||||||
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
|
|
||||||
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
|
|
||||||
# restore acme & dkim state to staging2.testrun.org
|
|
||||||
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true
|
|
||||||
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
|
|
||||||
|
|
||||||
- name: add hpk42 key to staging server
|
|
||||||
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
|
|
||||||
|
|
||||||
- name: run deploy-chatmail offline tests
|
|
||||||
run: pytest --pyargs cmdeploy
|
|
||||||
|
|
||||||
- run: |
|
|
||||||
cmdeploy init staging2.testrun.org
|
|
||||||
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
|
|
||||||
|
|
||||||
- run: cmdeploy run --verbose --skip-dns-check
|
|
||||||
|
|
||||||
- name: set DNS entries
|
|
||||||
run: |
|
|
||||||
cmdeploy dns --zonefile staging-generated.zone --verbose
|
|
||||||
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
|
||||||
cat .github/workflows/staging.testrun.org-default.zone
|
|
||||||
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
|
|
||||||
ssh root@ns.testrun.org systemctl reload nsd
|
|
||||||
|
|
||||||
- name: cmdeploy test
|
|
||||||
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
|
|
||||||
|
|
||||||
- name: cmdeploy dns
|
|
||||||
run: cmdeploy dns -v
|
|
||||||
|
|
||||||
@@ -38,7 +38,9 @@ class Config:
|
|||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params.get("filtermail_smtp_port_incoming", "10081")
|
params.get("filtermail_smtp_port_incoming", "10081")
|
||||||
)
|
)
|
||||||
self.filtermail_http_port = int(params.get("filtermail_http_port", "10082"))
|
self.filtermail_http_port_incoming = int(
|
||||||
|
params.get("filtermail_http_port_incoming", "10082")
|
||||||
|
)
|
||||||
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
||||||
self.postfix_reinject_port_incoming = int(
|
self.postfix_reinject_port_incoming = int(
|
||||||
params.get("postfix_reinject_port_incoming", "10026")
|
params.get("postfix_reinject_port_incoming", "10026")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
"""CGI script for creating new accounts."""
|
"""CGI script for creating new accounts."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
@@ -14,6 +15,16 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits
|
|||||||
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_ip(host):
|
||||||
|
if host.startswith("[") and host.endswith("]"):
|
||||||
|
return host
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
return f"[{host}]"
|
||||||
|
except ValueError:
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
def create_newemail_dict(config: Config):
|
def create_newemail_dict(config: Config):
|
||||||
user = "".join(
|
user = "".join(
|
||||||
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
||||||
@@ -22,7 +33,7 @@ def create_newemail_dict(config: Config):
|
|||||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||||
for _ in range(config.password_min_length + 3)
|
for _ in range(config.password_min_length + 3)
|
||||||
)
|
)
|
||||||
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
|
return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}")
|
||||||
|
|
||||||
|
|
||||||
def create_dclogin_url(email, password):
|
def create_dclogin_url(email, password):
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ def test_create_newemail_dict(example_config):
|
|||||||
assert ac1["password"] != ac2["password"]
|
assert ac1["password"] != ac2["password"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_newemail_dict_ip(make_config):
|
||||||
|
config = make_config("1.2.3.4")
|
||||||
|
ac = create_newemail_dict(config)
|
||||||
|
assert ac["email"].endswith("@[1.2.3.4]")
|
||||||
|
|
||||||
|
|
||||||
def test_create_dclogin_url():
|
def test_create_dclogin_url():
|
||||||
url = create_dclogin_url("user@example.org", "p@ss w+rd")
|
url = create_dclogin_url("user@example.org", "p@ss w+rd")
|
||||||
assert url.startswith("dclogin:")
|
assert url.startswith("dclogin:")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import io
|
|||||||
import os
|
import os
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from pyinfra import host
|
||||||
|
from pyinfra.facts.server import Command
|
||||||
from pyinfra.operations import files, server, systemd
|
from pyinfra.operations import files, server, systemd
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +13,17 @@ def has_systemd():
|
|||||||
return os.path.isdir("/run/systemd/system")
|
return os.path.isdir("/run/systemd/system")
|
||||||
|
|
||||||
|
|
||||||
|
def is_in_container() -> bool:
|
||||||
|
"""Return True if running inside a container (Docker, LXC, etc.)."""
|
||||||
|
return (
|
||||||
|
host.get_fact(
|
||||||
|
Command,
|
||||||
|
"systemd-detect-virt --container --quiet 2>/dev/null && echo yes || true",
|
||||||
|
)
|
||||||
|
== "yes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def blocked_service_startup():
|
def blocked_service_startup():
|
||||||
"""Prevent services from auto-starting during package installation.
|
"""Prevent services from auto-starting during package installation.
|
||||||
|
|||||||
@@ -108,9 +108,7 @@ def run_cmd(args, out):
|
|||||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||||
|
|
||||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
||||||
if ssh_host in ["localhost", "@docker"]:
|
if ssh_host == "localhost":
|
||||||
if ssh_host == "@docker":
|
|
||||||
env["CHATMAIL_NOPORTCHECK"] = "True"
|
|
||||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||||
|
|
||||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||||
@@ -316,7 +314,7 @@ def add_ssh_host_option(parser):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ssh-host",
|
"--ssh-host",
|
||||||
dest="ssh_host",
|
dest="ssh_host",
|
||||||
help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
|
help="Run commands on 'localhost' or on a specific SSH host "
|
||||||
"instead of chatmail.ini's mail_domain.",
|
"instead of chatmail.ini's mail_domain.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -378,9 +376,7 @@ def get_parser():
|
|||||||
|
|
||||||
def get_sshexec(ssh_host: str, verbose=True):
|
def get_sshexec(ssh_host: str, verbose=True):
|
||||||
if ssh_host in ["localhost", "@local"]:
|
if ssh_host in ["localhost", "@local"]:
|
||||||
return LocalExec(verbose, docker=False)
|
return LocalExec(verbose)
|
||||||
elif ssh_host == "@docker":
|
|
||||||
return LocalExec(verbose, docker=True)
|
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f"[ssh] login to {ssh_host}")
|
print(f"[ssh] login to {ssh_host}")
|
||||||
return SSHExec(ssh_host, verbose=verbose)
|
return SSHExec(ssh_host, verbose=verbose)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
Chat Mail pyinfra deploy.
|
Chat Mail pyinfra deploy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -28,6 +27,7 @@ from .basedeploy import (
|
|||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
has_systemd,
|
has_systemd,
|
||||||
|
is_in_container,
|
||||||
)
|
)
|
||||||
from .dovecot.deployer import DovecotDeployer
|
from .dovecot.deployer import DovecotDeployer
|
||||||
from .external.deployer import ExternalTlsDeployer
|
from .external.deployer import ExternalTlsDeployer
|
||||||
@@ -150,9 +150,6 @@ class UnboundDeployer(Deployer):
|
|||||||
self.need_restart = False
|
self.need_restart = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
# Run local DNS resolver `unbound`. `resolvconf` takes care of
|
|
||||||
# setting up /etc/resolv.conf to use 127.0.0.1 as the resolver.
|
|
||||||
|
|
||||||
# On an IPv4-only system, if unbound is started but not configured,
|
# On an IPv4-only system, if unbound is started but not configured,
|
||||||
# it causes subsequent steps to fail to resolve hosts.
|
# it causes subsequent steps to fail to resolve hosts.
|
||||||
with blocked_service_startup():
|
with blocked_service_startup():
|
||||||
@@ -162,6 +159,31 @@ class UnboundDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
|
# Remove dynamic resolver managers that compete for /etc/resolv.conf.
|
||||||
|
apt.packages(
|
||||||
|
name="Purge resolvconf",
|
||||||
|
packages=["resolvconf"],
|
||||||
|
present=False,
|
||||||
|
extra_uninstall_args="--purge",
|
||||||
|
)
|
||||||
|
# systemd-resolved can't be purged due to dependencies; stop and mask.
|
||||||
|
server.shell(
|
||||||
|
name="Stop and mask systemd-resolved",
|
||||||
|
commands=[
|
||||||
|
"systemctl stop systemd-resolved.service || true",
|
||||||
|
"systemctl mask systemd-resolved.service",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Configure unbound resolver with Quad9 fallback and a trailing newline
|
||||||
|
# (SolusVM bug).
|
||||||
|
files.put(
|
||||||
|
name="Write static resolv.conf",
|
||||||
|
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
||||||
|
dest="/etc/resolv.conf",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
)
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Generate root keys for validating DNSSEC",
|
name="Generate root keys for validating DNSSEC",
|
||||||
commands=[
|
commands=[
|
||||||
@@ -568,14 +590,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
Deployment().perform_stages([WebsiteDeployer(config)])
|
Deployment().perform_stages([WebsiteDeployer(config)])
|
||||||
return
|
return
|
||||||
|
|
||||||
if host.get_fact(Port, port=53) != "unbound":
|
|
||||||
files.line(
|
|
||||||
name="Add 9.9.9.9 to resolv.conf",
|
|
||||||
path="/etc/resolv.conf",
|
|
||||||
# Guard against resolv.conf missing a trailing newline (SolusVM bug).
|
|
||||||
line="\nnameserver 9.9.9.9",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if mtail_address interface is available (if configured)
|
# Check if mtail_address interface is available (if configured)
|
||||||
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
|
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
|
||||||
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
||||||
@@ -584,7 +598,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if not os.environ.get("CHATMAIL_NOPORTCHECK"):
|
if not is_in_container():
|
||||||
port_services = [
|
port_services = [
|
||||||
(["master", "smtpd"], 25),
|
(["master", "smtpd"], 25),
|
||||||
("unbound", 53),
|
("unbound", 53),
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ from cmdeploy.basedeploy import (
|
|||||||
blocked_service_startup,
|
blocked_service_startup,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
|
is_in_container,
|
||||||
)
|
)
|
||||||
|
|
||||||
DOVECOT_VERSION = "2.3.21+dfsg1-3"
|
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
|
||||||
|
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
|
||||||
|
|
||||||
DOVECOT_SHA256 = {
|
DOVECOT_SHA256 = {
|
||||||
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
||||||
@@ -40,11 +42,14 @@ class DovecotDeployer(Deployer):
|
|||||||
with blocked_service_startup():
|
with blocked_service_startup():
|
||||||
debs = []
|
debs = []
|
||||||
for pkg in ("core", "imapd", "lmtpd"):
|
for pkg in ("core", "imapd", "lmtpd"):
|
||||||
deb = _download_dovecot_package(pkg, arch)
|
deb, changed = _download_dovecot_package(pkg, arch)
|
||||||
|
self.need_restart |= changed
|
||||||
if deb:
|
if deb:
|
||||||
debs.append(deb)
|
debs.append(deb)
|
||||||
if debs:
|
if debs:
|
||||||
deb_list = " ".join(debs)
|
deb_list = " ".join(debs)
|
||||||
|
# First dpkg may fail on missing dependencies (stderr suppressed);
|
||||||
|
# apt-get --fix-broken pulls them in, then dpkg retries cleanly.
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Install dovecot packages",
|
name="Install dovecot packages",
|
||||||
commands=[
|
commands=[
|
||||||
@@ -53,6 +58,7 @@ class DovecotDeployer(Deployer):
|
|||||||
f"dpkg --force-confdef --force-confold -i {deb_list}",
|
f"dpkg --force-confdef --force-confold -i {deb_list}",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
self.need_restart = True
|
||||||
files.put(
|
files.put(
|
||||||
name="Pin dovecot packages to block Debian dist-upgrades",
|
name="Pin dovecot packages to block Debian dist-upgrades",
|
||||||
src=io.StringIO(
|
src=io.StringIO(
|
||||||
@@ -61,15 +67,30 @@ class DovecotDeployer(Deployer):
|
|||||||
"Pin-Priority: -1\n"
|
"Pin-Priority: -1\n"
|
||||||
),
|
),
|
||||||
dest="/etc/apt/preferences.d/pin-dovecot",
|
dest="/etc/apt/preferences.d/pin-dovecot",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
)
|
)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self.config.mail_domain, self.units)
|
configure_remote_units(self.config.mail_domain, self.units)
|
||||||
self.need_restart, self.daemon_reload = _configure_dovecot(self.config)
|
config_restart, self.daemon_reload = _configure_dovecot(self.config)
|
||||||
|
self.need_restart |= config_restart
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
activate_remote_units(self.units)
|
activate_remote_units(self.units)
|
||||||
|
|
||||||
|
# Detect stale binary: package installed but service still runs old (deleted) binary.
|
||||||
|
if not self.disable_mail and not self.need_restart:
|
||||||
|
stale = host.get_fact(
|
||||||
|
Command,
|
||||||
|
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);'
|
||||||
|
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
|
||||||
|
" && echo STALE || true",
|
||||||
|
)
|
||||||
|
if stale == "STALE":
|
||||||
|
self.need_restart = True
|
||||||
|
|
||||||
restart = False if self.disable_mail else self.need_restart
|
restart = False if self.disable_mail else self.need_restart
|
||||||
|
|
||||||
systemd.service(
|
systemd.service(
|
||||||
@@ -94,22 +115,22 @@ def _pick_url(primary, fallback):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _download_dovecot_package(package: str, arch: str):
|
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
|
||||||
"""Download a dovecot .deb if needed, return its path (or None)."""
|
"""Download a dovecot .deb if needed, return (path, changed)."""
|
||||||
arch = "amd64" if arch == "x86_64" else arch
|
arch = "amd64" if arch == "x86_64" else arch
|
||||||
arch = "arm64" if arch == "aarch64" else arch
|
arch = "arm64" if arch == "aarch64" else arch
|
||||||
|
|
||||||
pkg_name = f"dovecot-{package}"
|
pkg_name = f"dovecot-{package}"
|
||||||
sha256 = DOVECOT_SHA256.get((package, arch))
|
sha256 = DOVECOT_SHA256.get((package, arch))
|
||||||
if sha256 is None:
|
if sha256 is None:
|
||||||
apt.packages(packages=[pkg_name])
|
op = apt.packages(packages=[pkg_name])
|
||||||
return None
|
return None, bool(getattr(op, "changed", False))
|
||||||
|
|
||||||
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
|
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
|
||||||
if DOVECOT_VERSION in installed_versions:
|
if DOVECOT_PACKAGE_VERSION in installed_versions:
|
||||||
return None
|
return None, False
|
||||||
|
|
||||||
url_version = DOVECOT_VERSION.replace("+", "%2B")
|
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
||||||
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
||||||
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
|
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
|
||||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
|
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
|
||||||
@@ -124,18 +145,7 @@ def _download_dovecot_package(package: str, arch: str):
|
|||||||
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
|
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
|
||||||
)
|
)
|
||||||
|
|
||||||
return deb_filename
|
return deb_filename, True
|
||||||
|
|
||||||
|
|
||||||
def _can_set_inotify_limits() -> bool:
|
|
||||||
is_container = (
|
|
||||||
host.get_fact(
|
|
||||||
Command,
|
|
||||||
"systemd-detect-virt --container --quiet 2>/dev/null && echo yes || true",
|
|
||||||
)
|
|
||||||
== "yes"
|
|
||||||
)
|
|
||||||
return not is_container
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
|
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
|
||||||
@@ -173,10 +183,10 @@ def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]
|
|||||||
|
|
||||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||||
# it is recommended to set the following inotify limits
|
# it is recommended to set the following inotify limits
|
||||||
can_modify = _can_set_inotify_limits()
|
can_modify = not is_in_container()
|
||||||
for name in ("max_user_instances", "max_user_watches"):
|
for name in ("max_user_instances", "max_user_watches"):
|
||||||
key = f"fs.inotify.{name}"
|
key = f"fs.inotify.{name}"
|
||||||
value = host.get_fact(Sysctl)[key]
|
value = host.get_fact(Sysctl).get(key, 0)
|
||||||
if value > 65534:
|
if value > 65534:
|
||||||
continue
|
continue
|
||||||
if not can_modify:
|
if not can_modify:
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ protocol lmtp {
|
|||||||
# mail_lua and push_notification_lua are needed for Lua push notification handler.
|
# mail_lua and push_notification_lua are needed for Lua push notification handler.
|
||||||
# <https://doc.dovecot.org/2.3/configuration_manual/push_notification/#configuration>
|
# <https://doc.dovecot.org/2.3/configuration_manual/push_notification/#configuration>
|
||||||
mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua
|
mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua
|
||||||
|
|
||||||
|
# Disable fsync for LMTP. May lose delivered message,
|
||||||
|
# but unlikely to cause problems with multiple relays.
|
||||||
|
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/#fsyncing
|
||||||
|
mail_fsync = never
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin {
|
plugin {
|
||||||
@@ -252,6 +257,9 @@ protocol imap {
|
|||||||
# sort -sn <(sed 's/ / C: /' *.in) <(sed 's/ / S: /' cat *.out)
|
# sort -sn <(sed 's/ / C: /' *.in) <(sed 's/ / S: /' cat *.out)
|
||||||
|
|
||||||
rawlog_dir = %h
|
rawlog_dir = %h
|
||||||
|
|
||||||
|
# Disable fsync for IMAP. May lose IMAP changes like setting flags.
|
||||||
|
mail_fsync = never
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ http {
|
|||||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||||
|
|
||||||
location /mxdeliv/ {
|
location /mxdeliv/ {
|
||||||
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port }};
|
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -87,9 +87,8 @@ class SSHExec:
|
|||||||
class LocalExec:
|
class LocalExec:
|
||||||
FuncError = FuncError
|
FuncError = FuncError
|
||||||
|
|
||||||
def __init__(self, verbose=False, docker=False):
|
def __init__(self, verbose=False):
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.docker = docker
|
|
||||||
|
|
||||||
def __call__(self, call, kwargs=None, log_callback=None):
|
def __call__(self, call, kwargs=None, log_callback=None):
|
||||||
if kwargs is None:
|
if kwargs is None:
|
||||||
@@ -101,10 +100,6 @@ class LocalExec:
|
|||||||
if not title:
|
if not title:
|
||||||
title = call.__name__
|
title = call.__name__
|
||||||
where = "locally"
|
where = "locally"
|
||||||
if self.docker:
|
|
||||||
if call == remote.rdns.perform_initial_checks:
|
|
||||||
kwargs["pre_command"] = "docker exec chatmail "
|
|
||||||
where = "in docker"
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
print_stderr(f"Running {where}: {title}(**{kwargs})")
|
print_stderr(f"Running {where}: {title}(**{kwargs})")
|
||||||
return self(call, kwargs, log_callback=print_stderr)
|
return self(call, kwargs, log_callback=print_stderr)
|
||||||
|
|||||||
@@ -71,6 +71,44 @@ class TestSSHExecutor:
|
|||||||
assert (now - since_date).total_seconds() < 60 * 60 * 51
|
assert (now - since_date).total_seconds() < 60 * 60 * 51
|
||||||
|
|
||||||
|
|
||||||
|
def test_dovecot_main_process_matches_installed_binary(sshdomain):
|
||||||
|
sshexec = get_sshexec(sshdomain)
|
||||||
|
main_pid = int(
|
||||||
|
sshexec(
|
||||||
|
call=remote.rshell.shell,
|
||||||
|
kwargs=dict(
|
||||||
|
command="timeout 10 systemctl show -p MainPID --value dovecot.service"
|
||||||
|
),
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
assert main_pid != 0, "dovecot.service MainPID is 0 -- service not running?"
|
||||||
|
|
||||||
|
exe = sshexec(
|
||||||
|
call=remote.rshell.shell,
|
||||||
|
kwargs=dict(command=f"timeout 10 readlink /proc/{main_pid}/exe"),
|
||||||
|
).strip()
|
||||||
|
status_text = sshexec(
|
||||||
|
call=remote.rshell.shell,
|
||||||
|
kwargs=dict(
|
||||||
|
command="timeout 10 systemctl show -p StatusText --value dovecot.service"
|
||||||
|
),
|
||||||
|
).strip()
|
||||||
|
installed_version = sshexec(
|
||||||
|
call=remote.rshell.shell, kwargs=dict(command="timeout 10 dovecot --version")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
assert not exe.endswith("(deleted)"), (
|
||||||
|
f"running dovecot binary was deleted (stale after upgrade): {exe}"
|
||||||
|
)
|
||||||
|
expected_status_text = f"v{installed_version}"
|
||||||
|
assert status_text == expected_status_text or status_text.startswith(
|
||||||
|
f"{expected_status_text} "
|
||||||
|
), (
|
||||||
|
f"dovecot status version mismatch: "
|
||||||
|
f"StatusText={status_text!r}, installed={installed_version!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_timezone_env(remote):
|
def test_timezone_env(remote):
|
||||||
for line in remote.iter_output("env"):
|
for line in remote.iter_output("env"):
|
||||||
print(line)
|
print(line)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import imaplib
|
import imaplib
|
||||||
|
import ipaddress
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@@ -14,6 +15,14 @@ from chatmaild.config import read_config
|
|||||||
conftestdir = Path(__file__).parent
|
conftestdir = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip(domain):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(domain)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
"--slow", action="store_true", default=False, help="also run slow tests"
|
"--slow", action="store_true", default=False, help="also run slow tests"
|
||||||
@@ -282,6 +291,7 @@ def gencreds(chatmail_config):
|
|||||||
|
|
||||||
def gen(domain=None):
|
def gen(domain=None):
|
||||||
domain = domain if domain else chatmail_config.mail_domain
|
domain = domain if domain else chatmail_config.mail_domain
|
||||||
|
addr_domain = f"[{domain}]" if _is_ip(domain) else domain
|
||||||
while 1:
|
while 1:
|
||||||
num = next(count)
|
num = next(count)
|
||||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||||
@@ -295,7 +305,7 @@ def gencreds(chatmail_config):
|
|||||||
password = "".join(
|
password = "".join(
|
||||||
random.choices(alphanumeric, k=chatmail_config.password_min_length)
|
random.choices(alphanumeric, k=chatmail_config.password_min_length)
|
||||||
)
|
)
|
||||||
yield f"{user}@{domain}", f"{password}"
|
yield f"{user}@{addr_domain}", f"{password}"
|
||||||
|
|
||||||
return lambda domain=None: next(gen(domain))
|
return lambda domain=None: next(gen(domain))
|
||||||
|
|
||||||
@@ -344,9 +354,22 @@ class ChatmailACFactory:
|
|||||||
accounts = []
|
accounts = []
|
||||||
for _ in range(num):
|
for _ in range(num):
|
||||||
account = self.dc.add_account()
|
account = self.dc.add_account()
|
||||||
future = account.add_or_update_transport.future(
|
addr, password = self.gencreds(domain)
|
||||||
self._make_transport(domain)
|
if _is_ip(domain):
|
||||||
)
|
# Use DCLOGIN scheme with explicit server hosts,
|
||||||
|
# matching how madmail presents its addresses to users.
|
||||||
|
qr = (
|
||||||
|
f"dclogin:{addr}"
|
||||||
|
f"?p={password}&v=1"
|
||||||
|
f"&ih={domain}&ip=993"
|
||||||
|
f"&sh={domain}&sp=465"
|
||||||
|
f"&ic=3&ss=default"
|
||||||
|
)
|
||||||
|
future = account.add_transport_from_qr.future(qr)
|
||||||
|
else:
|
||||||
|
future = account.add_or_update_transport.future(
|
||||||
|
self._make_transport(domain)
|
||||||
|
)
|
||||||
futures.append(future)
|
futures.append(future)
|
||||||
|
|
||||||
# ensure messages stay in INBOX so that they can be
|
# ensure messages stay in INBOX so that they can be
|
||||||
|
|||||||
238
cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py
Normal file
238
cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
from contextlib import nullcontext
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyinfra.facts.deb import DebPackages
|
||||||
|
|
||||||
|
from cmdeploy.dovecot import deployer as dovecot_deployer
|
||||||
|
|
||||||
|
|
||||||
|
def make_host(*fact_pairs):
|
||||||
|
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*fact_pairs: tuples of (fact_class, fact_value) to register
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SimpleNamespace with get_fact that raises a clear error if an
|
||||||
|
unexpected fact type is requested.
|
||||||
|
"""
|
||||||
|
facts = dict(fact_pairs)
|
||||||
|
|
||||||
|
def get_fact(cls):
|
||||||
|
if cls not in facts:
|
||||||
|
registered = ", ".join(c.__name__ for c in facts)
|
||||||
|
raise LookupError(
|
||||||
|
f"unexpected get_fact({cls.__name__}); "
|
||||||
|
f"only registered: {registered}"
|
||||||
|
)
|
||||||
|
return facts[cls]
|
||||||
|
|
||||||
|
return SimpleNamespace(get_fact=get_fact)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def deployer():
|
||||||
|
return dovecot_deployer.DovecotDeployer(
|
||||||
|
SimpleNamespace(mail_domain="chat.example.org"),
|
||||||
|
disable_mail=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patch_blocked(monkeypatch):
|
||||||
|
monkeypatch.setattr(dovecot_deployer, "blocked_service_startup", nullcontext)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_files_put(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer.files,
|
||||||
|
"put",
|
||||||
|
lambda **kwargs: SimpleNamespace(changed=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def track_shell(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer.server,
|
||||||
|
"shell",
|
||||||
|
lambda **kwargs: calls.append(kwargs) or SimpleNamespace(changed=False),
|
||||||
|
)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
|
||||||
|
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION
|
||||||
|
downloads = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
make_host((DebPackages, {"dovecot-core": [epoch_version]})),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"_pick_url",
|
||||||
|
lambda primary, fallback: primary,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer.files,
|
||||||
|
"download",
|
||||||
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
||||||
|
|
||||||
|
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
|
||||||
|
assert changed is False, "should not flag changed when version already installed"
|
||||||
|
assert downloads == [], "should not download when version already installed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
downloads = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
make_host((DebPackages, {})),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"_pick_url",
|
||||||
|
lambda primary, fallback: primary,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer.files,
|
||||||
|
"download",
|
||||||
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
||||||
|
|
||||||
|
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
||||||
|
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
|
||||||
|
|
||||||
|
# Verify the returned path uses archive version, not package version (with epoch)
|
||||||
|
assert changed is True, "should flag changed when package not yet installed"
|
||||||
|
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
|
||||||
|
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
|
||||||
|
f"deb path should use archive version (no epoch), got {deb!r}"
|
||||||
|
)
|
||||||
|
assert len(downloads) == 1, "files.download should be called exactly once"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
||||||
|
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
make_host(
|
||||||
|
(
|
||||||
|
dovecot_deployer.DebPackages,
|
||||||
|
{
|
||||||
|
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
||||||
|
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
||||||
|
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
downloads = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer.files,
|
||||||
|
"download",
|
||||||
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
deployer.install()
|
||||||
|
|
||||||
|
assert downloads == [], "should not download when all packages epoch-matched"
|
||||||
|
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
|
||||||
|
assert deployer.need_restart is False, (
|
||||||
|
"need_restart should be False when nothing changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_unsupported_arch_falls_back_to_apt(
|
||||||
|
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
||||||
|
):
|
||||||
|
# For unsupported architectures, all fact lookups return the arch string.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
SimpleNamespace(get_fact=lambda cls: "riscv64"),
|
||||||
|
)
|
||||||
|
apt_calls = []
|
||||||
|
|
||||||
|
# Mirrors apt.packages() return value: OperationMeta with .changed property.
|
||||||
|
# Only lmtpd triggers a change to verify |= accumulation of changed flags.
|
||||||
|
def fake_apt(**kwargs):
|
||||||
|
apt_calls.append(kwargs)
|
||||||
|
changed = "lmtpd" in kwargs["packages"][0]
|
||||||
|
return SimpleNamespace(changed=changed)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
|
||||||
|
|
||||||
|
deployer.install()
|
||||||
|
|
||||||
|
actual_pkgs = [c["packages"] for c in apt_calls]
|
||||||
|
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], (
|
||||||
|
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
|
||||||
|
)
|
||||||
|
assert track_shell == [], "should not run dpkg for unsupported arch"
|
||||||
|
assert deployer.need_restart is True, (
|
||||||
|
"need_restart should be True when apt installed a package"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_runs_dpkg_when_packages_need_download(
|
||||||
|
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
make_host(
|
||||||
|
(dovecot_deployer.DebPackages, {}),
|
||||||
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"_pick_url",
|
||||||
|
lambda primary, fallback: primary,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer.files,
|
||||||
|
"download",
|
||||||
|
lambda **kwargs: SimpleNamespace(changed=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
deployer.install()
|
||||||
|
|
||||||
|
assert len(track_shell) == 1, (
|
||||||
|
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
|
||||||
|
)
|
||||||
|
cmds = track_shell[0]["commands"]
|
||||||
|
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
|
||||||
|
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
|
||||||
|
assert "apt-get -y --fix-broken install" in cmds[1]
|
||||||
|
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
|
||||||
|
assert deployer.need_restart is True, (
|
||||||
|
"need_restart should be True after dpkg install"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_url_falls_back_on_primary_error(monkeypatch):
|
||||||
|
def raise_error(req, timeout):
|
||||||
|
raise OSError("connection timeout")
|
||||||
|
|
||||||
|
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
|
||||||
|
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
|
||||||
|
assert result == "http://fallback", (
|
||||||
|
f"should fall back when primary fails, got {result!r}"
|
||||||
|
)
|
||||||
@@ -18,8 +18,15 @@ if command -v lsb_release 2>&1 >/dev/null; then
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 -m venv --upgrade-deps venv
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
echo "Using uv for faster environment setup..."
|
||||||
venv/bin/pip install -e chatmaild
|
uv venv venv
|
||||||
venv/bin/pip install -e cmdeploy
|
uv pip install --python venv/bin/python -e chatmaild
|
||||||
venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs
|
uv pip install --python venv/bin/python -e cmdeploy
|
||||||
|
uv pip install --python venv/bin/python sphinx sphinxcontrib-mermaid sphinx-autobuild furo
|
||||||
|
else
|
||||||
|
python3 -m venv --upgrade-deps venv
|
||||||
|
venv/bin/pip install -e chatmaild
|
||||||
|
venv/bin/pip install -e cmdeploy
|
||||||
|
venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo
|
||||||
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user