refactor: simplify remote process chain and harden ActivityMonitor (#9189)

This commit is contained in:
Andras Bacsai 2026-03-26 13:28:37 +01:00 committed by GitHub
commit 2729dffb3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 155 additions and 131 deletions

View file

@ -43,7 +43,7 @@ ### Backend Structure (app/)
- **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`. - **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`.
- **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations. - **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations.
- **Helpers/** — Global helpers loaded via `bootstrap/includeHelpers.php` from `bootstrap/helpers/` — organized into `shared.php`, `constants.php`, `versions.php`, `subscriptions.php`, `domains.php`, `docker.php`, `services.php`, `github.php`, `proxy.php`, `notifications.php`. - **Helpers/** — Global helpers loaded via `bootstrap/includeHelpers.php` from `bootstrap/helpers/` — organized into `shared.php`, `constants.php`, `versions.php`, `subscriptions.php`, `domains.php`, `docker.php`, `services.php`, `github.php`, `proxy.php`, `notifications.php`.
- **Data/** — Spatie Laravel Data DTOs (e.g., `CoolifyTaskArgs`, `ServerMetadata`). - **Data/** — Spatie Laravel Data DTOs (e.g., `ServerMetadata`).
- **Enums/** — PHP enums (TitleCase keys). Key enums: `ProcessStatus`, `Role` (MEMBER/ADMIN/OWNER with rank comparison), `BuildPackTypes`, `ProxyTypes`, `ContainerStatusTypes`. - **Enums/** — PHP enums (TitleCase keys). Key enums: `ProcessStatus`, `Role` (MEMBER/ADMIN/OWNER with rank comparison), `BuildPackTypes`, `ProxyTypes`, `ContainerStatusTypes`.
- **Rules/** — Custom validation rules (`ValidGitRepositoryUrl`, `ValidServerIp`, `ValidHostname`, `DockerImageFormat`, etc.). - **Rules/** — Custom validation rules (`ValidGitRepositoryUrl`, `ValidServerIp`, `ValidHostname`, `DockerImageFormat`, etc.).

View file

@ -1,54 +0,0 @@
<?php
namespace App\Actions\CoolifyTask;
use App\Data\CoolifyTaskArgs;
use App\Jobs\CoolifyTask;
use Spatie\Activitylog\Models\Activity;
/**
* The initial step to run a `CoolifyTask`: a remote SSH process
* with monitoring/tracking/trace feature. Such thing is made
* possible using an Activity model and some attributes.
*/
class PrepareCoolifyTask
{
protected Activity $activity;
protected CoolifyTaskArgs $remoteProcessArgs;
public function __construct(CoolifyTaskArgs $remoteProcessArgs)
{
$this->remoteProcessArgs = $remoteProcessArgs;
if ($remoteProcessArgs->model) {
$properties = $remoteProcessArgs->toArray();
unset($properties['model']);
$this->activity = activity()
->withProperties($properties)
->performedOn($remoteProcessArgs->model)
->event($remoteProcessArgs->type)
->log('[]');
} else {
$this->activity = activity()
->withProperties($remoteProcessArgs->toArray())
->event($remoteProcessArgs->type)
->log('[]');
}
}
public function __invoke(): Activity
{
$job = new CoolifyTask(
activity: $this->activity,
ignore_errors: $this->remoteProcessArgs->ignore_errors,
call_event_on_finish: $this->remoteProcessArgs->call_event_on_finish,
call_event_data: $this->remoteProcessArgs->call_event_data,
);
dispatch($job);
$this->activity->refresh();
return $this->activity;
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace App\Data;
use App\Enums\ProcessStatus;
use Illuminate\Database\Eloquent\Model;
use Spatie\LaravelData\Data;
/**
* The parameters to execute a CoolifyTask, organized in a DTO.
*/
class CoolifyTaskArgs extends Data
{
public function __construct(
public string $server_uuid,
public string $command,
public string $type,
public ?string $type_uuid = null,
public ?int $process_id = null,
public ?Model $model = null,
public ?string $status = null,
public bool $ignore_errors = false,
public $call_event_on_finish = null,
public $call_event_data = null
) {
if (is_null($status)) {
$this->status = ProcessStatus::QUEUED->value;
}
}
}

View file

@ -2,7 +2,9 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Server;
use App\Models\User; use App\Models\User;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
@ -10,6 +12,7 @@ class ActivityMonitor extends Component
{ {
public ?string $header = null; public ?string $header = null;
#[Locked]
public $activityId = null; public $activityId = null;
public $eventToDispatch = 'activityFinished'; public $eventToDispatch = 'activityFinished';
@ -57,25 +60,47 @@ public function hydrateActivity()
$activity = Activity::find($this->activityId); $activity = Activity::find($this->activityId);
if ($activity) { if (! $activity) {
$teamId = data_get($activity, 'properties.team_id'); $this->activity = null;
if ($teamId && $teamId !== currentTeam()?->id) {
return;
}
$currentTeamId = currentTeam()?->id;
// Check team_id stored directly in activity properties
$activityTeamId = data_get($activity, 'properties.team_id');
if ($activityTeamId !== null) {
if ((int) $activityTeamId !== (int) $currentTeamId) {
$this->activity = null; $this->activity = null;
return; return;
} }
$this->activity = $activity;
return;
}
// Fallback: verify ownership via the server that ran the command
$serverUuid = data_get($activity, 'properties.server_uuid');
if ($serverUuid) {
$server = Server::where('uuid', $serverUuid)->first();
if ($server && (int) $server->team_id !== (int) $currentTeamId) {
$this->activity = null;
return;
}
if ($server) {
$this->activity = $activity;
return;
}
} }
$this->activity = $activity; // Fail closed: no team_id and no server_uuid means we cannot verify ownership
} $this->activity = null;
public function updatedActivityId($value)
{
if ($value) {
$this->hydrateActivity();
$this->isPollingActive = true;
self::$eventDispatched = false;
}
} }
public function polling() public function polling()

View file

@ -1,9 +1,10 @@
<?php <?php
use App\Actions\CoolifyTask\PrepareCoolifyTask;
use App\Data\CoolifyTaskArgs;
use App\Enums\ActivityTypes; use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Helpers\SshMultiplexingHelper; use App\Helpers\SshMultiplexingHelper;
use App\Helpers\SshRetryHandler;
use App\Jobs\CoolifyTask;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey; use App\Models\PrivateKey;
@ -38,29 +39,46 @@ function remote_process(
if (Auth::check()) { if (Auth::check()) {
$teams = Auth::user()->teams->pluck('id'); $teams = Auth::user()->teams->pluck('id');
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
throw new \Exception('User is not part of the team that owns this server'); throw new Exception('User is not part of the team that owns this server');
} }
} }
SshMultiplexingHelper::ensureMultiplexedConnection($server); SshMultiplexingHelper::ensureMultiplexedConnection($server);
return resolve(PrepareCoolifyTask::class, [ $properties = [
'remoteProcessArgs' => new CoolifyTaskArgs( 'server_uuid' => $server->uuid,
server_uuid: $server->uuid, 'command' => $command_string,
command: $command_string, 'type' => $type,
type: $type, 'type_uuid' => $type_uuid,
type_uuid: $type_uuid, 'status' => ProcessStatus::QUEUED->value,
model: $model, 'team_id' => $server->team_id,
ignore_errors: $ignore_errors, ];
call_event_on_finish: $callEventOnFinish,
call_event_data: $callEventData, $activityLog = activity()
), ->withProperties($properties)
])(); ->event($type);
if ($model) {
$activityLog->performedOn($model);
}
$activity = $activityLog->log('[]');
dispatch(new CoolifyTask(
activity: $activity,
ignore_errors: $ignore_errors,
call_event_on_finish: $callEventOnFinish,
call_event_data: $callEventData,
));
$activity->refresh();
return $activity;
} }
function instant_scp(string $source, string $dest, Server $server, $throwError = true) function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{ {
return \App\Helpers\SshRetryHandler::retry( return SshRetryHandler::retry(
function () use ($source, $dest, $server) { function () use ($source, $dest, $server) {
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest); $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command); $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
} }
$command_string = implode("\n", $command); $command_string = implode("\n", $command);
return \App\Helpers\SshRetryHandler::retry( return SshRetryHandler::retry(
function () use ($server, $command_string) { function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(30)->run($sshCommand); $process = Process::timeout(30)->run($sshCommand);
@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
$command_string = implode("\n", $command); $command_string = implode("\n", $command);
$effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout'); $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
return \App\Helpers\SshRetryHandler::retry( return SshRetryHandler::retry(
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing); $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
$process = Process::timeout($effectiveTimeout)->run($sshCommand); $process = Process::timeout($effectiveTimeout)->run($sshCommand);
@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
if ($ignored) { if ($ignored) {
// TODO: Create new exception and disable in sentry // TODO: Create new exception and disable in sentry
throw new \RuntimeException($errorMessage, $exitCode); throw new RuntimeException($errorMessage, $exitCode);
} }
throw new \RuntimeException($errorMessage, $exitCode); throw new RuntimeException($errorMessage, $exitCode);
} }
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
@ -194,7 +212,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true, associative: true,
flags: JSON_THROW_ON_ERROR flags: JSON_THROW_ON_ERROR
); );
} catch (\JsonException $e) { } catch (JsonException $e) {
// If JSON decoding fails, try to clean up the logs and retry // If JSON decoding fails, try to clean up the logs and retry
try { try {
// Ensure valid UTF-8 encoding // Ensure valid UTF-8 encoding
@ -204,7 +222,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true, associative: true,
flags: JSON_THROW_ON_ERROR flags: JSON_THROW_ON_ERROR
); );
} catch (\JsonException $e) { } catch (JsonException $e) {
// If it still fails, return empty collection to prevent crashes // If it still fails, return empty collection to prevent crashes
return collect([]); return collect([]);
} }
@ -353,7 +371,7 @@ function checkRequiredCommands(Server $server)
} }
try { try {
instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server); instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server);
} catch (\Throwable) { } catch (Throwable) {
break; break;
} }
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false); $commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);

