fix(engine-b): real backup/monitoring registration + OC_PASS scoping

- CreateCustomerAdmin puts OC_PASS on the docker invocation (survives the &&).
- RegisterBackup creates a real vzdump job via ProxmoxClient::createBackupJob.
- RegisterMonitoring registers via a new MonitoringClient service (interface +
  fake + http). Both persist the external id [E] before the breadcrumb, so
  acceptance reflects a real registration, not a fabricated row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:33:45 +02:00
parent 64e3ca421d
commit b4cf94ee1a
12 changed files with 129 additions and 8 deletions

View File

@ -5,6 +5,8 @@ namespace App\Providers;
use App\Provisioning\PipelineRegistry;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Ssh\PhpseclibRemoteShell;
@ -32,6 +34,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(ProxmoxClient::class, HttpProxmoxClient::class);
$this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class);
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
$this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
}
/**

View File

@ -46,8 +46,11 @@ class CreateCustomerAdmin extends CustomerStep
? 'user:resetpassword --password-from-env '.escapeshellarg($username)
: 'user:add --password-from-env --group=admin '.escapeshellarg($username);
// Pass the password via env (OC_PASS), never on the argv/command line.
$this->guest($pve, $run, 'OC_PASS='.escapeshellarg($password).' '.$occ.' -e OC_PASS app php occ '.$action);
// Pass the password via env (OC_PASS) on the docker invocation itself —
// an assignment before `cd` would not survive the `&&`.
$this->guest($pve, $run,
'cd /opt/nextcloud && OC_PASS='.escapeshellarg($password).
' docker compose exec -T -e OC_PASS app php occ '.$action);
// Persist only the username (encrypted ref); hand the password to step 15
// encrypted-in-context for delivery, then it is scrubbed. Never plaintext.

View File

@ -4,9 +4,12 @@ namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
class RegisterBackup extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'register_backup';
@ -24,14 +27,18 @@ class RegisterBackup extends CustomerStep
}
$instance = $this->instance($run);
$jobId = 'vzdump-'.$instance->vmid.'-daily';
$schedule = 'daily 02:00';
// Create a real scheduled vzdump job on the host [E] before recording it.
$jobId = $this->pve->forHost($instance->host)
->createBackupJob((string) $run->context('node'), (int) $instance->vmid, $schedule);
$this->recordResource($run, $instance->host, 'backup_job_id', $jobId);
$instance->backups()->create([
'external_job_id' => $jobId,
'schedule' => 'daily 02:00',
'schedule' => $schedule,
'status' => 'scheduled',
]);
$this->recordResource($run, $instance->host, 'backup_job_id', $jobId);
return StepResult::advance();
}

View File

@ -4,9 +4,12 @@ namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Monitoring\MonitoringClient;
class RegisterMonitoring extends CustomerStep
{
public function __construct(private MonitoringClient $monitoring) {}
public function key(): string
{
return 'register_monitoring';
@ -24,15 +27,17 @@ class RegisterMonitoring extends CustomerStep
}
$instance = $this->instance($run);
$targetId = 'mon-'.$instance->vmid;
$url = 'https://'.$instance->subdomain.'.'.config('provisioning.dns.zone').'/status.php';
// Register with the monitoring service [E] before recording it.
$targetId = $this->monitoring->registerTarget($instance->subdomain, $url);
$this->recordResource($run, $instance->host, 'monitoring_target_id', $targetId);
$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,24 @@
<?php
namespace App\Services\Monitoring;
class FakeMonitoringClient implements MonitoringClient
{
/** @var array<string, string> id => url */
public array $targets = [];
private int $counter = 1;
public function registerTarget(string $name, string $url): string
{
$id = 'mon-'.$this->counter++;
$this->targets[$id] = $url;
return $id;
}
public function deregisterTarget(string $externalId): void
{
unset($this->targets[$externalId]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Services\Monitoring;
use Illuminate\Support\Facades\Http;
/**
* Registers uptime targets with the configured monitoring service. If none is
* configured, returns a deterministic id (no-op) so provisioning still records a
* stable breadcrumb. Not unit-tested (live I/O).
*/
class HttpMonitoringClient implements MonitoringClient
{
public function registerTarget(string $name, string $url): string
{
$endpoint = (string) config('services.monitoring.url');
if (blank($endpoint)) {
return 'mon-'.substr(sha1($url), 0, 12);
}
return (string) Http::withToken((string) config('services.monitoring.token'))
->post(rtrim($endpoint, '/').'/monitors', ['friendly_name' => $name, 'url' => $url, 'type' => 'https'])
->throw()->json('monitor.id');
}
public function deregisterTarget(string $externalId): void
{
$endpoint = (string) config('services.monitoring.url');
if (filled($endpoint)) {
Http::withToken((string) config('services.monitoring.token'))
->delete(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw();
}
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Services\Monitoring;
interface MonitoringClient
{
/** Register an uptime/monitoring target; returns the external target id. */
public function registerTarget(string $name, string $url): string;
public function deregisterTarget(string $externalId): void;
}

View File

@ -165,4 +165,15 @@ class FakeProxmoxClient implements ProxmoxClient
{
$this->firewallCalls[] = (string) $vmid;
}
/** @var array<int, string> */
public array $backupJobs = [];
public function createBackupJob(string $node, int $vmid, string $schedule): string
{
$id = 'backup-'.$vmid;
$this->backupJobs[] = $id;
return $id;
}
}

View File

@ -133,4 +133,17 @@ class HttpProxmoxClient implements ProxmoxClient
}
$this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1])->throw();
}
public function createBackupJob(string $node, int $vmid, string $schedule): string
{
$data = $this->http()->asForm()->post('/cluster/backup', [
'vmid' => $vmid,
'schedule' => $schedule,
'storage' => 'local',
'mode' => 'snapshot',
'enabled' => 1,
])->throw()->json('data');
return is_string($data) ? $data : (string) ($data['id'] ?? 'backup-'.$vmid);
}
}

View File

@ -55,4 +55,7 @@ interface ProxmoxClient
/** @param array<int, array<string, mixed>> $rules */
public function applyFirewall(string $node, int $vmid, array $rules): void;
/** Create a scheduled vzdump backup job for the VM; returns the job id. */
public function createBackupJob(string $node, int $vmid, string $schedule): string;
}

View File

@ -39,4 +39,9 @@ return [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
'monitoring' => [
'url' => env('MONITORING_API_URL'),
'token' => env('MONITORING_API_TOKEN'),
],
];

View File

@ -62,12 +62,14 @@ function fakeServices(): array
$pve = new \App\Services\Proxmox\FakeProxmoxClient;
$dns = new \App\Services\Dns\FakeHetznerDnsClient;
$traefik = new \App\Services\Traefik\FakeTraefikWriter;
$monitoring = new \App\Services\Monitoring\FakeMonitoringClient;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
app()->instance(\App\Services\Wireguard\WireguardHub::class, $hub);
app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve);
app()->instance(\App\Services\Dns\HetznerDnsClient::class, $dns);
app()->instance(\App\Services\Traefik\TraefikWriter::class, $traefik);
app()->instance(\App\Services\Monitoring\MonitoringClient::class, $monitoring);
return ['shell' => $shell, 'hub' => $hub, 'pve' => $pve, 'dns' => $dns, 'traefik' => $traefik];
return ['shell' => $shell, 'hub' => $hub, 'pve' => $pve, 'dns' => $dns, 'traefik' => $traefik, 'monitoring' => $monitoring];
}