diff --git a/app/Provisioning/Steps/Customer/ConfigureInstanceMail.php b/app/Provisioning/Steps/Customer/ConfigureInstanceMail.php new file mode 100644 index 0000000..d6f3aa9 --- /dev/null +++ b/app/Provisioning/Steps/Customer/ConfigureInstanceMail.php @@ -0,0 +1,96 @@ +instance($run); + + if ($instance === null) { + return StepResult::fail('instance_missing'); + } + + $config = GuestMailConfig::for($instance); + + if (! $config->available()) { + return StepResult::fail($config->problem() ?? 'mail_unavailable'); + } + + $pve = $this->pve->forHost($instance->host); + + // Der Schluessel ist ein fester occ-Name aus GuestMailConfig::values(), + // keine Benutzereingabe — genauso unmaskiert wie 'files default_quota' + // in ApplyStorageQuota. Maskiert wird nur der WERT, siehe Kopfkommentar. + foreach ($config->values() as $schluessel => $wert) { + $this->guest($pve, $run, NextcloudOcc::command( + 'config:system:set '.$schluessel.' --value='.escapeshellarg($wert) + )); + } + + // Das Passwort wie jeder andere Wert. Verstecken laesst es sich auf + // dieser Maschine nicht — siehe Kopfkommentar. + $this->guest($pve, $run, NextcloudOcc::command( + 'config:system:set mail_smtppassword --value='.escapeshellarg($config->password()) + )); + + return StepResult::advance(); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 5185797..9ae8917 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -80,6 +80,10 @@ return [ Customer\ConfigureNetwork::class, Customer\DeployApplicationStack::class, Customer\ConfigureNextcloud::class, + // Ohne diesen Schritt verschickt die Kundeninstanz ueberhaupt + // keine Mail — auch nicht die Einladung an einen Mitarbeiter, die + // Nextcloud selbst verschickt. + Customer\ConfigureInstanceMail::class, // The allowance the customer paid for, enforced on the machine. // It was never applied anywhere before: quota_gb reached the // instance row and stopped there, so every package delivered the @@ -241,6 +245,15 @@ return [ Host\SecureHostFirewall::class, ], + /* + | Einer bestehenden Instanz den Mailversand nachtragen. Ein Schritt, + | derselbe wie im Aufbau — ein zweiter Weg, dieselben Werte zu + | schreiben, wuerde driften. + */ + 'instance-mail' => [ + Customer\ConfigureInstanceMail::class, + ], + /* | A storage pack bought, or given back. | diff --git a/tests/Feature/Provisioning/ConfigureInstanceMailTest.php b/tests/Feature/Provisioning/ConfigureInstanceMailTest.php new file mode 100644 index 0000000..6825eb9 --- /dev/null +++ b/tests/Feature/Provisioning/ConfigureInstanceMailTest.php @@ -0,0 +1,118 @@ +create([ + 'pipeline' => 'instance-mail', + 'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 201], + ]); +} + +/** + * Eine Instanz, die tatsaechlich auf einem Host steht. + * + * Der Schritt ruft ProxmoxClient::forHost($instance->host) auf — genau wie + * ApplyStorageQuota, dessen Testaufbau (ApplyStorageQuotasTest::unquotedInstance()) + * aus demselben Grund einen Host anlegt. Eine Instanz ohne host_id gibt es in + * diesem Bestand fuer eine Maschine, die tatsaechlich Gastbefehle bekommt, + * nicht — FakeProxmoxClient::forHost() verlangt ein echtes Host-Objekt, weil + * der reale Client genauso wenig ohne einen Host wuesste, wohin er soll. + */ +function instanzAufHost(): Instance +{ + return Instance::factory()->create(['host_id' => Host::factory()]); +} + +function versandbereitFuerSchritt(): void +{ + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Settings::set('mail.encryption', 'tls'); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', 'password' => 'geheim', + 'active' => true, 'authenticates' => true, + ]); +} + +it('traegt jeden Wert einzeln in den Gast', function () { + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = instanzAufHost(); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('config:system:set mail_smtphost --value=') + ->and($befehle)->toContain('mail.clupilot.cloud') + ->and($befehle)->toContain('config:system:set mail_domain --value=') + ->and($befehle)->toContain('clupilot.cloud'); +}); + +it('maskiert das Passwort, sodass es keinen zweiten Befehl starten kann', function () { + // Verstecken laesst sich der Wert auf dieser Maschine nicht (siehe + // Kopfkommentar des Schrittes). Was sehr wohl gilt und geprueft gehoert: + // er darf aus seiner Klammerung nicht ausbrechen. Der Befehl laeuft als + // root auf einer Kundenmaschine — ein Semikolon im Passwort waere dort + // ein zweiter Befehl. + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', + 'password' => "boes'; touch /tmp/PWNED; echo '", + 'active' => true, 'authenticates' => true, + ]); + + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz(instanzAufHost())); + + $passwortbefehl = collect($pve->guestCommands) + ->first(fn ($b) => str_contains($b, 'mail_smtppassword')); + + // Der ganze Wert steht in EINEM maskierten Argument: das Semikolon darf + // nicht ausserhalb der Anfuehrungszeichen stehen. + expect($passwortbefehl)->toContain(escapeshellarg("boes'; touch /tmp/PWNED; echo '")); +}); + +it('schreibt GAR NICHTS, wenn der Mailserver fehlt', function () { + Settings::set('mail.host', ''); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + $ergebnis = app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create())); + + expect($pve->guestCommands)->toBe([]) + ->and($ergebnis->type)->toBe(\App\Provisioning\StepResult::FAIL) + ->and($ergebnis->reason)->toBe('no_server'); +}); + +it('schreibt beim zweiten Lauf erneut, statt sich mit einem Merker zu sperren', function () { + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = instanzAufHost(); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + $ersteRunde = count($pve->guestCommands); + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + + // config:system:set ist von sich aus wiederholbar — derselbe Wert zweimal + // geschrieben ist derselbe Wert. Der Schritt darf deshalb ohne Merker + // erneut laufen; das ist bei einer Nachruestung ueber den Bestand der + // Normalfall, nicht die Ausnahme. + expect(count($pve->guestCommands))->toBe($ersteRunde * 2); +}); diff --git a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php index f2426e8..2232296 100644 --- a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php +++ b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php @@ -2,12 +2,14 @@ use App\Models\Host; use App\Models\Instance; +use App\Models\Mailbox; use App\Models\Order; use App\Models\ProvisioningRun; use App\Models\RunResource; use App\Notifications\CloudReady; use App\Provisioning\RunRunner; use App\Provisioning\Steps\Customer\CloneVirtualMachine; +use App\Support\Settings; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; @@ -20,6 +22,20 @@ it('provisions a paid order all the way to active (mocked)', function () { $s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}'); // deploy health + acceptance probe // Fake defaults: tasks stopped/OK, guest agent up, cert reachable. + // ConfigureInstanceMail faellt jetzt in dieser Pipeline: ohne Server und + // Absenderpostfach faellt der ganze Lauf (Absicht, siehe GuestMailConfig + // und ConfigureInstanceMail selbst). Ein frischer Betrieb braucht diese + // Einrichtung einmalig, genau wie den Hetzner-DNS-Token oder den + // Stripe-Schluessel — hier nachgestellt, damit dieser Lauf ueberhaupt bis + // zum Ende kommt. + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', 'password' => 'geheim', + 'active' => true, 'authenticates' => true, + ]); + Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]); $order = Order::factory()->withSubscription()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']); $run = ProvisioningRun::factory()->create([ @@ -49,7 +65,7 @@ it('provisions a paid order all the way to active (mocked)', function () { ->and($instance->cert_ok)->toBeTrue() ->and($instance->onboardingTasks()->count())->toBeGreaterThan(0) // The storage allowance was DELIVERED, not merely walked past. This test - // covered all sixteen steps and asserted nothing about the quota one, so it + // covers every step of the pipeline and asserted nothing about the quota one, so it // proved that inserting the step did not break the pipeline rather than // that the step does anything — and quota_applied_gb is written only after // the guest accepted the figure, which is the whole distinction between an diff --git a/tests/Feature/Provisioning/CustomerStepBaseTest.php b/tests/Feature/Provisioning/CustomerStepBaseTest.php index 0125ce6..65df1cd 100644 --- a/tests/Feature/Provisioning/CustomerStepBaseTest.php +++ b/tests/Feature/Provisioning/CustomerStepBaseTest.php @@ -30,7 +30,7 @@ it('builds every customer machine with the storage allowance that was sold', fun // that the quota step is IN it: the allowance was applied nowhere for the // whole life of the pipeline — quota_gb reached the instance row and // stopped — so every package handed out the whole disk. - expect(config('provisioning.pipelines.customer'))->toHaveCount(16) + expect(config('provisioning.pipelines.customer'))->toHaveCount(17) ->toContain(ApplyStorageQuota::class); });