View file

@ -1,9 +1,11 @@
<?php <?php
use App\Livewire\ActivityMonitor; use App\Livewire\ActivityMonitor;
use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Exceptions\CannotUpdateLockedPropertyException;
use Livewire\Livewire; use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
@ -17,7 +19,7 @@
$this->otherTeam = Team::factory()->create(); $this->otherTeam = Team::factory()->create();
}); });
test('hydrateActivity blocks access to another teams activity', function () { test('hydrateActivity blocks access to another teams activity via team_id', function () {
$otherActivity = Activity::create([ $otherActivity = Activity::create([
'log_name' => 'default', 'log_name' => 'default',
'description' => 'test activity', 'description' => 'test activity',
@ -27,12 +29,12 @@
$this->actingAs($this->user); $this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]); session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class) Livewire::test(ActivityMonitor::class)
->set('activityId', $otherActivity->id) ->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null); ->assertSet('activity', null);
}); });
test('hydrateActivity allows access to own teams activity', function () { test('hydrateActivity allows access to own teams activity via team_id', function () {
$ownActivity = Activity::create([ $ownActivity = Activity::create([
'log_name' => 'default', 'log_name' => 'default',
'description' => 'test activity', 'description' => 'test activity',
@ -43,13 +45,13 @@
session(['currentTeam' => ['id' => $this->team->id]]); session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class) $component = Livewire::test(ActivityMonitor::class)
->set('activityId', $ownActivity->id); ->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull(); expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($ownActivity->id); expect($component->get('activity')->id)->toBe($ownActivity->id);
}); });
test('hydrateActivity allows access to activity without team_id in properties', function () { test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
$legacyActivity = Activity::create([ $legacyActivity = Activity::create([
'log_name' => 'default', 'log_name' => 'default',
'description' => 'legacy activity', 'description' => 'legacy activity',
@ -59,9 +61,72 @@
$this->actingAs($this->user); $this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]); session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $legacyActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity blocks access to activity from another teams server via server_uuid', function () {
$otherServer = Server::factory()->create([
'team_id' => $this->otherTeam->id,
]);
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['server_uuid' => $otherServer->uuid],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $otherActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity allows access to activity from own teams server via server_uuid', function () {
$ownServer = Server::factory()->create([
'team_id' => $this->team->id,
]);
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['server_uuid' => $ownServer->uuid],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class) $component = Livewire::test(ActivityMonitor::class)
->set('activityId', $legacyActivity->id); ->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull(); expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($legacyActivity->id); expect($component->get('activity')->id)->toBe($ownActivity->id);
}); });
test('hydrateActivity returns null for non-existent activity id', function () {
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', 99999)
->assertSet('activity', null);
});
test('activityId property is locked and cannot be set from client', function () {
$otherActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['team_id' => $this->otherTeam->id],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
// Attempting to set a #[Locked] property from the client should throw
Livewire::test(ActivityMonitor::class)
->set('activityId', $otherActivity->id)
->assertStatus(500);
})->throws(CannotUpdateLockedPropertyException::class);