Skip to content
3 changes: 3 additions & 0 deletions autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@
require_once(__DIR__ . '/deployer/requirements/task/requirements.php');
require_once(__DIR__ . '/deployer/requirements/task/check_locales.php');
require_once(__DIR__ . '/deployer/requirements/task/check_packages.php');
require_once(__DIR__ . '/deployer/requirements/task/check_image_processing.php');
require_once(__DIR__ . '/deployer/requirements/task/check_php_extensions.php');
require_once(__DIR__ . '/deployer/requirements/task/check_php_settings.php');
require_once(__DIR__ . '/deployer/requirements/task/check_database.php');
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/list.php');

/*
* dev
Expand Down
58 changes: 42 additions & 16 deletions deployer/requirements/config/set.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
// Enable/disable per check category
set('requirements_check_locales_enabled', true);
set('requirements_check_packages_enabled', true);
set('requirements_check_image_processing_enabled', true);
set('requirements_check_php_extensions_enabled', true);
set('requirements_check_php_settings_enabled', true);
set('requirements_check_database_enabled', true);
set('requirements_check_user_enabled', true);
set('requirements_check_env_enabled', true);
set('requirements_check_eol_enabled', true);

// Locales
set('requirements_locales', ['de_DE.utf8', 'en_US.utf8']);
Expand All @@ -23,7 +25,6 @@
set('requirements_packages', [
'rsync' => 'rsync',
'curl' => 'curl',
'graphicsmagick' => 'gm',
'ghostscript' => 'gs',
'git' => 'git',
'gzip' => 'gzip',
Expand All @@ -34,35 +35,48 @@
'composer' => 'composer',
]);

// PHP minimum version (framework-aware)
set('requirements_php_min_version', function (): string {
if (has('app_type') && get('app_type') === 'typo3') {
return '8.2.0';
}

return '8.1.0';
});

// PHP extensions (framework-aware)
set('requirements_php_extensions', function (): array {
if (has('app_type') && get('app_type') === 'typo3') {
return [
'curl',
'gd',
'mbstring',
'soap',
'pdo',
'session',
'xml',
'zip',
'filter',
'tokenizer',
'mbstring',
'intl',
'apcu',
'pdo',
'pdo_mysql',
'json',
'fileinfo',
'gd',
'zip',
'openssl',
'curl',
'apcu',
];
}

return [
'curl',
'gd',
'mbstring',
'pdo',
'session',
'xml',
'zip',
'filter',
'tokenizer',
'mbstring',
'intl',
'pdo',
'pdo_mysql',
'curl',
'gd',
'zip',
];
});

Expand All @@ -71,15 +85,27 @@
'max_execution_time' => '240',
'memory_limit' => '512M',
'max_input_vars' => '1500',
'pcre.jit' => '1',
'date.timezone' => 'Europe/Berlin',
'post_max_size' => '31M',
'upload_max_filesize' => '30M',
'opcache.memory_consumption' => '256',
]);

// Database
set('requirements_mariadb_min_version', '10.2.7');
set('requirements_mysql_min_version', '8.0.0');
set('requirements_mariadb_min_version', '10.4.3');
set('requirements_mysql_min_version', '8.0.17');

// Image processing
set('requirements_graphicsmagick_min_version', '1.3');
set('requirements_imagemagick_min_version', '6.0');

// Composer
set('requirements_composer_min_version', '2.1.0');

// End-of-life check (endoflife.date API)
set('requirements_eol_warn_months', 6);
set('requirements_eol_api_timeout', 5);

// User / permissions
set('requirements_user_group', 'www-data');
Expand Down
191 changes: 191 additions & 0 deletions deployer/requirements/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Deployer;

use Deployer\Exception\RunException;
use Symfony\Component\Console\Helper\Table;

const REQUIREMENT_OK = 'OK';
Expand All @@ -29,6 +30,7 @@
'max_execution_time',
'max_input_vars',
'opcache.memory_consumption',
'pcre.jit', // Treated as numeric (>= 1) to ensure enabled
];

function addRequirementRow(string $check, string $status, string $info = ''): void
Expand Down Expand Up @@ -140,6 +142,195 @@ function meetsPhpRequirement(string $actual, string $expected, string $setting):
return $actual === $expected;
}

/**
* Try to detect the version of a CLI tool on the remote server.
*/
function detectPackageVersion(string $command): ?string
{
$versionCmd = match ($command) {
'exiftool' => 'exiftool -ver 2>&1 | head -1',
default => "$command --version 2>&1 | head -1",
};

try {
$output = trim(run($versionCmd));

if ($output !== '' && preg_match('/(\d+[\d.]*)/', $output, $matches)) {
return $matches[1];
}
} catch (RunException) {
// Command doesn't support version flag
}

return null;
}

