mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
fix(applications): report a failed stop instead of failing silently
- Do not ignore errors of the docker stop and rm commands, so a failed SSH call does not mark a running application as exited. - Log the failure and broadcast ApplicationStopFailed; the application page shows an error toast. Closes #11063 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
18f74536e4
commit
e49e540ec0
@@ -107,6 +107,9 @@
|
||||
- Host test runs share `storage/` with the dev container. Fake the `ssh-keys`/`ssh-mux` disks and `Process` in tests that run seeders or write SSH files, or the test deletes/chowns the dev instance's SSH keys and SSH breaks for `www-data`.
|
||||
- Dev QEMU servers from `dev:qemu` are seeded, not validated: they have no `coolify` Docker network, and Alpine has no bash until `InstallPrerequisites` runs.
|
||||
|
||||
## Report failures of queued actions
|
||||
- A value that an action returns when it runs as a queued job (`::dispatch()`) goes nowhere. Do not report a failure only with a return string or `throwError: false`; log it and broadcast it to the page that started the action.
|
||||
|
||||
## Format only your own files
|
||||
- `pint --dirty` also rewrites uncommitted files that belong to other work in the tree. When the tree has unrelated changes, pass your changed paths to Pint.
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Actions\Application;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Events\ApplicationStopFailed;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopApplication
|
||||
@@ -50,7 +52,11 @@ class StopApplication
|
||||
$commands[] = "docker rm -f {$escapedContainerName}";
|
||||
}
|
||||
|
||||
instant_remote_process(command: $commands, server: $server, throwError: false);
|
||||
try {
|
||||
instant_remote_process(command: $commands, server: $server);
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($removeContainers && $application->build_pack === 'dockercompose') {
|
||||
@@ -64,8 +70,13 @@ class StopApplication
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$teamId = $application->environment->project->team->id;
|
||||
if ($errors !== []) {
|
||||
return implode(' ', $errors);
|
||||
$message = implode(' ', $errors);
|
||||
Log::warning('Failed to stop application', ['application_uuid' => $application->uuid, 'error' => $message]);
|
||||
ApplicationStopFailed::dispatch($teamId, $application->uuid, $message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
$status = [
|
||||
@@ -82,7 +93,7 @@ class StopApplication
|
||||
}
|
||||
$application->update($status);
|
||||
|
||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||
ServiceStatusChanged::dispatch($teamId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Tells the application page that a queued stop did not stop the application.
|
||||
*/
|
||||
class ApplicationStopFailed implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public string $applicationUuid,
|
||||
public string $message,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("team.{$this->teamId}")];
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class Heading extends Component
|
||||
...$this->teamChannelListeners([
|
||||
'ServiceStatusChanged' => 'checkStatus',
|
||||
'ServiceChecked' => '$refresh',
|
||||
'ApplicationStopFailed' => 'stopFailed',
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -77,6 +78,20 @@ class Heading extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{applicationUuid?: mixed, message?: mixed} $event
|
||||
*/
|
||||
public function stopFailed(array $event): void
|
||||
{
|
||||
if (($event['applicationUuid'] ?? null) !== $this->application->uuid || ! is_string($event['message'] ?? null)) {
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('error', 'Failed to stop the application.', $event['message']);
|
||||
}
|
||||
|
||||
public function manualCheckStatus()
|
||||
{
|
||||
$this->checkStatus();
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Events\ApplicationStopFailed;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Livewire\Project\Application\Heading;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Bus::fake();
|
||||
Event::fake([ServiceStatusChanged::class, ApplicationStopFailed::class]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, 'docker_version' => '28.0.0']);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* `docker ps` finds one running container; the stop commands give the result in $stopResult.
|
||||
*/
|
||||
function fakeRunningContainerWithStopResult(ProcessResult $stopResult): void
|
||||
{
|
||||
Process::fake(function ($process) use ($stopResult) {
|
||||
if (str_contains($process->command, 'docker ps -a --filter')) {
|
||||
return Process::result(output: json_encode(['Names' => 'app-container', 'Labels' => '', 'State' => 'running']));
|
||||
}
|
||||
|
||||
return str_contains($process->command, 'docker stop') ? $stopResult : Process::result(output: '');
|
||||
});
|
||||
}
|
||||
|
||||
test('a stop that the server rejects keeps the status and reports the error', function () {
|
||||
fakeRunningContainerWithStopResult(Process::result(errorOutput: 'Error response from daemon: cannot stop container', exitCode: 1));
|
||||
|
||||
$result = StopApplication::run($this->application, dockerCleanup: false);
|
||||
|
||||
expect($result)->toContain('cannot stop container')
|
||||
->and($this->application->fresh()->status)->toBe('running:healthy');
|
||||
Event::assertDispatched(ApplicationStopFailed::class, fn (ApplicationStopFailed $event) => $event->teamId === $this->team->id
|
||||
&& $event->applicationUuid === $this->application->uuid
|
||||
&& str_contains($event->message, 'cannot stop container'));
|
||||
Event::assertNotDispatched(ServiceStatusChanged::class);
|
||||
});
|
||||
|
||||
test('a stop on a server that is not functional reports the error', function () {
|
||||
$this->server->settings->update(['is_reachable' => false]);
|
||||
Process::fake();
|
||||
|
||||
StopApplication::run($this->application->fresh(), dockerCleanup: false);
|
||||
|
||||
expect($this->application->fresh()->status)->toBe('running:healthy');
|
||||
Event::assertDispatched(ApplicationStopFailed::class, fn (ApplicationStopFailed $event) => str_contains($event->message, 'is not functional'));
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
test('a successful stop marks the application exited and reports no error', function () {
|
||||
fakeRunningContainerWithStopResult(Process::result(output: 'app-container'));
|
||||
|
||||
expect(StopApplication::run($this->application, dockerCleanup: false))->toBeNull()
|
||||
->and($this->application->fresh()->status)->toStartWith('exited');
|
||||
Event::assertNotDispatched(ApplicationStopFailed::class);
|
||||
Event::assertDispatched(ServiceStatusChanged::class);
|
||||
});
|
||||
|
||||
test('the application heading shows a stop failure only for its own application', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->teams()->attach($this->team, ['role' => 'admin']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(Heading::class, ['application' => $this->application])
|
||||
->call('stopFailed', ['applicationUuid' => 'another-application', 'message' => 'Server is down.'])
|
||||
->assertNotDispatched('error')
|
||||
->call('stopFailed', ['applicationUuid' => $this->application->uuid, 'message' => 'Server is down.'])
|
||||
->assertDispatched('error', 'Failed to stop the application.', 'Server is down.');
|
||||
});
|
||||
Reference in New Issue
Block a user