Check the machines, the DNS and the post as well

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 13:05:18 +02:00
parent b1e5bf8023
commit 325cee50ca
9 changed files with 672 additions and 0 deletions

View File

@ -4,6 +4,9 @@ namespace App\Support;
use App\Support\Readiness\BillingChecks;
use App\Support\Readiness\Check;
use App\Support\Readiness\DeliveryChecks;
use App\Support\Readiness\OnboardingChecks;
use App\Support\Readiness\ProvisioningChecks;
/**
* Jedes Pflichtfeld dieser Installation an einer Stelle, gruppiert nach dem,
@ -30,6 +33,9 @@ final class Readiness
{
return [
...BillingChecks::all(),
...OnboardingChecks::all(),
...ProvisioningChecks::all(),
...DeliveryChecks::all(),
];
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Support\Readiness;
use App\Models\Mailbox;
use App\Models\MailTemplate;
use App\Services\Secrets\SecretVault;
/**
* What has to be in place for the customer to actually find out any of this
* happened: a mail transport that leaves the server, an address to send
* from, and for support a way to answer and to hear back.
*/
final class DeliveryChecks
{
public const GROUP = 'delivery';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'delivery.mailer_not_log',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mailer_not_log'),
breaks: __('readiness.delivery.mailer_not_log_breaks'),
tab: 'mail',
satisfied: config('mail.default') !== 'log',
),
new Check(
key: 'delivery.mailbox',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mailbox'),
breaks: __('readiness.delivery.mailbox_breaks'),
tab: 'mail',
satisfied: Mailbox::query()->exists(),
),
new Check(
key: 'delivery.mail_templates',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.delivery.mail_templates'),
breaks: __('readiness.delivery.mail_templates_breaks'),
tab: 'templates',
satisfied: MailTemplate::query()->exists(),
),
new Check(
key: 'delivery.inbound_password',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.delivery.inbound_password'),
breaks: __('readiness.delivery.inbound_password_breaks'),
tab: 'integrations',
satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')),
),
];
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Support\Readiness;
use App\Models\Datacenter;
use App\Services\Secrets\SecretVault;
use App\Services\Wireguard\ConfigVault;
use App\Support\ProvisioningSettings;
/**
* What has to be in place before a single host can be onboarded at all: the
* SSH identity CluPilot deploys to every host, the two encryption keys the
* vault and the VPN config store need to work at all, the WireGuard hub a new
* host joins, and somewhere to onboard it into.
*/
final class OnboardingChecks
{
public const GROUP = 'onboarding';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'onboarding.ssh_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.ssh_key'),
breaks: __('readiness.onboarding.ssh_key_breaks'),
tab: 'integrations',
// EstablishSshTrust only deploys the PUBLIC half. Every step
// after it (HostStep's own shell helper), and later every
// Traefik write on a host already onboarded (SshTraefikWriter),
// connects with THIS private key.
satisfied: filled(app(SecretVault::class)->get('ssh.private_key')),
),
new Check(
key: 'onboarding.secrets_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.secrets_key'),
breaks: __('readiness.onboarding.secrets_key_breaks'),
tab: 'integrations',
// isUsable() rather than a bare filled() on the config value —
// SecretCipher's own docblock warns that a second, shorter
// rule that only checks emptiness lets a malformed-but-non-
// empty key sail through, exactly the gap that once let two
// other call sites reach an uncaught RuntimeException.
satisfied: app(SecretVault::class)->isUsable(),
),
new Check(
key: 'onboarding.vpn_config_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.vpn_config_key'),
breaks: __('readiness.onboarding.vpn_config_key_breaks'),
tab: 'integrations',
// Same reuse-not-reimplement reasoning as isUsable() above:
// ConfigVault::available() is the one place that already knows
// what counts as a usable key.
satisfied: ConfigVault::available(),
),
new Check(
key: 'onboarding.wg_hub',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.wg_hub'),
breaks: __('readiness.onboarding.wg_hub_breaks'),
tab: 'integrations',
// Two of three is no half a tunnel, it is none: ConfigureWireguard
// writes the peer config from all three at once. Subnet is read
// straight from config(), never through Settings/ProvisioningSettings
// — that class's own docblock explains why: the compose file
// (vpn-dns, vpn-gateway) reads the same CLUPILOT_WG_SUBNET at
// container boot, so a console-only override would let the
// console show one value while the running containers use another.
satisfied: filled(ProvisioningSettings::wgHubPublicKey())
&& filled(ProvisioningSettings::wgEndpoint())
&& filled((string) config('provisioning.wireguard.subnet')),
),
new Check(
key: 'onboarding.datacenter',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.datacenter'),
breaks: __('readiness.onboarding.datacenter_breaks'),
tab: 'datacenters',
satisfied: Datacenter::query()->exists(),
),
];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Support\Readiness;
use App\Models\Host;
use App\Models\PlanVersion;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
/**
* What has to be in place for a PAID order to actually turn into a running,
* reachable machine: somewhere to put it, a name for it, and a router that
* knows about it.
*/
final class ProvisioningChecks
{
public const GROUP = 'provisioning';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'provisioning.dns_token',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.dns_token'),
breaks: __('readiness.provisioning.dns_token_breaks'),
tab: 'integrations',
satisfied: filled(app(SecretVault::class)->get('dns.token')),
),
new Check(
key: 'provisioning.dns_zone',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.dns_zone'),
breaks: __('readiness.provisioning.dns_zone_breaks'),
tab: 'integrations',
satisfied: filled(ProvisioningSettings::dnsZone()),
),
new Check(
key: 'provisioning.traefik_path',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.provisioning.traefik_path'),
breaks: __('readiness.provisioning.traefik_path_breaks'),
tab: 'integrations',
satisfied: filled(ProvisioningSettings::traefikDynamicPath()),
),
new Check(
key: 'provisioning.usable_host',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.usable_host'),
breaks: __('readiness.provisioning.usable_host_breaks'),
tab: 'hosts',
// `active` ALONE is not enough. A host without a readable
// token is still selectable to ReserveResources, and the
// order then dies in CloneVirtualMachine — after payment.
satisfied: Host::query()
->where('status', 'active')
->whereNotNull('api_token_ref')
->exists(),
),
new Check(
key: 'provisioning.vm_template',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.vm_template'),
breaks: __('readiness.provisioning.vm_template_breaks'),
tab: 'plans',
// A field check only. Whether the template actually EXISTS on
// a node is answered by the button in Task 9 — that has to
// ask Proxmox, and a live API call does not belong on every
// page load of this readiness page.
satisfied: PlanVersion::query()
->whereNotNull('published_at')
->whereNull('template_vmid')
->doesntExist(),
),
new Check(
key: 'provisioning.monitoring_token',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.provisioning.monitoring_token'),
breaks: __('readiness.provisioning.monitoring_token_breaks'),
tab: 'integrations',
// Warning, never blocking: RegisterMonitoring itself already
// degrades (retries, then continues) rather than fail the run
// — this page must not claim a stricter rule than the pipeline
// actually enforces.
satisfied: filled(app(SecretVault::class)->get('monitoring.token')),
),
];
}
}

