diff --git a/README.md b/README.md index 3571e99..8a97ae1 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,25 @@ The CI workflow is configured to build with the repository root as the Docker bu - Or enable the entrypoint-based fixup in php-fpm by setting `CHOWN_ON_START=1` for the `php-fpm` service (the entrypoint is guarded — it only runs when this env is explicitly enabled). - ## Logging & debugging +## Logging & debugging - - PHP-FPM streams master/worker logs plus PHP fatals to stderr, so `docker compose logs php-fpm` (or your platform equivalent) will always contain the messages you need for incident response. - - The auto-prepend bootstrap additionally installs shutdown/exception hooks that write uncaught throwables and fatal errors to stderr even if WordPress or a plugin tampers with `ini_set()`. - - When you need full stack traces in the browser, set `FORCE_DEBUG_ERRORS=1` on the `php-fpm` service. The bootstrap enables verbose output and emits a warning in the container logs reminding you to turn it back off (leaving it on in production leaks stack traces to clients). - - Remove or unset `FORCE_DEBUG_ERRORS` after troubleshooting so production responses stay clean. +- PHP-FPM streams master/worker logs plus PHP fatals to stderr, so `docker compose logs php-fpm` (or your platform equivalent) will always contain the messages you need for incident response. +- The auto-prepend bootstrap additionally installs shutdown/exception hooks that write uncaught throwables and fatal errors to stderr even if WordPress or a plugin tampers with `ini_set()`. +- When you need full stack traces in the browser, set `FORCE_DEBUG_ERRORS=1` on the `php-fpm` service. The bootstrap enables verbose output and emits a warning in the container logs reminding you to turn it back off (leaving it on in production leaks stack traces to clients). +- Remove or unset `FORCE_DEBUG_ERRORS` after troubleshooting so production responses stay clean. + +## Health checks + +- The nginx config exposes `/healthz`, which bypasses WordPress entirely and executes `/usr/local/share/auvem/health/healthcheck.php` inside PHP-FPM. The script verifies the `/var/www/html` mount and optionally performs a MySQL connection test when `WORDPRESS_DB_*` variables are present. +- HTTP 200 means the platform (nginx ↔ php-fpm ↔ database) is healthy. Any filesystem or database failures return HTTP 503 with a short JSON payload detailing which probe failed. This lets you point uptime monitors at `/healthz` while still separately watching the public homepage for regressions caused by client code. +- Example local probe: `curl -fsS http://localhost:8080/healthz | jq`. + +## WP-CLI baked in + +- The PHP-FPM image now ships with the official `wp` binary, so you can run administrative remediation tasks without installing extra tooling on target hosts. +- Typical usage: `docker compose exec php-fpm wp --allow-root user update admin --user_pass='NewPassword123!' --skip-email --path=/var/www/html`. +- The binary runs as root inside the container, so remember the `--allow-root` flag (or create an app-level user if you prefer tighter isolation). +- Automation hooks (e.g., your client portal) can wrap `wp` commands for password resets, cron runs, plugin inspections, etc., provided they bind-mount the site into `/var/www/html`. ## Local Testing & Development diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 78535b6..65a71ea 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -42,6 +42,16 @@ server { location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } + # Fleet readiness probe that bypasses WordPress and executes a bundled PHP script. + location = /healthz { + access_log off; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/local/share/auvem/health/healthcheck.php; + fastcgi_param SCRIPT_NAME /healthz; + fastcgi_param PATH_INFO ""; + fastcgi_pass php-fpm:9000; + } + # Main WordPress front-controller. # All non-file requests fall through to index.php. location / { diff --git a/php-fpm/7.4/Dockerfile b/php-fpm/7.4/Dockerfile index a13e649..e115eb9 100644 --- a/php-fpm/7.4/Dockerfile +++ b/php-fpm/7.4/Dockerfile @@ -27,6 +27,7 @@ RUN set -eux; \ # Install runtime dependencies apk add --no-cache \ bash \ + curl \ freetype \ libjpeg-turbo \ libpng \ @@ -67,6 +68,10 @@ RUN set -eux; \ # Use production php.ini cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"; \ \ + # Install WP-CLI for remedial administration + curl -fSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp; \ + chmod +x /usr/local/bin/wp; \ + \ # Clean up build dependencies apk del .build-deps; \ rm -rf /var/cache/apk/* /tmp/* @@ -85,6 +90,10 @@ COPY php-fpm/conf.d/ /usr/local/etc/php/conf.d/ # Copy the force-debug script (enablement is handled via conf.d/99-force-debug.ini) COPY --chown=app:app shared/php-fpm/force-debug.php /usr/local/etc/php/force-debug.php +# Copy shared healthcheck assets +RUN mkdir -p /usr/local/share/auvem/health +COPY shared/php-fpm/healthcheck.php /usr/local/share/auvem/health/healthcheck.php + # Copy pool configuration from this directory COPY --chown=app:app php-fpm/${BASE_VERSION}/www.conf /usr/local/etc/php-fpm.d/www.conf # Copy the global php-fpm configuration so logging defaults are predictable diff --git a/shared/php-fpm/healthcheck.php b/shared/php-fpm/healthcheck.php new file mode 100644 index 0000000..f2e3c2f --- /dev/null +++ b/shared/php-fpm/healthcheck.php @@ -0,0 +1,94 @@ + 'ok', + 'php_version' => PHP_VERSION, + 'checks' => [], +]; +$httpStatus = 200; + +// Filesystem/webroot probe +$webroot = '/var/www/html'; +$fsStatus = [ + 'name' => 'filesystem', + 'status' => 'ok', + 'detail' => 'Webroot mounted', +]; +if (!is_dir($webroot)) { + $fsStatus['status'] = 'error'; + $fsStatus['detail'] = 'Missing /var/www/html mount'; + $httpStatus = 503; +} elseif (!is_readable($webroot)) { + $fsStatus['status'] = 'error'; + $fsStatus['detail'] = 'Webroot not readable'; + $httpStatus = 503; +} +$response['checks'][] = $fsStatus; + +// Database probe (optional if env vars missing) +$requiredEnv = ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME']; +$dbConfig = []; +$missingEnv = []; +foreach ($requiredEnv as $var) { + $value = getenv($var); + if ($value === false || $value === '') { + $missingEnv[] = $var; + } else { + $dbConfig[$var] = $value; + } +} + +$dbStatus = [ + 'name' => 'database', + 'status' => 'skipped', + 'detail' => 'Database env vars missing', +]; + +if (empty($missingEnv)) { + [$host, $port] = (function (string $rawHost): array { + if (preg_match('/^\[(.+)\](?::(\d+))?$/', $rawHost, $matches)) { + return [$matches[1], isset($matches[2]) ? (int) $matches[2] : 3306]; + } + if (substr_count($rawHost, ':') === 1 && strpos($rawHost, '::') === false) { + [$h, $p] = explode(':', $rawHost, 2); + return [$h, (int) $p]; + } + return [$rawHost, 3306]; + })($dbConfig['WORDPRESS_DB_HOST']); + + $mysqli = @mysqli_init(); + if ($mysqli !== false) { + @mysqli_options($mysqli, MYSQLI_OPT_CONNECT_TIMEOUT, 3); + $connected = @$mysqli->real_connect( + $host, + $dbConfig['WORDPRESS_DB_USER'], + $dbConfig['WORDPRESS_DB_PASSWORD'], + $dbConfig['WORDPRESS_DB_NAME'], + $port + ); + if ($connected) { + $dbStatus['status'] = 'ok'; + $dbStatus['detail'] = 'DB connection OK'; + $mysqli->close(); + } else { + $dbStatus['status'] = 'error'; + $dbStatus['detail'] = sprintf('DB connect failed: %s', $mysqli->connect_error ?? 'unknown error'); + $httpStatus = 503; + } + } else { + $dbStatus['status'] = 'error'; + $dbStatus['detail'] = 'Unable to init mysqli'; + $httpStatus = 503; + } +} else { + $dbStatus['detail'] = 'Missing env: ' . implode(', ', $missingEnv); +} +$response['checks'][] = $dbStatus; + +http_response_code($httpStatus); +echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";