Adds a live per-ISBN BooksRun API check to the checkout-time price recheck (Order\PriceRecheck) so a booksrun-sourced offer is verified fresh, not trusted from a potentially day-stale local cache -- fails closed on any problem (API error, no stock), matching Appendix B's "stale supplier cost must never be trusted as final." Live values are written back to the same offer_id row so SupplierOrderJob's later fulfillment-time read sees them too. Also wires a real daily Action Scheduler job (scheduling/dedup/batch-write infrastructure) for BooksRun's FTP inventory feed, but leaves the actual connect+parse step (Import\BooksRunOfferSync::connect_and_parse_feed()) as a deliberate stub -- no FTP access or sample feed exists yet, and guessing at a file format would silently write wrong data once real access arrives. The stub fails loud on the manual CLI trigger, quiet on the scheduled job (no point alarming on an expected, known state). Ingram, BooksRun AutoBuy, production email, and tax/legal/monitoring are explicitly out of scope for this pass -- narrowed by user request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2gmgaDVXcRGCwLfdXQajD
152 lines
7.4 KiB
Bash
Executable File
152 lines
7.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Builds and brings up an environment, then makes sure WordPress/WooCommerce
|
|
# are installed and configured to match design doc §01/§03/§07. Safe to run
|
|
# repeatedly — every step is idempotent.
|
|
set -euo pipefail
|
|
|
|
ENVIRONMENT="${1:?Usage: deploy.sh <dev|staging|production>}"
|
|
export ENVIRONMENT
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
case "$ENVIRONMENT" in
|
|
dev|staging|production) ;;
|
|
*) echo "environment must be 'dev', 'staging', or 'production'" >&2; exit 1 ;;
|
|
esac
|
|
|
|
ENV_FILE=".env.${ENVIRONMENT}"
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
echo "missing $ENV_FILE — copy .env.example to $ENV_FILE and fill it in" >&2
|
|
exit 1
|
|
fi
|
|
# shellcheck source=lib/env.sh
|
|
source "$REPO_ROOT/deploy/lib/env.sh"
|
|
|
|
SITE_URL="$(env_get "$ENV_FILE" SITE_URL)"
|
|
SITE_TITLE="$(env_get "$ENV_FILE" SITE_TITLE)"
|
|
WP_ADMIN_USER="$(env_get "$ENV_FILE" WP_ADMIN_USER)"
|
|
WP_ADMIN_PASSWORD="$(env_get "$ENV_FILE" WP_ADMIN_PASSWORD)"
|
|
WP_ADMIN_EMAIL="$(env_get "$ENV_FILE" WP_ADMIN_EMAIL)"
|
|
|
|
# DB_PASSWORD/DB_ROOT_PASSWORD are stripped from what Compose itself loads:
|
|
# Compose scans every value in --env-file for $identifier-looking patterns
|
|
# as part of building its own interpolation table, and warns "variable not
|
|
# set" for any it finds — even though nothing consumes these two via ${VAR}
|
|
# anymore (they only flow through secrets/, via env_get below, which reads
|
|
# the real, unfiltered $ENV_FILE directly). Filtering them out of Compose's
|
|
# copy is what makes that warning actually go away, permanently, regardless
|
|
# of what characters end up in either password.
|
|
COMPOSE_ENV_FILE="$(mktemp)"
|
|
trap 'rm -f "$COMPOSE_ENV_FILE"' EXIT
|
|
grep -Ev '^(DB_PASSWORD|DB_ROOT_PASSWORD)=' "$ENV_FILE" > "$COMPOSE_ENV_FILE"
|
|
|
|
# -p pins the Compose project name to the environment (default is the
|
|
# directory name, which every environment shares — that made staging quietly
|
|
# 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 ${COMPOSE_ENV_FILE}"
|
|
# wp-cli runs as www-data (who already owns wp-config.php etc.), not root —
|
|
# `docker exec` defaults to root only because the image never sets a
|
|
# non-root default user for exec sessions; the actual web-facing php-fpm
|
|
# workers already run as www-data regardless.
|
|
WP="$COMPOSE exec -T -u www-data wordpress wp"
|
|
|
|
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"
|
|
# Optional, unlike the two above — an environment without a Hardcover token
|
|
# just leaves HardcoverAdapter::is_configured() false. Written unconditionally
|
|
# (even empty) so the bind mount in docker-compose.yml always has a real file
|
|
# to point at, never a directory Docker auto-creates for a missing path (the
|
|
# exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent).
|
|
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
|
|
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token"
|
|
# Same reasoning/pattern as Hardcover above — optional, written unconditionally.
|
|
# Payments\HelcimConfig::is_configured() is false (and the gateway stays
|
|
# unconfigured) until a real sandbox token lands in HELCIM_API_TOKEN.
|
|
HELCIM_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HELCIM_API_TOKEN)"
|
|
printf '%s' "$HELCIM_API_TOKEN_VALUE" > "$SECRETS_DIR/helcim_api_token"
|
|
if [[ -z "$HELCIM_API_TOKEN_VALUE" ]]; then
|
|
echo " (HELCIM_API_TOKEN not set in ${ENV_FILE} — Helcim gateway will install but stay unconfigured)"
|
|
fi
|
|
# Same pattern again — optional, written unconditionally.
|
|
# Integration\BooksRunAdapter::is_configured() is false until a real key
|
|
# lands in BOOKSRUN_API_KEY. BOOKSRUN_FTP_PASSWORD is a placeholder: no
|
|
# FTP access exists yet either (Import\BooksRunOfferSync is a stub), but
|
|
# the secret-file path is wired up now so nothing changes here once it does.
|
|
BOOKSRUN_API_KEY_VALUE="$(env_get "$ENV_FILE" BOOKSRUN_API_KEY)"
|
|
printf '%s' "$BOOKSRUN_API_KEY_VALUE" > "$SECRETS_DIR/booksrun_api_key"
|
|
BOOKSRUN_FTP_PASSWORD_VALUE="$(env_get "$ENV_FILE" BOOKSRUN_FTP_PASSWORD)"
|
|
printf '%s' "$BOOKSRUN_FTP_PASSWORD_VALUE" > "$SECRETS_DIR/booksrun_ftp_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" "$SECRETS_DIR/hardcover_api_token" "$SECRETS_DIR/helcim_api_token" "$SECRETS_DIR/booksrun_api_key" "$SECRETS_DIR/booksrun_ftp_password"
|
|
|
|
echo "==> building and starting ${ENVIRONMENT}"
|
|
$COMPOSE up -d --build
|
|
|
|
echo "==> waiting for WordPress core files (the official image copies them in on first boot of an empty volume, which takes a few seconds)"
|
|
for i in $(seq 1 60); do
|
|
if $COMPOSE exec -T wordpress test -f /var/www/html/wp-settings.php >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
if [[ "$i" -eq 60 ]]; then
|
|
echo "wordpress core files did not appear in time" >&2
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
echo "==> ensuring WordPress is installed"
|
|
if ! $WP core is-installed; then
|
|
$WP core install \
|
|
--url="${SITE_URL:?set SITE_URL in ${ENV_FILE}}" \
|
|
--title="${SITE_TITLE:-Bookstore}" \
|
|
--admin_user="${WP_ADMIN_USER:?set WP_ADMIN_USER in ${ENV_FILE}}" \
|
|
--admin_password="${WP_ADMIN_PASSWORD:?set WP_ADMIN_PASSWORD in ${ENV_FILE}}" \
|
|
--admin_email="${WP_ADMIN_EMAIL:?set WP_ADMIN_EMAIL in ${ENV_FILE}}" \
|
|
--skip-email
|
|
fi
|
|
|
|
echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)"
|
|
$WP theme install blocksy --activate
|
|
|
|
echo "==> installing/activating required plugins"
|
|
$WP plugin install woocommerce redis-cache blocksy-companion helcim-commerce-for-woocommerce --activate
|
|
$WP plugin activate bookstore-core
|
|
$WP redis enable || true
|
|
|
|
echo "==> enabling WooCommerce HPOS (custom order tables)"
|
|
$WP option update woocommerce_custom_orders_table_enabled yes
|
|
$WP option update woocommerce_custom_orders_table_data_sync_enabled yes
|
|
|
|
# WooCommerce's default (unset -> '1:1') force-crops every product
|
|
# thumbnail to a square — confirmed directly: a 335x500 portrait book cover
|
|
# was landing at a hard-cropped 300x300 shop-grid thumbnail, cutting off
|
|
# the top and bottom of the cover. 'uncropped' makes it a proportional
|
|
# 300x448 instead. wc_get_image_size() caches the computed size in the
|
|
# object cache, so the option change alone doesn't take effect until flushed.
|
|
echo "==> uncropped product thumbnails (book covers are portrait, not square)"
|
|
$WP option update woocommerce_thumbnail_cropping uncropped
|
|
$WP cache flush
|
|
|
|
if [[ "$ENVIRONMENT" != "production" ]]; then
|
|
echo "==> discouraging search engines (non-production)"
|
|
$WP option update blog_public 0
|
|
fi
|
|
|
|
echo "==> flushing caches and rewrite rules"
|
|
$WP cache flush
|
|
$WP rewrite flush
|
|
|
|
echo "==> deploy complete: ${ENVIRONMENT}"
|