From 41cbd7192c8d4d9f400835d5d5ad79311b69d6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 14 Jul 2025 10:41:30 +0200 Subject: [PATCH 1/8] :sparkles: Add support for apk package manager (on Alpine Linux) --- app/Actions/Server/CheckUpdates.php | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..e801a1c997 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -106,6 +106,15 @@ class CheckUpdates $out['osId'] = $osId; $out['package_manager'] = $packageManager; + return $out; + case 'apk': + instant_remote_process(['apk update -q'], $server); + $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); + + $out = $this->parseApkOutput($output); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + return $out; default: return [ @@ -273,4 +282,32 @@ class CheckUpdates return $result; } + + private function parseApkOutput(string $output): array + { + $updates = []; + $lines = explode("\n", $output); + + foreach ($lines as $line) { + // Skip empty lines + if (empty($line)) { + continue; + } + + // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] + if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) { + $updates[] = [ + 'package' => $matches[1], + 'new_version' => $matches[2], + 'architecture' => $matches[3], + 'current_version' => $matches[4], + ]; + } + } + + return [ + 'total_updates' => count($updates), + 'updates' => $updates, + ]; + } } From e6a838ad50b4197a039d0798d7d78f89daaba4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 14 Jul 2025 11:05:42 +0200 Subject: [PATCH 2/8] :zap: Also support upgrade packages --- app/Actions/Server/UpdatePackage.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..d77d5b87ee 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -58,6 +58,10 @@ class UpdatePackage $commandAll = 'pacman -Syu --noconfirm'; $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; + case 'apk': + $commandAll = 'apk update && apk upgrade'; + $commandInstall = 'apk upgrade '.$package; + break; default: return [ 'error' => 'OS not supported', From 7cf713848294fb11703995ffcc1890a3dba8cef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 14 Jul 2025 11:16:44 +0200 Subject: [PATCH 3/8] :sparkles: Add support to install Docker --- app/Actions/Server/InstallDocker.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..2f01ecee2f 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,6 +79,15 @@ class InstallDocker $command = $command->merge([$this->getSuseDockerInstallCommand()]); } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge([ + "echo 'Installing Prerequisites...'", + 'zypper update -y', + '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', + ]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } From 57401630a4ef3a52f2cb6fc9e5867424316bf80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Tue, 15 Jul 2025 17:33:58 +0200 Subject: [PATCH 4/8] :pencil2: Fix copy-paste issue --- app/Actions/Server/InstallDocker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2f01ecee2f..1b86cf9dd8 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -82,7 +82,7 @@ class InstallDocker } elseif ($supported_os_type->contains('alpine')) { $command = $command->merge([ "echo 'Installing Prerequisites...'", - 'zypper update -y', + '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', From 1bfde7d7c6d77f9d334950e164bd32f6050337db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Tue, 15 Jul 2025 17:34:22 +0200 Subject: [PATCH 5/8] :memo: Update template --- resources/views/livewire/server/security/patches.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 6a88d8d405..07a7964cbe 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications - can be managed from + Automated package discovery currently supports apk, apt, dnf, and zypper. Weekly status + notifications can be managed from notification settings. From 7ec1c939bfe51a8a05d2a565a2cca297c0fb3682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 22 Dec 2025 11:52:12 +0100 Subject: [PATCH 6/8] :sparkles: Install docker on alpine --- app/Actions/Server/InstallDocker.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 1b86cf9dd8..2256f186e0 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -80,14 +80,7 @@ class InstallDocker } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); } elseif ($supported_os_type->contains('alpine')) { - $command = $command->merge([ - "echo 'Installing Prerequisites...'", - '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->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -163,6 +156,13 @@ class InstallDocker 'systemctl start docker.service'; } + private function getAlpineDockerInstallCommand(): string + { + return "echo 'Installing Prerequisites...' && ". + 'apk update && '. + 'apk add docker'; + } + private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; From 47a14765e14cbe2081138f4bf44c612d7e4a07f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Tue, 11 Aug 2026 16:46:15 +0200 Subject: [PATCH 7/8] :sparkles: Install prerequisites and Docker on Alpine InstallPrerequisites had no alpine branch and threw before Docker install was ever reached. Enable the community repository (commented out on some images) and install curl/wget/git/jq with apk. The Docker install now also pulls docker-cli-compose, which validateDockerCompose() requires, creates /etc/docker since Alpine's docker package does not ship it, and enables/starts the OpenRC service. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GXciegHbJhXbGvVn7mwgbR --- app/Actions/Server/InstallDocker.php | 8 +++++--- app/Actions/Server/InstallPrerequisites.php | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2256f186e0..f4c5bdf6a5 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -158,9 +158,11 @@ class InstallDocker private function getAlpineDockerInstallCommand(): string { - return "echo 'Installing Prerequisites...' && ". - 'apk update && '. - 'apk add docker'; + return 'apk update && '. + 'apk add docker docker-cli-compose && '. + 'mkdir -p /etc/docker && '. + 'rc-update add docker default && '. + 'service docker start'; } private function getGenericDockerInstallCommand(): string diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..ffb8f1208e 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,6 +53,17 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", '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', + ]); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } 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 8/8] 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 f4c5bdf6a5..552445d728 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -95,9 +95,8 @@ class InstallDocker "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 @@ class InstallDocker 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 ffb8f1208e..57fd4f1d7c 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -54,16 +54,7 @@ class InstallPrerequisites '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 @@ class InstallPrerequisites 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 d77d5b87ee..2b06e06011 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -60,7 +60,7 @@ class UpdatePackage 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 1a9b688f40..137f50fbbd 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -210,12 +210,18 @@ class SshMultiplexingHelper $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 07a7964cbe..0c1f28fd41 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,7 +35,7 @@ - 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 f06e50c1a9..a52b382028 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -156,7 +156,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->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 0000000000..d8050c84d9 --- /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 d2d4ae600f..4dedc7a768 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase ); } + 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');