Hand the mail settings to the container, and rebuild on deploy
Two silent failures sitting between Phase A and a working invitation. The compose file never passed MAIL_* through. Compose does not give a container the contents of .env — it only substitutes into the compose file — so the settings could be filled in correctly and read by nobody, with the app reporting mail as unconfigured and the values sitting right there on disk. test_deployment.py now fails the build whenever .env.example and docker-compose.yml drift apart, which is how this happened and how it would happen again. The app's code is baked into the image (COPY apps /srv/apps), so a pull followed by --force-recreate runs the old code and says nothing. Added scripts/deploy-board.sh: rebuild, sync the compose file, restart, print the log, and name any setting present in .env.example and missing from the live .env. It never touches .env itself. Also: "no mail server configured" is now told apart from "the mail server refused". They want different things done about them, and the first one sent an admin looking for an SMTP error that never existed. The new-member form says so before it is filled in rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
This commit is contained in:
parent
e95c732c6f
commit
ed6b568d60
@ -129,6 +129,10 @@ def new():
|
||||
# The form says so before it is filled in; offering a ticked checkbox
|
||||
# and reporting the problem on submit wastes the work of filling it.
|
||||
can_create_accounts=bool(current_app.config.get("ADMIN_TOKEN")),
|
||||
# Same courtesy for mail: without a server configured the invitation
|
||||
# cannot leave the box, and the admin has to pass the link on by
|
||||
# hand. Worth knowing before filling the form rather than after.
|
||||
can_send_mail=mail.configured(),
|
||||
gitea_url=current_app.config["GITEA_URL"].rstrip("/"),
|
||||
)
|
||||
|
||||
@ -175,19 +179,25 @@ def new():
|
||||
return redirect(url_for("members.new"))
|
||||
|
||||
invite_link = None
|
||||
mail_problem = None
|
||||
if create_account:
|
||||
link = auth.invite_url(invites.issue(cursor.lastrowid, "invite"))
|
||||
try:
|
||||
mail.send_invite(email, display_name or login, link)
|
||||
except (mail.MailFailed, mail.MailNotConfigured):
|
||||
except mail.MailNotConfigured:
|
||||
# Nothing is broken — this server has simply never been given a mail
|
||||
# server. Told apart from a failure on purpose: an admin sent looking
|
||||
# for an SMTP error that does not exist is an afternoon wasted.
|
||||
invite_link, mail_problem = link, "unconfigured"
|
||||
except mail.MailFailed:
|
||||
# The account exists and the member cannot reach it. Showing the
|
||||
# admin the link is the difference between a delayed invitation and
|
||||
# a person who simply never gets in.
|
||||
invite_link = link
|
||||
invite_link, mail_problem = link, "failed"
|
||||
|
||||
return render_template("member_created.html", login=login,
|
||||
email=email, created=create_account,
|
||||
invite_link=invite_link,
|
||||
invite_link=invite_link, mail_problem=mail_problem,
|
||||
role_label=ROLE_LABELS[role])
|
||||
|
||||
|
||||
|
||||
@ -12,14 +12,28 @@
|
||||
{# The account exists but the invitation never left the building. Showing the
|
||||
link is the difference between a delayed invitation and a person who
|
||||
simply never gets in. #}
|
||||
<p class="flash flash--error">
|
||||
No se pudo enviar el correo. Pásale este enlace por un canal privado —
|
||||
sirve una sola vez y caduca en 7 días.
|
||||
{% if mail_problem == 'unconfigured' %}
|
||||
<p class="flash">
|
||||
Este servidor todavía no envía correo. Pásale este enlace por un canal
|
||||
privado — sirve una sola vez y caduca en 7 días.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="flash flash--error">
|
||||
No se pudo enviar el correo: el servidor de correo rechazó el envío o no
|
||||
respondió. Pásale este enlace por un canal privado — sirve una sola vez y
|
||||
caduca en 7 días.
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="password mono">{{ invite_link }}</p>
|
||||
<p class="muted small">
|
||||
{% if mail_problem == 'unconfigured' %}
|
||||
Añade los valores <span class="mono">MAIL_*</span> en
|
||||
<span class="mono">/srv/board/.env</span> para que la próxima invitación
|
||||
salga sola.
|
||||
{% else %}
|
||||
Revisa la configuración de correo del servidor para que la próxima
|
||||
invitación salga sola.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
|
||||
@ -37,6 +37,16 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create_accounts and not can_send_mail %}
|
||||
{# Not an error: the account is still created and the invitation still
|
||||
issued. But it leaves by hand, and finding that out after filling the
|
||||
form is how an invitation ends up believed sent and never delivered. #}
|
||||
<p class="muted small">
|
||||
Este servidor todavía no envía correo, así que la invitación no se
|
||||
enviará sola: al terminar te mostraremos el enlace para que se lo pases.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can_make_admin %}
|
||||
<label for="role">Rol</label>
|
||||
<select id="role" name="role">
|
||||
|
||||
46
apps/board/tests/test_deployment.py
Normal file
46
apps/board/tests/test_deployment.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Guards on the gap between what the app reads and what the server hands it.
|
||||
|
||||
This is the one class of bug the rest of the suite cannot see. Every test here
|
||||
runs against a Flask config built in-process, so a setting can be documented in
|
||||
`.env.example`, read in `app.py`, and still reach the container as nothing at
|
||||
all — because Compose does not pass `.env` to a container, it only substitutes
|
||||
into the compose file. The symptom is a feature reporting itself unconfigured
|
||||
with the settings sitting right there on disk, which is a miserable thing to
|
||||
debug from the outside.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
INFRA = Path(__file__).resolve().parents[3] / "infra" / "board"
|
||||
ENV_EXAMPLE = INFRA / ".env.example"
|
||||
COMPOSE = INFRA / "docker-compose.yml"
|
||||
|
||||
SETTING = re.compile(r"^([A-Z][A-Z0-9_]*)=", re.MULTILINE)
|
||||
|
||||
|
||||
def documented() -> set[str]:
|
||||
return set(SETTING.findall(ENV_EXAMPLE.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def test_every_documented_setting_reaches_the_container():
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
missing = sorted(
|
||||
name for name in documented()
|
||||
if f"${{{name}}}" not in compose and f"${{{name}:-" not in compose
|
||||
)
|
||||
assert not missing, (
|
||||
"these are in .env.example but never substituted into "
|
||||
f"docker-compose.yml, so the container never sees them: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_mail_settings_are_optional_at_the_compose_level():
|
||||
"""`${MAIL_HOST}` without a default makes Compose warn on every command for
|
||||
a server that has deliberately not configured mail. Running without it is a
|
||||
supported state, so it must be a quiet one."""
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
for name in sorted(n for n in documented() if n.startswith("MAIL_")):
|
||||
assert f"${{{name}:-" in compose, f"{name} has no default in docker-compose.yml"
|
||||
@ -262,3 +262,35 @@ def test_linking_an_existing_account_sends_nothing(
|
||||
"role": "user", "create_account": "",
|
||||
})
|
||||
assert outbox == []
|
||||
|
||||
|
||||
# --- when the server cannot send at all -----------------------------------
|
||||
|
||||
def test_an_unconfigured_server_does_not_blame_the_mail_server(
|
||||
app, client, post, owner_id, sign_in, monkeypatch):
|
||||
"""No MAIL_HOST is not a failure, and saying "no se pudo enviar" sends the
|
||||
admin hunting for an SMTP error that was never produced."""
|
||||
monkeypatch.setattr(gitea, "admin_create_user",
|
||||
lambda login, email, name, password: None)
|
||||
app.config["MAIL_HOST"] = ""
|
||||
sign_in(owner_id)
|
||||
|
||||
page = post("/comunidad/miembros/nuevo", {
|
||||
"login": "maria", "display_name": "María", "email": "m@example.com",
|
||||
"role": "user", "create_account": "on",
|
||||
}).get_data(as_text=True)
|
||||
|
||||
assert "todavía no envía correo" in page
|
||||
assert "No se pudo enviar el correo" not in page
|
||||
assert "/comunidad/invitacion/" in page
|
||||
|
||||
|
||||
def test_the_form_warns_before_it_is_filled_in(app, client, owner_id, sign_in):
|
||||
app.config["MAIL_HOST"] = ""
|
||||
sign_in(owner_id)
|
||||
assert "no envía correo" in client.get(
|
||||
"/comunidad/miembros/nuevo").get_data(as_text=True)
|
||||
|
||||
app.config["MAIL_HOST"] = "smtp.example.com"
|
||||
assert "no envía correo" not in client.get(
|
||||
"/comunidad/miembros/nuevo").get_data(as_text=True)
|
||||
|
||||
@ -372,6 +372,33 @@ It cannot be used to take ownership later: that is deliberate, because otherwise
|
||||
editing a file on disk would be a quieter route to the top than asking for it.
|
||||
Ownership moves only through *Transferir titularidad* inside the app.
|
||||
|
||||
#### Updating it later — a pull is not a deploy
|
||||
|
||||
The app's code is **inside the image**: `docker/board/Dockerfile` ends with
|
||||
`COPY apps /srv/apps`. So pulling new commits into `~/vienalatina` changes
|
||||
nothing that is running, and neither does `docker compose up -d
|
||||
--force-recreate` — same image tag, same layers, same old code. Everything
|
||||
reports success and the server behaves exactly as it did before, which is the
|
||||
most expensive kind of nothing.
|
||||
|
||||
An update is a rebuild:
|
||||
|
||||
```sh
|
||||
cd ~/vienalatina
|
||||
git pull
|
||||
sudo bash scripts/deploy-board.sh
|
||||
```
|
||||
|
||||
That rebuilds the image, copies the compose file across, restarts, and prints
|
||||
the log. It never touches `/srv/board/.env` — that file holds the secrets and
|
||||
lives only on the server — but it does compare it against `.env.example` and
|
||||
name any setting that has appeared in the repository and is missing from yours.
|
||||
New settings are always added by hand.
|
||||
|
||||
The database schema is applied at start-up with `CREATE TABLE IF NOT EXISTS`,
|
||||
so a release that adds a table needs no migration step: the table appears when
|
||||
the new code does.
|
||||
|
||||
### 11.4 Route it through Caddy
|
||||
|
||||
Add to the `vienalatina.com` block in `/etc/caddy/Caddyfile` (already present in
|
||||
@ -622,9 +649,20 @@ perfectly in a mail client fails here, and the error it produces is a timeout
|
||||
rather than anything that names the cause.
|
||||
|
||||
```sh
|
||||
cd /srv/board && sudo docker compose up -d --force-recreate
|
||||
cd ~/vienalatina && sudo bash scripts/deploy-board.sh
|
||||
```
|
||||
|
||||
Not `docker compose up -d --force-recreate` on its own: if the code that reads
|
||||
these settings arrived in the same pull, that restart runs the old image and
|
||||
mail stays unconfigured with the settings sitting right there in `.env`. The
|
||||
script rebuilds first.
|
||||
|
||||
The variables also have to be listed in `/srv/board/docker-compose.yml`, which
|
||||
they now are. Compose does not hand `.env` to a container — it substitutes into
|
||||
the compose file — so a setting added to `.env` and not to the compose file is
|
||||
read by nobody. `apps/board/tests/test_deployment.py` fails the build if the
|
||||
two ever drift apart again.
|
||||
|
||||
Test it by adding a member with an address you can read. If the mail cannot be
|
||||
sent, the screen says so and shows you the invitation link to pass on by hand —
|
||||
the account is created either way, so a mail problem delays somebody rather
|
||||
|
||||
@ -35,6 +35,23 @@ services:
|
||||
- GITEA_ADMIN_TOKEN=${GITEA_ADMIN_TOKEN:-}
|
||||
|
||||
- BOARD_DB=/data/board.db
|
||||
|
||||
# Outgoing mail, for invitations and password resets. Empty MAIL_HOST is
|
||||
# a supported state: the members area still works, but nobody can be
|
||||
# invited or recover a password, and the screens say so.
|
||||
#
|
||||
# Every one of these has to be listed here. Compose does not hand the
|
||||
# contents of .env to the container by itself — it only substitutes into
|
||||
# this file — so a variable added to .env and not added here is read by
|
||||
# nobody, and the app reports mail as unconfigured with the settings
|
||||
# sitting right there on disk.
|
||||
- MAIL_HOST=${MAIL_HOST:-}
|
||||
- MAIL_PORT=${MAIL_PORT:-587}
|
||||
- MAIL_SECURITY=${MAIL_SECURITY:-starttls}
|
||||
- MAIL_USER=${MAIL_USER:-}
|
||||
- MAIL_PASSWORD=${MAIL_PASSWORD:-}
|
||||
- "MAIL_FROM=${MAIL_FROM:-Viena Latina <hola@vienalatina.com>}"
|
||||
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
|
||||
67
scripts/deploy-board.sh
Executable file
67
scripts/deploy-board.sh
Executable file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Put the current checkout of the members area onto the server.
|
||||
#
|
||||
# The app's code is baked into the image by docker/board/Dockerfile (`COPY apps
|
||||
# /srv/apps`), so pulling new commits changes nothing on its own — the container
|
||||
# keeps running the code that was in the image when it was built. `docker
|
||||
# compose up -d --force-recreate` does not help either: same tag, same layers,
|
||||
# same old code. That is a quiet failure, because everything reports success and
|
||||
# the site behaves exactly as it did before.
|
||||
#
|
||||
# This script is the whole update, in the order that matters:
|
||||
#
|
||||
# git pull # done by you, first
|
||||
# sudo bash scripts/deploy-board.sh
|
||||
#
|
||||
# It never touches /srv/board/.env. That file holds the secrets and exists only
|
||||
# on the server; the repository has .env.example instead, and the two drifting
|
||||
# apart is expected — new settings appear in the example and have to be copied
|
||||
# across by hand.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TARGET="${BOARD_DIR:-/srv/board}"
|
||||
IMAGE="vienalatina/board:1"
|
||||
|
||||
cd "$REPO"
|
||||
|
||||
echo "==> Building $IMAGE from $(git rev-parse --short HEAD)"
|
||||
docker build -t "$IMAGE" -f docker/board/Dockerfile .
|
||||
|
||||
echo "==> Syncing compose files into $TARGET (never .env)"
|
||||
mkdir -p "$TARGET"
|
||||
cp "$REPO/infra/board/docker-compose.yml" "$TARGET/docker-compose.yml"
|
||||
cp "$REPO/infra/board/.env.example" "$TARGET/.env.example"
|
||||
|
||||
if [ ! -f "$TARGET/.env" ]; then
|
||||
echo "No $TARGET/.env — copy .env.example to .env and fill it in first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Settings that appear in the example and not in the live file. Nothing is
|
||||
# copied automatically: some of them are secrets, and a blank line silently
|
||||
# added to .env is worse than a line missing loudly.
|
||||
missing="$(comm -23 \
|
||||
<(grep -oE '^[A-Z][A-Z0-9_]*=' "$TARGET/.env.example" | sort -u) \
|
||||
<(grep -oE '^[A-Z][A-Z0-9_]*=' "$TARGET/.env" | sort -u) || true)"
|
||||
if [ -n "$missing" ]; then
|
||||
echo
|
||||
echo "!! These settings exist in .env.example but not in your .env:"
|
||||
echo "$missing" | sed 's/^/ /'
|
||||
echo " Add them to $TARGET/.env and run this again if the feature needs them."
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "==> Restarting"
|
||||
cd "$TARGET"
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
# The schema is applied at start-up with CREATE TABLE IF NOT EXISTS, so a new
|
||||
# table arrives with the new code. If the container is not up a few seconds
|
||||
# later it died during that, and the log says why.
|
||||
sleep 3
|
||||
docker compose ps
|
||||
echo
|
||||
echo "Recent log:"
|
||||
docker compose logs --tail 20 board
|
||||
Loading…
Reference in New Issue
Block a user