From 419893d93dfb445f509e4e629552977f56f4e22e Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 17 Aug 2026 01:45:14 +0200 Subject: [PATCH] stash --- backend/Dockerfile | 13 - backend/configure.py | 59 +- .../docker-compose.yml} | 56 +- deploy/prod/.gitignore | 2 + deploy/prod/Dockerfile | 14 - deploy/prod/Dockerfile.backend | 27 + deploy/prod/Dockerfile.frontend | 18 + deploy/prod/Dockerfile.wiki | 17 + deploy/prod/README.md | 206 +++++++ deploy/prod/inventory.example.yml | 42 ++ deploy/prod/playbook.yml | 543 ++++++++++++++++++ 11 files changed, 925 insertions(+), 72 deletions(-) delete mode 100644 backend/Dockerfile rename deploy/{docker-compose.override.yml => dev/docker-compose.yml} (55%) create mode 100644 deploy/prod/.gitignore delete mode 100644 deploy/prod/Dockerfile create mode 100644 deploy/prod/Dockerfile.backend create mode 100644 deploy/prod/Dockerfile.frontend create mode 100644 deploy/prod/Dockerfile.wiki create mode 100644 deploy/prod/README.md create mode 100644 deploy/prod/inventory.example.yml create mode 100644 deploy/prod/playbook.yml diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 9e12f8f..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:alpine -WORKDIR /app -RUN apk add --no-cache gcc musl-dev python3-dev -COPY requirements.txt /app -RUN pip install --upgrade pip && pip install -r requirements.txt -COPY . /app -RUN python configure.py -RUN python manage.py collectstatic --noinput -CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure -# TODO serve static files with nginx and remove --insecure -EXPOSE 8000 - - diff --git a/backend/configure.py b/backend/configure.py index 92338eb..cb04c8c 100755 --- a/backend/configure.py +++ b/backend/configure.py @@ -32,25 +32,41 @@ def yesno(prompt, default=False): def configure(): - if not os.path.exists('.env'): - if not yesno("the .env file does not exist, do you want to create it?", default=True): - print('Aborting') - exit(0) - if not os.path.exists('.env.dist'): - print('No .env.dist file found') - exit(1) - else: - from shutil import copyfile - copyfile('.env.dist', '.env') + # Keys this function may generate/update, tracked so that if .env turns + # out to be unwritable (e.g. a prod container running as an unprivileged + # user, whose real config comes from --env-file instead) we can still + # print the resulting configuration for the operator to apply manually, + # rather than silently discarding it or crashing. + tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS'] + unwritable = False - env = dotenv.load_dotenv('.env') - if not env or not os.getenv('SECRET_KEY'): + if not os.path.exists('.env'): + if yesno("the .env file does not exist, do you want to create it?", default=True): + if not os.path.exists('.env.dist'): + print('No .env.dist file found') + else: + for key in dotenv.dotenv_values('.env.dist'): + if key not in tracked_keys: + tracked_keys.append(key) + from shutil import copyfile + try: + copyfile('.env.dist', '.env') + except PermissionError: + unwritable = True + + dotenv.load_dotenv('.env') + if not os.getenv('SECRET_KEY'): from django.core.management.utils import get_random_secret_key print('No SECRET_KEY found in .env file, generating one...') - with open('.env', 'a') as f: - f.write('\nSECRET_KEY=') - f.write(get_random_secret_key()) - f.write('\n') + secret_key = get_random_secret_key() + os.environ['SECRET_KEY'] = secret_key + try: + with open('.env', 'a') as f: + f.write('\nSECRET_KEY=') + f.write(secret_key) + f.write('\n') + except PermissionError: + unwritable = True # TODO rename ALLOWED_HOSTS to something more self-explanatory current_hosts = os.getenv('ALLOWED_HOSTS') @@ -59,7 +75,16 @@ def configure(): if yesno("Do you want to add ALLOWED_HOSTS?"): hosts = input("Enter a comma-separated list of allowed hosts: ") joined_hosts = current_hosts + ',' + hosts if current_hosts else hosts - dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts) + os.environ['ALLOWED_HOSTS'] = joined_hosts + try: + dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts) + except PermissionError: + unwritable = True + + if unwritable: + print('Could not write .env (read-only working directory) - resulting configuration:') + for key in tracked_keys: + print('{}={}'.format(key, os.getenv(key, ''))) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") import django diff --git a/deploy/docker-compose.override.yml b/deploy/dev/docker-compose.yml similarity index 55% rename from deploy/docker-compose.override.yml rename to deploy/dev/docker-compose.yml index 77f21c7..abd20f2 100644 --- a/deploy/docker-compose.override.yml +++ b/deploy/dev/docker-compose.yml @@ -1,49 +1,49 @@ version: '3.8' -name: deploy +name: dev services: backend-a: build: - context: ../backend/ + context: ../../backend/ dockerfile: ../deploy/dev/Dockerfile.backend environment: TOOLSHED_DB_PATH: /mnt/db.sqlite3 TOOLSHED_USERFILES_PATH: /mnt/userfiles TOOLSHED_SETUP_PATH: /mnt/testdata.py volumes: - - ../backend:/code - - ../deploy/dev/instance_a/a.env:/code/.env - - ../deploy/dev/instance_a/testdata.py:/mnt/testdata.py - - ../deploy/dev/instance_a/a.sqlite3:/mnt/db.sqlite3 - - ../deploy/dev/instance_a/userfiles:/mnt/userfiles + - ../../backend:/code + - ./instance_a/a.env:/code/.env + - ./instance_a/testdata.py:/mnt/testdata.py + - ./instance_a/a.sqlite3:/mnt/db.sqlite3 + - ./instance_a/userfiles:/mnt/userfiles expose: - 8000 command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" backend-b: build: - context: ../backend/ + context: ../../backend/ dockerfile: ../deploy/dev/Dockerfile.backend environment: TOOLSHED_DB_PATH: /mnt/db.sqlite3 TOOLSHED_USERFILES_PATH: /mnt/userfiles TOOLSHED_SETUP_PATH: /mnt/testdata.py volumes: - - ../backend:/code - - ../deploy/dev/instance_b/b.env:/code/.env - - ../deploy/dev/instance_b/testdata.py:/mnt/testdata.py - - ../deploy/dev/instance_b/b.sqlite3:/mnt/db.sqlite3 - - ../deploy/dev/instance_b/userfiles:/mnt/userfiles + - ../../backend:/code + - ./instance_b/b.env:/code/.env + - ./instance_b/testdata.py:/mnt/testdata.py + - ./instance_b/b.sqlite3:/mnt/db.sqlite3 + - ./instance_b/userfiles:/mnt/userfiles expose: - 8000 command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" frontend: build: - context: ../frontend/ + context: ../../frontend/ dockerfile: ../deploy/dev/Dockerfile.frontend volumes: - - ../frontend:/app + - ../../frontend:/app - /app/node_modules expose: - 5173 @@ -51,11 +51,11 @@ services: wiki: build: - context: ../ + context: ../../ dockerfile: deploy/dev/Dockerfile.wiki volumes: - - ../mkdocs.yml:/wiki/mkdocs.yml - - ../docs:/wiki/docs + - ../../mkdocs.yml:/wiki/mkdocs.yml + - ../../docs:/wiki/docs expose: - 8001 command: mkdocs serve --dev-addr=0.0.0.0:8001 @@ -63,12 +63,12 @@ services: proxy-a: build: context: ./ - dockerfile: dev/Dockerfile.proxy + dockerfile: Dockerfile.proxy volumes: - - ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro - - ./dev/instance_a/dns.json:/var/www/dns.json:ro - - ./dev/instance_a/domains.json:/var/www/domains.json:ro - - ./dev/instance_a/userfiles:/var/www/userfiles:ro + - ./instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro + - ./instance_a/dns.json:/var/www/dns.json:ro + - ./instance_a/domains.json:/var/www/domains.json:ro + - ./instance_a/userfiles:/var/www/userfiles:ro ports: - "127.0.0.1:8080:8080" - "127.0.0.3:5353:5353" @@ -76,19 +76,19 @@ services: proxy-b: build: context: ./ - dockerfile: dev/Dockerfile.proxy + dockerfile: Dockerfile.proxy volumes: - - ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro - - ./dev/instance_b/userfiles:/var/www/userfiles:ro + - ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro + - ./instance_b/userfiles:/var/www/userfiles:ro ports: - "127.0.0.2:8080:8080" dns: build: - context: ./dev/ + context: ./ dockerfile: Dockerfile.dns volumes: - - ./dev/zone.json:/dns/zone.json + - ./zone.json:/dns/zone.json expose: - 8053 networks: diff --git a/deploy/prod/.gitignore b/deploy/prod/.gitignore new file mode 100644 index 0000000..cb88020 --- /dev/null +++ b/deploy/prod/.gitignore @@ -0,0 +1,2 @@ +.secrets/ +inventory.yml diff --git a/deploy/prod/Dockerfile b/deploy/prod/Dockerfile deleted file mode 100644 index cbff043..0000000 --- a/deploy/prod/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:alpine as builder -WORKDIR /app -COPY ./package.json /app/package.json -COPY . /app -RUN npm install -RUN npm run build - - -FROM nginx:alpine as runner -RUN apk add --update npm -WORKDIR /app -COPY --from=builder /app/dist /usr/share/nginx/html -COPY ./nginx.conf /etc/nginx/nginx.conf -EXPOSE 80 diff --git a/deploy/prod/Dockerfile.backend b/deploy/prod/Dockerfile.backend new file mode 100644 index 0000000..b6cf9af --- /dev/null +++ b/deploy/prod/Dockerfile.backend @@ -0,0 +1,27 @@ +# Production image for the Django backend. +# Runs migrations then serves the app with gunicorn on port 8000. +# Static files are collected at build time into /app/staticfiles and +# served by the backend itself behind the host nginx reverse proxy. + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DJANGO_SETTINGS_MODULE=backend.settings + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt gunicorn + +COPY . . + +# collectstatic only needs Django settings to import cleanly, not a real +# secret; the actual SECRET_KEY is injected at container runtime via +# --env-file and overrides this. +RUN SECRET_KEY=build-time-placeholder python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 --workers 3"] diff --git a/deploy/prod/Dockerfile.frontend b/deploy/prod/Dockerfile.frontend new file mode 100644 index 0000000..12dec8d --- /dev/null +++ b/deploy/prod/Dockerfile.frontend @@ -0,0 +1,18 @@ +# Build-only image for the Vue frontend. +# It is never run as a service: ansible builds this image once, runs it +# with the host output directory bind-mounted at /output, the container +# copies the compiled static build into it, and exits. Nginx on the host +# then serves that directory directly. + +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY extras/ ./extras/ +RUN npm ci +COPY . . +RUN npm run build + +FROM alpine AS export +COPY --from=build /app/dist /dist +VOLUME /output +CMD ["sh", "-c", "rm -rf /output/* && cp -a /dist/. /output/"] diff --git a/deploy/prod/Dockerfile.wiki b/deploy/prod/Dockerfile.wiki new file mode 100644 index 0000000..48b9b6e --- /dev/null +++ b/deploy/prod/Dockerfile.wiki @@ -0,0 +1,17 @@ +# Build-only image for the project wiki (mkdocs). +# It is never run as a service: ansible builds this image once, runs it +# with the host output directory bind-mounted at /output, the container +# copies the built static site into it, and exits. Nginx on the host +# then serves that directory directly, the same way it does the frontend. + +FROM python:3.11-slim AS build +WORKDIR /wiki +RUN pip install --no-cache-dir mkdocs +COPY mkdocs.yml ./ +COPY docs/ ./docs/ +RUN mkdocs build + +FROM alpine AS export +COPY --from=build /wiki/site /site +VOLUME /output +CMD ["sh", "-c", "rm -rf /output/* && cp -a /site/. /output/"] diff --git a/deploy/prod/README.md b/deploy/prod/README.md new file mode 100644 index 0000000..e879ede --- /dev/null +++ b/deploy/prod/README.md @@ -0,0 +1,206 @@ +# Toolshed production deployment — manual steps + +`playbook.yml` automates installing docker.io and nginx (plus certbot, and +obtaining/renewing a TLS certificate with it, on hosts that manage their own +— see `behind_tls_proxy` below), building the backend, frontend and wiki +images, exporting the frontend and wiki static builds for nginx to serve, +writing the small `/local/domains` and `/local/dns` fixture files the +frontend fetches directly (registration domain list and DoH resolver +preference — see `toolshed_register_domains`/`toolshed_doh_resolvers` in +`playbook.yml`), configuring nginx, and installing the `toolshed-backend` +systemd service. It does **not** set up the target server or DNS. Those are +manual, one-time steps and are covered here. Seeding the backend's shared +reference data is also a manual, one-time step — see +[First superuser & shared reference data](#5-first-superuser--shared-reference-data). + +## 1. Server & firewall + +- A Debian/Ubuntu host reachable over SSH. +- Copy `inventory.example.yml` to `inventory.yml` (git-ignored, since it + holds real hostnames/IPs) and fill in your host(s) — see + [Per-deployment configuration](#2-per-deployment-configuration). +- Inbound TCP 80 open in the firewall/security group. Also open 443 unless + `behind_tls_proxy: true` — and keep both open permanently, not just for the + initial deploy: certbot's renewal timer needs 80 for the ACME HTTP-01 + challenge and 443 for HTTPS traffic for as long as this host is live. + +## 2. Per-deployment configuration + +Each entry under `hosts:` in `inventory.yml` is its own independent +deployment (its own repo checkout, database, domain, systemd service and +Django `SECRET_KEY` — nothing is shared between hosts). Set these as +host_vars directly on each host entry, not via `-e` on the command line, +so a single `inventory.yml` can hold several unrelated deployments safely: + +```yaml +toolshed: + hosts: + my-server: + ansible_host: 203.0.113.10 + ansible_user: deploy + toolshed_domain: toolshed.webdomain.tld + toolshed_handle_domain: yourtoolshed.tld # optional, see below + toolshed_repo_url: git@example.com:your-org/toolshed.git + behind_tls_proxy: false +``` + +- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django + `ALLOWED_HOSTS`, and the hostname you'll point a TLS cert at — e.g. + `toolshed.webdomain.tld`. Required, no default. This is not necessarily the + same as the **handle domain** your users log in with (the part after `@` + in `user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate. +- `toolshed_handle_domain` — the **handle domain**, only needed when it's + different from `toolshed_domain`. Omit it when the two are the same (it + then defaults to `toolshed_domain`). Set so nginx/Django accept requests + for either domain, whichever ends up as the `Host` header. +- `toolshed_repo_url` — the git remote the playbook checks out and builds + from. Required, no default. +- `toolshed_version` — the branch, tag or commit to check out and build. + Optional, defaults to `stable`. +- `behind_tls_proxy` — `true` if TLS for this host is already terminated by + something in front of it (e.g. an external reverse proxy or load + balancer) that forwards plain HTTP here; `false` if this nginx has to + terminate TLS itself. This controls two things: + - Whether nginx trusts an upstream `X-Forwarded-Proto` header or sets its + own — get this wrong and Django's `SECURE_PROXY_SSL_HEADER` check + (`backend/backend/settings.py`) will treat every request as insecure or, + flipped the other way, treat plain HTTP as secure. + - Whether the playbook manages TLS at all. When `false`, it automatically + obtains a Let's Encrypt certificate via certbot and switches nginx over + to it — nothing to do manually beyond DNS (below). certbot's own systemd + timer keeps renewing it afterwards, independent of the playbook. +- `toolshed_letsencrypt_email` — required whenever `behind_tls_proxy` is + `false`; the account email certbot registers the certificate under + (used only for renewal-failure notices). Ignored otherwise. +- `http_port` — optional, defaults to `80`. Only relevant when + `behind_tls_proxy: true` and whatever's in front of this host forwards to + a nonstandard port instead of 80. +- `doh_resolvers` — optional, defaults to `["1.1.1.1", "8.8.8.8"]` (the same + hardcoded fallback the frontend itself uses, see `frontend/src/dns.js`). + DNS-over-HTTPS resolvers the frontend uses to look up a handle domain's + `_toolshed-server._tcp` SRV record before it has a cached preference. + Written to `/local/dns` at deploy time; only worth overriding as a + host_var (or `-e doh_resolvers='["9.9.9.9"]'`) if you want this + deployment to prefer a specific resolver. + +## 3. DNS + +There are two distinct domains at play here, and it's easy to conflate them: + +- **Web domain** — the machine's actual hostname: nginx `server_name`, + Django `ALLOWED_HOSTS`, your TLS cert, what's in `toolshed_domain`. This is + what an A/AAAA record has to resolve to the server's IP for. +- **Handle domain** — the part after the `@` in a username, e.g. + `user@yourtoolshed.tld`. Toolshed usernames don't encode a server address + directly; the frontend resolves the handle domain to a server via an SRV + record, `_toolshed-server._tcp..` (see + `frontend/src/store.js`, `lookupServer`). What's in `toolshed_handle_domain` + (see [Per-deployment configuration](#2-per-deployment-configuration)) only + makes nginx/Django accept it as a `Host` header — publishing the actual SRV + record is still a separate, manual DNS step, covered below. + +The SRV lookup happens for every login, not just federation with other +servers, so **every** deployment needs it published for its own handle +domain — even a standalone server that only ever serves itself. + +These two domains can be **the same** or **completely different**, and +that's exactly the choice between an A record and an SRV record: + +- **Same domain**: if `yourtoolshed.tld` is both the web domain and the + handle domain, it needs both an A record (so the domain itself resolves to + the server) and an SRV record that happens to point back at itself. +- **Different domains**: the handle domain only needs the SRV record — no A + record of its own — pointing at whatever web domain the server actually + lives at. This is useful when the handle you give out (short, brandable, + independent of hosting) shouldn't have to match wherever the box is + actually deployed (a subdomain of a shared hosting provider, an internal + service name, etc.). + +**a) A/AAAA record — web domain → server IP:** + +```sh +dig A +``` + +**b) SRV record — handle domain → web domain + port.** Use port 443: the +federation protocol is HTTPS-only. + +```sh +dig _toolshed-server._tcp. SRV +``` + +For example, with a handle domain of `yourtoolshed.tld` and a web domain of +`toolshed.webdomain.tld`: + +``` +$ dig _toolshed-server._tcp.yourtoolshed.tld srv +_toolshed-server._tcp.yourtoolshed.tld. 300 IN SRV 10 10 443 toolshed.webdomain.tld. + +$ dig toolshed.webdomain.tld A +toolshed.webdomain.tld. 300 IN A 203.0.113.10 +``` + +If you instead want `yourtoolshed.tld` itself to be the web domain too, its +SRV record just points at itself (`... SRV 10 10 443 yourtoolshed.tld.`) and +it additionally needs its own A record. + +## 4. Secrets + +`toolshed_secret_key` is generated once per host by the playbook (via the +`password` lookup, keyed by the host's inventory name) and stored as +`.secrets/_secret_key` on the *control* machine, not on +the target. Back these files up — losing one invalidates all sessions and +signed cookies for that deployment on its next redeploy. They're git-ignored +on purpose; never commit them. + +## 5. First superuser & shared reference data + +The production backend image only runs `migrate` and `collectstatic` at +startup (see `Dockerfile.backend`) — unlike the dev compose setup, it never +runs the interactive `configure.py`. Two things dev gets "for free" from that +script therefore need doing manually, once, after a host's backend container +is first up (run these on the target host itself, or prefix with +`ssh `): + +- **Superuser account:** + + ```sh + docker exec -it toolshed-backend python manage.py createsuperuser + ``` + +- **Shared reference data** (the standard categories/properties/tags + shipped in `backend/shared_data/*.json` — tools, electrical, screws, IT, + etc.): without this step a fresh deployment starts with none of them. + Run `configure.py` interactively (the `-it` flags matter — the script's + prompts only appear with a real tty) and answer "yes" when it asks to + import them: + + ```sh + docker exec -it toolshed-backend python configure.py + ``` + + The other prompts it asks first (create `.env`, create a database) are + harmless to answer "yes" to as well: the container already gets its real + `SECRET_KEY`/`ALLOWED_HOSTS`/db path from the environment (the systemd unit + passes them via `--env-file`, see the "Write backend environment file" task + in `playbook.yml`), those checks just look for files at paths relative to + `/app` that don't exist in this container, and re-running `migrate` against + the real database is idempotent. You can say "no" to the superuser prompt + here if you already created one above. + +## 6. Running the playbook + +Always target one host at a time with `--limit` — running against the whole +`toolshed` group in one invocation would apply every host's own +`toolshed_domain`/`toolshed_repo_url` correctly (they're per-host vars, see +[Per-deployment configuration](#2-per-deployment-configuration)), but rolls +out all deployments back-to-back in one run, which is rarely what you want: + +```sh +ansible-playbook -i inventory.yml playbook.yml --limit my-server +``` + +Re-run it to roll out a new version to that host. It deploys whatever +`toolshed_version` is set for that host (`stable` by default) — set the +host_var for a persistent change, or pass `-e toolshed_version=` +for a one-off deploy of something else. diff --git a/deploy/prod/inventory.example.yml b/deploy/prod/inventory.example.yml new file mode 100644 index 0000000..9979f81 --- /dev/null +++ b/deploy/prod/inventory.example.yml @@ -0,0 +1,42 @@ +--- +# Copy this file to inventory.yml (git-ignored) and fill in your real +# hosts. Each entry under hosts: is an independent deployment - see the +# README's "Per-deployment configuration" section for what each var means. + +toolshed: + hosts: + my-server: + ansible_host: 203.0.113.10 + ansible_user: deploy + # toolshed_domain is the "web domain" - see the README's DNS section + # for how this relates to the separate "handle domain" your users + # log in with (user@yourtoolshed.tld). + toolshed_domain: toolshed.webdomain.tld + # Optional - only needed if the handle domain differs from the web + # domain above. Omit it entirely when they're the same. + toolshed_handle_domain: yourtoolshed.tld + toolshed_repo_url: git@example.com:your-org/toolshed.git + # Optional - branch, tag or commit to deploy. Defaults to "stable". + toolshed_version: stable + # true if something in front of this host already terminates TLS + # (reverse proxy/load balancer), false if this nginx must do it itself. + behind_tls_proxy: false + # Required whenever behind_tls_proxy is false: the playbook obtains + # its own Let's Encrypt certificate via certbot, which needs an + # account email for renewal notices. + toolshed_letsencrypt_email: admin@example.com + + # A second, unrelated deployment behind an existing TLS-terminating + # proxy - remove this if you only run one instance. Here the handle + # domain and web domain are the same, so toolshed_handle_domain is + # simply omitted, and toolshed_letsencrypt_email isn't needed since + # this nginx never handles TLS itself. + my-other-server: + ansible_host: my-other-server.example.com + ansible_user: deploy + toolshed_domain: toolshed.example.com + toolshed_repo_url: git@example.com:your-org/toolshed.git + behind_tls_proxy: true + # Only needed if the proxy in front forwards to something other than + # port 80 on this host. + http_port: 8080 diff --git a/deploy/prod/playbook.yml b/deploy/prod/playbook.yml new file mode 100644 index 0000000..455d5fd --- /dev/null +++ b/deploy/prod/playbook.yml @@ -0,0 +1,543 @@ +--- +# Production deploy for toolshed. +# +# - installs docker.io and nginx on the target (plus certbot, unless +# behind_tls_proxy is true) +# - checks out the source and builds the backend and frontend docker images +# - runs the frontend image once to export its static build, which nginx +# then serves directly (the frontend image is never run as a service) +# - configures nginx (inline template, no separate .conf file) and, unless +# behind_tls_proxy is true, obtains/renews a Let's Encrypt certificate via +# certbot and switches nginx over to it automatically - no manual TLS step +# - installs and manages a systemd service that runs the backend container +# +# Usage (each host is its own independent deployment - always target one +# at a time, never the whole "toolshed" group in one run): +# ansible-playbook -i inventory.yml playbook.yml --limit my-server +# +# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional), +# toolshed_version (optional, defaults to "stable"), behind_tls_proxy and +# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are +# per-deployment and must be set as host_vars in inventory.yml (copy +# inventory.example.yml) rather than here or via -e, so that each host in +# the "toolshed" group can point at its own repo/domain/branch. They're read +# with `mandatory`/`default()` below instead of being declared in play +# `vars:`, since play vars always take precedence over inventory host_vars +# and would otherwise silently override whatever is set per-host. + +- name: Deploy toolshed + hosts: toolshed + become: true + + vars: + toolshed_src_dir: /opt/toolshed/src + toolshed_data_dir: /opt/toolshed/data + toolshed_dist_dir: /var/www/toolshed + + toolshed_backend_image: toolshed-backend + toolshed_frontend_image: toolshed-frontend-builder + toolshed_wiki_image: toolshed-wiki-builder + toolshed_backend_container: toolshed-backend + toolshed_backend_port: 8000 + toolshed_wiki_dist_dir: /var/www/toolshed-wiki + toolshed_local_dir: /var/www/toolshed-local + # Domain(s) this server accepts registrations for (the "handle domain" - + # see the README's DNS section). Served as a static /local/domains + # fixture that the frontend's registration/pairing forms fetch to + # populate their domain dropdown (frontend/src/views/Register.vue, + # Pairing.vue) - without it that dropdown is just empty. + toolshed_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | unique }}" + # DoH resolvers the frontend falls back to for SRV lookups when it has + # no cached preference yet, served as a static /local/dns fixture. These + # match the frontend's own hardcoded fallback (frontend/src/dns.js), so + # this mostly makes the choice explicit and per-host overridable (e.g. + # -e doh_resolvers='["9.9.9.9"]') rather than changing behavior. + toolshed_doh_resolvers: "{{ doh_resolvers | default(['1.1.1.1', '8.8.8.8']) }}" + # Docker tags can't contain "/", but toolshed_version is a git ref and + # branch names like "jedi/proto/frontend" do - sanitize before using it + # as an image tag. The raw value is still used as-is for the actual git + # checkout, where slashes are fine. + toolshed_image_tag: "{{ (toolshed_version | default('stable')) | replace('/', '-') }}" + + toolshed_debug: "False" + # Plain HTTP listen port. Only relevant behind an external proxy that + # forwards to something other than 80 (see http_port in inventory.yml); + # when this nginx terminates TLS itself, the public port is always 443. + toolshed_http_port: "{{ http_port | default(80) }}" + toolshed_letsencrypt_webroot: /var/www/letsencrypt + # Nginx sets its own X-Forwarded-Proto from $scheme when it terminates + # TLS itself. Behind an external TLS-terminating proxy, $scheme at this + # nginx is always "http" (the proxy already stripped TLS one hop + # earlier), so overwriting the header with $scheme would tell Django + # every request is insecure. In that case pass through the proxy's own + # header instead. + toolshed_x_forwarded_proto: >- + {{ '$http_x_forwarded_proto' if (behind_tls_proxy | default(false) | bool) else '$scheme' }} + # The web domain (toolshed_domain, mandatory) and the handle domain + # (toolshed_handle_domain, optional - defaults to the web domain when + # they're the same) both need to be accepted by nginx/Django, since + # either may show up as the Host header depending on how the admin set + # up DNS for this deployment. Deduplicated so setting them equal + # doesn't produce a repeated entry. + toolshed_hostnames: >- + {{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname), + toolshed_handle_domain | default(toolshed_domain)] | unique }} + # Generated once per host on the controller and reused on every + # subsequent run against that host, keyed by inventory_hostname so + # separate deployments never end up sharing a Django SECRET_KEY. + toolshed_secret_key: >- + {{ lookup('ansible.builtin.password', + playbook_dir ~ '/.secrets/' ~ inventory_hostname ~ '_secret_key length=64 chars=ascii_letters,digits') }} + + # Rendered twice against the same var (see the tasks below): once before + # a certificate exists (serves the site plainly over toolshed_http_port, + # or over 80/plain-HTTP forever if behind_tls_proxy), and once after + # certbot has obtained one, at which point the plain HTTP vhost switches + # to a redirect and a 443 vhost with the real content appears. Whichever + # of those two states applies, toolshed_cert (a registered `stat` result, + # undefined/false until it's checked) decides which one renders - this + # is the "another nginx config" from a single inline template, driven by + # behind_tls_proxy and certificate state rather than a separate file. + toolshed_nginx_conf: | + upstream toolshed_backend { + server 127.0.0.1:{{ toolshed_backend_port }}; + } + + {% macro toolshed_locations() %} + location /api { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /auth { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /media { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /djangoadmin { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /docs { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /static { + proxy_pass http://toolshed_backend/static; + } + + location /wiki/ { + alias {{ toolshed_wiki_dist_dir }}/; + try_files $uri $uri/ =404; + } + + location = /wiki { + return 301 /wiki/; + } + + # Static fixtures the frontend fetches directly (registration + # domain list, DoH resolver preference) - see toolshed_register_domains + # and toolshed_doh_resolvers above. + location /local/ { + alias {{ toolshed_local_dir }}/; + try_files $uri.json =404; + add_header Content-Type application/json; + } + + # Vue-router history mode: fall back to index.html for + # any path that isn't a real static file. + location / { + try_files $uri $uri/ /index.html; + } + {% endmacro %} + + {% if behind_tls_proxy | default(false) | bool %} + server { + listen {{ toolshed_http_port }}; + listen [::]:{{ toolshed_http_port }}; + server_name {{ toolshed_hostnames | join(' ') }}; + + client_max_body_size 128M; + root {{ toolshed_dist_dir }}; + index index.html; + {{ toolshed_locations() }} + } + {% else %} + {% set tls_active = toolshed_cert.stat.exists | default(false) %} + server { + listen {{ toolshed_http_port }}; + listen [::]:{{ toolshed_http_port }}; + server_name {{ toolshed_hostnames | join(' ') }}; + + location /.well-known/acme-challenge/ { + root {{ toolshed_letsencrypt_webroot }}; + } + {% if tls_active %} + + location / { + return 301 https://$host$request_uri; + } + {% else %} + + client_max_body_size 128M; + root {{ toolshed_dist_dir }}; + index index.html; + {{ toolshed_locations() }} + {% endif %} + } + {% if tls_active %} + + server { + listen 443 ssl; + listen [::]:443 ssl; + server_name {{ toolshed_hostnames | join(' ') }}; + + ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem; + + client_max_body_size 128M; + root {{ toolshed_dist_dir }}; + index index.html; + {{ toolshed_locations() }} + } + {% endif %} + {% endif %} + + tasks: + - name: Install docker.io and nginx + ansible.builtin.apt: + name: + - docker.io + - nginx + state: present + update_cache: true + + - name: Install certbot + ansible.builtin.apt: + name: certbot + state: present + when: not (behind_tls_proxy | default(false) | bool) + + - name: Ensure docker is running and enabled + ansible.builtin.systemd: + name: docker + state: started + enabled: true + + - name: Ensure nginx is running and enabled + ansible.builtin.systemd: + name: nginx + state: started + enabled: true + + - name: Checkout toolshed source + ansible.builtin.git: + repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}" + dest: "{{ toolshed_src_dir }}" + version: "{{ toolshed_version | default('stable') }}" + force: true + # frontend/extras is registered as a submodule but unused and its + # pinned commit isn't fetchable from upstream - don't let a broken + # submodule block the checkout. + recursive: false + + - name: Create toolshed system user + ansible.builtin.user: + name: toolshed + system: true + shell: /usr/sbin/nologin + home: "{{ toolshed_data_dir }}" + create_home: false + register: toolshed_user + + - name: Create backend data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: toolshed + group: toolshed + mode: "0750" + loop: + - "{{ toolshed_data_dir }}" + - "{{ toolshed_data_dir }}/userfiles" + + - name: Create frontend static output directory + ansible.builtin.file: + path: "{{ toolshed_dist_dir }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + + - name: Write backend environment file + ansible.builtin.copy: + dest: "{{ toolshed_data_dir }}/backend.env" + # Root-owned and unreadable by the toolshed user on purpose: this is + # read by the docker daemon (root) via --env-file at container + # start and injected directly as env vars, so the containerized app + # - which runs as the toolshed user, see the systemd unit below - + # never needs filesystem access to its own SECRET_KEY. + owner: root + group: root + mode: "0600" + content: | + DEBUG={{ toolshed_debug }} + SECRET_KEY={{ toolshed_secret_key }} + ALLOWED_HOSTS={{ toolshed_hostnames | join(',') }} + SERVE_X_ACCEL_REDIRECT=False + TOOLSHED_DB_PATH=/data/db.sqlite3 + TOOLSHED_USERFILES_PATH=/data/userfiles + notify: restart backend + + - name: Build backend docker image + ansible.builtin.command: + cmd: >- + docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }} + -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend + changed_when: true + notify: restart backend + + - name: Tag backend image as latest + ansible.builtin.command: + cmd: docker tag {{ toolshed_backend_image }}:{{ toolshed_image_tag }} {{ toolshed_backend_image }}:latest + changed_when: true + notify: restart backend + + - name: Install systemd unit for the backend container + ansible.builtin.copy: + dest: /etc/systemd/system/toolshed-backend.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Toolshed backend (Django) container + After=docker.service network-online.target + Requires=docker.service + Wants=network-online.target + + [Service] + TimeoutStartSec=0 + Restart=always + ExecStartPre=-/usr/bin/docker stop {{ toolshed_backend_container }} + ExecStartPre=-/usr/bin/docker rm {{ toolshed_backend_container }} + ExecStart=/usr/bin/docker run --rm --name {{ toolshed_backend_container }} \ + --user {{ toolshed_user.uid }}:{{ toolshed_user.group }} \ + --env-file {{ toolshed_data_dir }}/backend.env \ + -v {{ toolshed_data_dir }}:/data \ + -p 127.0.0.1:{{ toolshed_backend_port }}:8000 \ + {{ toolshed_backend_image }}:latest + ExecStop=/usr/bin/docker stop {{ toolshed_backend_container }} + + [Install] + WantedBy=multi-user.target + notify: restart backend + + # Installed before the bootstrap nginx flush_handlers below (which + # flushes every pending handler, not just reload nginx) - otherwise a + # fresh host would flush "restart backend" before this unit file exists + # and fail with "Could not find the requested service". + - name: Ensure toolshed-backend service is enabled and started + ansible.builtin.systemd: + name: toolshed-backend + daemon_reload: true + enabled: true + state: started + + - name: Build frontend builder docker image + ansible.builtin.command: + cmd: >- + docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} + -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_src_dir }}/frontend + changed_when: true + + - name: Run frontend builder once to export the static build + ansible.builtin.command: + cmd: docker run --rm -v {{ toolshed_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} + changed_when: true + + - name: Fix ownership of exported frontend build + ansible.builtin.file: + path: "{{ toolshed_dist_dir }}" + owner: www-data + group: www-data + recurse: true + + - name: Create wiki static output directory + ansible.builtin.file: + path: "{{ toolshed_wiki_dist_dir }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + + - name: Build wiki builder docker image + ansible.builtin.command: + cmd: >- + docker build -t {{ toolshed_wiki_image }}:{{ toolshed_image_tag }} + -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.wiki {{ toolshed_src_dir }} + changed_when: true + + - name: Run wiki builder once to export the static site + ansible.builtin.command: + cmd: docker run --rm -v {{ toolshed_wiki_dist_dir }}:/output {{ toolshed_wiki_image }}:{{ toolshed_image_tag }} + changed_when: true + + - name: Fix ownership of exported wiki build + ansible.builtin.file: + path: "{{ toolshed_wiki_dist_dir }}" + owner: www-data + group: www-data + recurse: true + + - name: Create local fixtures directory + ansible.builtin.file: + path: "{{ toolshed_local_dir }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + + - name: Write registration domain list fixture + ansible.builtin.copy: + dest: "{{ toolshed_local_dir }}/domains.json" + owner: www-data + group: www-data + mode: "0644" + content: "{{ toolshed_register_domains | to_nice_json }}" + + - name: Write DoH resolver fixture + ansible.builtin.copy: + dest: "{{ toolshed_local_dir }}/dns.json" + owner: www-data + group: www-data + mode: "0644" + content: "{{ toolshed_doh_resolvers | to_nice_json }}" + + - name: Create ACME HTTP-01 challenge webroot + ansible.builtin.file: + path: "{{ toolshed_letsencrypt_webroot }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + when: not (behind_tls_proxy | default(false) | bool) + + - name: Check for an existing Let's Encrypt certificate + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" + register: toolshed_cert + when: not (behind_tls_proxy | default(false) | bool) + + - name: Configure nginx site for toolshed (bootstrap) + ansible.builtin.copy: + dest: /etc/nginx/sites-available/toolshed.conf + owner: root + group: root + mode: "0644" + content: "{{ toolshed_nginx_conf }}" + notify: reload nginx + + - name: Remove default nginx site + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + notify: reload nginx + + - name: Enable toolshed nginx site + ansible.builtin.file: + src: /etc/nginx/sites-available/toolshed.conf + dest: /etc/nginx/sites-enabled/toolshed.conf + state: link + notify: reload nginx + + # Certbot's webroot check (below) needs nginx already serving the + # bootstrap config from the tasks above, so force the reload now + # instead of waiting for the end of the play. + - name: Apply the bootstrap nginx config now + ansible.builtin.meta: flush_handlers + + - name: Ensure the certbot renewal deploy-hook directory exists + ansible.builtin.file: + path: /etc/letsencrypt/renewal-hooks/deploy + state: directory + mode: "0755" + when: not (behind_tls_proxy | default(false) | bool) + + - name: Reload nginx after certbot renews a certificate + ansible.builtin.copy: + dest: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + owner: root + group: root + mode: "0755" + content: | + #!/bin/sh + systemctl reload nginx + when: not (behind_tls_proxy | default(false) | bool) + + - name: Obtain or renew the Let's Encrypt certificate + ansible.builtin.command: + cmd: >- + certbot certonly --webroot -w {{ toolshed_letsencrypt_webroot }} + -d {{ toolshed_hostnames | join(' -d ') }} + --non-interactive --agree-tos + -m {{ toolshed_letsencrypt_email | mandatory('toolshed_letsencrypt_email must be set as a host_var for ' ~ inventory_hostname ~ ' since behind_tls_proxy is false there') }} + register: toolshed_certbot + changed_when: "'Certificate not yet due for renewal' not in toolshed_certbot.stdout" + when: not (behind_tls_proxy | default(false) | bool) + + - name: Re-check the certificate now that certbot has run + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" + register: toolshed_cert + when: not (behind_tls_proxy | default(false) | bool) + + - name: Configure nginx site for toolshed (final) + ansible.builtin.copy: + dest: /etc/nginx/sites-available/toolshed.conf + owner: root + group: root + mode: "0644" + content: "{{ toolshed_nginx_conf }}" + notify: reload nginx + + handlers: + - name: validate nginx config + ansible.builtin.command: nginx -t + listen: reload nginx + changed_when: false + + - name: reload nginx + ansible.builtin.systemd: + name: nginx + state: reloaded + listen: reload nginx + + - name: restart backend + ansible.builtin.systemd: + name: toolshed-backend + daemon_reload: true + state: restarted + listen: restart backend