View File

@ -23,4 +23,43 @@ return [
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
],
'onboarding' => [
'ssh_key' => 'SSH-Schlüssel für Hosts',
'ssh_key_breaks' => 'EstablishSshTrust legt nur den öffentlichen Teil auf dem Host ab; jeder Schritt danach — und jede Traefik-Änderung auf einem bereits laufenden Host — verbindet sich mit diesem privaten Schlüssel. Ohne ihn bricht das Onboarding im zweiten Schritt ab, und kein Routing-Update erreicht mehr einen Host, der längst live ist.',
'secrets_key' => 'Tresor-Schlüssel (SECRETS_KEY)',
'secrets_key_breaks' => 'Ohne ihn kann kein Geheimnis in dieser Installation gespeichert oder gelesen werden — nicht der Stripe-Schlüssel, nicht der DNS-Token, kein Host-Token. Die Konsole zeigt jedes dieser Felder als leer, egal was je eingetragen wurde.',
'vpn_config_key' => 'VPN-Konfigurationsschlüssel (VPN_CONFIG_KEY)',
'vpn_config_key_breaks' => 'Ohne ihn verweigert ConfigVault das Speichern jeder VPN-Konfiguration, statt sie im Klartext abzulegen — ein ausgestellter Zugang lässt sich dann nirgends mehr nachlesen.',
'wg_hub' => 'WireGuard-Hub vollständig konfiguriert',
'wg_hub_breaks' => 'Zwei von drei Werten sind kein halber Tunnel, sondern gar keiner: ConfigureWireguard schreibt die Peer-Konfiguration aus allen dreien auf einmal. Fehlt einer, bekommt der neue Host keinen Tunnel zur Verwaltung, und niemand kann ihn erreichen.',
'datacenter' => 'Mindestens ein Rechenzentrum',
'datacenter_breaks' => 'Ohne Rechenzentrum hat das Host-Anlegen-Formular nichts zur Auswahl, und ValidateHostInput bricht als allererster Schritt der Kette ab — noch bevor überhaupt eine SSH-Verbindung versucht wird.',
],
'provisioning' => [
'dns_token' => 'Hetzner-DNS-Token',
'dns_token_breaks' => 'Ohne ihn scheitert ConfigureDnsAndTls am A-Eintrag für die neue Instanz — mitten in der Bereitstellung, nachdem die Maschine schon läuft und der Kunde schon bezahlt hat.',
'dns_zone' => 'DNS-Zone',
'dns_zone_breaks' => 'Jede Kunden-Subdomain, der Link in der Fertig-Mail und die Überwachungs-URL entstehen, indem diese Zone an einen Hostnamen gehängt wird. Leer bedeutet: jede neue Instanz bekommt einen Namen, der auf nichts zeigt.',
'traefik_path' => 'Traefik-Konfigurationspfad',
'traefik_path_breaks' => 'Leer, und SshTraefikWriter schreibt die Routendatei an die Wurzel des Dateisystems statt in Traefiks eigenes Verzeichnis. Der Schritt meldet trotzdem Erfolg — Traefik sieht die neue Route nur nie, und die Cloud des Kunden bleibt über ihre Adresse unerreichbar.',
'usable_host' => 'Mindestens ein einsetzbarer Host',
'usable_host_breaks' => 'Ein aktiver Host ohne lesbaren Token wird von ReserveResources trotzdem für eine neue Bestellung ausgewählt. Der Auftrag stirbt danach in CloneVirtualMachine — nachdem der Kunde schon bezahlt hat.',
'vm_template' => 'VM-Vorlage je veröffentlichter Version',
'vm_template_breaks' => 'Eine veröffentlichte Version ohne template_vmid lässt CloneVirtualMachine sofort mit „template_missing" scheitern — für jede Bestellung dieses Plans, nachdem der Kunde schon bezahlt hat.',
'monitoring_token' => 'Überwachungs-Token',
'monitoring_token_breaks' => 'Ohne ihn scheitert RegisterMonitoring; die Cloud wird trotzdem ausgeliefert, nur ohne Überwachung, bis der Schlüssel nachgetragen wird. Überwachung darf eine Bereitstellung nie aufhalten.',
],
'delivery' => [
'mailer_not_log' => 'Mailtransport ist kein Logfile',
'mailer_not_log_breaks' => 'Bei mail.default=log läuft die Maschine, der Beleg wird ausgestellt, und die Mail, die dem Kunden seine fertige Cloud ankündigt, landet in einer Datei auf dem Server. Der Kunde erfährt nie, dass er bezahlt hat und etwas läuft.',
'mailbox' => 'Mindestens eine Mailbox',
'mailbox_breaks' => 'Ohne Mailbox wirft der Versand aus CompleteProvisioning eine Ausnahme — der letzte Schritt der Bereitstellung schlägt fehl und wiederholt sich, obwohl die Maschine längst läuft und der Kunde schon bezahlt hat.',
'mail_templates' => 'Mindestens eine Antwortvorlage',
'mail_templates_breaks' => 'Ohne Vorlage tippt jeder Support-Mitarbeiter dieselbe Antwort neu — und zwei Kunden bekommen zwei verschiedene Auskünfte zur selben Frage.',
'inbound_password' => 'Passwort für eingehende Mail',
'inbound_password_breaks' => 'Ohne es liefert der Mail-Abruf jedes Mal eine leere Liste. Der Zeitplan läuft unauffällig weiter, aber keine Kundenantwort erreicht je die Inbox der Konsole, und nirgends erscheint ein Fehler.',
],
];

View File

@ -23,4 +23,43 @@ return [
'catalogue_synced' => 'Stripe catalogue in sync',
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
],
'onboarding' => [
'ssh_key' => 'SSH key for hosts',
'ssh_key_breaks' => 'EstablishSshTrust only deploys the public half onto the host; every step after it — and every Traefik change on a host already live — connects with this private key. Without it, onboarding stalls at the second step, and no routing update reaches a host that is already running.',
'secrets_key' => 'Vault key (SECRETS_KEY)',
'secrets_key_breaks' => 'Without it, nothing can be stored or read in this installation — not the Stripe key, not the DNS token, no host token. The console shows every one of these fields as empty, no matter what was ever entered.',
'vpn_config_key' => 'VPN config key (VPN_CONFIG_KEY)',
'vpn_config_key_breaks' => 'Without it, ConfigVault refuses to store any VPN config rather than write it in the clear — an issued access can then not be looked up anywhere again.',
'wg_hub' => 'WireGuard hub fully configured',
'wg_hub_breaks' => 'Two of three values are not half a tunnel, they are none: ConfigureWireguard writes the peer config from all three at once. Missing one leaves the new host with no management tunnel, and nobody can reach it.',
'datacenter' => 'At least one datacenter',
'datacenter_breaks' => 'Without a datacenter the host-create form has nothing to offer, and ValidateHostInput aborts as the very first step of the chain — before an SSH connection is even attempted.',
],
'provisioning' => [
'dns_token' => 'Hetzner DNS token',
'dns_token_breaks' => 'Without it, ConfigureDnsAndTls fails to create the A record for the new instance — in the middle of provisioning, after the machine is already running and the customer has already paid.',
'dns_zone' => 'DNS zone',
'dns_zone_breaks' => 'Every customer subdomain, the link in the ready mail, and the monitoring URL are built by appending this zone to a hostname. Empty means every new instance gets a name that points at nothing.',
'traefik_path' => 'Traefik config path',
'traefik_path_breaks' => 'Empty, and SshTraefikWriter writes the route file at the filesystem root instead of Traefik\'s own directory. The step still reports success — Traefik simply never sees the new route, and the customer\'s cloud stays unreachable at its address.',
'usable_host' => 'At least one usable host',
'usable_host_breaks' => 'An active host with no readable token is still picked by ReserveResources for a new order. The order then dies in CloneVirtualMachine — after the customer has already paid.',
'vm_template' => 'VM template for every published version',
'vm_template_breaks' => 'A published version with no template_vmid makes CloneVirtualMachine fail instantly with "template_missing" — for every order of that plan, after the customer has already paid.',
'monitoring_token' => 'Monitoring token',
'monitoring_token_breaks' => 'Without it, RegisterMonitoring fails; the cloud still gets delivered, just without monitoring until the key is added. Monitoring must never hold up a delivery.',
],
'delivery' => [
'mailer_not_log' => 'Mail transport is not a log file',
'mailer_not_log_breaks' => 'With mail.default=log the machine runs, the invoice is issued, and the mail announcing the finished cloud lands in a file on the server. The customer never learns they paid for something that is running.',
'mailbox' => 'At least one mailbox',
'mailbox_breaks' => 'With no mailbox, sending from CompleteProvisioning throws — the last step of provisioning fails and keeps retrying, even though the machine is already running and the customer has already paid.',
'mail_templates' => 'At least one reply template',
'mail_templates_breaks' => 'Without a template, every support reply is typed from scratch — and two customers get two different answers to the same question.',
'inbound_password' => 'Inbound mail password',
'inbound_password_breaks' => 'Without it, fetching mail returns an empty list every time. The schedule keeps running quietly, but no customer reply ever reaches the console inbox, and no error appears anywhere.',
],
];

