fix(engine-b): default-deny firewall, disk-based capacity, synchronous credential mail

- applyFirewall sets policy_in=DROP so only 80/443 are exposed.
- Capacity/placement account for disk_gb (the real VM allocation), not the
  smaller Nextcloud user quota — no systematic overcommit.
- CloudReady sent synchronously again; the step retries on mail failure with the
  password preserved (no lost credential in a failed queue job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:39:15 +02:00
parent c79874ccfe
commit 5cf1964d7d
5 changed files with 27 additions and 22 deletions

View File

@ -63,15 +63,16 @@ class Host extends Model implements ProvisioningSubject
} }
/** /**
* Storage already committed. A failed instance still counts while its VM * Host storage already committed, counted as the VM disk allocation
* exists (has a vmid) the disk is really consumed until cleanup; a failure * (disk_gb) since that is what is actually placed on the host not the
* before any VM was created (no vmid) releases its quota. * (smaller) Nextcloud user quota. A failed instance still counts while its VM
* exists (has a vmid); a failure before any VM was created releases it.
*/ */
public function committedGb(): int public function committedGb(): int
{ {
return (int) $this->instances() return (int) $this->instances()
->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')) ->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid'))
->sum('quota_gb'); ->sum('disk_gb');
} }
/** Storage still available for new instances. */ /** Storage still available for new instances. */

View File

@ -4,22 +4,21 @@ namespace App\Notifications;
use App\Models\Instance; use App\Models\Instance;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
/** /**
* Delivers the initial admin credentials once provisioning completes. Queued for * Delivers the initial admin credentials once provisioning completes.
* durable delivery; the password is passed transiently and never persisted in
* plaintext.
* *
* Delivery is at-least-once: a worker crash in the tiny window between sending * Sent SYNCHRONOUSLY from CompleteProvisioning (not queued): the step only
* and recording the `credentials_sent` breadcrumb can re-send (a duplicate * records the `credentials_sent` breadcrumb and scrubs the password AFTER the
* welcome email). Exactly-once would need a transactional outbox (v1.1); a * send returns, so a mail failure re-runs the step (password preserved) instead
* duplicate is preferred over locking the customer out with a lost credential. * of losing the credential in a failed queue job. Delivery is at-least-once a
* crash in the tiny window after send but before the breadcrumb can re-send a
* duplicate welcome email, which is preferable to a lost credential.
*/ */
class CloudReady extends Notification implements ShouldQueue class CloudReady extends Notification
{ {
use Queueable; use Queueable;

View File

@ -39,7 +39,7 @@ class ReserveResources extends CustomerStep
// Place + create under a per-datacenter lock so concurrent reservations // Place + create under a per-datacenter lock so concurrent reservations
// can't both pick the same host and overcommit its capacity. // can't both pick the same host and overcommit its capacity.
$instance = Cache::lock('placement:'.$order->datacenter, 30)->block(10, function () use ($order, $plan) { $instance = Cache::lock('placement:'.$order->datacenter, 30)->block(10, function () use ($order, $plan) {
$host = Host::placeableIn($order->datacenter, (int) $plan['quota_gb']); $host = Host::placeableIn($order->datacenter, (int) $plan['disk_gb']);
if ($host === null) { if ($host === null) {
return null; return null;
} }

View File

@ -131,7 +131,12 @@ class HttpProxmoxClient implements ProxmoxClient
foreach ($rules as $rule) { foreach ($rules as $rule) {
$this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/firewall/rules", $rule)->throw(); $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/firewall/rules", $rule)->throw();
} }
$this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1])->throw(); // Default-deny inbound so only the explicit ACCEPT rules (80/443) are open.
$this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/firewall/options", [
'enable' => 1,
'policy_in' => 'DROP',
'policy_out' => 'ACCEPT',
])->throw();
} }
public function createBackupJob(string $node, int $vmid, string $schedule): string public function createBackupJob(string $node, int $vmid, string $schedule): string

View File

@ -53,18 +53,18 @@ it('encrypts nc_admin_ref at rest', function () {
it('computes host capacity from committed instance quotas', function () { it('computes host capacity from committed instance quotas', function () {
$host = Host::factory()->active()->create(['total_gb' => 1000, 'reserve_pct' => 15]); $host = Host::factory()->active()->create(['total_gb' => 1000, 'reserve_pct' => 15]);
// freeGb = 850 // freeGb = 850
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 100, 'status' => 'active']); Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 120, 'status' => 'active']);
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 200, 'status' => 'provisioning']); Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 220, 'status' => 'provisioning']);
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 500, 'status' => 'failed', 'vmid' => null]); // released (no VM) Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 500, 'status' => 'failed', 'vmid' => null]); // released (no VM)
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 150, 'status' => 'failed', 'vmid' => 900]); // VM still exists → counts Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 160, 'status' => 'failed', 'vmid' => 900]); // VM still exists → counts
expect($host->committedGb())->toBe(450) // 100 + 200 + 150 expect($host->committedGb())->toBe(500) // 120 + 220 + 160 (disk allocations)
->and($host->availableGb())->toBe(400); // 850 - 450 ->and($host->availableGb())->toBe(350); // 850 - 500
}); });
it('places an instance on an active host in the datacenter with room', function () { it('places an instance on an active host in the datacenter with room', function () {
$full = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-a', 'total_gb' => 100, 'reserve_pct' => 15]); $full = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-a', 'total_gb' => 100, 'reserve_pct' => 15]);
Instance::factory()->create(['host_id' => $full->id, 'quota_gb' => 80, 'status' => 'active']); Instance::factory()->create(['host_id' => $full->id, 'disk_gb' => 80, 'status' => 'active']); // free 85, committed 80 → 5 left
$room = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-b', 'total_gb' => 1000, 'reserve_pct' => 15]); $room = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-b', 'total_gb' => 1000, 'reserve_pct' => 15]);
Host::factory()->active()->create(['datacenter' => 'hel', 'name' => 'pve-hel-a', 'total_gb' => 1000]); Host::factory()->active()->create(['datacenter' => 'hel', 'name' => 'pve-hel-a', 'total_gb' => 1000]);