From 96790e6531908bff8110917b18d9c4e38dee6e62 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:45:02 +0200 Subject: [PATCH] feat(server): support Alpine package management and Docker setup Add Alpine prerequisites, Docker CLI plugins, and OpenRC service handling while falling back to sh for SSH commands on hosts without Bash. --- app/Actions/Server/InstallDocker.php | 24 +++++-- app/Actions/Server/InstallPrerequisites.php | 25 +++++--- app/Actions/Server/UpdatePackage.php | 2 +- app/Helpers/SshMultiplexingHelper.php | 8 ++- .../server/security/patches.blade.php | 2 +- tests/Feature/SshMultiplexingLockTest.php | 2 +- .../Server/AlpinePackageManagerTest.php | 62 +++++++++++++++++++ tests/Unit/SshMultiplexingDisableTest.php | 10 +++ 8 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/Actions/Server/AlpinePackageManagerTest.php diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index f4c5bdf6a..552445d72 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -95,9 +95,8 @@ public function handle(Server $server) "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null", 'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json', "echo 'Restarting Docker Engine...'", - 'systemctl enable docker >/dev/null 2>&1 || true', - 'systemctl restart docker', ]); + $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine'))); if ($server->isSwarm()) { $command = $command->merge([ 'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true', @@ -159,10 +158,23 @@ private function getArchDockerInstallCommand(): string private function getAlpineDockerInstallCommand(): string { return 'apk update && '. - 'apk add docker docker-cli-compose && '. - 'mkdir -p /etc/docker && '. - 'rc-update add docker default && '. - 'service docker start'; + 'apk add docker docker-cli-buildx docker-cli-compose && '. + 'mkdir -p /etc/docker'; + } + + private function getDockerServiceCommands(bool $usesOpenRc): array + { + if ($usesOpenRc) { + return [ + 'rc-update add docker default', + 'rc-service docker restart', + ]; + } + + return [ + 'systemctl enable docker >/dev/null 2>&1 || true', + 'systemctl restart docker', + ]; } private function getGenericDockerInstallCommand(): string diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index ffb8f1208..57fd4f1d7 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -54,16 +54,7 @@ public function handle(Server $server) 'pacman -Syu --noconfirm --needed curl wget git jq', ]); } elseif ($supported_os_type->contains('alpine')) { - // The community repository holds docker and is commented out on some Alpine images - $command = $command->merge([ - "echo 'Installing Prerequisites for Alpine Linux...'", - "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", - 'apk update', - 'command -v curl >/dev/null || apk add curl', - 'command -v wget >/dev/null || apk add wget', - 'command -v git >/dev/null || apk add git', - 'command -v jq >/dev/null || apk add jq', - ]); + $command = $command->merge($this->getAlpinePrerequisiteCommands()); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } @@ -72,4 +63,18 @@ public function handle(Server $server) return remote_process($command, $server); } + + private function getAlpinePrerequisiteCommands(): array + { + return [ + "echo 'Installing Prerequisites for Alpine Linux...'", + "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", + 'apk update', + 'command -v bash >/dev/null || apk add bash', + 'command -v curl >/dev/null || apk add curl', + 'command -v wget >/dev/null || apk add wget', + 'command -v git >/dev/null || apk add git', + 'command -v jq >/dev/null || apk add jq', + ]; + } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index d77d5b87e..2b06e0601 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -60,7 +60,7 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s break; case 'apk': $commandAll = 'apk update && apk upgrade'; - $commandInstall = 'apk upgrade '.$package; + $commandInstall = 'apk upgrade '.$sanitizedPackage; break; default: return [ diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 1a9b688f4..137f50fbb 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -210,12 +210,18 @@ public static function generateSshCommand(Server $server, string $command, bool $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); + $remoteShellCommand = self::remoteShellCommand(); - return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; } + private static function remoteShellCommand(): string + { + return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi'; + } + public static function getConnectionTimeout(Server $server): int { $timeout = data_get($server, 'settings.connection_timeout'); diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 07a7964cb..0c1f28fd4 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,7 +35,7 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full - Automated package discovery currently supports apk, apt, dnf, and zypper. Weekly status + Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status notifications can be managed from notification settings. diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index f06e50c1a..a52b38202 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -156,7 +156,7 @@ function makeMuxServer(): Server ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'bash -se' << \\") + ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php new file mode 100644 index 000000000..d8050c84d --- /dev/null +++ b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php @@ -0,0 +1,62 @@ +invoke(new InstallPrerequisites); + + expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); +}); + +it('installs every Docker CLI plugin required on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); + + $command = $method->invoke(new InstallDocker); + + expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); +}); + +it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); + + $action = new InstallDocker; + $commands = $method->invoke($action, true); + + expect($commands) + ->toBe(['rc-update add docker default', 'rc-service docker restart']) + ->each->not->toContain('systemctl') + ->and($method->invoke($action, false)) + ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); +}); + +it('parses Alpine package updates', function () { + $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); + $output = <<<'OUTPUT' +docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] +libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] +OUTPUT; + + $result = $method->invoke(new CheckUpdates, $output); + + expect($result)->toBe([ + 'total_updates' => 2, + 'updates' => [ + [ + 'package' => 'docker-cli-compose', + 'new_version' => '2.31.0-r5', + 'architecture' => 'x86_64', + 'current_version' => '2.31.0-r4', + ], + [ + 'package' => 'libcrypto3', + 'new_version' => '3.3.4-r0', + 'architecture' => 'aarch64', + 'current_version' => '3.3.3-r0', + ], + ], + ]); +}); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index d2d4ae600..4dedc7a76 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,6 +23,16 @@ public function test_generate_ssh_command_method_exists() ); } + public function test_remote_shell_prefers_bash_and_falls_back_to_sh() + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); + + $this->assertSame( + 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', + $reflection->invoke(null) + ); + } + public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');