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
parent
c79874ccfe
commit
5cf1964d7d
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue