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:
Andras Bacsai
2026-10-24 00:45:13 +02:00
co-authored by Claude Opus 5.5
parent 18f74536e4
commit e49e540ec0
5 changed files with 170 additions and 3 deletions
+3
View File
@@ -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.
+14 -3
View File
@@ -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;
}
+27
View File
@@ -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.');
});