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
* exists (has a vmid) the disk is really consumed until cleanup; a failure
* before any VM was created (no vmid) releases its quota.
* Host storage already committed, counted as the VM disk allocation
* (disk_gb) since that is what is actually placed on the host not the
* (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
{
return (int) $this->instances()
->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid'))
->sum('quota_gb');
->sum('disk_gb');
}
/** Storage still available for new instances. */

View File

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

View File

@ -39,7 +39,7 @@ class ReserveResources extends CustomerStep
// Place + create under a per-datacenter lock so concurrent reservations
// can't both pick the same host and overcommit its capacity.
$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) {
return null;
}

View File

@ -131,7 +131,12 @@ class HttpProxmoxClient implements ProxmoxClient
foreach ($rules as $rule) {
$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

View File

@ -53,18 +53,18 @@ it('encrypts nc_admin_ref at rest', function () {
it('computes host capacity from committed instance quotas', function () {
$host = Host::factory()->active()->create(['total_gb' => 1000, 'reserve_pct' => 15]);
// freeGb = 850
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 100, 'status' => 'active']);
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 200, '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, 'quota_gb' => 150, 'status' => 'failed', 'vmid' => 900]); // VM still exists → counts
Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 120, 'status' => 'active']);
Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 220, 'status' => 'provisioning']);
Instance::factory()->create(['host_id' => $host->id, 'disk_gb' => 500, 'status' => 'failed', 'vmid' => null]); // released (no VM)
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
->and($host->availableGb())->toBe(400); // 850 - 450
expect($host->committedGb())->toBe(500) // 120 + 220 + 160 (disk allocations)
->and($host->availableGb())->toBe(350); // 850 - 500
});
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]);
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]);
Host::factory()->active()->create(['datacenter' => 'hel', 'name' => 'pve-hel-a', 'total_gb' => 1000]);