feat(engine-b): 15-step customer pipeline + DNS/Traefik services

validate->reserve(placement)->clone->cloudinit->start->guestagent->network->
deploy->nextcloud->admin->dns/tls->backup->monitoring->acceptance->complete.
HetznerDnsClient + TraefikWriter (interface+fake+real), CloudReady notification,
hosts.node (set by RegisterCapacity). Secrets transient/encrypted, never
plaintext. 21 tests incl. mocked end-to-end + crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 11:50:26 +02:00
parent e5aeb3989e
commit 446da58061
29 changed files with 1413 additions and 4 deletions

View File

@ -17,7 +17,7 @@ class Host extends Model implements ProvisioningSubject
protected $fillable = [ protected $fillable = [
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey', 'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores', 'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'status', 'last_seen_at', 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at',
]; ];
protected $hidden = ['api_token_ref']; protected $hidden = ['api_token_ref'];

View File

@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\Instance;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Delivers the initial admin credentials once provisioning completes. The
* password is passed transiently and never persisted in plaintext.
*/
class CloudReady extends Notification
{
use Queueable;
public function __construct(
public Instance $instance,
public string $adminUser,
public string $adminPassword,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$url = 'https://'.$this->instance->subdomain.'.'.config('provisioning.dns.zone');
return (new MailMessage)
->subject(__('provisioning.mail.ready_subject'))
->greeting(__('provisioning.mail.ready_greeting'))
->line(__('provisioning.mail.ready_line'))
->line(__('provisioning.mail.ready_user', ['user' => $this->adminUser]))
->line(__('provisioning.mail.ready_password', ['password' => $this->adminPassword]))
->action(__('provisioning.mail.ready_action'), $url);
}
}

View File

