Files
coolify/tests/Unit/Actions/Server/ConfigureAutomaticSecurityUpdatesTest.php
T
Andras BacsaiandClaude Opus 5.5 4ca0b4415c feat(patches): add automatic security updates per server
- New server settings: is_auto_security_updates_enabled and
  auto_security_updates_time (maintenance window, HH:MM in the time zone
  of the server). Both move with a server transfer.
- Debian/Ubuntu: install unattended-upgrades and write
  52coolify-unattended-upgrades. `#clear` drops the distro origin lists,
  so only the security origins stay (Debian <codename>-security, Ubuntu
  <codename>-security, and the Ubuntu Pro ESM security origins). A
  drop-in for apt-daily-upgrade.timer sets the time.
  https://manpages.debian.org/bookworm/apt/apt.conf.5.en.html
  https://github.com/mvo5/unattended-upgrades/blob/master/README.md
- RHEL family: install dnf-automatic (dnf5-plugin-automatic on dnf5),
  set upgrade_type = security and apply_updates = yes in
  /etc/dnf/automatic.conf, and add a drop-in for dnf-automatic.timer
  (dnf4) or dnf5-automatic.timer (dnf5).
  https://dnf.readthedocs.io/en/latest/automatic.html
  https://dnf5.readthedocs.io/en/latest/dnf5_plugins/automatic.8.html
- Off removes the Coolify files, so the operating system default comes
  back. Other package managers show "not supported".
- The schedule and the automatic updates share one form and one
  unsaved bar. The remote commands run only when the automatic update
  settings change; a failure keeps the old setting.
- Commands are written for non-root SSH users (tee and single sh -c
  scripts) and were run in Debian 12, Ubuntu 24.04, Rocky 9, and Fedora
  containers as root and as a sudo user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:41:42 +02:00

125 lines
5.6 KiB
PHP

<?php
use App\Actions\Server\ConfigureAutomaticSecurityUpdates;
use App\Models\Server;
/**
* @param list<string> $commands
* @return list<string>
*/
function autoSecurityUpdatesDecodedPayloads(array $commands): array
{
preg_match_all("/echo '?([A-Za-z0-9+\/=]{8,})'? \| base64 -d/", implode("\n", $commands), $matches);
return array_map(fn (string $payload) => base64_decode($payload, true), $matches[1]);
}
function autoSecurityUpdatesNonRootServer(): Server
{
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$server->shouldReceive('setAttribute')->andReturnSelf();
$server->user = 'ubuntu';
return $server;
}
afterEach(function () {
Mockery::close();
});
test('only apt and dnf support automatic security updates', function (?string $packageManager, bool $supported) {
expect(ConfigureAutomaticSecurityUpdates::supportFor($packageManager))->toBe($supported);
})->with([
['apt', true],
['dnf', true],
['zypper', false],
['apk', false],
['pacman', false],
[null, false],
]);
test('apt installs unattended-upgrades and allows only the security origins at the chosen time', function () {
$commands = ConfigureAutomaticSecurityUpdates::commands('apt', true, '04:30');
[$config, $dropIn] = autoSecurityUpdatesDecodedPayloads($commands);
expect($commands[0])->toContain('command -v unattended-upgrade')
->toContain('DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades')
->and(implode("\n", $commands))
->toContain('tee /etc/apt/apt.conf.d/52coolify-unattended-upgrades > /dev/null')
->toContain('tee /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf > /dev/null')
->toContain('chmod 644 /etc/apt/apt.conf.d/52coolify-unattended-upgrades /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf')
->and($commands)->toContain('systemctl daemon-reload')
->toContain('systemctl enable --now apt-daily.timer apt-daily-upgrade.timer')
->and($config)
->toContain('APT::Periodic::Unattended-Upgrade "1";')
->toContain('#clear Unattended-Upgrade::Allowed-Origins;')
->toContain('#clear Unattended-Upgrade::Origins-Pattern;')
->toContain('"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";')
->toContain('"origin=Ubuntu,archive=${distro_codename}-security";')
->not->toContain('archive=${distro_codename}-updates')
->and($dropIn)->toBe("[Timer]\nOnCalendar=\nOnCalendar=*-*-* 04:30\nRandomizedDelaySec=0\n");
});
test('apt disable removes only the Coolify files', function () {
expect(ConfigureAutomaticSecurityUpdates::commands('apt', false, '03:00'))->toBe([
'rm -f /etc/apt/apt.conf.d/52coolify-unattended-upgrades /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf',
'systemctl daemon-reload',
]);
});
test('dnf installs dnf-automatic, applies only security updates and sets the timer of dnf4 or dnf5', function () {
$commands = ConfigureAutomaticSecurityUpdates::commands('dnf', true, '22:15');
[$dropIn] = autoSecurityUpdatesDecodedPayloads($commands);
expect($commands[0])->toBe('dnf install -y dnf-automatic')
->and($commands[1])
->toContain('f=/etc/dnf/automatic.conf')
->toContain('(upgrade_type|apply_updates)[[:space:]]*=/d')
->toContain('a upgrade_type = security')
->toContain('a apply_updates = yes')
->and($commands[2])
->toContain('t=dnf-automatic.timer; if [ -e /usr/lib/systemd/system/dnf5-automatic.timer ]; then t=dnf5-automatic.timer; fi;')
->toContain('/etc/systemd/system/$t.d/coolify.conf')
->toContain('systemctl enable --now $t')
->and($dropIn)->toBe("[Timer]\nOnCalendar=\nOnCalendar=*-*-* 22:15\nRandomizedDelaySec=0\n");
});
test('dnf disable stops the timer and removes the Coolify drop-in', function () {
$commands = ConfigureAutomaticSecurityUpdates::commands('dnf', false, '03:00');
expect($commands)->toHaveCount(1)
->and($commands[0])
->toContain('systemctl disable --now $t')
->toContain('rm -f /etc/systemd/system/$t.d/coolify.conf')
->toContain('systemctl daemon-reload')
->not->toContain('automatic.conf');
});
test('every command runs as root for a non-root SSH user', function (string $packageManager, bool $enabled) {
$commands = ConfigureAutomaticSecurityUpdates::commands($packageManager, $enabled, '03:00');
$parsed = parseCommandsByLineForSudo(collect($commands), autoSecurityUpdatesNonRootServer());
foreach ($parsed as $line) {
$isSudoLine = str_starts_with($line, 'sudo ') && ! str_contains($line, ' | ');
$isSudoShellScript = isSingleSudoShellScript($line) && substr_count($line, 'sudo') === 1;
$isSudoTeePipe = preg_match("/^echo '[A-Za-z0-9+\/=]+' \| sudo base64 -d \| sudo tee \/etc\/\S+ > \/dev\/null$/", $line) === 1;
expect($isSudoLine || $isSudoShellScript || $isSudoTeePipe)->toBeTrue("Line does not run as root: {$line}");
}
})->with([
'apt enabled' => ['apt', true],
'apt disabled' => ['apt', false],
'dnf enabled' => ['dnf', true],
'dnf disabled' => ['dnf', false],
]);
test('handle rejects a time that is not HH:MM', function (string $time) {
ConfigureAutomaticSecurityUpdates::run(new Server, 'apt', true, $time);
})->throws(InvalidArgumentException::class)->with(['24:00', '3:00', '03:60', '03:00; reboot', '']);
test('handle rejects an unsupported package manager', function () {
ConfigureAutomaticSecurityUpdates::run(new Server, 'zypper', true, '03:00');
})->throws(RuntimeException::class);