feat(patches): record package updates, reboots, and auto-update changes in the audit log

- ui.server.packages_update_requested: user, server, packages (or all),
  package manager, and the activity of the update.
- ui.server.packages_update_finished: written when the remote process
  finishes (also when it fails), with the result and the exit code of
  the activity. The update gets a fresh type_uuid (an indexed activity
  property), so the listener finds exactly this activity. The actor
  comes from the request, because the listener runs in the queue.
- ui.server.reboot_requested with result started or failed.
- ui.server.auto_security_updates_updated (enabled, time, package
  manager, result) and ui.server.patch_settings_updated for a changed
  check frequency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-19 10:44:03 +02:00
co-authored by Claude Opus 5.5
parent 83ea5cc1f7
commit f32ecca44c
5 changed files with 166 additions and 24 deletions
+12 -2
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Str;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
@@ -54,9 +55,18 @@ class UpdatePackage
];
}
return remote_process($commands, $server, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [
$updateId = (string) Str::uuid();
$packages = $all ? [] : [$package];
$user = auth()->user();
return remote_process($commands, $server, type_uuid: $updateId, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [
'update_id' => $updateId,
'server_uuid' => $server->uuid,
'packages' => $all ? [] : [$package],
'packages' => $packages,
'package_manager' => $packageManager,
'actor_id' => $user?->id,
'actor_name' => $user?->name,
'actor_email' => $user?->email,
]);
} catch (\Exception $e) {
return [
@@ -1,20 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\ServerPackagesUpdateFinished;
use App\Jobs\ServerPatchCheckJob;
use App\Models\Server;
class CheckPatchesAfterPackageUpdate
{
public function handle(ServerPackagesUpdateFinished $event): void
{
$server = Server::query()->where('uuid', $event->data['server_uuid'] ?? null)->first();
if ($server === null) {
return;
}
ServerPatchCheckJob::dispatchManual($server);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Listeners;
use App\Enums\ProcessStatus;
use App\Events\ServerPackagesUpdateFinished;
use App\Jobs\ServerPatchCheckJob;
use App\Models\Server;
use Spatie\Activitylog\Models\Activity;
/**
* Records the result of a package update in the audit log and checks the server again.
*/
class FinishServerPackagesUpdate
{
public function handle(ServerPackagesUpdateFinished $event): void
{
$server = Server::query()->where('uuid', $event->data['server_uuid'] ?? null)->first();
if ($server === null) {
return;
}
$activity = Activity::query()->where('properties->type_uuid', $event->data['update_id'] ?? null)->first();
$status = data_get($activity, 'properties.status');
$packages = $event->data['packages'] ?? [];
auditLog('ui.server.packages_update_finished', [
'team_id' => $server->team_id,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'packages' => $packages === [] ? 'all' : $packages,
'package_manager' => $event->data['package_manager'] ?? null,
'activity_id' => $activity?->id,
'result' => $status === ProcessStatus::FINISHED->value ? 'success' : 'failed',
'exit_code' => data_get($activity, 'properties.exitCode'),
'actor_id' => $event->data['actor_id'] ?? null,
'actor_name' => $event->data['actor_name'] ?? null,
'actor_email' => $event->data['actor_email'] ?? null,
], $status === ProcessStatus::FINISHED->value ? 'info' : 'error');
ServerPatchCheckJob::dispatchManual($server);
}
}
+40 -2
View File
@@ -121,7 +121,14 @@ class Patches extends Component
{
try {
$this->authorize('update', $this->server);
RebootServer::run($this->server);
try {
RebootServer::run($this->server);
} catch (\Throwable $e) {
$this->auditPatchAction('reboot_requested', ['result' => 'failed', 'error' => $e->getMessage()], 'error');
throw $e;
}
$this->auditPatchAction('reboot_requested', ['result' => 'started']);
$this->dispatch('success', 'Reboot started. The server is back in a few minutes, and Coolify then checks it again.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -152,13 +159,26 @@ class Patches extends Component
if (! ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) {
throw new \Exception('Automatic security updates are supported only on Debian, Ubuntu, and RHEL-family servers. Run "Check for updates" first, so Coolify knows the package manager.');
}
ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time);
$auditContext = ['enabled' => $this->autoSecurityUpdates, 'time' => $time, 'package_manager' => $packageManager];
try {
ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time);
} catch (\Throwable $e) {
$this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'failed', 'error' => $e->getMessage()], 'error');
throw $e;
}
}
$settings->patch_check_frequency = $frequency === '' ? null : $frequency;
$settings->is_auto_security_updates_enabled = $this->autoSecurityUpdates;
$settings->auto_security_updates_time = $time;
$changedFields = auditChangedFields($settings);
$settings->save();
if ($autoUpdatesChanged) {
$this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'success']);
} elseif ($changedFields !== []) {
$this->auditPatchAction('patch_settings_updated', ['changed_fields' => $changedFields]);
}
$this->syncSettings();
$this->dispatch('success', 'Patch settings saved.');
} catch (\Throwable $e) {
@@ -187,6 +207,19 @@ class Patches extends Component
return true;
}
/**
* @param array<string, mixed> $context
*/
private function auditPatchAction(string $action, array $context, string $level = 'info'): void
{
auditLog("ui.server.{$action}", [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
...$context,
], $level);
}
/**
* A transferred server is an archive: another instance manages the host.
*/
@@ -215,6 +248,11 @@ class Patches extends Component
return;
}
$this->auditPatchAction('packages_update_requested', [
'packages' => $all ? 'all' : [$package],
'package_manager' => $packageManager,
'activity_id' => $activity->id,
]);
$this->dispatch('startupdate');
$this->dispatch('activityMonitor', $activity->id);
}
@@ -3,11 +3,13 @@
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\ConfigureAutomaticSecurityUpdates;
use App\Actions\Server\RebootServer;
use App\Actions\Server\UpdatePackage;
use App\Events\ServerPackagesUpdateFinished;
use App\Events\ServerPatchCheckFinished;
use App\Jobs\ServerManagerJob;
use App\Jobs\ServerPatchCheckJob;
use App\Livewire\Server\Security\Patches;
use App\Models\AuditEvent;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -22,6 +24,7 @@ use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
@@ -401,3 +404,71 @@ describe('automatic security updates', function () {
->assertDispatched('error');
});
});
describe('audit trail', function () {
beforeEach(function () {
Queue::fake();
$this->withoutDefer();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly();
});
it('records who requested a package update', function () {
$activity = Activity::query()->create(['log_name' => 'default', 'description' => '', 'properties' => []]);
UpdatePackage::shouldRun()->once()->andReturn($activity);
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('updatePackage', 'openssl');
$event = AuditEvent::query()->where('event', 'ui.server.packages_update_requested')->sole();
expect($event->actor_email)->toBe($this->admin->email)
->and($event->team_id)->toBe($this->team->id)
->and($event->resource_uuid)->toBe($this->server->uuid)
->and($event->metadata['packages'])->toBe(['openssl'])
->and($event->metadata['activity_id'])->toBe($activity->id);
});
it('records the result of a finished package update and checks the server again', function (string $status, string $result) {
$activity = Activity::query()->create(['log_name' => 'default', 'description' => '', 'properties' => [
'type_uuid' => 'update-1', 'status' => $status, 'exitCode' => $status === 'finished' ? 0 : 100,
]]);
auth()->logout();
ServerPackagesUpdateFinished::dispatch([
'update_id' => 'update-1', 'server_uuid' => $this->server->uuid, 'packages' => [], 'package_manager' => 'apt',
'actor_id' => $this->admin->id, 'actor_name' => $this->admin->name, 'actor_email' => $this->admin->email,
]);
$event = AuditEvent::query()->where('event', 'ui.server.packages_update_finished')->sole();
expect($event->actor_email)->toBe($this->admin->email)
->and($event->team_id)->toBe($this->team->id)
->and($event->metadata)->toMatchArray(['packages' => 'all', 'result' => $result, 'activity_id' => $activity->id]);
Queue::assertPushed(ServerPatchCheckJob::class);
})->with([
['finished', 'success'],
['error', 'failed'],
]);
it('records a reboot and a failed reboot', function () {
RebootServer::shouldRun()->once();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('rebootServer');
RebootServer::shouldRun()->once()->andThrow(new RuntimeException('Server is not reachable or not ready.'));
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('rebootServer');
expect(AuditEvent::query()->where('event', 'ui.server.reboot_requested')->orderBy('id')->get()->map(fn ($event) => $event->metadata['result'])->all())
->toBe(['started', 'failed']);
});
it('records a change of the automatic security updates', function () {
ConfigureAutomaticSecurityUpdates::shouldRun()->once();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('autoSecurityUpdates', true)
->call('submit');
$event = AuditEvent::query()->where('event', 'ui.server.auto_security_updates_updated')->sole();
expect($event->actor_email)->toBe($this->admin->email)
->and($event->metadata)->toMatchArray(['enabled' => true, 'time' => '03:00', 'package_manager' => 'apt', 'result' => 'success']);
});
});