CluPilotCloud/app/Provisioning/Jobs/SyncSeatToNextcloud.php

183 lines
7.4 KiB
PHP

<?php
namespace App\Provisioning\Jobs;
use App\Models\Instance;
use App\Models\Seat;
use App\Services\Nextcloud\NextcloudUsers;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
/**
* Spiegelt einen Sitz in die Nextcloud des Kunden.
*
* Läuft auf der `provisioning`-Warteschlange, weil nur dieser Arbeiter den
* Tunnel und die Proxmox-Zugangsdaten hat — das Portal hat weder das eine noch
* das andere und darf es auch gar nicht versuchen.
*
* Daraus folgt: der Knopf wirkt nicht sofort. Deshalb schreibt dieser Auftrag
* zurück, was WIRKLICH passiert ist (`nc_state`, `nc_error`) — sonst drückt
* der Inhaber wieder und wieder, weil nichts sichtbar geschieht. Genau die
* Beschwerde, die dieses Projekt schon zweimal hatte.
*
* `tries = 1`: ein blinder Wiederholungslauf gegen einen nicht erreichbaren
* Gast bringt nichts, und der Inhaber hat einen Knopf dafür.
*/
class SyncSeatToNextcloud implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
public string $seatUuid,
/** invite | role | disable | enable | restore */
public string $action,
) {
$this->onConnection('provisioning');
$this->onQueue('provisioning');
}
/** Auch was an handle() vorbeifliegt, muss am Sitz sichtbar werden. */
public function failed(?Throwable $e): void
{
Seat::query()->where('uuid', $this->seatUuid)->update([
'nc_state' => Seat::STATE_FAILED,
'nc_error' => 'unexpected',
]);
}
public function handle(NextcloudUsers $users): void
{
$seat = Seat::query()->with('customer')->where('uuid', $this->seatUuid)->first();
if ($seat === null) {
return;
}
// Erneut geprüft, nicht der Seite geglaubt: eine geschlossene Instanz
// kann ihre VMID auf demselben Host weiterverliehen haben — dieselbe
// Falle, die in IssueInstanceAdminAccess schon beschrieben steht.
$instance = $seat->customer?->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')->first();
if ($instance === null || $instance->host === null || blank($instance->vmid)) {
$this->record($seat, false, 'no_instance');
return;
}
$ok = match ($this->action) {
'invite' => $this->invite($users, $instance, $seat),
'role' => $users->applyRole($instance, $seat),
'disable' => $users->disable($instance, $seat),
'enable' => $users->enable($instance, $seat),
// Der Wiederholen-Knopf an einer OFFENEN Zeile. Er weiss nicht,
// welcher der beiden Befehle beim letzten Mal scheiterte — das
// stünde nirgends am Sitz, und ein Feld dafür wäre eine
// weitere Behauptung über die Cloud des Kunden, die irgendwann
// nicht mehr stimmt. Beide zu schicken ist billiger und ehrlicher
// als es zu raten; `user:enable` auf ein offenes Konto ist
// folgenlos, und das Setzen der Rolle ist es ohnehin. Siehe
// Users::retry().
'restore' => $users->enable($instance, $seat) && $users->applyRole($instance, $seat),
default => false,
};
$this->record($seat, $ok, $ok ? null : 'guest_failed');
}
/**
* Einladen setzt in einem Zug auch die Gruppe: ein Benutzer, der angelegt
* ist und in keiner Rolle steckt, sieht in seiner neuen Cloud nichts und
* meldet sich am ersten Tag beim Inhaber.
*
* Das Anlegen wird dabei SOFORT vermerkt, noch bevor die Gruppe gesetzt
* wird. `nc_synced_at` beantwortet die Frage „gibt es dieses Konto dort
* überhaupt?", und ab dem geglückten `user:add` lautet die Antwort ja —
* auch wenn der nächste Befehl scheitert. Die Seite entscheidet genau
* daran, ob ein späteres Sperren etwas zu sperren hätte; ohne diese Zeile
* bliebe der gefährlichste Fall offen: Konto angelegt, Rolle gescheitert,
* Zugang entzogen — und nie eine Sperre losgeschickt.
*
* `nc_state` bleibt davon unberührt: der Versuch ist erst gelungen, wenn
* auch die Rolle sitzt.
*/
private function invite(NextcloudUsers $users, Instance $instance, Seat $seat): bool
{
// VOR dem Vermerk gelesen: war dieser Sitz schon einmal in der
// Nextcloud, ist dies eine Wiederaufnahme — und sein Konto dort steht
// gesperrt, weil `revoke()` ein `disable` geschickt hat. `user:welcome`
// macht eine Sperre nicht rückgängig; ohne diese Zeile bekäme der
// Wiederaufgenommene eine Willkommensmail zu einer Tür, die zu bleibt.
// Bei einem frisch angelegten Konto entfällt der Befehl ganz, statt
// sich auf ein folgenloses `user:enable` zu verlassen.
$wiederaufnahme = $seat->nc_synced_at !== null;
if (! $users->invite($instance, $seat)) {
return false;
}
$seat->forceFill(['nc_synced_at' => now()])->save();
/*
* Zwischen Absenden und Ausführen kann sich die Absicht geändert
* haben — dieselbe Begründung wie beim Vermerk oben.
*
* Der Arbeiter teilt sich die Warteschlange mit der bezahlten
* Bereitstellung; das Fenster ist MINUTEN lang, nicht Millisekunden.
* In dieser Zeit merkt ein Inhaber seinen Fehlgriff und entzieht. Ohne
* diese Zeilen führe der Auftrag danach blind aus, was beim Absenden
* galt: die Zeile zeichnete „Entfernt" und in der Cloud stände ein
* aktives, freigeschaltetes Konto.
*
* Frisch aus der Datenbank, nicht am mitgeschleppten Modell: dieses
* Objekt stammt vom Anfang des Auftrags und trägt den Status von
* damals.
*/
$gesperrtGewollt = in_array(
Seat::query()->where('uuid', $seat->uuid)->value('status'),
['revoked', 'suspended'],
true,
);
// Eine Tür aufzumachen, um sie im selben Auftrag wieder zuzuziehen,
// wäre im besten Fall folgenlos und im schlechteren ein offenes
// Zeitfenster.
if ($wiederaufnahme && ! $gesperrtGewollt && ! $users->enable($instance, $seat)) {
return false;
}
$rolleOk = $users->applyRole($instance, $seat);
if (! $gesperrtGewollt) {
return $rolleOk;
}
// Die Sperre geht auch dann noch hinaus, wenn die Rolle scheiterte:
// an ihr hängt ein beendeter Zugang, und eine misslungene Gruppe darf
// ihn nicht offen lassen. Gemeldet wird trotzdem beides — die Zeile
// stimmt nur, wenn sie den Fehlschlag zeigt.
return $users->disable($instance, $seat) && $rolleOk;
}
/**
* `nc_synced_at` bleibt beim Fehlschlag stehen, statt geleert zu werden:
* wann dieser Sitz zuletzt WIRKLICH stimmte, ist genau die Angabe, die
* einen Fehlschlag einordnet — heute Morgen oder vor drei Wochen.
*/
private function record(Seat $seat, bool $ok, ?string $grund): void
{
$seat->forceFill([
'nc_state' => $ok ? Seat::STATE_SYNCED : Seat::STATE_FAILED,
'nc_error' => $grund,
'nc_synced_at' => $ok ? now() : $seat->nc_synced_at,
])->save();
}
}