diff --git a/autoload.php b/autoload.php index daf1072..9008301 100644 --- a/autoload.php +++ b/autoload.php @@ -52,6 +52,7 @@ require_once(__DIR__ . '/deployer/requirements/task/check_user.php'); require_once(__DIR__ . '/deployer/requirements/task/check_env.php'); require_once(__DIR__ . '/deployer/requirements/task/check_eol.php'); +require_once(__DIR__ . '/deployer/requirements/task/check_disk_space.php'); require_once(__DIR__ . '/deployer/requirements/task/list.php'); require_once(__DIR__ . '/deployer/requirements/task/health.php'); diff --git a/deployer/requirements/config/set.php b/deployer/requirements/config/set.php index 93a1191..220bcd9 100644 --- a/deployer/requirements/config/set.php +++ b/deployer/requirements/config/set.php @@ -20,6 +20,7 @@ set('requirements_check_eol_enabled', true); set('requirements_check_database_grants_enabled', true); set('requirements_check_health_enabled', true); +set('requirements_check_disk_space_enabled', true); // Locales set('requirements_locales', ['de_DE.utf8', 'en_US.utf8']); @@ -113,6 +114,13 @@ // Health check set('requirements_health_url', 'http://localhost'); +// Disk space (percent used; webspace path defaults to the deploy path) +set('requirements_disk_space_warn_percent', 80); +set('requirements_disk_space_fail_percent', 95); +set('requirements_disk_space_webspace_path', function (): string { + return has('deploy_path') ? get('deploy_path') : '.'; +}); + // User / permissions set('requirements_user_group', 'www-data'); set('requirements_deploy_path_permissions', '2770'); diff --git a/deployer/requirements/functions.php b/deployer/requirements/functions.php index 27c0702..c45d70c 100644 --- a/deployer/requirements/functions.php +++ b/deployer/requirements/functions.php @@ -517,6 +517,77 @@ function resolveDatabaseCredentials(): ?array ]; } +/** + * Run a query against the remote database via the `mysql`/`mariadb` CLI client, with the password masked in output. + * + * @param array{user: string, password: string, host: string, port: int} $credentials + * + * @throws RunException if the connection or query fails + */ +function runMysqlQuery(array $credentials, string $query): string +{ + $mysqlBin = has('mysql') ? get('mysql') : 'mysql'; + + return run(sprintf( + '%s --connect-timeout=5 -u %s -p%s -h %s -P %d -N -e %s 2>&1', + escapeshellarg($mysqlBin), + escapeshellarg($credentials['user']), + "'%secret%'", + escapeshellarg($credentials['host']), + $credentials['port'], + escapeshellarg($query) + ), secret: $credentials['password']); +} + +/** + * Run `df` against a path on the remote host and record a requirement row based on used-space thresholds. + */ +function checkDiskSpaceAtPath(string $label, string $path, int $warnPercent, int $failPercent): void +{ + try { + $output = trim(run('df -kP ' . escapeshellarg($path) . ' | tail -n 1')); + } catch (RunException) { + addRequirementRow($label, REQUIREMENT_SKIP, "Could not read disk usage for $path"); + + return; + } + + $columns = preg_split('/\s+/', $output); + + if (!is_array($columns) || count($columns) < 5) { + addRequirementRow($label, REQUIREMENT_SKIP, "Unexpected df output for $path"); + + return; + } + + $usedPercent = (int) rtrim($columns[4], '%'); + $availableHuman = formatKilobytes((int) $columns[3]); + + $status = match (true) { + $usedPercent >= $failPercent => REQUIREMENT_FAIL, + $usedPercent >= $warnPercent => REQUIREMENT_WARN, + default => REQUIREMENT_OK, + }; + + addRequirementRow($label, $status, "{$usedPercent}% used, $availableHuman free ($path)"); +} + +/** + * Format a `df -k` kilobyte value as a human-readable size. + */ +function formatKilobytes(int $kilobytes): string +{ + if ($kilobytes >= 1024 * 1024) { + return round($kilobytes / (1024 * 1024), 1) . 'G'; + } + + if ($kilobytes >= 1024) { + return round($kilobytes / 1024, 1) . 'M'; + } + + return $kilobytes . 'K'; +} + /** * Parse SHOW GRANTS output and check required grants on global level (*.*). * diff --git a/deployer/requirements/task/check_database_grants.php b/deployer/requirements/task/check_database_grants.php index a495dd8..a703874 100644 --- a/deployer/requirements/task/check_database_grants.php +++ b/deployer/requirements/task/check_database_grants.php @@ -49,19 +49,10 @@ function checkRootGrants(): void return; } - $mysqlBin = has('mysql') ? get('mysql') : 'mysql'; $connectInfo = sprintf('%s@%s:%d', $credentials['user'], $credentials['host'], $credentials['port']); try { - $output = run(sprintf( - '%s --connect-timeout=5 -u %s -p%s -h %s -P %d -N -e %s 2>&1', - escapeshellarg($mysqlBin), - escapeshellarg($credentials['user']), - "'%secret%'", - escapeshellarg($credentials['host']), - $credentials['port'], - escapeshellarg('SHOW GRANTS FOR CURRENT_USER()') - ), secret: $credentials['password']); + $output = runMysqlQuery($credentials, 'SHOW GRANTS FOR CURRENT_USER()'); } catch (RunException) { addRequirementRow('Database: connectivity', REQUIREMENT_FAIL, "Cannot connect as $connectInfo"); diff --git a/deployer/requirements/task/check_disk_space.php b/deployer/requirements/task/check_disk_space.php new file mode 100644 index 0000000..404d86f --- /dev/null +++ b/deployer/requirements/task/check_disk_space.php @@ -0,0 +1,66 @@ +hidden(); + +/** + * @param array{user: string, password: string, host: string, port: int} $credentials + */ +function detectDatabaseDatadir(array $credentials): ?string +{ + try { + $output = trim(runMysqlQuery($credentials, "SHOW VARIABLES LIKE 'datadir'")); + } catch (RunException) { + return null; + } + + // Output format: "datadir\t/var/lib/mysql/", possibly preceded by mysql client warning lines + // on stderr (merged into stdout by runMysqlQuery()), so only the last line is relevant. + $lines = explode("\n", $output); + $columns = explode("\t", trim((string) end($lines))); + $path = $columns[1] ?? null; + + return ('' !== $path && null !== $path) ? $path : null; +} diff --git a/deployer/requirements/task/list.php b/deployer/requirements/task/list.php index f838902..23150e2 100644 --- a/deployer/requirements/task/list.php +++ b/deployer/requirements/task/list.php @@ -134,5 +134,14 @@ } } + // Disk Space + if (get('requirements_check_disk_space_enabled')) { + writeln(''); + writeln('Disk Space'); + writeln(sprintf(' Warn: >= %d%% used', (int) get('requirements_disk_space_warn_percent'))); + writeln(sprintf(' Fail: >= %d%% used', (int) get('requirements_disk_space_fail_percent'))); + writeln(' Checked: webspace path, and database data directory when the DB host is local'); + } + writeln(''); })->desc('List server requirements'); diff --git a/deployer/requirements/task/requirements.php b/deployer/requirements/task/requirements.php index 34bc9aa..a746275 100644 --- a/deployer/requirements/task/requirements.php +++ b/deployer/requirements/task/requirements.php @@ -16,6 +16,7 @@ 'requirements:check:user', 'requirements:check:env', 'requirements:check:eol', + 'requirements:check:disk_space', 'requirements:check:summary', ])->desc('Check server requirements'); diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 63b8cf1..82679c6 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -120,6 +120,27 @@ Checks installed PHP and database (MariaDB/MySQL) versions against the [endoflif The warning threshold is configurable (default: 6 months before EOL). +### Disk space + +Checks free disk space (via `df`) against two used-space thresholds: + +| Condition | Status | +|-----------|--------| +| Used space >= fail threshold (default: 95%) | FAIL | +| Used space >= warn threshold (default: 80%) | WARN | +| Otherwise | OK | + +Two locations are checked: + +- **Webspace**: the configured `requirements_disk_space_webspace_path` (defaults to `deploy_path`). +- **Database**: only when the resolved database host (see "Database grants" credential resolution above) is `127.0.0.1`/`localhost`, i.e. reachable from the deploy target. The data directory is read via `SHOW VARIABLES LIKE 'datadir'`. If the database lives on a separate host, this check is skipped, since disk usage on an arbitrary remote host cannot be read via SSH from the deploy target. + +Like every other check in this recipe, FAIL/WARN are reported in the summary table only — they do not stop the `requirements:check` command itself. To block a deployment on disk space, wire the check into the deploy pipeline in the consuming project, e.g.: + +```php +before('deploy:prepare', 'requirements:check:disk_space'); +``` + ## Health check A standalone task that verifies critical services are running on the target host. This is useful as a quick smoke test before or after deployment. @@ -201,6 +222,12 @@ set('requirements_check_database_grants_enabled', true); // Health check set('requirements_check_health_enabled', true); set('requirements_health_url', 'https://example.com'); + +// Disk space check +set('requirements_check_disk_space_enabled', true); +set('requirements_disk_space_warn_percent', 80); +set('requirements_disk_space_fail_percent', 95); +set('requirements_disk_space_webspace_path', '/var/www/html'); ``` ## Extending with custom checks