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\AuditEvent;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Services\FleetService;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use LivewireUI\Modal\ModalComponent;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,14 +89,17 @@ class CreateServer extends ModalComponent
|
||||||
{
|
{
|
||||||
$data = $this->validate();
|
$data = $this->validate();
|
||||||
|
|
||||||
// Atomic: a failure creating the credential or audit must not leave a
|
// Atomic: create server + credential, then VERIFY the SSH login. A failed
|
||||||
// server row without its required SSH credential.
|
// 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 = DB::transaction(function () use ($data) {
|
||||||
$server = Server::create([
|
$server = Server::create([
|
||||||
'name' => trim($data['name']),
|
'name' => trim($data['name']),
|
||||||
'ip' => trim($data['ip']),
|
'ip' => trim($data['ip']),
|
||||||
'ssh_port' => (int) $data['sshPort'],
|
'ssh_port' => (int) $data['sshPort'],
|
||||||
'status' => 'offline',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$server->credential()->create([
|
$server->credential()->create([
|
||||||
|
|
@ -104,6 +110,14 @@ class CreateServer extends ModalComponent
|
||||||
'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null,
|
'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([
|
AuditEvent::create([
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
'server_id' => $server->id,
|
'server_id' => $server->id,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ return [
|
||||||
'credential_name_label' => 'Zugangs-Name (optional)',
|
'credential_name_label' => 'Zugangs-Name (optional)',
|
||||||
'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User',
|
'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User',
|
||||||
'validation_ip_or_host' => 'Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.',
|
'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_name' => 'Name',
|
||||||
'attr_ip' => 'IP/Host',
|
'attr_ip' => 'IP/Host',
|
||||||
'attr_ssh_port' => 'SSH-Port',
|
'attr_ssh_port' => 'SSH-Port',
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ return [
|
||||||
'credential_name_label' => 'Access name (optional)',
|
'credential_name_label' => 'Access name (optional)',
|
||||||
'credential_name_placeholder' => 'e.g. Root login · Deploy user',
|
'credential_name_placeholder' => 'e.g. Root login · Deploy user',
|
||||||
'validation_ip_or_host' => 'Please enter a valid IP address or hostname.',
|
'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_name' => 'Name',
|
||||||
'attr_ip' => 'IP/Host',
|
'attr_ip' => 'IP/Host',
|
||||||
'attr_ssh_port' => 'SSH port',
|
'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