Compare commits

...

3 Commits

Author SHA1 Message Date
ddf250b61a Merge branch 'claude/relaxed-faraday-h4zd09' of https://github.com/pablovolenski/vienalatina
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-25 18:55:24 +00:00
Claude
bf7ff3025a
Say which remote to pull from, because git will not
The server's main tracks gitea/main while new work lands on a branch on
GitHub, so a bare `git pull` asks Gitea, finds it level, and answers
"Already up to date." That sentence is true about the wrong remote and
reads exactly like there is nothing to fetch — which is how the last
deploy stalled on a script that had never arrived.

Step 8 now names a `github` remote beside `gitea`, and the update recipe
in 11.3 pulls from it by name with --no-edit, pushes to gitea, then
rebuilds. 11.3 previously said plain `git pull`, as though the server
tracked GitHub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-25 18:54:00 +00:00
Claude
ed6b568d60
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
2026-09-25 18:42:39 +00:00
8 changed files with 267 additions and 8 deletions

View File

@ -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])

View File

@ -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 %}

View File

@ -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">

View 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"

View File

@ -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)

View File

@ -193,9 +193,16 @@ your laptop):
cd ~/vienalatina
git remote add gitea https://git.vienalatina.com/pablo/vienalatina.git
git push gitea main
# Name the GitHub remote too, while you are here. The clone in step 5 called it
# `origin`, but `main` came to track `gitea/main` — so a bare `git pull` asks
# Gitea, and new work arrives on GitHub. Having both named saves you from typing
# the URL every time you deploy.
git remote add github https://github.com/pablovolenski/vienalatina.git
git remote -v
```
(It will ask for your Gitea username/password.)
(Gitea will ask for your Gitea username/password.)
In Woodpecker (**https://ci.vienalatina.com**):
@ -372,6 +379,51 @@ 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.
**And `git pull` on its own will not fetch it.** This is worth knowing before
it costs you an afternoon: `main` on the server tracks `gitea/main`, while new
work is pushed to a branch on **GitHub**. So a bare `git pull` asks Gitea,
finds Gitea level with your local `main`, and answers *"Already up to date."* —
which is true about the wrong remote, and reads exactly like there is nothing
to do.
An update is a named pull, a push to Gitea so the build pipeline sees it, and a
rebuild:
```sh
cd ~/vienalatina
git pull --no-edit github <the-branch-name> # e.g. claude/relaxed-faraday-h4zd09
git push gitea main
sudo bash scripts/deploy-board.sh
```
`--no-edit` accepts the default merge message. Without it git opens an editor,
which is a strange place to find yourself mid-deploy.
If `github` is not a remote yet, add it once — see the end of step 8:
```sh
git remote add github https://github.com/pablovolenski/vienalatina.git
```
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 +674,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

View File

@ -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
View 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