feat(servers): verify SSH on create + start in "Initialisierung"
CreateServer now probes the entered SSH login inside the create transaction; a
failed probe throws and rolls back (no half-registered server), surfacing the
SSH reason on the form. Successful creation starts the server as `pending`
("Initialisierung") instead of red/offline until first contact promotes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
parent
2c25303903
commit
a9334d11de
|
|
@ -4,9 +4,12 @@ namespace App\Livewire\Modals;
|
|||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
|
|
@ -86,14 +89,17 @@ class CreateServer extends ModalComponent
|
|||
{
|
||||
$data = $this->validate();
|
||||
|
||||
// Atomic: a failure creating the credential or audit must not leave a
|
||||
// server row without its required SSH credential.
|
||||
// Atomic: create server + credential, then VERIFY the SSH login. A failed
|
||||
// probe throws -> the whole transaction rolls back, so no half-registered
|
||||
// server is left behind, and the operator sees the real SSH reason on the
|
||||
// form. A verified server starts as 'pending' ("Initialisierung") — never
|
||||
// red — until the first contact promotes it.
|
||||
$server = DB::transaction(function () use ($data) {
|
||||
$server = Server::create([
|
||||
'name' => trim($data['name']),
|
||||
'ip' => trim($data['ip']),
|
||||
'ssh_port' => (int) $data['sshPort'],
|
||||
'status' => 'offline',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$server->credential()->create([
|
||||
|
|
@ -104,6 +110,14 @@ class CreateServer extends ModalComponent
|
|||
'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null,
|
||||
]);
|
||||
|
||||
$server->load('credential');
|
||||
$probe = app(FleetService::class)->testConnection($server);
|
||||
if (! $probe['ok']) {
|
||||
throw ValidationException::withMessages([
|
||||
'secret' => __('modals.create_server.validation_ssh_failed', ['error' => Str::limit((string) $probe['error'], 120)]),
|
||||
]);
|
||||
}
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ return [
|
|||
'credential_name_label' => 'Zugangs-Name (optional)',
|
||||
'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User',
|
||||
'validation_ip_or_host' => 'Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.',
|
||||
'validation_ssh_failed' => 'SSH-Verbindung fehlgeschlagen: :error. Bitte Zugangsdaten prüfen.',
|
||||
'attr_name' => 'Name',
|
||||
'attr_ip' => 'IP/Host',
|
||||
'attr_ssh_port' => 'SSH-Port',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ return [
|
|||
'credential_name_label' => 'Access name (optional)',
|
||||
'credential_name_placeholder' => 'e.g. Root login · Deploy user',
|
||||
'validation_ip_or_host' => 'Please enter a valid IP address or hostname.',
|
||||
'validation_ssh_failed' => 'SSH connection failed: :error. Please check the credentials.',
|
||||
'attr_name' => 'Name',
|
||||
'attr_ip' => 'IP/Host',
|
||||
'attr_ssh_port' => 'SSH port',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\CreateServer;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateServerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function form(Testable $c): Testable
|
||||
{
|
||||
return $c->set('name', 'web-1')->set('ip', '10.0.0.5')->set('sshPort', 22)
|
||||
->set('username', 'root')->set('authType', 'password')->set('secret', 'pw');
|
||||
}
|
||||
|
||||
public function test_bad_ssh_blocks_creation_and_rolls_back(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => false, 'error' => 'auth failed']));
|
||||
|
||||
$this->form(Livewire::test(CreateServer::class))->call('save')->assertHasErrors('secret');
|
||||
|
||||
$this->assertSame(0, Server::count());
|
||||
}
|
||||
|
||||
public function test_good_ssh_creates_server_as_pending(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => true, 'error' => null]));
|
||||
|
||||
$this->form(Livewire::test(CreateServer::class))->call('save')->assertHasNoErrors();
|
||||
|
||||
$this->assertSame(1, Server::count());
|
||||
$this->assertSame('pending', Server::first()->status);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue