diff --git a/.github/workflows/README.md b/.github/workflows/README.md index adfe4e7..fe74f65 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -2,7 +2,11 @@ `ci.yml` runs on pull requests and pushes without production secrets. It covers backend tests, race tests, `go vet`, frontend lint/build, Docker image builds, -and Compose validation. +Compose validation, and the deployment script test suite +(`deploy/scripts/deploy_scripts_test.sh`). That suite stubs `docker`, `curl`, and +`nginx` on `PATH`, so it needs no daemon or privileges — it exercises slot +selection, the transactional Nginx switch and its restore-on-failure paths, and +the preflight checks. `publish.yml` runs only from a successful `CI` workflow run on `main` that was triggered by a trusted push. It validates @@ -14,3 +18,12 @@ VPN. Automatic deployments use the trusted publish run `head_sha`; manual deployments accept an already-published image SHA as data only. Deployment code is always checked out from the protected default branch, never from the supplied image SHA. + +`rollback.yml` is manual (`workflow_dispatch`) and switches Nginx back to the +other slot, which is already running the previous release. It pulls no images, so +it holds no `packages` permission. It shares the `delta-production-deploy` +concurrency group with `deploy.yml` so a rollback can never interleave with a +deployment. `slot: auto` targets whichever slot is currently inactive; `blue` or +`green` names one explicitly. To recover an *older* image SHA instead, run +`deploy.yml` manually with that SHA — rollback only moves traffic between the two +slots that are already up. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 869ff3e..921447a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,16 @@ jobs: cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend + # The deployment scripts are the least reversible code in the repo, so their + # test suite runs on every PR. It stubs docker/curl/nginx on PATH and needs no + # daemon, database, or privileges. + deploy-scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Deploy script tests + run: deploy/scripts/deploy_scripts_test.sh + compose-validation: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml new file mode 100644 index 0000000..d15b755 --- /dev/null +++ b/.github/workflows/rollback.yml @@ -0,0 +1,68 @@ +name: Rollback Delta + +# Manual only. Rollback switches Nginx back to the other slot, which is already +# running the previous release, so it pulls no images and needs no registry +# access. Without this workflow the only way to invoke rollback.sh is an SSH +# session into Delta from inside the VPN — during an incident, which is exactly +# when that is hardest. +on: + workflow_dispatch: + inputs: + slot: + description: 'Slot to activate ("auto" picks the currently inactive one)' + required: true + default: auto + type: choice + options: + - auto + - blue + - green + +# Deliberately the SAME group as Deploy Delta, so a rollback can never interleave +# with a deployment. deploy.sh/rollback.sh also take an exclusive flock on the +# host, so this is belt and braces. +concurrency: + group: delta-production-deploy + cancel-in-progress: false + +# No packages:read — rollback never pulls an image. +permissions: + contents: read + +jobs: + rollback: + runs-on: + - self-hosted + - drexel-vpn + - delta + - triangle-cms + environment: production + steps: + - name: Checkout trusted deployment code + uses: actions/checkout@v4 + with: + # Same trust boundary as deploy.yml: deployment code always comes from + # the protected default branch. + ref: ${{ github.event.repository.default_branch }} + + - name: Switch Nginx to the target slot + env: + ENV_FILE: ${{ vars.DELTA_CMS_ENV_FILE }} + NGINX_ACTIVE_INCLUDE: ${{ vars.DELTA_NGINX_ACTIVE_INCLUDE }} + PUBLIC_BASE_URL: ${{ vars.DELTA_PUBLIC_BASE_URL }} + # Read into the environment rather than interpolated into the script + # body, so the input is data and never shell syntax. + SLOT: ${{ inputs.slot }} + run: | + case "${SLOT}" in + auto) + deploy/scripts/rollback.sh + ;; + blue|green) + deploy/scripts/rollback.sh "${SLOT}" + ;; + *) + echo "invalid slot: ${SLOT}" >&2 + exit 2 + ;; + esac diff --git a/deploy/README.md b/deploy/README.md index f58fe15..1f452b7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -82,6 +82,96 @@ the generated active upstream include. The directory should be owned by owned by `triangle-runner:triangle-runner` with mode `0644`. The host Nginx site such as `/etc/nginx/sites-available/triangle-cms.conf` remains root-owned. +### Installing the Nginx site + +Steps 6-8 above, concretely. The repo is not checked out on Delta, so copy the +two files over first (from a workstation, at the repo root): + +```bash +scp deploy/nginx/triangle-cms.conf \ + deploy/nginx/triangle-cms-active-upstreams.conf.example \ + @:/tmp/ +``` + +Then on Delta: + +```bash +sudo cp /tmp/triangle-cms.conf /etc/nginx/sites-available/triangle-cms.conf +sudo ln -sf /etc/nginx/sites-available/triangle-cms.conf /etc/nginx/sites-enabled/ + +sudo install -d -o triangle-runner -g triangle-runner -m 0750 /etc/nginx/triangle-cms +sudo install -o triangle-runner -g triangle-runner -m 0644 \ + /tmp/triangle-cms-active-upstreams.conf.example \ + /etc/nginx/triangle-cms/active-upstreams.conf + +# The stock default site also matches `server_name _` and can win the vhost pick. +sudo rm -f /etc/nginx/sites-enabled/default + +sudo nginx -t && sudo systemctl reload nginx +``` + +Nginx will not start without `active-upstreams.conf`, since the site `include`s +it unconditionally. A passing `nginx -t` *before* the site is enabled only +validates the stock config and proves nothing. + +### Media serving + +`location /wp-content/` reads the migrated WordPress corpus straight off CephFS. +It has no dependency on the containers, the runner, or the database, so it can be +brought up on its own before the rest of the stack exists. `/` and `/v1/` return +502 until a slot is deployed; that is expected and does not affect media. + +Verify the mount and that the Nginx worker user can traverse to it: + +```bash +mountpoint /mnt/cephfs +sudo -u www-data ls /mnt/cephfs/media/wp-content/uploads >/dev/null && echo ok +``` + +A failure there is almost always missing execute permission on a path component +(`sudo chmod o+x /mnt/cephfs /mnt/cephfs/media`), not the Nginx config. On +RHEL-family hosts SELinux blocks the read separately; check `ausearch -m avc -ts +recent` and set `httpd_read_user_content`. + +Smoke test with a real file: + +```bash +find /mnt/cephfs/media/wp-content/uploads -name '*.jpg' | head -1 +curl -I http://localhost/wp-content/uploads/YYYY/MM/name.jpg +``` + +Expect `200` with `Cache-Control: public, max-age=2592000, immutable`. + +### Media library + +Serving the files is independent of *listing* them. The CMS media page reads a +`media` table, which starts empty: the rsynced corpus is on disk but unknown to +the database. After the media rsync completes, populate it once from the CMS +(Media -> Reindex) or directly: + +```bash +curl -X POST https://localhost/v1/media/index # admin session required +``` + +It walks `MEDIA_ROOT/wp-content/uploads`, skips WordPress's generated `-WxH` +thumbnails, and inserts a row per original. It is idempotent and safe to re-run — +already-indexed files are skipped and any alt text set in the CMS is preserved — +so re-run it after any later out-of-band rsync. Uploads through the CMS index +themselves and need no reindex. + +Note this walks the whole tree, so on a large corpus over CephFS the first run +takes a while; run it once at cutover rather than on a schedule. + +### Disk + +Blue/green keeps two frontend and two backend images resident, plus whatever +prior tags have not been reaped. Delta's root filesystem is small (15 GB), so +prune before it fills: + +```bash +docker image prune -af --filter 'until=168h' +``` + ## Required Host Environment Copy `cms.env.example` to the private host env path and fill it with real values. @@ -103,6 +193,12 @@ The file must contain the exact immutable image tag for the active deployment: - `CMS_SESSION_TTL_SECONDS` - `CMS_AUTO_PROMOTE_ALL_ADMINS` - `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP` +- `MEDIA_HOST_PATH` - host path to the CephFS media tree, bind-mounted into the + backend. Defaults to `/mnt/cephfs/media`. +- `MEDIA_ROOT` - the same tree as seen *inside* the container. Leave at + `/mnt/cephfs/media` unless the bind-mount target changes. +- `MEDIA_BASE_URL` - public origin that serves `/wp-content/`, used to build + media URLs returned by the upload endpoint. Empty yields relative URLs. Keep `CMS_AUTO_PROMOTE_ALL_ADMINS=false` and `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false` in production. Rebuild taxonomy diff --git a/deploy/cms.env.dryrun.example b/deploy/cms.env.dryrun.example new file mode 100644 index 0000000..ef4570e --- /dev/null +++ b/deploy/cms.env.dryrun.example @@ -0,0 +1,62 @@ +# Triangle CMS — Delta DRY RUN env template. Copy to the host-only cms.env, +# fill the <...> placeholders, and keep it out of git. +# +# scp deploy/cms.env.dryrun.example tadmin@10.248.40.168:/tmp/ +# # on Delta: fill it in, then `chmod 600` it at its final path +# +# Values already filled below are specific to the dry run on Delta +# (10.248.40.168, HTTP, no TLS, throwaway local database). Every one of them +# changes at production cutover — see cms.env.example and README.md. + +# --- Images ------------------------------------------------------------------ +# Deployments use immutable full-commit-SHA tags; there is no `latest`. Set this +# to the SHA you want to run. If nothing has been published to GHCR yet, build +# locally on Delta instead and point these at the local image names. +CMS_IMAGE_TAG= +CMS_BACKEND_IMAGE=ghcr.io/drexeltriangle/triangle-cms-backend +CMS_FRONTEND_IMAGE=ghcr.io/drexeltriangle/triangle-cms-frontend + +# --- Database ---------------------------------------------------------------- +# Points at the throwaway node from compose.mariadb-dev.yml, reachable by +# service name over triangle_net. At cutover this becomes the MaxScale endpoint +# (port 4006) and MARIADB_ROOT_PASSWORD disappears entirely. +DB_NAME=triangle +DB_USER=triangle_user +DB_PASSWORD= +DB_HOST=mariadb-dev +DB_PORT=3306 +MARIADB_ROOT_PASSWORD= + +# --- OIDC -------------------------------------------------------------------- +# The backend will NOT start without these; there are no defaults. The redirect +# URI must be registered verbatim with the identity provider or login fails at +# the callback. Register this exact HTTP/IP form for the dry run. +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=http://10.248.40.168/v1/auth/callback + +# --- Frontend ---------------------------------------------------------------- +# Origin the browser actually uses. Must match what Nginx serves or CORS and +# cookies break. No trailing slash. +FRONTEND_ORIGIN=http://10.248.40.168 + +# --- Session / behaviour ----------------------------------------------------- +CMS_SESSION_TTL_SECONDS=604800 +# Dry run only: lets any admin-role SSO user in without manual promotion, so you +# can actually get past the login wall. MUST be false in production. +CMS_AUTO_PROMOTE_ALL_ADMINS=true +# MUST stay false against the init_schema.sql seed. The rebuild reads an +# articles.categories column (server/internal/database/taxonomy.go) that the +# seed dump does not have, and the resulting "Unknown column 'categories'" +# error is FATAL -- the backend crash-loops and never becomes healthy. +CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false + +# --- Media ------------------------------------------------------------------- +# Host Nginx already serves /wp-content/ from this tree (verified working). +# MEDIA_BASE_URL is used to build URLs returned by the upload endpoint, and must +# match the base the ETL used when it wrote photo_url into the seed data -- +# otherwise uploads and seeded articles disagree about where images live. +MEDIA_HOST_PATH=/mnt/cephfs/media +MEDIA_ROOT=/mnt/cephfs/media +MEDIA_BASE_URL=http://10.248.40.168 diff --git a/deploy/cms.env.example b/deploy/cms.env.example index f69e63d..0b1952f 100644 --- a/deploy/cms.env.example +++ b/deploy/cms.env.example @@ -14,3 +14,6 @@ OIDC_REDIRECT_URI= CMS_SESSION_TTL_SECONDS= CMS_AUTO_PROMOTE_ALL_ADMINS= CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP= +MEDIA_HOST_PATH= +MEDIA_ROOT= +MEDIA_BASE_URL= diff --git a/deploy/compose.cms.yml b/deploy/compose.cms.yml index 5858821..462b4e3 100644 --- a/deploy/compose.cms.yml +++ b/deploy/compose.cms.yml @@ -29,6 +29,14 @@ x-backend-base: &backend-base CMS_SESSION_TTL_SECONDS: ${CMS_SESSION_TTL_SECONDS:-604800} CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false} CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} + # Media: legacy WP uploads migrated to CephFS. The upload endpoint writes new + # assets under MEDIA_ROOT; MEDIA_BASE_URL is the public host that serves them. + MEDIA_ROOT: ${MEDIA_ROOT:-/mnt/cephfs/media} + MEDIA_BASE_URL: ${MEDIA_BASE_URL:-} + volumes: + # CephFS media tree (host). rw so the upload endpoint can store new files; + # host Nginx serves the same tree read-only (see deploy/nginx/triangle-cms.conf). + - ${MEDIA_HOST_PATH:-/mnt/cephfs/media}:/mnt/cephfs/media:rw healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/v1/health/db || exit 1"] interval: 10s diff --git a/deploy/compose.mariadb-dev.yml b/deploy/compose.mariadb-dev.yml new file mode 100644 index 0000000..e23f5a5 --- /dev/null +++ b/deploy/compose.mariadb-dev.yml @@ -0,0 +1,66 @@ +# Triangle CMS — THROWAWAY MariaDB for a Delta dry run. NOT for production. +# +# Production runs a primary + read replica behind MaxScale on dedicated hosts +# (compose.mariadb-primary.yml / compose.mariadb-replica.yml). Those are tuned +# for dedicated 8 GB boxes and will OOM on Delta, which has ~3.8 GB total and is +# also running both CMS slots. This file exists purely so the CMS can be brought +# up end-to-end before that hardware is provisioned. +# +# It is an OVERLAY on compose.cms.yml, so the database joins the same +# `triangle_net` bridge and the backend can reach it by service name: +# +# docker compose -f compose.cms.yml -f compose.mariadb-dev.yml \ +# --env-file cms.env up -d +# +# With that, cms.env sets DB_HOST=mariadb-dev and DB_PORT=3306. +# +# Tear this down when the real DB host lands — repoint DB_HOST at MaxScale and +# drop the second -f flag. The named volume below is deliberately distinct from +# the production volume names so it can be removed without ambiguity: +# +# docker compose -f compose.cms.yml -f compose.mariadb-dev.yml down +# docker volume rm triangle-cms_mariadb_dev_data + +services: + mariadb-dev: + image: mariadb:11.7 + restart: unless-stopped + command: ["--log-error=/var/lib/mysql/error.log"] + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required} + # Unlike the production primary, this node DOES create the app database and + # user on first init — there is no replication or provisioning step here. + MARIADB_DATABASE: ${DB_NAME:?DB_NAME is required} + MARIADB_USER: ${DB_USER:?DB_USER is required} + MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + volumes: + - mariadb_dev_data:/var/lib/mysql + - ./mariadb/dev.cnf:/etc/mysql/conf.d/dev.cnf:ro,z + # Loopback only. Nothing outside Delta should reach this, and the backend + # talks to it over triangle_net rather than through the host. + ports: + - "127.0.0.1:${MARIADB_PORT_FORWARD:-3306}:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - triangle_net + + # Production cannot express this -- the database is external there, so the + # backend just retries until the endpoint answers. Here the DB is a sibling + # service, so wait for it and keep the startup logs clean. + backend-blue: + depends_on: + mariadb-dev: + condition: service_healthy + + backend-green: + depends_on: + mariadb-dev: + condition: service_healthy + +volumes: + mariadb_dev_data: diff --git a/deploy/compose.mariadb-primary.yml b/deploy/compose.mariadb-primary.yml new file mode 100644 index 0000000..b48e412 --- /dev/null +++ b/deploy/compose.mariadb-primary.yml @@ -0,0 +1,43 @@ +# Triangle CMS — MariaDB PRIMARY (writes), on its OWN physical server. +# Tuned by deploy/mariadb/primary.cnf. Source of GTID replication to the replica +# and the backend that MaxScale routes writes to. +# +# Usage on the primary host: +# docker compose -f compose.mariadb-primary.yml --env-file cms.env up -d +# +# 3306 is published on the host's internal NIC (MARIADB_BIND_ADDR) so the replica +# server AND the MaxScale/app host can reach it. Firewall it to exactly those two +# IPs — nothing else should touch the database directly. + +name: triangle-mariadb-primary + +services: + mariadb-primary: + image: mariadb:11.7 + restart: unless-stopped + command: ["--log-error=/var/lib/mysql/error.log"] + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required} + MARIADB_DATABASE: ${MARIADB_DATABASE:-triangle} + MARIADB_USER: ${MARIADB_USER:-triangle_user} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:?MARIADB_PASSWORD is required} + # Consumed by the init scripts that create the replication and MaxScale users. + REPL_USER: ${REPL_USER:?REPL_USER is required} + REPL_PASSWORD: ${REPL_PASSWORD:?REPL_PASSWORD is required} + MAXSCALE_USER: ${MAXSCALE_USER:?MAXSCALE_USER is required} + MAXSCALE_PASSWORD: ${MAXSCALE_PASSWORD:?MAXSCALE_PASSWORD is required} + volumes: + - mariadb_primary_data:/var/lib/mysql + - ./mariadb/primary.cnf:/etc/mysql/conf.d/primary.cnf:ro,z + - ./mariadb/primary-initdb:/docker-entrypoint-initdb.d:ro,z + ports: + - "${MARIADB_BIND_ADDR:-127.0.0.1}:${MARIADB_PORT_FORWARD:-3306}:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + mariadb_primary_data: diff --git a/deploy/compose.mariadb-replica.yml b/deploy/compose.mariadb-replica.yml new file mode 100644 index 0000000..f7ce0d8 --- /dev/null +++ b/deploy/compose.mariadb-replica.yml @@ -0,0 +1,41 @@ +# Triangle CMS — MariaDB READ REPLICA, deployed on its OWN physical server +# (separate from the primary and from the CMS/app host). It replicates +# asynchronously from the primary over the network via GTID. +# +# Replication is established with a one-time provisioning step (dump the primary +# with GTID position, load it here, then CHANGE MASTER / START SLAVE) — see +# deploy/mariadb/README.md. This compose only runs the tuned server; it does NOT +# auto-create the app database (data arrives via replication). +# +# Usage on the replica host: +# docker compose -f compose.mariadb-replica.yml --env-file cms.env up -d +# ./mariadb/setup-replica.sh # one-time, after the primary is up + +name: triangle-mariadb-replica + +services: + mariadb-replica: + image: mariadb:11.7 + restart: unless-stopped + command: ["--log-error=/var/lib/mysql/error.log"] + environment: + # Root only: the app schema/users arrive via replication, so we deliberately + # do NOT set MARIADB_DATABASE/MARIADB_USER here (that would create diverging + # local objects and fight the replicated stream). + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required} + volumes: + - mariadb_replica_data:/var/lib/mysql + - ./mariadb/replica.cnf:/etc/mysql/conf.d/replica.cnf:ro,z + - ./mariadb/setup-replica.sh:/opt/setup-replica.sh:ro,z + # Expose reads to the CMS host only; firewall to the app server's IP. + ports: + - "${MARIADB_REPLICA_BIND_ADDR:-127.0.0.1}:${MARIADB_PORT_FORWARD:-3306}:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + mariadb_replica_data: diff --git a/deploy/compose.observability.yml b/deploy/compose.observability.yml new file mode 100644 index 0000000..f2c99d0 --- /dev/null +++ b/deploy/compose.observability.yml @@ -0,0 +1,77 @@ +# Observability stack (Loki + Promtail + Grafana), deployed and upgraded +# independently of the CMS. This is intentionally a SEPARATE Compose project from +# compose.cms.yml: a CMS deploy must never `docker compose down` this stack, and +# an observability change must never touch the CMS slots or MariaDB. +# +# Promtail scrapes container logs from the host's /var/lib/docker/containers, so +# it collects CMS stdout regardless of the CMS being on a different network. +# +# Usage (from this directory): +# docker compose -f compose.observability.yml --env-file cms.env up -d +# +# Grafana admin credentials come from cms.env (host-only, not committed). + +name: triangle-observability + +services: + loki: + image: grafana/loki:3.5.6 + restart: unless-stopped + command: ["-config.file=/etc/loki/config.yaml"] + healthcheck: + test: ["CMD", "loki", "-verify-config=true", "-config.file=/etc/loki/config.yaml"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 10s + volumes: + - ../observability/loki-config.yml:/etc/loki/config.yaml:ro,z + - loki_data:/loki + networks: + - observability_net + + promtail: + image: grafana/promtail:3.5.6 + restart: unless-stopped + user: "0:0" + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + command: ["-config.file=/etc/promtail/config.yml"] + depends_on: + loki: + condition: service_healthy + volumes: + - ../observability/promtail-config.yml:/etc/promtail/config.yml:ro,z + - promtail_positions:/tmp + - /var/lib/docker/containers:/var/lib/docker/containers:ro,z + networks: + - observability_net + + grafana: + image: grafana/grafana:12.2.0 + restart: unless-stopped + depends_on: + loki: + condition: service_healthy + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?GRAFANA_ADMIN_USER is required} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD is required} + ports: + - "127.0.0.1:3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ../observability/grafana/provisioning/datasources/loki.yml:/etc/grafana/provisioning/datasources/loki.yml:ro,z + networks: + - observability_net + +volumes: + loki_data: + promtail_positions: + grafana_data: + +networks: + observability_net: + driver: bridge diff --git a/deploy/mariadb/dev.cnf b/deploy/mariadb/dev.cnf new file mode 100644 index 0000000..bc56c6a --- /dev/null +++ b/deploy/mariadb/dev.cnf @@ -0,0 +1,42 @@ +[mysqld] +# ============================================================================= +# Triangle CMS — THROWAWAY dry-run MariaDB (see compose.mariadb-dev.yml). +# Sized for Delta: ~3.8 GB RAM TOTAL, shared with both CMS slots and host Nginx. +# Do not copy these values to a production node — primary.cnf/replica.cnf are +# the tuned configs, and they assume a dedicated 8 GB host. +# ============================================================================= + +# --- InnoDB memory ----------------------------------------------------------- +# 512M leaves room for the CMS containers on a 3.8 GB box. The article corpus is +# small enough that this still caches most of the working set. +innodb_buffer_pool_size = 512M +innodb_log_file_size = 128M # MariaDB has no innodb_redo_log_capacity +innodb_log_buffer_size = 16M + +# --- Durability: relaxed; this data is disposable and re-seedable ------------- +innodb_flush_log_at_trx_commit = 2 +sync_binlog = 0 + +# --- Concurrency ------------------------------------------------------------- +# Both CMS slots pool connections, but there is no real traffic during a dry run. +max_connections = 100 +thread_cache_size = 16 +table_open_cache = 1000 +table_definition_cache = 800 + +# --- Protocol ---------------------------------------------------------------- +# Must stay generous: article bodies and seed dumps contain large blobs. +max_allowed_packet = 64M +wait_timeout = 600 +interactive_timeout = 600 + +# --- Character set (matches the schema: utf8mb4) ----------------------------- +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# --- Slow query log ---------------------------------------------------------- +slow_query_log = 1 +slow_query_log_file = /var/lib/mysql/slow-query.log +long_query_time = 1 + +# No binary log, no server_id, no GTID: nothing replicates from this node. diff --git a/deploy/mariadb/primary-initdb/10-replication-user.sh b/deploy/mariadb/primary-initdb/10-replication-user.sh new file mode 100755 index 0000000..493fc71 --- /dev/null +++ b/deploy/mariadb/primary-initdb/10-replication-user.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Runs once, during the PRIMARY's first initialization (docker-entrypoint-initdb.d). +# Creates the least-privilege user the replica uses to pull the binlog. Uses env +# vars so no secret is written into a committed file. +# +# Required env (from cms.env / compose): MARIADB_ROOT_PASSWORD, REPL_USER, REPL_PASSWORD. +set -eu + +: "${REPL_USER:?REPL_USER is required}" +: "${REPL_PASSWORD:?REPL_PASSWORD is required}" + +mariadb -uroot -p"${MARIADB_ROOT_PASSWORD}" </dev/null | grep -q "Slave_IO_Running: Yes"; then + echo "replica already running; nothing to do" + exit 0 +fi + +echo "waiting for primary ${PRIMARY_HOST}:${PRIMARY_PORT} ..." +until mariadb -h"${PRIMARY_HOST}" -P"${PRIMARY_PORT}" -u"${REPL_USER}" -p"${REPL_PASSWORD}" \ + -e "SELECT 1" >/dev/null 2>&1; do + sleep 3 +done + +echo "dumping ${MARIADB_DATABASE} from primary (GTID-consistent) ..." +# --gtid emits SET GLOBAL gtid_slave_pos=...; --single-transaction = no lock on InnoDB. +mariadb-dump -h"${PRIMARY_HOST}" -P"${PRIMARY_PORT}" -u"${REPL_USER}" -p"${REPL_PASSWORD}" \ + --single-transaction --gtid --routines --triggers --events \ + --databases "${MARIADB_DATABASE}" > /tmp/primary-dump.sql + +echo "loading dump into replica ..." +local_sql < /tmp/primary-dump.sql +rm -f /tmp/primary-dump.sql + +echo "starting replication ..." +local_sql < /mnt/cephfs/media/wp-content/x + try_files $uri =404; # never fall through to the app + autoindex off; + limit_except GET HEAD { deny all; } + + # Refuse to serve executables/scripts outright (there were .php/.exe + # web-shells on the WP origin; the rsync excluded them, this is defense + # in depth). + location ~* \.(php\d?|phtml|phar|cgi|pl|py|sh|exe|asp|aspx|jsp)$ { return 403; } + + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Security-Policy "default-src 'none'; img-src 'self'" always; + # Filenames encode the exact size, so files are immutable. Set this only + # via add_header -- `expires` would emit a second, weaker Cache-Control + # and CDNs disagree about which duplicate wins. + add_header Cache-Control "public, max-age=2592000, immutable" always; + } + + # Never serve dotfiles (keep ACME http-01 working if it is ever added here). + location ~ /\.(?!well-known) { deny all; } + location / { proxy_pass $triangle_cms_frontend; } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f945dcc..3768c5c 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,8 +1,6 @@ -import { Bell } from "lucide-react" import { useNavigate } from "react-router-dom" import { useSessionAuth } from "../auth/sessionAuthContext" import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, @@ -22,11 +20,6 @@ export default function Header() { return (
- - ))}
-
- {filtered.length === 0 ? ( -
+ {error ? ( +
+ {error} +
+ ) : null} + {actionError ? ( +
+ {actionError} +
+ ) : null} + +
+ {isLoading ? ( +
Loading comments...
+ ) : comments.length === 0 ? ( +

No comments found.

) : ( - filtered.map((comment) => ( -
-
- {comment.author[0].toUpperCase()} -
-
-
- {comment.author} - {comment.email} - - {comment.status} - - {comment.date} -
-

- On: {comment.article} -

-

{comment.excerpt}

-
-
- {comment.status !== "approved" && ( - - )} - {comment.status !== "spam" && ( - - )} - -
-
- )) +
+ {comments.map((comment) => { + const isBusy = busyCommentId === comment.id + const hasMappedArticle = comment.article_slug && comment.article_id > 0 + const articlePath = hasMappedArticle ? `/articles/${encodeURIComponent(comment.article_slug)}/edit` : "" + const publicPath = comment.article_slug ? `${siteUrl}/article/${comment.article_slug}` : "" + + return ( +
+
+
+ {initials(comment.author_name)} +
+
+
+ {comment.author_name || "Anonymous"} + {comment.author_email ? {comment.author_email} : null} + + {comment.status || "pending"} + + {comment.parent_id > 0 ? Reply to #{comment.parent_id} : null} +
+ +
+ {hasMappedArticle ? ( + + {comment.article_title || `Article #${comment.article_id}`} + + ) : ( + Unmapped article + )} + {formatDate(comment.created_at_gmt ?? comment.created_at)} + {publicPath ? ( + + View + + + ) : null} +
+ + +
+
+ +
+ {comment.status !== "approved" ? ( + + ) : null} + {comment.status !== "spam" ? ( + + ) : null} + +
+
+ ) + })} +
)}
+ +
+

+ Page {page + 1} of {totalPages} +

+
+ + + + +
+
) } diff --git a/frontend/src/pages/mediaView.tsx b/frontend/src/pages/mediaView.tsx index cff8e53..b72f4c4 100644 --- a/frontend/src/pages/mediaView.tsx +++ b/frontend/src/pages/mediaView.tsx @@ -1,90 +1,294 @@ -import { useEffect, useState } from "react" -import { Search, Upload, Trash2, ImageOff } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { Copy, Check, ImageOff, RefreshCw, Search, Trash2, Upload, X } from "lucide-react" import { useApiFetch } from "../hooks/useApiFetch" +import { useCurrentUserRole } from "../hooks/useCurrentUserRole" type MediaItem = { - id: string + id: number + path: string url: string - fileName: string - fileSize?: string + file_name: string + mime_type?: string + size_bytes?: number + width?: number + height?: number + alt_text?: string + caption?: string + created_at?: string } -const normalizeMediaItems = (payload: unknown): MediaItem[] => { - const asRecord = (value: unknown): Record | null => - value && typeof value === "object" ? (value as Record) : null - const root = asRecord(payload) - const source = Array.isArray(payload) - ? payload - : Array.isArray(root?.items) - ? root.items - : Array.isArray(root?.media) - ? root.media - : [] - - const items = source - .map((raw) => asRecord(raw)) - .filter((item): item is Record => Boolean(item)) - .map((item, index) => { - const url = String(item.url ?? item.photo_url ?? "").trim() - const fileName = String(item.file_name ?? item.title ?? item.name ?? url).trim() - const id = String(item.id ?? item.media_id ?? index) - return { id, url, fileName } - }) - .filter((item) => item.url.length > 0) - - const deduped = new Map() - for (const item of items) { - if (!deduped.has(item.url)) deduped.set(item.url, item) +type MediaResponse = { + media?: MediaItem[] + pagination?: { + offset?: number + has_more?: boolean + total_count?: number + } +} + +type IndexReport = { + scanned?: number + added?: number + skipped?: number +} + +const PAGE_SIZE = 60 + +function formatBytes(bytes?: number) { + if (!bytes || bytes <= 0) return "" + const units = ["B", "KB", "MB", "GB"] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}` +} + +async function errorMessage(response: Response, fallback: string) { + try { + const body = (await response.json()) as { error?: string } + return body.error?.trim() || fallback + } catch { + return fallback } - return [...deduped.values()] } function MediaView() { const apiFetch = useApiFetch() + const { isAdmin } = useCurrentUserRole() + const fileInputRef = useRef(null) + const [mediaItems, setMediaItems] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [hasMore, setHasMore] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [error, setError] = useState(null) - const [searchQuery, setSearchQuery] = useState("") + const [notice, setNotice] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [selected, setSelected] = useState(null) + const [uploadStatus, setUploadStatus] = useState(null) + const [isIndexing, setIsIndexing] = useState(false) + // Debounce so typing doesn't fire a request per keystroke; the filter itself + // is applied server-side because the library is far too large to filter here. useEffect(() => { - let cancelled = false - const fetchMedia = async () => { + const timer = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timer) + }, [searchInput]) + + const fetchPage = useCallback( + async (offset: number, signal?: AbortSignal) => { + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + offset: String(offset), + }) + if (search) params.set("search", search) + + const response = await apiFetch(`/v1/media?${params.toString()}`, { signal }) + if (!response.ok) throw new Error(await errorMessage(response, `Request failed (${response.status})`)) + return (await response.json()) as MediaResponse + }, + [apiFetch, search], + ) + + useEffect(() => { + const controller = new AbortController() + + const load = async () => { setIsLoading(true) setError(null) try { - const response = await apiFetch("/v1/media?limit=200") - if (!response.ok) throw new Error(`Request failed (${response.status})`) - const payload = (await response.json()) as unknown - if (!cancelled) setMediaItems(normalizeMediaItems(payload)) + const payload = await fetchPage(0, controller.signal) + setMediaItems(payload.media ?? []) + setTotalCount(payload.pagination?.total_count ?? 0) + setHasMore(Boolean(payload.pagination?.has_more)) } catch (err) { - if (!cancelled) { - const message = err instanceof Error ? err.message : "Unable to load media." - setError(message) - } + if (controller.signal.aborted) return + setError(err instanceof Error ? err.message : "Unable to load media.") } finally { - if (!cancelled) setIsLoading(false) + if (!controller.signal.aborted) setIsLoading(false) } } - void fetchMedia() - return () => { cancelled = true } - }, [apiFetch]) - const filtered = mediaItems.filter((item) => - item.fileName.toLowerCase().includes(searchQuery.trim().toLowerCase()), - ) + void load() + return () => controller.abort() + }, [fetchPage]) + + const loadMore = async () => { + setIsLoadingMore(true) + try { + const payload = await fetchPage(mediaItems.length) + setMediaItems((current) => [...current, ...(payload.media ?? [])]) + setTotalCount(payload.pagination?.total_count ?? 0) + setHasMore(Boolean(payload.pagination?.has_more)) + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load more media.") + } finally { + setIsLoadingMore(false) + } + } + + const refresh = () => setSearch((current) => current) + + const handleUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return + setError(null) + setNotice(null) + + const uploaded: MediaItem[] = [] + const failures: string[] = [] + + for (const [index, file] of Array.from(files).entries()) { + setUploadStatus(`Uploading ${String(index + 1)} of ${String(files.length)}...`) + const body = new FormData() + body.append("file", file) + try { + const response = await apiFetch("/v1/media", { method: "POST", body }) + if (!response.ok) { + failures.push(`${file.name}: ${await errorMessage(response, `failed (${response.status})`)}`) + continue + } + const created = (await response.json()) as { id: number; path: string; url: string; content_type?: string; size?: number; width?: number; height?: number } + uploaded.push({ + id: created.id, + path: created.path, + url: created.url, + file_name: created.path.split("/").pop() ?? created.path, + mime_type: created.content_type, + size_bytes: created.size, + width: created.width, + height: created.height, + }) + } catch (err) { + failures.push(`${file.name}: ${err instanceof Error ? err.message : "upload failed"}`) + } + } + + setUploadStatus(null) + if (fileInputRef.current) fileInputRef.current.value = "" + // Newest first matches the default ordering, so prepend rather than refetch. + if (uploaded.length > 0) { + setMediaItems((current) => [...uploaded, ...current]) + setTotalCount((current) => current + uploaded.length) + setNotice(`Uploaded ${String(uploaded.length)} file${uploaded.length === 1 ? "" : "s"}.`) + } + if (failures.length > 0) setError(failures.join(" · ")) + } + + const handleDelete = async (item: MediaItem) => { + if (!window.confirm(`Delete ${item.file_name}? This removes the file from the media server.`)) return + setError(null) + setNotice(null) + + try { + const response = await apiFetch(`/v1/media/${String(item.id)}`, { method: "DELETE" }) + if (!response.ok) { + setError(await errorMessage(response, `Delete failed (${response.status})`)) + return + } + setMediaItems((current) => current.filter((candidate) => candidate.id !== item.id)) + setTotalCount((current) => Math.max(0, current - 1)) + setSelected((current) => (current?.id === item.id ? null : current)) + setNotice(`Deleted ${item.file_name}.`) + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to delete media.") + } + } + + const handleSaveDetails = async (item: MediaItem, altText: string, caption: string) => { + setError(null) + setNotice(null) + + try { + const response = await apiFetch(`/v1/media/${String(item.id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alt_text: altText, caption }), + }) + if (!response.ok) { + setError(await errorMessage(response, `Save failed (${response.status})`)) + return + } + const updated = (await response.json()) as MediaItem + setMediaItems((current) => current.map((candidate) => (candidate.id === updated.id ? updated : candidate))) + setSelected(updated) + setNotice("Details saved.") + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to save details.") + } + } + + const handleReindex = async () => { + setIsIndexing(true) + setError(null) + setNotice(null) + + try { + const response = await apiFetch("/v1/media/index", { method: "POST" }) + if (!response.ok) { + setError(await errorMessage(response, `Reindex failed (${response.status})`)) + return + } + const report = (await response.json()) as IndexReport + setNotice(`Indexed ${String(report.scanned ?? 0)} files — ${String(report.added ?? 0)} new.`) + if ((report.added ?? 0) > 0) refresh() + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to reindex media.") + } finally { + setIsIndexing(false) + } + } return (
{/* Header */} -
-

Media

- +
+
+

Media

+ {!isLoading && !error && ( +

+ {totalCount} item{totalCount === 1 ? "" : "s"} + {search ? ` matching "${search}"` : ""} +

+ )} +
+ + {isAdmin && ( +
+ + void handleUpload(e.target.files)} + ref={fileInputRef} + type="file" + /> + +
+ )}
{/* Search */} @@ -93,55 +297,237 @@ function MediaView() { setSearchQuery(e.target.value)} - placeholder="Search media..." + onChange={(e) => setSearchInput(e.target.value)} + placeholder="Search by file name, alt text, or caption..." type="search" - value={searchQuery} + value={searchInput} />
+ {notice && ( +
+ {notice} + +
+ )} + {error && ( +
+ {error} + +
+ )} + {/* Grid */} {isLoading ? (
Loading media...
- ) : error ? ( -
{error}
- ) : filtered.length === 0 ? ( + ) : mediaItems.length === 0 ? (
-

{searchQuery ? `No results for "${searchQuery}"` : "No media items yet."}

+

{search ? `No results for "${search}"` : "No media items yet."}

+ {!search && isAdmin && ( +

Upload a file, or run Reindex to pull in already-migrated images.

+ )}
) : ( -
- {filtered.map((item) => ( -
-
- {item.fileName -
+ <> +
+ {mediaItems.map((item) => ( +
+ {isAdmin && ( + + )} +
+

+ {item.file_name} +

+

+ {[item.width && item.height ? `${String(item.width)}×${String(item.height)}` : "", formatBytes(item.size_bytes)] + .filter(Boolean) + .join(" · ")} +

+
-

{item.fileName || item.url}

+ ))} +
+ + {hasMore && ( +
+
- ))} -
+ )} + )} - {!isLoading && !error && ( -

{filtered.length} item{filtered.length === 1 ? "" : "s"}

+ {/* Keyed on the asset so selecting a different one remounts the panel + with fresh field values instead of re-seeding them in an effect. */} + {selected && ( + setSelected(null)} + onSave={handleSaveDetails} + /> )}
) } +type MediaDetailPanelProps = { + item: MediaItem + canEdit: boolean + onClose: () => void + onSave: (item: MediaItem, altText: string, caption: string) => Promise +} + +function MediaDetailPanel({ item, canEdit, onClose, onSave }: MediaDetailPanelProps) { + const [altText, setAltText] = useState(item.alt_text ?? "") + const [caption, setCaption] = useState(item.caption ?? "") + const [isSaving, setIsSaving] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [onClose]) + + const copyPath = async () => { + try { + await navigator.clipboard.writeText(item.path) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + const save = async () => { + setIsSaving(true) + await onSave(item, altText, caption) + setIsSaving(false) + } + + return ( +
+