@ -3,10 +3,14 @@
namespace App\Providers; namespace App\Providers;
use App\Provisioning\PipelineRegistry; use App\Provisioning\PipelineRegistry;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Proxmox\HttpProxmoxClient; use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient; use App\Services\Proxmox\ProxmoxClient;
use App\Services\Ssh\PhpseclibRemoteShell; use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell; use App\Services\Ssh\RemoteShell;
use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub; use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub; use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -26,6 +30,8 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(RemoteShell::class, PhpseclibRemoteShell::class); $this->app->bind(RemoteShell::class, PhpseclibRemoteShell::class);
$this->app->bind(WireguardHub::class, LocalWireguardHub::class); $this->app->bind(WireguardHub::class, LocalWireguardHub::class);
$this->app->bind(ProxmoxClient::class, HttpProxmoxClient::class); $this->app->bind(ProxmoxClient::class, HttpProxmoxClient::class);
$this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class);
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
} }
/** /**

View File

@ -0,0 +1,65 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class CloneVirtualMachine extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'clone_vm';
}
public function maxDuration(): int
{
return 900;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$plan = $this->plan($run);
$templateVmid = (int) ($plan['template_vmid'] ?? 0);
if ($templateVmid === 0) {
return StepResult::fail('template_missing');
}
$pve = $this->pve->forHost($instance->host);
// Idempotent: vmid already minted → resume polling the clone task.
if ($this->hasResource($run, 'vmid')) {
return $this->pollClone($pve, $node, (string) $run->context('clone_upid'));
}
$vmid = $pve->nextVmid();
$upid = $pve->cloneVm($node, $templateVmid, $vmid, 'nc-'.$instance->subdomain);
// [E] persist vmid + task ref BEFORE polling (crash-safe).
$instance->update(['vmid' => $vmid, 'status' => 'provisioning']);
$run->mergeContext(['vmid' => $vmid, 'clone_upid' => $upid]);
$this->recordResource($run, $instance->host, 'vmid', (string) $vmid);
return $this->pollClone($pve, $node, $upid);
}
private function pollClone(ProxmoxClient $pve, string $node, string $upid): StepResult
{
$task = $pve->taskStatus($node, $upid);
if (($task['status'] ?? '') === 'running') {
return StepResult::poll(10, 'cloning template');
}
if (($task['exitstatus'] ?? 'OK') !== 'OK') {
return StepResult::fail('clone_failed: '.($task['exitstatus'] ?? 'unknown'));
}
return StepResult::advance();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Notifications\CloudReady;
use App\Provisioning\StepResult;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Notification;
class CompleteProvisioning extends CustomerStep
{
public function key(): string
{
return 'complete_provisioning';
}
public function maxDuration(): int
{
return 60;
}
public function execute(ProvisioningRun $run): StepResult
{
$order = $this->order($run);
$instance = $this->instance($run);
$instance->update(['status' => 'active']);
$order->update(['status' => 'active']);
foreach (['invite_users', 'upload_logo', 'connect_desktop', 'explore_apps'] as $key) {
$instance->onboardingTasks()->firstOrCreate(['key' => $key], ['done' => false]);
}
// Deliver the initial credentials, then scrub the transient password.
$encrypted = $run->context('admin_password');
if ($encrypted !== null) {
Notification::route('mail', $order->customer->email)->notify(
new CloudReady($instance, (string) $instance->nc_admin_ref, Crypt::decryptString($encrypted)),
);
$run->forgetContext('admin_password');
}
return StepResult::advance();
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class ConfigureCloudInit extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'configure_cloud_init';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
$pve = $this->pve->forHost($instance->host);
// Idempotent: setting the same values / resizing to a size not smaller is a no-op.
$pve->setCloudInit($node, $vmid, [
'ipconfig0' => 'ip=dhcp',
'ciuser' => 'clupilot',
'nameserver' => '1.1.1.1',
]);
$pve->resizeDisk($node, $vmid, 'scsi0', '+'.$instance->disk_gb.'G');
return StepResult::advance();
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Traefik\TraefikWriter;
class ConfigureDnsAndTls extends CustomerStep
{
public function __construct(
private HetznerDnsClient $dns,
private TraefikWriter $traefik,
) {}
public function key(): string
{
return 'configure_dns_and_tls';
}
public function maxDuration(): int
{
return 900;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$host = $instance->host;
$fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone');
// DNS — persist the record id [E] before anything downstream.
if (! $this->hasResource($run, 'dns_record_id')) {
$recordId = $this->dns->upsertRecord($fqdn, 'A', $host->public_ip);
$instance->dnsRecords()->create([
'provider' => 'hetzner', 'record_id' => $recordId,
'fqdn' => $fqdn, 'type' => 'A', 'value' => $host->public_ip,
]);
$this->recordResource($run, $host, 'dns_record_id', $recordId);
}
// Traefik file-provider route (subdomain → guest over the tunnel).
if (! $instance->route_written) {
$this->traefik->write($instance->subdomain, $host->wg_ip ?? $host->public_ip);
$instance->update(['route_written' => true]);
}
// TLS via HTTP-01 — poll until the certificate is served.
if (! $instance->cert_ok) {
if ($this->traefik->certReachable($fqdn)) {
$instance->update(['cert_ok' => true]);
} elseif ($run->started_at !== null && $run->started_at->copy()->addSeconds(840)->isPast()) {
return StepResult::fail('cert_timeout');
} else {
return StepResult::poll(20, 'waiting for certificate');
}
}
return StepResult::advance();
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class ConfigureNetwork extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'configure_network';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
$pve = $this->pve->forHost($instance->host);
$this->guest($pve, $run, 'hostnamectl set-hostname '.escapeshellarg($instance->subdomain));
// Allow HTTP/HTTPS, deny everything else (management stays on the tunnel).
$pve->applyFirewall($node, $vmid, [
['type' => 'in', 'action' => 'ACCEPT', 'proto' => 'tcp', 'dport' => '80'],
['type' => 'in', 'action' => 'ACCEPT', 'proto' => 'tcp', 'dport' => '443'],
]);
return StepResult::advance();
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class ConfigureNextcloud extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'configure_nextcloud';
}
public function maxDuration(): int
{
return 300;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$pve = $this->pve->forHost($instance->host);
$fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone');
$occ = 'cd /opt/nextcloud && docker compose exec -T app php occ ';
// Idempotent occ calls (setting the same values is a no-op).
$this->guest($pve, $run, $occ.'config:system:set trusted_domains 1 --value='.escapeshellarg($fqdn));
if (filled($instance->custom_domain)) {
$this->guest($pve, $run, $occ.'config:system:set trusted_domains 2 --value='.escapeshellarg($instance->custom_domain));
}
$this->guest($pve, $run, $occ.'background:cron');
$this->guest($pve, $run, $occ.'config:system:set default_phone_region --value=DE');
return StepResult::advance();
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
class CreateCustomerAdmin extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'create_customer_admin';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
// Idempotent: admin already created.
if ($this->hasResource($run, 'nc_admin')) {
return StepResult::advance();
}
$instance = $this->instance($run);
$pve = $this->pve->forHost($instance->host);
$username = 'admin';
$password = Str::random(20);
// Pass the password via env (OC_PASS), never on the argv/command line.
$this->guest($pve, $run,
'cd /opt/nextcloud && OC_PASS='.escapeshellarg($password).
' docker compose exec -T -e OC_PASS app php occ user:add --password-from-env --group=admin '.
escapeshellarg($username));
// Persist only the username (encrypted ref); hand the password to step 15
// encrypted-in-context for delivery, then it is scrubbed. Never plaintext.
$instance->update(['nc_admin_ref' => $username]);
$run->mergeContext(['admin_password' => Crypt::encryptString($password)]);
$this->recordResource($run, $instance->host, 'nc_admin', $username);
return StepResult::advance();
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Str;
class DeployApplicationStack extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'deploy_application_stack';
}
public function maxDuration(): int
{
return 1200;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
$pve = $this->pve->forHost($instance->host);
// Deploy once (guarded); the generated DB password is transient.
if (! $run->context('stack_deployed')) {
$dbPassword = Str::random(28);
$run->mergeContext(['db_password' => $dbPassword]);
$this->guest($pve, $run,
'install -d /opt/nextcloud && printf %s '.escapeshellarg('MYSQL_PASSWORD='.$dbPassword."\n").
' > /opt/nextcloud/.env');
$this->guest($pve, $run, 'cd /opt/nextcloud && docker compose up -d');
$run->mergeContext(['stack_deployed' => true]);
}
// Poll until Nextcloud answers (poll doesn't consume the retry budget).
$health = $pve->guestExec($node, $vmid,
'curl -sf http://localhost/status.php >/dev/null 2>&1 && echo ok || echo wait');
if (trim($health['out-data'] ?? '') !== 'ok') {
return StepResult::poll(15, 'waiting for application stack');
}
$run->forgetContext('db_password'); // scrub the secret once the stack is up
return StepResult::advance();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
class RegisterBackup extends CustomerStep
{
public function key(): string
{
return 'register_backup';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
if ($this->hasResource($run, 'backup_job_id')) {
return StepResult::advance();
}
$instance = $this->instance($run);
$jobId = 'vzdump-'.$instance->vmid.'-daily';
$instance->backups()->create([
'external_job_id' => $jobId,
'schedule' => 'daily 02:00',
'status' => 'scheduled',
]);
$this->recordResource($run, $instance->host, 'backup_job_id', $jobId);
return StepResult::advance();
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
class RegisterMonitoring extends CustomerStep
{
public function key(): string
{
return 'register_monitoring';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
if ($this->hasResource($run, 'monitoring_target_id')) {
return StepResult::advance();
}
$instance = $this->instance($run);
$targetId = 'mon-'.$instance->vmid;
$url = 'https://'.$instance->subdomain.'.'.config('provisioning.dns.zone').'/status.php';
$instance->monitoringTargets()->create([
'external_id' => $targetId,
'url' => $url,
'status' => 'up',
]);
$this->recordResource($run, $instance->host, 'monitoring_target_id', $targetId);
return StepResult::advance();
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use Illuminate\Support\Str;
class ReserveResources extends CustomerStep
{
public function key(): string
{
return 'reserve_resources';
}
public function maxDuration(): int
{
return 60;
}
public function execute(ProvisioningRun $run): StepResult
{
$order = $this->order($run);
// Idempotent: an instance already exists for this order.
$existing = Instance::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
$this->putContext($run, $existing);
return StepResult::advance();
}
$plan = $this->plan($run);
$host = Host::placeableIn($order->datacenter, (int) $plan['quota_gb']);
if ($host === null) {
return StepResult::fail('no_capacity');
}
$instance = Instance::create([
'customer_id' => $order->customer_id,
'order_id' => $order->id,
'host_id' => $host->id,
'plan' => $order->plan,
'quota_gb' => $plan['quota_gb'],
'disk_gb' => $plan['disk_gb'],
'ram_mb' => $plan['ram_mb'],
'cores' => $plan['cores'],
'subdomain' => $this->uniqueSubdomain($order),
'status' => 'reserving',
]);
$this->putContext($run, $instance);
$this->recordResource($run, $host, 'instance_id', (string) $instance->id);
return StepResult::advance();
}
private function putContext(ProvisioningRun $run, Instance $instance): void
{
$run->mergeContext([
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node ?? 'pve',
'subdomain' => $instance->subdomain,
'plan' => $instance->plan,
]);
}
private function uniqueSubdomain(Order $order): string
{
$base = Str::slug($order->customer->name) ?: 'cloud';
do {
$subdomain = $base.'-'.Str::lower(Str::random(5));
} while (Instance::query()->where('subdomain', $subdomain)->exists());
return $subdomain;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traefik\TraefikWriter;
/**
* Gate: nothing grants the customer access until every check here passes.
*/
class RunAcceptanceChecks extends CustomerStep
{
public function __construct(
private ProxmoxClient $pve,
private TraefikWriter $traefik,
) {}
public function key(): string
{
return 'run_acceptance_checks';
}
public function maxDuration(): int
{
return 180;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
$fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone');
if (! $instance->cert_ok || ! $this->traefik->certReachable($fqdn)) {
return StepResult::fail('acceptance_failed:cert');
}
$status = $this->pve->forHost($instance->host)
->guestExec($node, $vmid, 'curl -sf http://localhost/status.php');
if ((int) ($status['exitcode'] ?? 1) !== 0) {
return StepResult::fail('acceptance_failed:nextcloud');
}
if (! $this->hasResource($run, 'nc_admin')) {
return StepResult::fail('acceptance_failed:admin');
}
if (! $this->hasResource($run, 'backup_job_id')) {
return StepResult::fail('acceptance_failed:backup');
}
if (! $this->hasResource($run, 'monitoring_target_id')) {
return StepResult::fail('acceptance_failed:monitoring');
}
return StepResult::advance();
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class StartVirtualMachine extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'start_vm';
}
public function maxDuration(): int
{
return 180;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
$pve = $this->pve->forHost($instance->host);
// Idempotent: already running.
if (($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running') {
return StepResult::advance();
}
$upid = $run->context('start_upid');
if ($upid === null) {
$upid = $pve->startVm($node, $vmid);
$run->mergeContext(['start_upid' => $upid]);
}
$task = $pve->taskStatus($node, $upid);
if (($task['status'] ?? '') === 'running') {
return StepResult::poll(5, 'starting VM');
}
if (($task['exitstatus'] ?? 'OK') !== 'OK') {
return StepResult::fail('start_failed: '.($task['exitstatus'] ?? 'unknown'));
}
return StepResult::advance();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
class ValidateOrder extends CustomerStep
{
public function key(): string
{
return 'validate_order';
}
public function maxDuration(): int
{
return 60;
}
public function execute(ProvisioningRun $run): StepResult
{
$order = $this->order($run);
if (! in_array($order->status, ['paid', 'provisioning'], true)) {
return StepResult::fail('order_not_paid');
}
if ($this->plan($run) === []) {
return StepResult::fail('unknown_plan');
}
if (blank($order->datacenter)) {
return StepResult::fail('invalid_datacenter');
}
$order->update(['status' => 'provisioning']);
return StepResult::advance();
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class WaitForGuestAgent extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'wait_for_guest_agent';
}
public function maxDuration(): int
{
return 300;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
if ($this->pve->forHost($instance->host)->guestAgentPing($node, $vmid)) {
return StepResult::advance();
}
// Own deadline (poll doesn't consume the retry budget).
if ($run->started_at !== null && $run->started_at->copy()->addSeconds(270)->isPast()) {
return StepResult::fail('guest_agent_timeout');
}
return StepResult::poll(10, 'waiting for guest agent');
}
}

View File

@ -51,6 +51,7 @@ class RegisterCapacity extends HostStep
'total_ram_mb' => $ramMb, 'total_ram_mb' => $ramMb,
'total_gb' => $totalGb, 'total_gb' => $totalGb,
'pve_version' => $status['pveversion'] ?? null, 'pve_version' => $status['pveversion'] ?? null,
'node' => $node,
'last_seen_at' => now(), 'last_seen_at' => now(),
]); ]);