View File

@ -0,0 +1,92 @@
<?php
use App\Models\Mailbox;
use App\Models\MailTemplate;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
function deliveryCheck(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* `mail.default = log` heißt: die VM läuft, der Beleg ist geschrieben, und die
* Mail, die dem Kunden seine fertige Cloud ankündigt, liegt in einer Datei auf
* dem Server. Der Kunde erfährt nie, dass er eine Cloud hat. Kein Fehler
* taucht irgendwo auf.
*/
it('refuses to call an installation ready while mail goes to the log file', function () {
config()->set('mail.default', 'log');
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
});
it('accepts a real mailer', function () {
config()->set('mail.default', 'smtp');
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue();
});
it('needs at least one mailbox to send from', function () {
// Eine Migration seedet fünf Mailboxen (no-reply, support, billing,
// office, info) für jede Installation — auch für diese Suite. Auf den
// leeren Zustand zurückgesetzt, den dieser Test eigentlich prüft (siehe
// clearMailboxSeed() in Pest.php).
clearMailboxSeed();
// CompleteProvisioning verschickt CloudReady SYNCHRON und wirft, wenn
// MailboxResolver keine Mailbox findet — der letzte Schritt der
// Bereitstellung scheitert dann, nachdem die Maschine schon läuft und der
// Kunde schon bezahlt hat.
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse();
Mailbox::factory()->create();
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue();
});
it('treats missing reply templates as a warning, never as a blocker', function () {
// Eine Migration seedet einen Grundstock an Antwortvorlagen für jede
// Installation — auch für diese Suite. Geleert, um den leeren Zustand zu
// prüfen, den ein frisches Setup ohne diese Migration tatsächlich hätte.
MailTemplate::query()->delete();
expect(deliveryCheck('delivery.mail_templates')->satisfied)->toBeFalse();
expect(deliveryCheck('delivery.mail_templates')->severity)->toBe(Check::SEVERITY_WARNING);
MailTemplate::query()->create([
'name' => 'Test Template',
'subject' => 'Re: your question',
'body' => 'Thanks for reaching out.',
'sort' => 1,
'active' => true,
]);
expect(deliveryCheck('delivery.mail_templates')->satisfied)->toBeTrue();
});
it('treats a missing inbound mail password as a warning, never as a blocker', function () {
OperatingMode::set(OperatingMode::Live);
expect(deliveryCheck('delivery.inbound_password')->satisfied)->toBeFalse();
expect(deliveryCheck('delivery.inbound_password')->severity)->toBe(Check::SEVERITY_WARNING);
app(SecretVault::class)->put('inbound_mail.password', 'secret', Operator::factory()->create(), OperatingMode::Live);
expect(deliveryCheck('delivery.inbound_password')->satisfied)->toBeTrue();
});
it('names what breaks for every delivery check, not just what is missing', function () {
foreach ([
'delivery.mailer_not_log',
'delivery.mailbox',
'delivery.mail_templates',
'delivery.inbound_password',
] as $key) {
expect(deliveryCheck($key)->breaks)->not->toBe('');
}
});

View File

@ -0,0 +1,112 @@
<?php
use App\Models\Datacenter;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
/**
* Die Bereitschaftsprüfung berichtet, was fehlt und sagt dazu, was
* kaputtgeht. Siehe BillingChecksTest für dieselbe Begründung.
*/
function onboardingCheck(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
it('needs at least one datacenter', function () {
// create_datacenters_table selbst legt 'fsn'/'hel' als Vorgabe an — auch
// in dieser Suite, da es sich um eine Migration und keinen Seeder
// handelt. Erst geleert, damit dieser Test den echten leeren Zustand
// prüft statt der Werkseinstellung dieser Installation.
Datacenter::query()->delete();
// ValidateHostInput ist der erste Schritt der Host-Kette und bricht ohne
// Rechenzentrum ab — bevor überhaupt eine SSH-Verbindung versucht wird.
expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeFalse();
Datacenter::factory()->create();
expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeTrue();
});
it('needs an ssh private key', function () {
// Mode selbst gesetzt (Testisolation: CACHE_STORE=array wird zwischen
// Testdateien nicht geleert), damit dieser Test nicht vom Rest der Zeile
// abhängt, in der die Suite läuft.
OperatingMode::set(OperatingMode::Live);
// Diese Installation hat in .env tatsächlich einen SSH-Schlüssel
// konfiguriert (CLUPILOT_SSH_PRIVATE_KEY_PATH), auf den SecretVault::get()
// zurückfällt, solange kein Tresor-Eintrag existiert — direkt
// zurückgesetzt, damit der Test nicht vom Entwicklerrechner abhängt.
config()->set('provisioning.ssh.private_key', '');
expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeFalse();
app(SecretVault::class)->put(
'ssh.private_key',
'-----BEGIN OPENSSH PRIVATE KEY-----',
Operator::factory()->create(),
OperatingMode::Live,
);
expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeTrue();
});
it('needs a usable vault key', function () {
// isUsable() statt eines bloßen filled()-Checks: SecretCipher warnt in
// seinem eigenen Kommentar genau davor, eine zweite, schwächere Regel zu
// bauen, die einen falsch geformten Schlüssel als gültig durchwinkt.
config()->set('admin_access.secrets_key', '');
expect(onboardingCheck('onboarding.secrets_key')->satisfied)->toBeFalse();
config()->set('admin_access.secrets_key', base64_encode(str_repeat('a', 32)));
expect(onboardingCheck('onboarding.secrets_key')->satisfied)->toBeTrue();
});
it('needs a usable vpn config key', function () {
config()->set('admin_access.vpn_config_key', '');
expect(onboardingCheck('onboarding.vpn_config_key')->satisfied)->toBeFalse();
config()->set('admin_access.vpn_config_key', base64_encode(str_repeat('b', 32)));
expect(onboardingCheck('onboarding.vpn_config_key')->satisfied)->toBeTrue();
});
it('needs all three wireguard values, not just some', function () {
// Zwei von drei ist kein halber Tunnel, sondern gar keiner. Subnet wird
// bewusst direkt über config() geprüft statt über Settings/
// ProvisioningSettings (siehe deren eigener Kopfkommentar: der Compose-
// Stack liest denselben Schlüssel beim Containerstart) — und hier im Test
// ebenso direkt zurückgesetzt, damit das Ergebnis nicht von den echten
// WireGuard-Werten dieser Installation abhängt.
config()->set('provisioning.wireguard.hub_public_key', '');
config()->set('provisioning.wireguard.endpoint', '');
config()->set('provisioning.wireguard.subnet', '');
expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeFalse();
Settings::set('provisioning.wg_hub_pubkey', 'abc=');
Settings::set('provisioning.wg_endpoint', '198.51.45.9:51820');
expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeFalse();
config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeTrue();
});
it('names what breaks for every onboarding check, not just what is missing', function () {
foreach ([
'onboarding.ssh_key',
'onboarding.secrets_key',
'onboarding.vpn_config_key',
'onboarding.wg_hub',
'onboarding.datacenter',
] as $key) {
expect(onboardingCheck($key)->breaks)->not->toBe('');
}
});

View File

@ -0,0 +1,136 @@
<?php
use App\Models\Host;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
function provisioningCheck(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* Am 2026-07-30 standen zwei Hosts auf `active`, ohne api_token_ref und mit
* Adressen aus dem Dokumentationsbereich (203.0.113.0/24, RFC 5737). In der
* Konsole waren sie von einem echten Host nicht zu unterscheiden und
* ReserveResources wählt aus `status = active`. Ein Kauf hätte sich einen
* dieser Phantom-Hosts reserviert und wäre NACH der Zahlung gestorben.
*/
it('does not accept an active host without a readable token', function () {
Host::factory()->create(['status' => 'active', 'api_token_ref' => null]);
expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse();
});
it('accepts an active host that carries a token', function () {
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1']);
expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeTrue();
});
it('does not accept a host that carries a token but is not active', function () {
Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1']);
expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse();
});
it('treats the monitoring token as a warning, never as a blocker', function () {
// Überwachung darf eine Bereitstellung nie aufhalten.
expect(provisioningCheck('provisioning.monitoring_token')->severity)
->toBe(Check::SEVERITY_WARNING);
});
it('needs a dns token', function () {
OperatingMode::set(OperatingMode::Live);
// Wie beim SSH-Schlüssel: HETZNER_DNS_TOKEN ist auf dieser Installation in
// .env tatsächlich gesetzt, und SecretVault::get() fällt darauf zurück,
// solange kein Tresor-Eintrag existiert. Zurückgesetzt, damit der Test
// nicht vom Entwicklerrechner abhängt.
config()->set('provisioning.dns.token', '');
expect(provisioningCheck('provisioning.dns_token')->satisfied)->toBeFalse();
app(SecretVault::class)->put('dns.token', 'token-value', Operator::factory()->create(), OperatingMode::Live);
expect(provisioningCheck('provisioning.dns_token')->satisfied)->toBeTrue();
});
it('needs a dns zone', function () {
// Direkt über config() zurückgesetzt statt sich auf den echten Wert
// dieser Installation zu verlassen — genau die Falle, die
// phpunit.xml zu CLUPILOT_DNS_ZONE dokumentiert.
config()->set('provisioning.dns.zone', '');
expect(provisioningCheck('provisioning.dns_zone')->satisfied)->toBeFalse();
Settings::set('provisioning.dns_zone', 'clupilot.cloud');
expect(provisioningCheck('provisioning.dns_zone')->satisfied)->toBeTrue();
});
it('treats a missing traefik path as a warning, never as a blocker', function () {
config()->set('provisioning.traefik.dynamic_path', '');
expect(provisioningCheck('provisioning.traefik_path')->satisfied)->toBeFalse();
expect(provisioningCheck('provisioning.traefik_path')->severity)->toBe(Check::SEVERITY_WARNING);
Settings::set('provisioning.traefik_dynamic_path', '/etc/traefik/dynamic');
expect(provisioningCheck('provisioning.traefik_path')->satisfied)->toBeTrue();
});
it('does not report a template gap for a draft version nobody can buy yet', function () {
$family = PlanFamily::query()->create(['key' => 'tmpl-draft', 'name' => 'Draft', 'tier' => 9]);
$family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
// published_at bleibt null: ein Entwurf, den niemand kaufen kann.
]);
expect(provisioningCheck('provisioning.vm_template')->satisfied)->toBeTrue();
});
it('reports a published version with no template as a gap', function () {
// CloneVirtualMachine scheitert sofort mit `template_missing`, sobald
// eine Bestellung dieses Plans läuft — nachdem der Kunde schon bezahlt hat.
$family = PlanFamily::query()->create(['key' => 'tmpl-missing', 'name' => 'No Template', 'tier' => 9]);
$version = $family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
]);
// published_at erst per update setzen: template_vmid ist FROZEN, sobald
// eine Version schon veröffentlicht in der Datenbank steht, aber der
// ERSTE Übergang von Entwurf zu veröffentlicht ist selbst kein Verstoß.
$version->update(['published_at' => now()]);
expect(provisioningCheck('provisioning.vm_template')->satisfied)->toBeFalse();
});
it('is satisfied once every published version carries a template', function () {
$family = PlanFamily::query()->create(['key' => 'tmpl-ok', 'name' => 'Has Template', 'tier' => 9]);
$family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(), 'published_at' => now(), 'template_vmid' => 100,
]);
expect(provisioningCheck('provisioning.vm_template')->satisfied)->toBeTrue();
});
it('names what breaks for every provisioning check, not just what is missing', function () {
foreach ([
'provisioning.dns_token',
'provisioning.dns_zone',
'provisioning.traefik_path',
'provisioning.usable_host',
'provisioning.vm_template',
'provisioning.monitoring_token',
] as $key) {
expect(provisioningCheck($key)->breaks)->not->toBe('');
}
});