/**
* Detect the database product and version from the remote server.
*
* @return array{product: string, label: string, version: string, cycle: string}|null
*/
function detectDatabaseProduct(): ?array
{
foreach (['mariadb', 'mysql'] as $command) {
try {
$versionOutput = trim(run("$command --version 2>/dev/null"));

if ($versionOutput === '') {
continue;
}

if (str_contains($versionOutput, 'MariaDB') && (
preg_match('/Distrib\s+((\d+\.\d+)[\d.]*)/', $versionOutput, $matches)
|| preg_match('/((\d+\.\d+)[\d.]*)-MariaDB/', $versionOutput, $matches)
)) {
return ['product' => 'mariadb', 'label' => 'MariaDB', 'version' => $matches[1], 'cycle' => $matches[2]];
}

if (preg_match('/Distrib\s+((\d+\.\d+)[\d.]*)/', $versionOutput, $matches)
|| preg_match('/Ver\s+((\d+\.\d+)[\d.]*)/', $versionOutput, $matches)
) {
return ['product' => 'mysql', 'label' => 'MySQL', 'version' => $matches[1], 'cycle' => $matches[2]];
}
} catch (RunException) {
continue;
}
}

return null;
}

/**
* Fetch release cycles from endoflife.date API.
*
* @return list<array{name: string, isEol: bool, eolFrom: ?string, isEoas: ?bool, eoasFrom: ?string, isMaintained: bool}>|null
*/
function fetchEolCycles(string $product, int $timeout = 5): ?array
{
$url = sprintf('https://endoflife.date/api/v1/products/%s/', urlencode($product));

$context = stream_context_create([
'http' => [
'timeout' => $timeout,
'header' => "Accept: application/json\r\nUser-Agent: move-elevator/deployer-tools\r\n",
],
]);

$response = @file_get_contents($url, false, $context);

if ($response === false) {
return null;
}

$data = json_decode($response, true);

if (!is_array($data) || !isset($data['result']['releases'])) {
return null;
}

return $data['result']['releases'];
}

/**
* Find the matching release cycle for a major.minor version.
*
* @param list<array{name: string}> $cycles
* @return array{name: string, isEol: bool, eolFrom: ?string, isEoas: ?bool, eoasFrom: ?string, isMaintained: bool}|null
*/
function findEolCycle(array $cycles, string $majorMinor): ?array
{
foreach ($cycles as $cycle) {
if ($cycle['name'] === $majorMinor) {
return $cycle;
}
}

return null;
}

/**
* Evaluate EOL status and add a requirement row.
*/
function evaluateEolStatus(string $label, array $cycle, int $warnMonths): void
{
$now = new \DateTimeImmutable();

if ($cycle['isEol'] ?? false) {
$eolDate = $cycle['eolFrom'] ?? 'unknown';
addRequirementRow("EOL: $label", REQUIREMENT_FAIL, "End of Life since $eolDate");

return;
}

$eolFrom = $cycle['eolFrom'] ?? null;

if ($eolFrom !== null) {
try {
$eolDate = new \DateTimeImmutable($eolFrom);
} catch (\Exception) {
addRequirementRow("EOL: $label", REQUIREMENT_SKIP, "Invalid EOL date from API: $eolFrom");

return;
}

$warnDate = $eolDate->modify("-{$warnMonths} months");

if ($now >= $warnDate) {
$interval = $now->diff($eolDate);

if ($interval->invert) {
addRequirementRow("EOL: $label", REQUIREMENT_FAIL, "End of Life since $eolFrom");

return;
}

$months = $interval->y * 12 + $interval->m;
$remaining = $months > 0 ? "in $months month(s)" : 'imminent';
addRequirementRow("EOL: $label", REQUIREMENT_WARN, "EOL $remaining ($eolFrom)");

return;
}
}

$isEoas = $cycle['isEoas'] ?? false;

if ($isEoas) {
$info = 'Security support only';
$info .= $eolFrom !== null ? ", EOL $eolFrom" : '';
addRequirementRow("EOL: $label", REQUIREMENT_WARN, $info);

return;
}

$info = 'Maintained';
$info .= $eolFrom !== null ? " until $eolFrom" : '';
addRequirementRow("EOL: $label", REQUIREMENT_OK, $info);
}

/**
* Check a single product against the endoflife.date API.
*/
function checkEolForProduct(string $label, string $product, string $cycle, int $warnMonths, int $timeout): void
{
$cycles = fetchEolCycles($product, $timeout);

if ($cycles === null) {
addRequirementRow("EOL: $label", REQUIREMENT_SKIP, 'Could not reach endoflife.date API');

return;
}

$match = findEolCycle($cycles, $cycle);

if ($match === null) {
addRequirementRow("EOL: $label", REQUIREMENT_SKIP, "Cycle $cycle not found in API");

return;
}

evaluateEolStatus("$label $cycle", $match, $warnMonths);
}

/**
* @return array<string, string>
*/
Expand Down
Loading