View File

@ -0,0 +1,29 @@
<?php
namespace App\Services\Dns;
use RuntimeException;
class FakeHetznerDnsClient implements HetznerDnsClient
{
/** @var array<string, string> fqdn => record_id */
public array $records = [];
public bool $failUpsert = false;
private int $counter = 1;
public function upsertRecord(string $fqdn, string $type, string $value): string
{
if ($this->failUpsert) {
throw new RuntimeException('hetzner dns 5xx');
}
return $this->records[$fqdn] ??= 'rec-'.$this->counter++;
}
public function deleteRecord(string $recordId): void
{
$this->records = array_filter($this->records, fn ($id) => $id !== $recordId);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Services\Dns;
interface HetznerDnsClient
{
/** Create or update a record; returns the provider record id. */
public function upsertRecord(string $fqdn, string $type, string $value): string;
public function deleteRecord(string $recordId): void;
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Services\Dns;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Hetzner DNS API client. Zone + token from config/provisioning.php.
* Not unit-tested (live I/O).
*/
class HttpHetznerDnsClient implements HetznerDnsClient
{
private function http(): PendingRequest
{
return Http::withHeaders(['Auth-API-Token' => (string) config('provisioning.dns.token')])
->baseUrl('https://dns.hetzner.com/api/v1')
->timeout(15);
}
private function zoneId(): string
{
$zone = (string) config('provisioning.dns.zone');
$zones = $this->http()->get('/zones', ['name' => $zone])->throw()->json('zones', []);
return $zones[0]['id'] ?? throw new RuntimeException("DNS zone {$zone} not found");
}
public function upsertRecord(string $fqdn, string $type, string $value): string
{
$zoneId = $this->zoneId();
$name = rtrim(str_replace('.'.config('provisioning.dns.zone'), '', $fqdn), '.');
$existing = collect($this->http()->get('/records', ['zone_id' => $zoneId])->throw()->json('records', []))
->first(fn ($r) => $r['name'] === $name && $r['type'] === $type);
if ($existing) {
$this->http()->put("/records/{$existing['id']}", [
'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300,
])->throw();
return (string) $existing['id'];
}
return (string) $this->http()->post('/records', [
'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300,
])->throw()->json('record.id');
}
public function deleteRecord(string $recordId): void
{
$this->http()->delete("/records/{$recordId}")->throw();
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Services\Traefik;
class FakeTraefikWriter implements TraefikWriter
{
/** @var array<string, string> subdomain => target */
public array $routes = [];
public bool $certReady = true;
public function write(string $subdomain, string $targetHost): void
{
$this->routes[$subdomain] = $targetHost;
}
public function remove(string $subdomain): void
{
unset($this->routes[$subdomain]);
}
public function certReachable(string $fqdn): bool
{
return $this->certReady;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Services\Traefik;
use Illuminate\Support\Facades\Http;
/**
* Writes Traefik file-provider YAML into the configured dynamic directory (where
* Traefik runs) and checks HTTP-01 cert issuance by probing the fqdn over HTTPS.
* The concrete write path (host SSH / mounted volume) is a deployment watch-item.
* Not unit-tested (live I/O).
*/
class SshTraefikWriter implements TraefikWriter
{
public function write(string $subdomain, string $targetHost): void
{
$zone = (string) config('provisioning.dns.zone');
$fqdn = "{$subdomain}.{$zone}";
$yaml = $this->render($subdomain, $fqdn, $targetHost);
$path = rtrim((string) config('provisioning.traefik.dynamic_path'), '/')."/{$subdomain}.yml";
file_put_contents($path, $yaml);
}
public function remove(string $subdomain): void
{
$path = rtrim((string) config('provisioning.traefik.dynamic_path'), '/')."/{$subdomain}.yml";
if (is_file($path)) {
unlink($path);
}
}
public function certReachable(string $fqdn): bool
{
return Http::timeout(5)->connectTimeout(5)->get("https://{$fqdn}/status.php")->successful();
}
private function render(string $subdomain, string $fqdn, string $targetHost): string
{
return implode("\n", [
'http:',
' routers:',
" {$subdomain}:",
" rule: \"Host(`{$fqdn}`)\"",
" service: \"{$subdomain}\"",
' entryPoints: ["websecure"]',
' tls:',
' certResolver: "letsencrypt"',
' services:',
" {$subdomain}:",
' loadBalancer:',
' servers:',
" - url: \"http://{$targetHost}:80\"",
'',
]);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Services\Traefik;
interface TraefikWriter
{
/** Write a file-provider router for subdomain → target host. */
public function write(string $subdomain, string $targetHost): void;
public function remove(string $subdomain): void;
/** True once the ACME (HTTP-01) certificate for the fqdn is served. */
public function certReachable(string $fqdn): bool;
}

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Proxmox node name (from RegisterCapacity), used by Subsystem B to
// address qemu/{node}/... endpoints. Nullable until a host is active.
Schema::table('hosts', function (Blueprint $table) {
$table->string('node')->nullable()->after('pve_version');
});
}
public function down(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->dropColumn('node');
});
}
};

View File

@ -0,0 +1,90 @@
<?php
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\RunResource;
use App\Notifications\CloudReady;
use App\Provisioning\RunRunner;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
it('provisions a paid order all the way to active (mocked)', function () {
Notification::fake();
Queue::fake(); // drive the runner manually, one step per advance
$s = fakeServices();
$s['pve']->guestScript('status.php', 0, 'ok'); // deploy health + acceptance probe
// Fake defaults: tasks stopped/OK, guest agent up, cert reachable.
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
$order = Order::factory()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']);
$run = ProvisioningRun::factory()->create([
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [],
]);
$runner = app(RunRunner::class);
for ($i = 0; $i < 60; $i++) {
$run->refresh();
if (in_array($run->status, ['completed', 'failed'], true)) {
break;
}
if ($run->next_attempt_at?->isFuture()) {
$run->update(['next_attempt_at' => now()]);
}
$runner->advance($run);
}
$run->refresh();
$instance = Instance::query()->where('order_id', $order->id)->firstOrFail();
expect($run->status)->toBe('completed')
->and($run->error)->toBeNull()
->and($order->fresh()->status)->toBe('active')
->and($instance->status)->toBe('active')
->and($instance->vmid)->not->toBeNull()
->and($instance->cert_ok)->toBeTrue()
->and($instance->onboardingTasks()->count())->toBeGreaterThan(0);
// Every external resource created exactly once.
foreach (['instance_id', 'vmid', 'dns_record_id', 'nc_admin', 'backup_job_id', 'monitoring_target_id'] as $kind) {
expect(RunResource::where('run_id', $run->id)->where('kind', $kind)->count())->toBe(1);
}
// Secrets scrubbed from state; credentials delivered.
expect($run->context('admin_password'))->toBeNull()
->and($run->context('db_password'))->toBeNull();
Notification::assertSentOnDemand(CloudReady::class);
});
it('does not duplicate external resources when a step re-runs after a crash', function () {
Notification::fake();
Queue::fake();
$s = fakeServices();
$s['pve']->guestScript('status.php', 0, 'ok');
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
$order = Order::factory()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']);
$run = ProvisioningRun::factory()->create([
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [],
]);
$runner = app(RunRunner::class);
// Drive a few steps, then rewind to the clone step and replay it.
for ($i = 0; $i < 5; $i++) {
$run->refresh();
if ($run->next_attempt_at?->isFuture()) {
$run->update(['next_attempt_at' => now()]);
}
$runner->advance($run);
}
$cloneIndex = array_search(
\App\Provisioning\Steps\Customer\CloneVirtualMachine::class,
config('provisioning.pipelines.customer'),
true,
);
$run->update(['current_step' => $cloneIndex, 'status' => 'running']);
$runner->advance($run->fresh());
expect(RunResource::where('run_id', $run->id)->where('kind', 'vmid')->count())->toBe(1);
});

View File

@ -0,0 +1,302 @@
<?php
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\RunResource;
use App\Notifications\CloudReady;
use App\Provisioning\Steps\Customer\CloneVirtualMachine;
use App\Provisioning\Steps\Customer\CompleteProvisioning;
use App\Provisioning\Steps\Customer\ConfigureCloudInit;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNetwork;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Provisioning\Steps\Customer\CreateCustomerAdmin;
use App\Provisioning\Steps\Customer\DeployApplicationStack;
use App\Provisioning\Steps\Customer\RegisterBackup;
use App\Provisioning\Steps\Customer\RegisterMonitoring;
use App\Provisioning\Steps\Customer\ReserveResources;
use App\Provisioning\Steps\Customer\RunAcceptanceChecks;
use App\Provisioning\Steps\Customer\StartVirtualMachine;
use App\Provisioning\Steps\Customer\ValidateOrder;
use App\Provisioning\Steps\Customer\WaitForGuestAgent;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Notification;
/** A run whose order is paid but nothing is reserved yet. */
function orderRun(array $attrs = [], array $context = []): ProvisioningRun
{
$order = Order::factory()->create($attrs);
return ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'context' => $context,
]);
}
/** A run with a host + reserved instance + full VM context. */
function reservedRun(array $extraContext = [], array $instanceAttrs = []): array
{
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
$order = Order::factory()->create(['datacenter' => 'fsn', 'plan' => 'start']);
$instance = Instance::factory()->create(array_merge([
'order_id' => $order->id, 'customer_id' => $order->customer_id,
'host_id' => $host->id, 'vmid' => 101, 'status' => 'provisioning',
], $instanceAttrs));
$run = ProvisioningRun::factory()->create([
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer',
'context' => array_merge([
'instance_id' => $instance->id, 'host_id' => $host->id, 'node' => 'pve',
'vmid' => 101, 'subdomain' => $instance->subdomain, 'plan' => 'start',
], $extraContext),
]);
return compact('host', 'order', 'instance', 'run');
}
// 1. ValidateOrder
it('validates a paid order and moves it to provisioning', function () {
$run = orderRun(['status' => 'paid', 'plan' => 'start']);
expect(app(ValidateOrder::class)->execute($run)->type)->toBe('advance')
->and($run->subject->fresh()->status)->toBe('provisioning');
});
it('fails validation for an unpaid order or unknown plan', function () {
expect(app(ValidateOrder::class)->execute(orderRun(['status' => 'pending']))->type)->toBe('fail');
expect(app(ValidateOrder::class)->execute(orderRun(['status' => 'paid', 'plan' => 'ghost']))->type)->toBe('fail');
});
// 2. ReserveResources
it('reserves an instance on a datacenter host and records it once', function () {
fakeServices();
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
$run = orderRun(['status' => 'provisioning', 'plan' => 'start', 'datacenter' => 'fsn']);
expect(app(ReserveResources::class)->execute($run)->type)->toBe('advance');
$run->refresh();
$instance = Instance::query()->where('order_id', $run->subject_id)->first();
expect($instance)->not->toBeNull()
->and($run->context('instance_id'))->toBe($instance->id)
->and($run->context('node'))->toBe('pve');
// Re-run: idempotent (no second instance, one breadcrumb).
app(ReserveResources::class)->execute($run->fresh());
expect(Instance::query()->where('order_id', $run->subject_id)->count())->toBe(1)
->and(RunResource::where('run_id', $run->id)->where('kind', 'instance_id')->count())->toBe(1);
});
it('fails reservation when no host has capacity', function () {
$run = orderRun(['status' => 'provisioning', 'plan' => 'start', 'datacenter' => 'nowhere']);
expect(app(ReserveResources::class)->execute($run)->type)->toBe('fail');
});
// 3. CloneVirtualMachine
it('clones the template, persisting vmid before polling, once', function () {
$s = fakeServices();
['run' => $run, 'instance' => $instance] = reservedRun([], ['vmid' => null]);
expect(app(CloneVirtualMachine::class)->execute($run)->type)->toBe('advance');
$run->refresh();
expect($run->context('vmid'))->not->toBeNull()
->and($instance->fresh()->vmid)->toBe($run->context('vmid'))
->and(RunResource::where('run_id', $run->id)->where('kind', 'vmid')->count())->toBe(1);
// Re-run: idempotent, no second clone.
app(CloneVirtualMachine::class)->execute($run->fresh());
expect(count($s['pve']->clonedVmids))->toBe(1);
});
it('polls while cloning and fails on a clone error', function () {
$s = fakeServices();
$s['pve']->forceTaskStatus = 'running';
['run' => $run] = reservedRun([], ['vmid' => null]);
expect(app(CloneVirtualMachine::class)->execute($run)->type)->toBe('poll');
$s2 = fakeServices();
$s2['pve']->taskExitStatus = 'clone failed';
['run' => $run2] = reservedRun([], ['vmid' => null]);
expect(app(CloneVirtualMachine::class)->execute($run2)->type)->toBe('fail');
});
it('fails clone when the plan has no template', function () {
fakeServices();
$host = Host::factory()->active()->create(['node' => 'pve']);
$order = Order::factory()->create(['plan' => 'start']);
$instance = Instance::factory()->create(['order_id' => $order->id, 'customer_id' => $order->customer_id, 'host_id' => $host->id]);
$run = ProvisioningRun::factory()->create([
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer',
'context' => ['instance_id' => $instance->id, 'node' => 'pve'],
]);
config()->set('provisioning.plans.start.template_vmid', 0);
expect(app(CloneVirtualMachine::class)->execute($run)->type)->toBe('fail');
});
// 4. ConfigureCloudInit
it('configures cloud-init and resizes the disk', function () {
$s = fakeServices();
['run' => $run] = reservedRun();
expect(app(ConfigureCloudInit::class)->execute($run)->type)->toBe('advance')
->and($s['pve']->cloudInitCalls)->toContain(101)
->and($s['pve']->resizeCalls)->not->toBeEmpty();
});
// 5. StartVirtualMachine
it('starts the VM and is idempotent when already running', function () {
$s = fakeServices();
['run' => $run] = reservedRun();
expect(app(StartVirtualMachine::class)->execute($run)->type)->toBe('advance')
->and($s['pve']->runningVmids)->toContain(101);
// already running → advance without another start
expect(app(StartVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
->and(count($s['pve']->runningVmids))->toBe(1);
});
// 6. WaitForGuestAgent
it('waits for the guest agent then advances', function () {
$s = fakeServices();
$s['pve']->guestAgentUp = false;
['run' => $run] = reservedRun();
expect(app(WaitForGuestAgent::class)->execute($run)->type)->toBe('poll');
$s['pve']->guestAgentUp = true;
expect(app(WaitForGuestAgent::class)->execute($run->fresh())->type)->toBe('advance');
});
it('fails when the guest agent never answers before the deadline', function () {
$s = fakeServices();
$s['pve']->guestAgentUp = false;
['run' => $run] = reservedRun();
$run->update(['started_at' => now()->subMinutes(10)]);
expect(app(WaitForGuestAgent::class)->execute($run->fresh())->type)->toBe('fail');
});
// 7. ConfigureNetwork
it('configures the guest network and firewall', function () {
$s = fakeServices();
['run' => $run] = reservedRun();
expect(app(ConfigureNetwork::class)->execute($run)->type)->toBe('advance')
->and($s['pve']->guestRan('set-hostname'))->toBeTrue()
->and($s['pve']->firewallCalls)->toContain('101');
});
// 8. DeployApplicationStack
it('deploys the stack, polling until healthy, and scrubs the db password', function () {
$s = fakeServices();
['run' => $run] = reservedRun();
// not healthy yet → poll, secret still present
expect(app(DeployApplicationStack::class)->execute($run)->type)->toBe('poll')
->and($run->fresh()->context('db_password'))->not->toBeNull();
// healthy → advance, secret scrubbed
$s['pve']->guestScript('status.php', 0, 'ok');
expect(app(DeployApplicationStack::class)->execute($run->fresh())->type)->toBe('advance')
->and($run->fresh()->context('db_password'))->toBeNull();
});
// 9. ConfigureNextcloud
it('configures nextcloud via occ', function () {
$s = fakeServices();
['run' => $run] = reservedRun();
expect(app(ConfigureNextcloud::class)->execute($run)->type)->toBe('advance')
->and($s['pve']->guestRan('occ config:system:set trusted_domains'))->toBeTrue();
});
// 10. CreateCustomerAdmin
it('creates the admin once, never persisting the password in plaintext', function () {
$s = fakeServices();
['run' => $run, 'instance' => $instance] = reservedRun();
expect(app(CreateCustomerAdmin::class)->execute($run)->type)->toBe('advance');
$run->refresh();
expect($instance->fresh()->nc_admin_ref)->toBe('admin')
->and($run->context('admin_password'))->not->toBeNull() // present but encrypted
->and(Crypt::decryptString($run->context('admin_password')))->toBeString()
->and(RunResource::where('run_id', $run->id)->where('kind', 'nc_admin')->count())->toBe(1);
// raw context in DB must not contain the decrypted password
$rawContext = \DB::table('provisioning_runs')->where('id', $run->id)->value('context');
expect($rawContext)->not->toContain(Crypt::decryptString($run->context('admin_password')));
// idempotent
$guestCountBefore = count($s['pve']->guestCommands);
app(CreateCustomerAdmin::class)->execute($run->fresh());
expect(count($s['pve']->guestCommands))->toBe($guestCountBefore);
});
// 11. ConfigureDnsAndTls
it('creates the dns record, writes the route, and polls for the cert', function () {
$s = fakeServices();
$s['traefik']->certReady = false;
['run' => $run, 'instance' => $instance] = reservedRun();
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('poll');
$run->refresh();
$instance->refresh();
expect(RunResource::where('run_id', $run->id)->where('kind', 'dns_record_id')->count())->toBe(1)
->and($instance->dnsRecords()->count())->toBe(1)
->and($instance->route_written)->toBeTrue()
->and($s['traefik']->routes)->toHaveKey($instance->subdomain);
// cert now reachable → advance, no duplicate dns record
$s['traefik']->certReady = true;
expect(app(ConfigureDnsAndTls::class)->execute($run->fresh())->type)->toBe('advance')
->and($instance->fresh()->cert_ok)->toBeTrue()
->and(RunResource::where('run_id', $run->id)->where('kind', 'dns_record_id')->count())->toBe(1);
});
// 12/13. Backup + Monitoring
it('registers a backup job and a monitoring target once each', function () {
fakeServices();
['run' => $run, 'instance' => $instance] = reservedRun();
expect(app(RegisterBackup::class)->execute($run)->type)->toBe('advance');
app(RegisterBackup::class)->execute($run->fresh());
expect($instance->fresh()->backups()->count())->toBe(1)
->and(RunResource::where('run_id', $run->id)->where('kind', 'backup_job_id')->count())->toBe(1);
expect(app(RegisterMonitoring::class)->execute($run->fresh())->type)->toBe('advance');
app(RegisterMonitoring::class)->execute($run->fresh());
expect($instance->fresh()->monitoringTargets()->count())->toBe(1)
->and(RunResource::where('run_id', $run->id)->where('kind', 'monitoring_target_id')->count())->toBe(1);
});
// 14. RunAcceptanceChecks
it('passes acceptance only when everything is green', function () {
fakeServices();
['run' => $run, 'instance' => $instance, 'host' => $host] = reservedRun([], ['cert_ok' => true]);
foreach (['nc_admin', 'backup_job_id', 'monitoring_target_id'] as $kind) {
RunResource::create(['run_id' => $run->id, 'host_id' => $host->id, 'kind' => $kind, 'external_id' => 'x']);
}
expect(app(RunAcceptanceChecks::class)->execute($run)->type)->toBe('advance');
// missing monitoring → fail
RunResource::where('run_id', $run->id)->where('kind', 'monitoring_target_id')->delete();
expect(app(RunAcceptanceChecks::class)->execute($run->fresh())->type)->toBe('fail');
// failing cert → fail
['run' => $run2, 'host' => $host2] = reservedRun([], ['cert_ok' => false]);
expect(app(RunAcceptanceChecks::class)->execute($run2)->type)->toBe('fail');
});
// 15. CompleteProvisioning
it('completes: activates instance+order, seeds onboarding, delivers credentials', function () {
Notification::fake();
fakeServices();
['run' => $run, 'instance' => $instance, 'order' => $order] = reservedRun([
'admin_password' => Crypt::encryptString('s3cret-pw'),
]);
expect(app(CompleteProvisioning::class)->execute($run)->type)->toBe('advance');
expect($instance->fresh()->status)->toBe('active')
->and($order->fresh()->status)->toBe('active')
->and($instance->fresh()->onboardingTasks()->count())->toBeGreaterThan(0)
->and($run->fresh()->context('admin_password'))->toBeNull();
Notification::assertSentOnDemand(CloudReady::class);
});

View File

@ -51,19 +51,23 @@ function something()
/** /**
* Bind fake provisioning services into the container and return them so a test * Bind fake provisioning services into the container and return them so a test
* can script/inspect SSH, WireGuard and Proxmox interactions. * can script/inspect SSH, WireGuard, Proxmox, DNS and Traefik interactions.
* *
* @return array{shell: \App\Services\Ssh\FakeRemoteShell, hub: \App\Services\Wireguard\FakeWireguardHub, pve: \App\Services\Proxmox\FakeProxmoxClient} * @return array{shell: \App\Services\Ssh\FakeRemoteShell, hub: \App\Services\Wireguard\FakeWireguardHub, pve: \App\Services\Proxmox\FakeProxmoxClient, dns: \App\Services\Dns\FakeHetznerDnsClient, traefik: \App\Services\Traefik\FakeTraefikWriter}
*/ */
function fakeServices(): array function fakeServices(): array
{ {
$shell = new \App\Services\Ssh\FakeRemoteShell; $shell = new \App\Services\Ssh\FakeRemoteShell;
$hub = new \App\Services\Wireguard\FakeWireguardHub; $hub = new \App\Services\Wireguard\FakeWireguardHub;
$pve = new \App\Services\Proxmox\FakeProxmoxClient; $pve = new \App\Services\Proxmox\FakeProxmoxClient;
$dns = new \App\Services\Dns\FakeHetznerDnsClient;
$traefik = new \App\Services\Traefik\FakeTraefikWriter;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
app()->instance(\App\Services\Wireguard\WireguardHub::class, $hub); app()->instance(\App\Services\Wireguard\WireguardHub::class, $hub);
app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);
app()->instance(\App\Services\Dns\HetznerDnsClient::class, $dns);
app()->instance(\App\Services\Traefik\TraefikWriter::class, $traefik);
return ['shell' => $shell, 'hub' => $hub, 'pve' => $pve]; return ['shell' => $shell, 'hub' => $hub, 'pve' => $pve, 'dns' => $dns, 'traefik' => $traefik];
} }