Files
bookstore/docker-compose.yml
T
twooey df34fe8e54 Fix five high-severity bugs from the full-session code review
- ProductSync: bsc_work.wc_product_id could go stale if a product was ever
  deleted out-of-band (wp-admin, a cleanup script). wc_get_product() then
  correctly detects "no product" and creates a new one, but the repoint was
  gated behind `if (!$existing_id)` — which was already true, so the stale
  ID never got corrected. Every future sync repeated this, one duplicate
  product per run. Fixed by making the repoint unconditional (cheap,
  idempotent UPDATE either way). Reproduced the exact scenario against dev
  (deleted a product out-of-band, ran sync-products twice) and confirmed:
  one repoint, zero duplicates, product count and per-work product count
  both correct across repeated runs.

- Commands.php (sync-hardcover-tags): wp_set_object_terms() with an empty
  array clears the taxonomy rather than leaving it alone — verified
  directly. Hardcover legitimately returns no moods/content-warnings for
  plenty of books, so a --force re-sync could silently wipe existing tags,
  including anything hand-tagged. Fixed by skipping the call per-category
  when that category's array is empty. Verified via a direct eval test:
  pre-existing genre tag survives a sync where genre comes back empty,
  mood still gets set normally.

- docker-compose.yml: two stray root-owned secrets/db_password and
  secrets/db_root_password directories were already sitting on disk —
  Docker auto-creating a bind-mount source as a directory from an earlier
  manual `docker compose` call that ran without ENVIRONMENT exported
  (reproducing the exact bug already fixed once this session). Removed
  the stray dirs and changed every `${ENVIRONMENT}` in a volume mount to
  `${ENVIRONMENT:?ENVIRONMENT must be set}` so Compose now hard-fails
  instead of silently defaulting to empty. Verified: unset ENVIRONMENT now
  fails config validation with a clear error; set, it still works.

- HARDCOVER_API_TOKEN moved to the same _FILE secrets pattern already used
  for DB_PASSWORD/DB_ROOT_PASSWORD (docker-compose.yml, deploy.sh,
  HardcoverAdapter.php) — it was a live, consumed secret still going
  through Compose's ${VAR} interpolation, exposed to the same
  mangling bug already fixed for the DB passwords, plus visible via
  `docker inspect`. deploy.sh now writes secrets/<env>/hardcover_api_token
  (optionally empty). Verified via deploy.sh dev + wp eval: empty file ->
  is_configured() false, a real token value -> true.

- backup.sh: mariadb-dump had no --single-transaction, so a dump against a
  live site would either table-lock for its duration or produce a
  non-atomic/inconsistent dump. Added; verified a real dump still runs
  clean and produces a valid, restorable-looking .sql.gz.
2026-08-27 15:40:25 -04:00

133 lines
5.7 KiB
YAML

# wordpress and cron are the same image and MUST share the same
# environment: the official image's wp-config.php reads getenv() live, at
# PHP runtime, in whichever container is executing — it does not bake
# values into the file once. If cron's env drifts from wordpress's (e.g.
# WORDPRESS_CONFIG_EXTRA missing), cron silently loses WP_REDIS_HOST,
# DISABLE_WP_CRON, etc. The anchor below is what prevents that drift.
# DB_PASSWORD/DB_ROOT_PASSWORD are deliberately NOT passed via ${VAR} here.
# Docker Compose's own interpolation mangles any value containing a `$`
# followed by a letter (confirmed with a bcrypt hash earlier, and again with
# a plain generated password: "$BRjTx3tlmSpz" got silently blanked out,
# breaking the DB connection). The official mariadb/wordpress images both
# support a `_FILE` convention specifically for this — point at a file path
# (never risky) and the container reads the raw contents itself, bypassing
# Compose's interpolation entirely. deploy.sh writes these files fresh from
# .env.<environment> before every `up`.
#
# These are plain bind mounts, NOT Compose's native `secrets:` block —
# Compose's `secrets:` construct reads the referenced file's *content* as
# part of its own config model and applies the same interpolation warning
# to it (confirmed: it still warned on "$BRjTx3tlmSpz" even after that
# value was fully removed from every ${VAR} and --env-file path). A plain
# bind mount never touches the file's content, only its path, so it's
# immune to this entirely.
#
# The remaining supplier/payment keys below are still passed the old ${VAR}
# way and remain exposed to the same class of bug — nothing consumes them
# yet (bookstore-core is still a stub for those), so when that code is
# written it should read `_FILE` variants the same way HARDCOVER_API_TOKEN
# now does below.
x-bookstore-env: &bookstore-env
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
WORDPRESS_TABLE_PREFIX: ${WP_TABLE_PREFIX:-wp_}
MAIL_CATCHER_HOST: mailhog
MAIL_CATCHER_PORT: 1025
WORDPRESS_CONFIG_EXTRA: |
define('DISABLE_WP_CRON', true);
define('WP_REDIS_HOST', 'redis');
define('WP_MEMORY_LIMIT', '256M');
# Passed through for the bookstore-core plugin's supplier adapters /
# payment integration (design doc §04/§05) — not read by WordPress core.
ACTIVE_SUPPLIER_ADAPTERS: ${ACTIVE_SUPPLIER_ADAPTERS:-gutenberg_test}
BOOKSRUN_API_KEY: ${BOOKSRUN_API_KEY:-}
INGRAM_API_KEY: ${INGRAM_API_KEY:-}
INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-}
HELCIM_API_TOKEN: ${HELCIM_API_TOKEN:-}
HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-}
MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-}
HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
volumes:
- db_data:/var/lib/mysql
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_root_password:/run/secrets/db_root_password:ro
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u$$MARIADB_USER -p\"$$(cat /run/secrets/db_password)\" --silent"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
wordpress:
build:
context: ./docker/php
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment: *bookstore-env
volumes:
- wp_core:/var/www/html
- wp_uploads:/var/www/html/wp-content/uploads
- wp_themes:/var/www/html/wp-content/themes
- ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core
- ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/hardcover_api_token:/run/secrets/hardcover_api_token:ro
cron:
build:
context: ./docker/php
restart: unless-stopped
# Safe here (unlike wordpress's own service): this is a plain shell loop,
# not php-fpm's master process, which needs to start as root so it can
# drop privileges to its www-data workers itself. Missed in the earlier
# root-hardening pass — found via a real bug: this container writing
# wp-cron-triggered uploads as root, silently blocking www-data-run
# wp-cli commands from writing new files into those same directories.
user: www-data
depends_on:
- wordpress
entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"]
environment: *bookstore-env
volumes:
- wp_core:/var/www/html
- wp_uploads:/var/www/html/wp-content/uploads
- wp_themes:/var/www/html/wp-content/themes
- ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core
- ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins
- ./docker/cron/entrypoint.sh:/usr/local/bin/cron-entrypoint.sh:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/hardcover_api_token:/run/secrets/hardcover_api_token:ro
volumes:
db_data:
redis_data:
wp_core:
wp_uploads:
wp_themes: