Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ ARG BASE_IMAGE=evo-salo-runtime:8.4
FROM ${BASE_IMAGE}

COPY build/ /var/www/html/
COPY .github/docker/entrypoint.sh /usr/local/bin/evo-entrypoint

# Friendly URLs: the repository ships the Apache rules as ht.access. The nginx
# and FrankenPHP runtimes carry their own rules inside the base image, and
Expand All @@ -16,6 +17,11 @@ RUN if [ -f /var/www/html/ht.access ] && [ ! -f /var/www/html/.htaccess ]; then
cp /var/www/html/ht.access /var/www/html/.htaccess; \
fi \
&& if command -v a2enmod > /dev/null 2>&1; then a2enmod rewrite; fi \
&& chown -R www-data:www-data /var/www/html
&& chown -R www-data:www-data /var/www/html \
&& chmod +x /usr/local/bin/evo-entrypoint

EXPOSE 80

# The scheduler runs inside the container next to the web server; see the
# script for why, and for the EVO_SCHEDULER=0 escape hatch.
ENTRYPOINT ["evo-entrypoint"]
52 changes: 52 additions & 0 deletions .github/docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/bin/sh
# Starts the Evolution CMS scheduler alongside the web server, then hands the
# container over to whatever the base runtime would have started on its own.
#
# The manager queues package installs, site updates and backups as system tasks
# and waits for `php artisan schedule:work` to pick them up; without it those
# tasks sit queued forever and the manager reports the scheduler as stale. A
# container has no cron, so the scheduler is supervised here instead.
set -e

APP_DIR=${EVO_APP_DIR:-/var/www/html}
SCHEDULER_USER=${EVO_SCHEDULER_USER:-www-data}
# Seconds to wait before restarting the scheduler after it exits. A crash loop
# (bad database credentials, say) then costs one line of log every 5s rather
# than filling the log as fast as PHP can boot.
SCHEDULER_RESTART_DELAY=${EVO_SCHEDULER_RESTART_DELAY:-5}

# EVO_SCHEDULER=0 turns the scheduler off for anyone running several replicas
# off one database, where a single scheduler elsewhere owns the queue.
if [ "${EVO_SCHEDULER:-1}" != "0" ] && [ -f "$APP_DIR/core/artisan" ]; then
(
while true; do
echo "[evo-entrypoint] starting php artisan schedule:work"
# The web server serves as www-data, so the scheduler writes cache,
# log and backup files as the same user — running it as root would
# leave files the server cannot rewrite.
su "$SCHEDULER_USER" -s /bin/sh -c "cd '$APP_DIR/core' && exec php artisan schedule:work" || true
echo "[evo-entrypoint] schedule:work exited, restarting in ${SCHEDULER_RESTART_DELAY}s"
sleep "$SCHEDULER_RESTART_DELAY"
done
) &
fi

# Overriding ENTRYPOINT clears the CMD inherited from the base image, so the
# default command each runtime ships has to be restated here. All three bases
# keep php's own docker-php-entrypoint, which is what finally execs these.
if [ "$#" -eq 0 ]; then
if command -v apache2-foreground > /dev/null 2>&1; then
set -- apache2-foreground
elif command -v salo-entrypoint > /dev/null 2>&1; then
set -- salo-entrypoint
elif command -v frankenphp > /dev/null 2>&1; then
# FrankenPHP's docker-php-entrypoint turns a leading "-" into
# "frankenphp run ...", so the flags are the whole command there.
set -- --config /etc/frankenphp/Caddyfile --adapter caddyfile
else
echo "[evo-entrypoint] no known server command found in this image" >&2
exit 1
fi
fi

exec docker-php-entrypoint "$@"
11 changes: 11 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,21 @@ jobs:
# request on its own (Caddy's 404, nginx's 404) sends no such header.
friendly=$(curl -s -D - -o /dev/null http://127.0.0.1:8899/no-such-page)
echo "front=$front manager=$manager"
# The image also runs the scheduler next to the web server: the
# manager queues package installs, updates and backups as system
# tasks that nothing picks up without it. The wait gives a scheduler
# that boots and dies time to log its restart, so a crash loop fails
# here rather than shipping as a quietly idle queue.
sleep 8
started=$(docker logs evo-smoke 2>&1 | grep -c 'starting php artisan schedule:work' || true)
restarted=$(docker logs evo-smoke 2>&1 | grep -c 'schedule:work exited' || true)
echo "scheduler started=$started restarted=$restarted"
docker logs evo-smoke 2>&1 | tail -30
docker rm -f evo-smoke > /dev/null
test "$front" = "200"
test "$manager" = "200"
test "$started" = "1"
test "$restarted" = "0"
echo "$friendly" | grep -qi '^x-powered-by: php' || {
echo "::error::friendly URL was not routed to index.php on ${SERVER}"
echo "$friendly"
Expand Down
80 changes: 78 additions & 2 deletions core/src/Console/SiteUpdateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -708,14 +708,35 @@ protected function composerBinaryCommand(): string
}

foreach ($this->composerBinaryCandidates() as $candidate) {
if (is_file($candidate) && is_executable($candidate)) {
if ($this->isExecutableFile($candidate)) {
return escapeshellarg($candidate);
}
}

return 'composer';
}

/**
* Check whether a path is something the shell can run.
*
* On Windows is_executable() answers false even for a genuine
* composer.bat — it does not consult PATHEXT the way the shell does — so
* every candidate would be rejected no matter which paths were offered.
* There the file existing is the only signal available.
*
* @since 3.5.8
* @param string $path Absolute path to test.
* @return bool
*/
protected function isExecutableFile(string $path): bool
{
if (!is_file($path)) {
return false;
}

return windows_os() ? true : is_executable($path);
}

/**
* Build fallback Composer executable candidates.
*
Expand All @@ -733,9 +754,55 @@ protected function composerBinaryCandidates(): array
$candidates[] = $home . '/.composer/composer';
}

// Appended rather than switched on the platform. Every candidate is
// filtered by isExecutableFile() anyway, so an entry that cannot exist
// here costs one is_file() call, while a platform branch would be a
// new way to guess wrong — under WSL, or wherever the environment does
// not match what PHP_OS_FAMILY suggests.
$candidates = array_merge($candidates, $this->windowsComposerBinaryCandidates());

return array_values(array_unique($candidates));
}

/**
* Build fallback Composer executable candidates for Windows layouts.
*
* The POSIX list finds nothing here: there is no /usr/local/bin, and a
* per-user install puts a shim in %APPDATA%\Composer rather than in a
* ~/.composer/composer file. Only shell-runnable shims are listed —
* composer.phar is deliberately absent, because it needs `php` in front of
* it and this list feeds a command that is executed directly.
*
* @since 3.5.8
* @return array<int, string>
*/
protected function windowsComposerBinaryCandidates(): array
{
$candidates = [];

// Where the Composer-Setup installer puts a machine-wide install.
$programData = trim((string) getenv('ProgramData'));
if ($programData !== '') {
$base = rtrim(str_replace('\\', '/', $programData), '/') . '/ComposerSetup/bin/composer';
$candidates[] = $base . '.bat';
$candidates[] = $base . '.exe';
}

// A per-user install.
$appData = trim((string) getenv('APPDATA'));
if ($appData !== '') {
$base = rtrim(str_replace('\\', '/', $appData), '/') . '/Composer/composer';
$candidates[] = $base . '.bat';
$candidates[] = $base . '.exe';
}

foreach ($this->homeDirectories() as $home) {
$candidates[] = $home . '/AppData/Roaming/Composer/composer.bat';
}

return array_values(array_unique(array_filter($candidates)));
}

/**
* Resolve possible home directories without relying on shell "~" expansion.
*
Expand Down Expand Up @@ -775,7 +842,16 @@ protected function shellCommandExists(string $command): bool
$output = [];
$exitCode = 1;

exec('command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1', $output, $exitCode);
// `command -v` is a POSIX shell builtin and /dev/null is a POSIX
// device; cmd.exe has neither, so on Windows this probe reported "not
// found" for every command — including ones plainly on PATH — and the
// resolver fell through to candidate paths that do not exist there
// either. `where` is the native equivalent and answers 0 when found.
$probe = windows_os()
? 'where ' . escapeshellarg($command) . ' >NUL 2>NUL'
: 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1';

exec($probe, $output, $exitCode);

return (int) $exitCode === 0;
}
Expand Down
123 changes: 37 additions & 86 deletions core/src/Console/SystemTasks/TaskWorkerCommand.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
<?php namespace EvolutionCMS\Console\SystemTasks;

use EvolutionCMS\Services\SystemTasks\ConsoleInstallFlowService;
use EvolutionCMS\Services\SystemTasks\ConsoleUninstallFlowService;
use EvolutionCMS\Services\SystemTasks\SiteUpdateFlowService;
use EvolutionCMS\Services\SystemTasks\SystemTaskService;
use EvolutionCMS\Services\SystemTasks\SystemTaskRegistry;
use EvolutionCMS\Services\SystemTasks\WorkerHealthService;
use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;
Expand Down Expand Up @@ -35,90 +33,43 @@ public function handle()
$workerHealth->markPick($host, $pid);

try {
switch ((string) $task->type) {
case 'console_install':
$flow = new ConsoleInstallFlowService();
$result = $flow->execute($task, function ($step, $progress, $message, $level = 'info', array $context = []) use (&$task, $taskService) {
$task = $taskService->updateTaskProgress(
$task,
'running',
(int) $progress,
(string) $step,
(string) $message,
(string) $level,
$context
);
});

$taskService->markTaskSucceeded(
$task,
isset($result['message']) ? (string) $result['message'] : 'System task completed successfully.',
isset($result['result']) && is_array($result['result']) ? $result['result'] : []
);
$workerHealth->markSuccess($host, $pid);
$this->info('[system:task-worker] console install task completed');
return self::SUCCESS;

case 'console_uninstall':
$flow = new ConsoleUninstallFlowService();
$result = $flow->execute($task, function ($step, $progress, $message, $level = 'info', array $context = []) use (&$task, $taskService) {
$task = $taskService->updateTaskProgress(
$task,
'running',
(int) $progress,
(string) $step,
(string) $message,
(string) $level,
$context
);
});

$taskService->markTaskSucceeded(
$task,
isset($result['message']) ? (string) $result['message'] : 'System task completed successfully.',
isset($result['result']) && is_array($result['result']) ? $result['result'] : []
);
$workerHealth->markSuccess($host, $pid);
$this->info('[system:task-worker] console uninstall task completed');
return self::SUCCESS;

case 'site_update':
$flow = new SiteUpdateFlowService();
$result = $flow->execute($task, function ($step, $progress, $message, $level = 'info', array $context = []) use (&$task, $taskService) {
$task = $taskService->updateTaskProgress(
$task,
'running',
(int) $progress,
(string) $step,
(string) $message,
(string) $level,
$context
);
});

$taskService->markTaskSucceeded(
$task,
isset($result['message']) ? (string) $result['message'] : 'Site update completed successfully.',
isset($result['result']) && is_array($result['result']) ? $result['result'] : []
);
$workerHealth->markSuccess($host, $pid);
$this->info('[system:task-worker] site update task completed');
return self::SUCCESS;

default:
$taskService->markTaskFailed(
$task,
'TASK_TYPE_NOT_ALLOWED',
'Unsupported system task type for this worker.'
);
$workerHealth->markFailure('TASK_TYPE_NOT_ALLOWED', $host, $pid);
Log::warning('[system:task-worker] unsupported task type', [
'task_id' => (int) $task->id,
'type' => (string) $task->type,
]);
$this->warn('[system:task-worker] unsupported task type');
return self::SUCCESS;
$type = (string) $task->type;
if (!SystemTaskRegistry::has($type)) {
$taskService->markTaskFailed(
$task,
'TASK_TYPE_NOT_ALLOWED',
'Unsupported system task type for this worker.'
);
$workerHealth->markFailure('TASK_TYPE_NOT_ALLOWED', $host, $pid);
Log::warning('[system:task-worker] unsupported task type', [
'task_id' => (int) $task->id,
'type' => $type,
]);
$this->warn('[system:task-worker] unsupported task type');
return self::SUCCESS;
}

$handler = SystemTaskRegistry::handler($type);
$result = $handler->execute($task, function ($step, $progress, $message, $level = 'info', array $context = []) use (&$task, $taskService) {
$task = $taskService->updateTaskProgress(
$task,
'running',
(int) $progress,
(string) $step,
(string) $message,
(string) $level,
$context
);
});

$taskService->markTaskSucceeded(
$task,
isset($result['message']) ? (string) $result['message'] : SystemTaskRegistry::label($type) . ' completed successfully.',
isset($result['result']) && is_array($result['result']) ? $result['result'] : []
);
$workerHealth->markSuccess($host, $pid);
$this->info('[system:task-worker] ' . $type . ' task completed');
return self::SUCCESS;
} catch (\Throwable $exception) {
$errorCode = 'TASK_EXECUTION_FAILED';
$taskService->markTaskFailed($task, $errorCode, $exception->getMessage(), [
Expand Down
Loading