Compare commits
3 Commits
e55ea94619
...
ddf250b61a
| Author | SHA1 | Date | |
|---|---|---|---|
| ddf250b61a | |||
|
|
bf7ff3025a | ||
|
|
ed6b568d60 |
@ -129,6 +129,10 @@ def new():
|
|||||||
# The form says so before it is filled in; offering a ticked checkbox
|
# 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.
|
# and reporting the problem on submit wastes the work of filling it.
|
||||||
can_create_accounts=bool(current_app.config.get("ADMIN_TOKEN")),
|
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("/"),
|
gitea_url=current_app.config["GITEA_URL"].rstrip("/"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -175,19 +179,25 @@ def new():
|
|||||||
return redirect(url_for("members.new"))
|
return redirect(url_for("members.new"))
|
||||||
|
|
||||||
invite_link = None
|
invite_link = None
|
||||||
|
mail_problem = None
|
||||||
if create_account:
|
if create_account:
|
||||||
link = auth.invite_url(invites.issue(cursor.lastrowid, "invite"))
|
link = auth.invite_url(invites.issue(cursor.lastrowid, "invite"))
|
||||||
try:
|
try:
|
||||||
mail.send_invite(email, display_name or login, link)
|
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
|
# The account exists and the member cannot reach it. Showing the
|
||||||
# admin the link is the difference between a delayed invitation and
|
# admin the link is the difference between a delayed invitation and
|
||||||
# a person who simply never gets in.
|
# a person who simply never gets in.
|
||||||
invite_link = link
|
invite_link, mail_problem = link, "failed"
|
||||||
|
|
||||||
return render_template("member_created.html", login=login,
|
return render_template("member_created.html", login=login,
|
||||||
email=email, created=create_account,
|
email=email, created=create_account,
|
||||||
invite_link=invite_link,
|
invite_link=invite_link, mail_problem=mail_problem,
|
||||||
role_label=ROLE_LABELS[role])
|
role_label=ROLE_LABELS[role])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,14 +12,28 @@
|
|||||||
{# The account exists but the invitation never left the building. Showing the
|
{# The account exists but the invitation never left the building. Showing the
|
||||||
link is the difference between a delayed invitation and a person who
|
link is the difference between a delayed invitation and a person who
|
||||||
simply never gets in. #}
|
simply never gets in. #}
|
||||||
<p class="flash flash--error">
|
{% if mail_problem == 'unconfigured' %}
|
||||||
No se pudo enviar el correo. Pásale este enlace por un canal privado —
|
<p class="flash">
|
||||||
sirve una sola vez y caduca en 7 días.
|
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>
|
</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="password mono">{{ invite_link }}</p>
|
||||||
<p class="muted small">
|
<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
|
Revisa la configuración de correo del servidor para que la próxima
|
||||||
invitación salga sola.
|
invitación salga sola.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -37,6 +37,16 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if can_make_admin %}
|
||||||
<label for="role">Rol</label>
|
<label for="role">Rol</label>
|
||||||
<select id="role" name="role">
|
<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": "",
|
"role": "user", "create_account": "",
|
||||||
})
|
})
|
||||||
assert outbox == []
|
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)
|
||||||
|
|||||||
@ -193,9 +193,16 @@ your laptop):
|
|||||||
cd ~/vienalatina
|
cd ~/vienalatina
|
||||||
git remote add gitea https://git.vienalatina.com/pablo/vienalatina.git
|
git remote add gitea https://git.vienalatina.com/pablo/vienalatina.git
|
||||||
git push gitea main
|
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**):
|
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.
|
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.
|
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
|
### 11.4 Route it through Caddy
|
||||||
|
|
||||||
Add to the `vienalatina.com` block in `/etc/caddy/Caddyfile` (already present in
|
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.
|
rather than anything that names the cause.
|
||||||
|
|
||||||
```sh
|
```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
|
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 —
|
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
|
the account is created either way, so a mail problem delays somebody rather
|
||||||
|
|||||||
@ -35,6 +35,23 @@ services:
|
|||||||
- GITEA_ADMIN_TOKEN=${GITEA_ADMIN_TOKEN:-}
|
- GITEA_ADMIN_TOKEN=${GITEA_ADMIN_TOKEN:-}
|
||||||
|
|
||||||
- BOARD_DB=/data/board.db
|
- 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:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
ports:
|
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