Add support for apk package manager (on Alpine Linux) (#6189)

This commit is contained in:
Andras Bacsai 2026-08-18 16:46:02 +02:00 committed by GitHub
commit f6467feeb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 164 additions and 6 deletions

View file

@ -106,6 +106,15 @@ public function handle(Server $server)
$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 @@ private function parsePacmanOutput(string $output): array
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,
];
}
}

View file

@ -79,6 +79,8 @@ public function handle(Server $server)
$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([$this->getAlpineDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@ -93,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',
@ -154,6 +155,28 @@ private function getArchDockerInstallCommand(): string
'systemctl start docker.service';
}
private function getAlpineDockerInstallCommand(): string
{
return 'apk update && '.
'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
{
return 'curl -fsSL https://get.docker.com | sh';

View file

@ -53,6 +53,8 @@ public function handle(Server $server)
"echo 'Installing Prerequisites for Arch Linux...'",
'pacman -Syu --noconfirm --needed curl wget git jq',
]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge($this->getAlpinePrerequisiteCommands());
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
@ -61,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',
];
}
}

View file

@ -58,6 +58,10 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s
$commandAll = 'pacman -Syu --noconfirm';
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
case 'apk':
$commandAll = 'apk update && apk upgrade';
$commandInstall = 'apk upgrade '.$sanitizedPackage;
break;
default:
return [
'error' => 'OS not supported',

View file

@ -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');

View file

@ -35,8 +35,8 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full
</x-slot:actions>
<x-callout type="info" title="Supported package managers">
Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications
can be managed from
Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status
notifications can be managed from
<a class="font-medium underline" href="{{ route('notifications.email') }}"
{{ wireNavigate() }}>notification settings</a>.
</x-callout>

View file

@ -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 '));

View file

@ -0,0 +1,62 @@
<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\InstallDocker;
use App\Actions\Server\InstallPrerequisites;
it('installs Bash while bootstrapping Alpine prerequisites', function () {
$method = new ReflectionMethod(InstallPrerequisites::class, 'getAlpinePrerequisiteCommands');
$commands = $method->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',
],
],
]);
});

View file

@ -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');