diff --git a/.env.example b/.env.example index 308e96e..5f221bc 100644 --- a/.env.example +++ b/.env.example @@ -3,11 +3,13 @@ # commit them. Same code everywhere; only these values differ (see # design doc §01, Environments & deployment). # -# CAVEAT for DB_PASSWORD, DB_ROOT_PASSWORD, and the API keys below: docker -# compose passes these into containers via ${VAR} interpolation, which will -# silently mangle a value containing `$` followed by a letter (it tries to -# resolve it as another variable and blanks it out if unset). Avoid `$` in -# these specific values, or double it ($$) if you must use one. +# CAVEAT for the API keys below (DB_PASSWORD/DB_ROOT_PASSWORD are exempt — +# deploy.sh writes those into secrets// files that bypass this +# entirely): docker compose passes these into containers via ${VAR} +# interpolation, which will silently mangle a value containing `$` followed +# by a letter (it tries to resolve it as another variable and blanks it out +# if unset). Avoid `$` in these specific values, or double it ($$) if you +# must use one — this bit us for real with a generated password once. # --- Site --- # (WP_ENVIRONMENT_TYPE is NOT set here — it's hardcoded per environment in diff --git a/.gitignore b/.gitignore index 4c4fc3a..d08f391 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !/.env.example /backups/ +/secrets/ vendor/ node_modules/ *.log diff --git a/Makefile b/Makefile index d6e46cd..6f66d09 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ ENV ?= dev +export ENVIRONMENT = $(ENV) COMPOSE = docker compose -p bookstore-$(ENV) -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file .env.$(ENV) .PHONY: up down ps logs shell wp deploy backup diff --git a/README.md b/README.md index b4a864b..477e5c3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ docker/cron/ Action Scheduler driver (system cron has no host deploy/deploy.sh idempotent bring-up + WP/WooCommerce config deploy/poll-deploy.sh host-crontab script: redeploys when its branch moves on Gitea deploy/backup.sh, restore.sh off-host backup; restore is staging-only, on purpose +secrets// DB passwords, written fresh by deploy.sh — gitignored, not manually edited wp-content/plugins/bookstore-core/ the one plugin that owns business logic wp-content/mu-plugins/ local-mail-catcher.php — routes mail to MailHog outside production ``` diff --git a/deploy/backup.sh b/deploy/backup.sh index 7179a1d..5a8cab8 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -4,6 +4,7 @@ set -euo pipefail ENVIRONMENT="${1:?Usage: backup.sh }" +export ENVIRONMENT REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" @@ -21,7 +22,7 @@ mkdir -p "$BACKUP_DIR" COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" echo "==> dumping database" -$COMPOSE exec -T db sh -c "exec mysqldump -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" \ +$COMPOSE exec -T db sh -c "exec mariadb-dump -u\"\$MARIADB_USER\" -p\"\$(cat /run/secrets/db_password)\" \"\$MARIADB_DATABASE\"" \ | gzip > "$BACKUP_DIR/db-${TIMESTAMP}.sql.gz" echo "==> archiving uploads" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index aa11427..4149917 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -5,6 +5,7 @@ set -euo pipefail ENVIRONMENT="${1:?Usage: deploy.sh }" +export ENVIRONMENT REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" @@ -32,6 +33,23 @@ WP_ADMIN_EMAIL="$(env_get "$ENV_FILE" WP_ADMIN_EMAIL)" # reuse dev's db_data volume/credentials the first time this ran). COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" +echo "==> writing DB secret files (bypasses compose's \${VAR} interpolation entirely)" +SECRETS_DIR="secrets/${ENVIRONMENT}" +mkdir -p "$SECRETS_DIR" +chmod 700 "secrets" "$SECRETS_DIR" 2>/dev/null || true +DB_PASSWORD_VALUE="$(env_get "$ENV_FILE" DB_PASSWORD)" +DB_ROOT_PASSWORD_VALUE="$(env_get "$ENV_FILE" DB_ROOT_PASSWORD)" +: "${DB_PASSWORD_VALUE:?set DB_PASSWORD in ${ENV_FILE}}" +: "${DB_ROOT_PASSWORD_VALUE:?set DB_ROOT_PASSWORD in ${ENV_FILE}}" +printf '%s' "$DB_PASSWORD_VALUE" > "$SECRETS_DIR/db_password" +printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password" +# 644, not 600: the db/wordpress/cron containers read this as their own +# (non-host-matching) container UID, e.g. www-data — chmod 600 made it +# unreadable to them. The containing directory (700, above) is what +# actually keeps other host users out; these just need to be world-readable +# within that already-restricted directory. +chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" + echo "==> building and starting ${ENVIRONMENT}" $COMPOSE up -d --build diff --git a/deploy/restore.sh b/deploy/restore.sh index bba037c..d454372 100755 --- a/deploy/restore.sh +++ b/deploy/restore.sh @@ -10,6 +10,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" ENV_FILE=".env.staging" +export ENVIRONMENT=staging COMPOSE="docker compose -p bookstore-staging -f docker-compose.yml -f docker-compose.staging.yml --env-file ${ENV_FILE}" @@ -18,7 +19,7 @@ read -r -p "type 'restore' to continue: " confirm [[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; } echo "==> restoring database" -gunzip -c "$DB_DUMP" | $COMPOSE exec -T db sh -c "exec mysql -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" +gunzip -c "$DB_DUMP" | $COMPOSE exec -T db sh -c "exec mariadb -u\"\$MARIADB_USER\" -p\"\$(cat /run/secrets/db_password)\" \"\$MARIADB_DATABASE\"" echo "==> restoring uploads" ARCHIVE_DIR="$(cd "$(dirname "$UPLOADS_ARCHIVE")" && pwd)" diff --git a/docker-compose.yml b/docker-compose.yml index 127ed95..93537a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,23 @@ # 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. before every `up`. The supplier/payment keys below are +# still passed the old way and remain exposed to the same class of bug — +# nothing consumes them yet (bookstore-core is still a stub), so when that +# code is written it should read `_FILE` variants the same way. x-bookstore-env: &bookstore-env WORDPRESS_DB_HOST: db WORDPRESS_DB_NAME: ${DB_NAME} WORDPRESS_DB_USER: ${DB_USER} - WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} + WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password WORDPRESS_TABLE_PREFIX: ${WP_TABLE_PREFIX:-wp_} MAIL_CATCHER_HOST: mailhog MAIL_CATCHER_PORT: 1025 @@ -26,6 +38,15 @@ x-bookstore-env: &bookstore-env HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} +x-bookstore-secrets: &bookstore-db-secret + - db_password + +secrets: + db_password: + file: ./secrets/${ENVIRONMENT}/db_password + db_root_password: + file: ./secrets/${ENVIRONMENT}/db_root_password + services: db: image: mariadb:11 @@ -33,12 +54,15 @@ services: environment: MARIADB_DATABASE: ${DB_NAME} MARIADB_USER: ${DB_USER} - MARIADB_PASSWORD: ${DB_PASSWORD} - MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_PASSWORD_FILE: /run/secrets/db_password + MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password + secrets: + - db_password + - db_root_password volumes: - db_data:/var/lib/mysql healthcheck: - test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u$$MARIADB_USER -p$$MARIADB_PASSWORD --silent"] + 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 @@ -64,6 +88,7 @@ services: redis: condition: service_healthy environment: *bookstore-env + secrets: *bookstore-db-secret volumes: - wp_core:/var/www/html - wp_uploads:/var/www/html/wp-content/uploads @@ -79,6 +104,7 @@ services: - wordpress entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"] environment: *bookstore-env + secrets: *bookstore-db-secret volumes: - wp_core:/var/www/html - wp_uploads:/var/www/html/wp-content/uploads