diff --git a/app/Actions/ApplyStorageAllowance.php b/app/Actions/ApplyStorageAllowance.php new file mode 100644 index 0000000..ada142e --- /dev/null +++ b/app/Actions/ApplyStorageAllowance.php @@ -0,0 +1,142 @@ +forInstance( + $subscription->instance ?? $subscription->customer?->instances()->latest('id')->first() + ); + } + + /** + * Start a storage run for this instance, or return null when there is + * nothing to start. + * + * Null is the ordinary answer and not a failure: a machine still being + * built, one whose VM has gone, one whose service has ended, or one that + * already has a run in flight all deliver the allowance from that run + * instead — every pipeline that touches storage reads the allowance when it + * gets there, which is this state or newer. + */ + public function forInstance(?Instance $instance): ?ProvisioningRun + { + if ($instance === null || ! $instance->hasLiveMachine()) { + return null; + } + + // The same lock ReapplyInstanceAddress and RestartInstance take, for the + // same reason: two bookings can land in one instant — a double click, a + // webhook delivered twice, an operator granting a pack while the + // customer buys one — and two runs against one machine would resize the + // same disk and race each other's occ calls. Nobody waits for it: a run + // that lost the race has nothing to add, because the one that won reads + // the same allowance. + $lock = Cache::lock('instance-storage:'.$instance->uuid, 30); + + if (! $lock->get()) { + return null; + } + + try { + return $this->start($instance); + } finally { + $lock->release(); + } + } + + private function start(Instance $instance): ?ProvisioningRun + { + $order = $instance->order; + + // Checked against the ORDER, because that is the subject every customer + // pipeline shares. A storage run beside an unfinished build would resize + // a disk the build is still writing; one beside a plan change would + // resize it twice from two different figures. Whatever is in flight + // reads the allowance itself when it reaches the quota step, so nothing + // is lost by standing aside. + if ($this->hasRunInFlight($order)) { + return null; + } + + $run = ProvisioningRun::create([ + 'subject_type' => Order::class, + 'subject_id' => $order->id, + 'pipeline' => 'storage', + 'status' => ProvisioningRun::STATUS_PENDING, + 'current_step' => 0, + // Everything the three steps read: instance_id is how CustomerStep + // finds the machine, node and vmid are how it reaches inside it. + 'context' => [ + 'instance_id' => $instance->id, + 'host_id' => $instance->host_id, + 'node' => $instance->host?->node, + 'vmid' => $instance->vmid, + 'subdomain' => $instance->subdomain, + ], + ]); + + AdvanceRunJob::dispatch($run->uuid); + + Log::info('Delivering a changed storage allowance.', [ + 'instance' => $instance->uuid, + 'run' => $run->uuid, + ]); + + return $run; + } + + private function hasRunInFlight(Order $order): bool + { + return ProvisioningRun::query() + ->where('subject_type', Order::class) + ->where('subject_id', $order->id) + ->whereIn('status', [ + ProvisioningRun::STATUS_PENDING, + ProvisioningRun::STATUS_RUNNING, + ProvisioningRun::STATUS_WAITING, + ProvisioningRun::STATUS_PAUSED, + ]) + ->exists(); + } +} diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index 2e9514e..366bb38 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -27,6 +27,14 @@ use RuntimeException; * customer would be shown rather than a developer's, so whatever refuses — * the portal, the console's grant screen, a webhook — says the same thing. * + * It is also where a booking stops being only a row. Storage packs are the one + * module that changes what the machine has to be, and they were sold for months + * without anything anywhere acting on them — so booking or cancelling one asks + * ApplyStorageAllowance for the run that makes the disk, the guest's filesystem + * and Nextcloud's quota match what the customer now pays for. Every route into a + * booking goes through here — the shop, a webhook, an operator's grant — which + * is why the delivery hangs off this action and not off any one of them. + * * `$overrides` exists for GrantAddon: a granted module is booked through this * same action, with its price replaced by what the customer actually pays and * its provenance stamped on the row. Empty for every ordinary booking. A grant @@ -66,17 +74,21 @@ class BookAddon } try { - return $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides); + $addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides); } catch (UniqueConstraintViolationException) { // A concurrent retry won. The unique index on (order_id, addon_key) // is what actually enforces "one order books one module" — the // lookup below is only the fast path, and two transactions can both // pass it. - return SubscriptionAddon::query() + $addon = SubscriptionAddon::query() ->where('order_id', $order?->id) ->where('addon_key', $addonKey) ->firstOrFail(); } + + $this->deliverStorage($subscription, $addonKey); + + return $addon; } /** @param array $overrides */ @@ -158,7 +170,7 @@ class BookAddon */ public function cancel(SubscriptionAddon $addon): SubscriptionAddon { - return DB::transaction(function () use ($addon) { + $cancelled = DB::transaction(function () use ($addon) { // Claim the cancellation conditionally, so two requests arriving // together write one event between them. Checking `isActive()` on // separate instances and updating afterwards lets both through, and @@ -184,5 +196,36 @@ class BookAddon return $addon; }); + + $this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key); + + return $cancelled; + } + + /** + * Make a change to the storage packs real on the machine. + * + * Storage is the one module whose booking changes what the machine has to + * BE: a pack is a hundred gigabytes the disk, the guest's filesystem and + * Nextcloud's quota all have to grow to, and cancelling one is the same + * sentence read backwards. Every other module is a licence, a support tier + * or a flag — nothing on the machine moves when one is booked. + * + * Deliberately AFTER the transaction has committed, never inside it. The run + * this starts is picked up by a queue worker in another process, and a + * worker that arrived first would read a contract whose booking had not been + * written yet — and would then deliver the allowance as it was a moment ago. + * + * Nothing here fails a booking. ApplyStorageAllowance returns null for every + * ordinary "not now" — no machine, one still being built, a run already in + * flight — and that run applies the same allowance when it gets there. + */ + private function deliverStorage(?Subscription $subscription, string $addonKey): void + { + if ($addonKey !== AddonCatalogue::STORAGE) { + return; + } + + app(ApplyStorageAllowance::class)($subscription); } } diff --git a/app/Console/Commands/ApplyStorageQuotas.php b/app/Console/Commands/ApplyStorageQuotas.php index 9d98a05..db8066b 100644 --- a/app/Console/Commands/ApplyStorageQuotas.php +++ b/app/Console/Commands/ApplyStorageQuotas.php @@ -6,6 +6,7 @@ use App\Models\Instance; use App\Models\Order; use App\Models\ProvisioningRun; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Services\Billing\StorageAllowance; use Illuminate\Console\Command; /** @@ -70,8 +71,13 @@ class ApplyStorageQuotas extends Command $started++; + // The whole allowance, packs included — the figure the run will + // actually write. Printing `quota_gb` would report the package while + // the sweep applied something larger. + $owed = StorageAllowance::for($instance)->totalGb(); + $this->line(($dryRun ? '[dry-run] ' : '') - ."{$instance->subdomain}: {$instance->quota_gb} GB " + ."{$instance->subdomain}: {$owed} GB " .'('.($instance->quota_applied_gb === null ? 'noch nie gesetzt' : "zuletzt {$instance->quota_applied_gb} GB").')'); if (! $dryRun) { diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php index 5346762..18bc14d 100644 --- a/app/Livewire/Admin/Instances.php +++ b/app/Livewire/Admin/Instances.php @@ -4,6 +4,7 @@ namespace App\Livewire\Admin; use App\Actions\RestartInstance; use App\Models\Instance; +use App\Services\Billing\StorageAllowance; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Lang; @@ -89,7 +90,12 @@ class Instances extends Component 'host' => $i->host?->name ?? '—', 'vmid' => $i->vmid ?? '—', 'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—', - 'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—', + // The whole allowance, packs included — the same figure the + // customer's own page states and the same one Nextcloud is + // told. An operator reading the package alone here would be + // looking at a smaller number than the machine enforces and + // would have no way of telling. + 'quota' => ($owed = StorageAllowance::for($i)->totalGb()) > 0 ? $owed.' GB' : '—', // A machine running on less CPU or RAM than it has been sold — // see Instance::restartIsPending(). Shown beside the status // rather than as one, because the instance is genuinely active diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index fa9c381..fc973af 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -4,15 +4,20 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; +use App\Models\Instance; +use App\Models\InstanceMetric; use App\Models\Order; use App\Models\Subscription; use App\Services\Billing\AddonCatalogue; use App\Services\Billing\CustomDomainAccess; use App\Services\Billing\DowngradeCheck; use App\Services\Billing\PlanCatalogue; +use App\Services\Billing\StorageAllowance; use App\Services\Billing\TaxTreatment; +use App\Services\Provisioning\DiskUsageProbe; use App\Services\Traffic\TrafficMeter; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Attributes\On; @@ -23,11 +28,22 @@ class Billing extends Component { use ResolvesCustomer; + /** + * The most storage packs one click may put in the cart. + * + * A ceiling rather than a rule: the blocked-downgrade card offers exactly + * the number that would clear the blocker, and that number is computed from + * a measurement. This exists because the method is reachable from anywhere + * that can POST to /livewire/update with any argument at all, and a cart + * with nine thousand lines in it is a page nobody can use again. + */ + private const MAX_STORAGE_PACKS = 50; + /** * Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is * mocked for now — the order is created with status 'pending'. */ - public function purchase(string $type, ?string $key = null): void + public function purchase(string $type, ?string $key = null, int $quantity = 1): void { $customer = $this->requireCustomer(); if ($customer === null) { @@ -115,7 +131,7 @@ class Billing extends Component // Replacement and insert run together with the customer row locked: // two clicks in flight could otherwise both delete and then both // insert, leaving exactly the two upgrades this rule exists to prevent. - $replaced = DB::transaction(function () use ($customer, $contract, $type, $plan, $addonKey, $amount, $datacenter) { + $replaced = DB::transaction(function () use ($customer, $contract, $type, $plan, $addonKey, $amount, $datacenter, $quantity) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); // Up and down are the same kind of change and contradict each @@ -128,16 +144,28 @@ class Billing extends Component ->delete() : 0; - Order::create([ - 'customer_id' => $customer->id, - 'plan' => $plan, - 'type' => $type, - 'addon_key' => $addonKey, - 'amount_cents' => $amount, - 'currency' => 'EUR', - 'datacenter' => $datacenter, - 'status' => 'pending', - ]); + // Storage is the one thing sold in packs, and a blocked downgrade + // can need several of them at once — a customer told they are 280 GB + // over should not have to click the same button three times and + // count. Everything else is one line whatever is asked for: a second + // upgrade contradicts the first, and a second entitlement is a + // second charge for one thing. + $lines = $type === 'storage' + ? max(1, min(self::MAX_STORAGE_PACKS, $quantity)) + : 1; + + foreach (range(1, $lines) as $ignored) { + Order::create([ + 'customer_id' => $customer->id, + 'plan' => $plan, + 'type' => $type, + 'addon_key' => $addonKey, + 'amount_cents' => $amount, + 'currency' => 'EUR', + 'datacenter' => $datacenter, + 'status' => 'pending', + ]); + } // The contract carries the booking, and it is booked HERE — at the // moment the customer decides — with the period end as it stands @@ -173,6 +201,72 @@ class Billing extends Component $this->dispatch('notify', message: __('billing.cart.removed')); } + /** + * The first way out of a downgrade blocked by data: buy the room instead of + * deleting the files. + * + * Raised by ConfirmBookStorage rather than called from the card, so the + * customer confirms a recurring charge in this product's own dialog and not + * in one the browser draws (R23). The work stays in purchase(), which + * already knows what a storage line costs and how the cart is written — + * duplicating that here is how the two would come to disagree. + */ + #[On('storage-packs-confirmed')] + public function bookStoragePacks(int $packs = 1): void + { + $this->purchase('storage', null, $packs); + } + + /** + * The other way out: delete data, then ask again NOW. + * + * The blocker is measured, and the measurement is taken on the sampler's + * rounds — so somebody who has just cleared forty gigabytes was being told + * to wait until tomorrow to find out it had worked. This takes the reading + * on demand and writes it into the same row the sampler fills, so every + * reader of the fill level sees it and nothing needs a second notion of + * "current". + * + * One `df` through the guest agent: a bounded read that cannot change + * anything on the machine. The mutating work storage needs — growing a disk, + * growing a filesystem, writing an occ setting — is not here and never will + * be; that goes through a provisioning run, where it is retried and visible. + * The cooldown is because this button is a button: a customer holding it + * down must not turn into a queue of guest commands against their own cloud. + */ + public function remeasureStorage(): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $instance = $customer->instances()->latest('id')->first(); + + if ($instance === null || ! $instance->hasLiveMachine()) { + $this->dispatch('notify', message: __('billing.storage_remeasure_unavailable')); + + return; + } + + if (! Cache::add('storage-remeasure:'.$instance->uuid, true, now()->addSeconds(20))) { + $this->dispatch('notify', message: __('billing.storage_remeasure_wait')); + + return; + } + + $metric = app(DiskUsageProbe::class)->record($instance); + + // Null is "we could not look", which is a different sentence from "you + // are fine" — a guest that did not answer must never read as an empty + // one, because the next thing the customer would do is press a downgrade + // button on the strength of it. + $this->dispatch('notify', message: __($metric === null + ? 'billing.storage_remeasure_unavailable' + : 'billing.storage_remeasured')); + } + /** * Why a module cannot go into the cart a second time. Null when it can. * @@ -210,13 +304,30 @@ class Billing extends Component * @param array $catalogue * @return array */ - private function currentTerms(?Subscription $subscription, array $catalogue): array + private function currentTerms(?Subscription $subscription, ?Instance $instance, array $catalogue): array { + // What the customer may store is the package PLUS the packs they have + // booked, and the card that states their terms has to say the figure + // that is actually enforced on their machine. Printing `quota_gb` there + // told a customer with two packs that they had 500 GB while Nextcloud + // was giving them 700. + $allowance = StorageAllowance::for($instance); + + // Nobody browsing has packs — a pack is booked onto a contract — so the + // catalogue's own figure is the whole answer there, and falling back to + // the allowance would print "0 GB" at somebody who has not bought yet. if ($subscription === null) { - return $catalogue; + return $catalogue + [ + 'storage_gb' => (int) ($catalogue['quota_gb'] ?? 0), + 'storage_packs' => 0, + 'storage_pack_gb' => 0, + ]; } return [ + 'storage_gb' => $allowance->totalGb(), + 'storage_packs' => $allowance->packs, + 'storage_pack_gb' => $allowance->packGb(), 'tier' => $subscription->tier, // Per month: the card says "/ month", and a yearly contract stores // the whole year. @@ -337,7 +448,7 @@ class Billing extends Component // BUY comes from the shop. Reading this off the catalogue showed a // customer today's price as though it were theirs, and dropped the // card entirely once their plan stopped being sold. - 'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []), + 'current' => $this->currentTerms($instance?->subscription, $instance, $plans[$currentKey] ?? []), 'instance' => $instance, 'plans' => $plans, 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(), @@ -366,6 +477,14 @@ class Billing extends Component // months is exactly the thing a customer should not have to // remember on their own. 'pendingChange' => $this->pendingChange(app(CustomDomainAccess::class)->contractOf($customer)), + // When the fill level a blocked downgrade is measured against was + // actually read. Stated rather than assumed current: a customer who + // has just deleted data has to be able to see that the number in + // front of them predates the deletion, and that pressing "neu + // messen" is what will change it. + 'storageMeasuredAt' => $instance !== null + ? InstanceMetric::latestDisk($instance)?->updated_at + : null, ]); } } diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index 6ac471d..e39431c 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -6,6 +6,7 @@ use App\Actions\RestartInstance; use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Instance; use App\Models\MaintenanceWindow; +use App\Services\Billing\StorageAllowance; use App\Support\ProvisioningSettings; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Number; @@ -84,7 +85,11 @@ class Cloud extends Component // we would sell someone new — never what this customer already has. $contract = $shown?->subscription; $planKey = $shown?->plan ?? 'team'; - $quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500); + // The whole allowance, packs included, asked of the one class that adds + // them up. Reading `quota_gb` here drew the ring against the package + // alone, so a customer who had bought two packs saw themselves at 90 % + // full of a quota their cloud was no longer enforcing. + $quota = StorageAllowance::for($shown)->totalGb() ?: 500; // Usage metering is not wired yet — scale the illustrative curve into the // instance's quota so the chart and the "x / y GB" label never disagree. $curveMax = max($growth); diff --git a/app/Livewire/ConfirmBookStorage.php b/app/Livewire/ConfirmBookStorage.php new file mode 100644 index 0000000..206413b --- /dev/null +++ b/app/Livewire/ConfirmBookStorage.php @@ -0,0 +1,46 @@ +packs = max(1, min(50, $packs)); + $this->packGb = (int) config('provisioning.storage_addon.gb', 0); + } + + public function proceed(): void + { + $this->dispatch('storage-packs-confirmed', packs: $this->packs); + $this->closeModal(); + } + + public function render() + { + return view('livewire.confirm-book-storage'); + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php index b7def82..c9b5410 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Concerns\HasUuid; +use App\Services\Billing\StorageAllowance; use Database\Factories\InstanceFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -74,19 +75,23 @@ class Instance extends Model * Has this machine's Nextcloud actually been told the allowance that was * sold? * - * Two columns rather than one because they answer two different questions: - * `quota_gb` is what the customer bought, `quota_applied_gb` is what the - * guest was last told. They agree on every machine the quota step has - * visited since it started recording, and they disagree on exactly the two - * cases worth finding — the estate built before the step existed, and any - * machine whose package changed while the run that should have followed did - * not finish. + * Two figures rather than one because they answer two different questions: + * what the customer is OWED, and what the guest was last TOLD. They agree on + * every machine the quota step has visited since it started recording, and + * they disagree on exactly the three cases worth finding — the estate built + * before the step existed, any machine whose package changed while the run + * that should have followed did not finish, and one whose storage packs were + * booked or cancelled without the delivery run reaching the end. + * + * What is owed is asked of StorageAllowance and not read off `quota_gb`: + * that column is the package alone, and a customer with a booked pack is + * owed more than their package includes. */ public function quotaIsEnforced(): bool { - return $this->quota_gb !== null - && $this->quota_gb > 0 - && (int) $this->quota_applied_gb === (int) $this->quota_gb; + $owed = StorageAllowance::for($this)->totalGb(); + + return $owed > 0 && (int) $this->quota_applied_gb === $owed; } /** diff --git a/app/Provisioning/Jobs/CollectInstanceTraffic.php b/app/Provisioning/Jobs/CollectInstanceTraffic.php index f330edf..7d6b7ac 100644 --- a/app/Provisioning/Jobs/CollectInstanceTraffic.php +++ b/app/Provisioning/Jobs/CollectInstanceTraffic.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Jobs; use App\Models\Instance; use App\Models\InstanceMetric; use App\Models\InstanceTraffic; +use App\Services\Provisioning\DiskUsageProbe; use App\Services\Proxmox\ProxmoxClient; use App\Services\Traffic\TrafficMeter; use Illuminate\Bus\Queueable; @@ -132,7 +133,12 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue private function recordDay($client, Instance $instance, int $rxDelta, int $txDelta): void { try { - $disk = $this->diskUsage($client, $instance); + // Asked of the probe rather than read here, because the portal now + // takes the same reading on demand — a customer who has just deleted + // data has to be able to ask again without waiting for this round. + // Two implementations of "how full is it" would eventually disagree, + // and the downgrade check blocks on the answer. + $disk = app(DiskUsageProbe::class)->read($instance); $metric = InstanceMetric::query()->firstOrCreate( ['instance_id' => $instance->id, 'day' => now()->toDateString()], @@ -158,40 +164,6 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue } } - /** - * How full the instance's data disk is, via the guest agent. - * - * Proxmox's own `disk` figure for a VM is the allocated image, not what is - * used inside it — it would show a customer 500 GB from the first day. - * `df` in the guest is the number they recognise. - * - * @return array{used: int, total: int}|null - */ - private function diskUsage($client, Instance $instance): ?array - { - $node = $instance->host->node ?? 'pve'; - - if (! $client->guestAgentPing($node, $instance->vmid)) { - return null; - } - - // -B1 so the numbers are bytes and no locale can reinterpret them. - $result = $client->guestExec($node, $instance->vmid, 'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /'); - $out = trim((string) ($result['out-data'] ?? $result['output'] ?? '')); - - // Header line, then one line of two integers. Anything else is a shell - // that answered something we did not ask for, and is discarded rather - // than parsed hopefully. - $lines = preg_split('/\r?\n/', $out) ?: []; - $last = trim((string) end($lines)); - - if (! preg_match('/^(\d+)\s+(\d+)$/', $last, $m)) { - return null; - } - - return ['total' => (int) $m[1], 'used' => (int) $m[2]]; - } - private function releasePreviousPeriodThrottle($client, Instance $instance): void { $previous = InstanceTraffic::query() diff --git a/app/Provisioning/Steps/Customer/ApplyStorageQuota.php b/app/Provisioning/Steps/Customer/ApplyStorageQuota.php index affc2bd..8209999 100644 --- a/app/Provisioning/Steps/Customer/ApplyStorageQuota.php +++ b/app/Provisioning/Steps/Customer/ApplyStorageQuota.php @@ -4,6 +4,7 @@ namespace App\Provisioning\Steps\Customer; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; +use App\Services\Billing\StorageAllowance; use App\Services\Proxmox\ProxmoxClient; /** @@ -14,6 +15,13 @@ use App\Services\Proxmox\ProxmoxClient; * customer on a 200 GB package gets 200 GB whatever size the underlying disk * happens to be, and Nextcloud is what enforces it. * + * What is written is the customer's whole allowance — the package PLUS every + * storage pack booked onto their contract — and it is asked of + * StorageAllowance rather than read off `instances.quota_gb`. That column is the + * package alone, and taking it as the answer is how a booked pack came to change + * nothing at all: the customer paid ten euros a month and Nextcloud went on + * enforcing the figure the package included. + * * `files default_quota`, not a per-user quota. An account with an explicit quota * stops following the default, so writing one onto the admin account would * freeze that account at today's figure and quietly exclude it from the next @@ -43,7 +51,7 @@ class ApplyStorageQuota extends CustomerStep return StepResult::fail('no_instance'); } - $quota = (int) $instance->quota_gb; + $quota = StorageAllowance::for($instance)->totalGb(); // No allowance recorded: setting "0 GB" would lock the customer out of // their own files, which is a far worse answer than leaving what is diff --git a/app/Provisioning/Steps/Customer/GrowGuestFilesystem.php b/app/Provisioning/Steps/Customer/GrowGuestFilesystem.php new file mode 100644 index 0000000..67c0f6c --- /dev/null +++ b/app/Provisioning/Steps/Customer/GrowGuestFilesystem.php @@ -0,0 +1,251 @@ + + */ + private const GROWERS = [ + 'ext2' => ['resize2fs', 'source'], + 'ext3' => ['resize2fs', 'source'], + 'ext4' => ['resize2fs', 'source'], + 'xfs' => ['xfs_growfs', 'target'], + 'btrfs' => ['btrfs filesystem resize max', 'target'], + ]; + + public function __construct(private ProxmoxClient $pve) {} + + public function key(): string + { + return 'grow_guest_filesystem'; + } + + public function maxDuration(): int + { + return 300; + } + + public function execute(ProvisioningRun $run): StepResult + { + $instance = $this->instance($run); + + if ($instance === null) { + return StepResult::fail('no_instance'); + } + + $node = (string) $run->context('node'); + $vmid = (int) $run->context('vmid'); + $pve = $this->pve->forHost($instance->host); + + // Nothing inside a machine can be grown from outside it. A guest that is + // not answering is retried rather than failed on the spot — it may still + // be coming up — and the run fails visibly once the budget is gone, + // which is the outcome an operator can act on. + if (! $pve->guestAgentPing($node, $vmid)) { + return StepResult::retry(20, 'guest agent not answering yet'); + } + + $mount = $this->dataFilesystem($pve, $run); + + if ($mount === null) { + return StepResult::fail('no_data_filesystem'); + } + + [$source, $type, $target] = $mount; + $grower = self::GROWERS[$type] ?? null; + + // Loudly, and this is the whole point of detecting the type. A + // filesystem nobody taught this step about must not be handed to + // resize2fs on the chance that it works: at best the command fails, at + // worst it writes ext4 structures over something else. A failed run says + // which filesystem it was; a silent no-op would leave the customer + // paying for space that never appeared. + if ($grower === null) { + return StepResult::fail('unsupported_filesystem: '.$type); + } + + $this->rescanDisk($pve, $run, $source); + $this->growPartition($pve, $run, $source); + + [$command, $argument] = $grower; + + $this->guest($pve, $run, $command.' '.escapeshellarg($argument === 'source' ? $source : $target)); + + return StepResult::advance(); + } + + /** + * The device, filesystem type and mount point that carry the customer's + * files. + * + * `findmnt --target` answers for whichever filesystem actually holds the + * path, so a `/var/www` that is only a directory on the root filesystem + * resolves to the root filesystem — the correct answer, and the same one the + * fill-level reading gets from `df`. The two paths are asked in the same + * order for exactly that reason: what this step grows must be what the meter + * measures, or a customer would be told they are full of a filesystem nobody + * enlarged. + * + * @return array{0: string, 1: string, 2: string}|null + */ + private function dataFilesystem(ProxmoxClient $pve, ProvisioningRun $run): ?array + { + $out = $this->guest($pve, $run, + 'findmnt -nro SOURCE,FSTYPE,TARGET --target /var/www 2>/dev/null ' + .'|| findmnt -nro SOURCE,FSTYPE,TARGET --target /'); + + foreach (preg_split('/\r?\n/', trim($out)) ?: [] as $line) { + $fields = preg_split('/\s+/', trim($line)) ?: []; + + // The first complete line. A bind mount can add more, and they + // describe the same filesystem — growing it once is growing it. + if (count($fields) >= 3 && str_starts_with($fields[0], '/dev/')) { + return [$fields[0], $fields[1], $fields[2]]; + } + } + + return null; + } + + /** + * Ask the kernel to look at the disk again. + * + * A read, and it always exits 0: a machine whose sysfs has no such node — + * another disk transport, an unusual image — is not a failed run, it is a + * machine whose kernel noticed the new size without being asked. The + * partition step immediately after is what actually proves whether the space + * arrived. + */ + private function rescanDisk(ProxmoxClient $pve, ProvisioningRun $run, string $source): void + { + $disk = $this->diskName($source); + + if ($disk === null) { + return; + } + + $rescan = '/sys/class/block/'.$disk.'/device/rescan'; + + $this->guest($pve, $run, + 'test -w '.escapeshellarg($rescan).' && echo 1 > '.escapeshellarg($rescan).'; ' + .'udevadm settle >/dev/null 2>&1; true'); + } + + /** + * Stretch the partition over whatever space the disk has gained. + * + * growpart's own answer for "there was nothing to do" is the word NOCHANGE + * on its output together with a NON-zero exit, and which non-zero code that + * is has moved between releases of cloud-utils. So the word is what is read + * and not the number — this step runs again on every retry and on every + * further pack, and a second run finding the partition already at full size + * is the ordinary case, not a failure. + */ + private function growPartition(ProxmoxClient $pve, ProvisioningRun $run, string $source): void + { + $partition = $this->partition($source); + + if ($partition === null) { + // A filesystem sitting straight on a disk, an LVM volume, a mapper + // device: there is no partition table entry to move, and guessing a + // device name here would run growpart against the wrong disk. Said + // out loud on the run, because the filesystem step below may then be + // the only thing that grows — and if it grows nothing, this line is + // the reason. + $run->events()->create([ + 'step' => $this->key(), + 'attempt' => $run->attempt, + 'outcome' => 'info', + 'message' => "Keine erweiterbare Partition erkannt ({$source}) — die Partitionstabelle bleibt " + .'unverändert, das Dateisystem wird trotzdem vergrößert.', + ]); + + return; + } + + [$disk, $number] = $partition; + + $result = $pve->guestExec( + (string) $run->context('node'), + (int) $run->context('vmid'), + 'growpart '.escapeshellarg($disk).' '.escapeshellarg($number).' 2>&1', + ); + + $out = trim((string) ($result['out-data'] ?? '')); + + if ((int) ($result['exitcode'] ?? 1) !== 0 && ! str_contains($out, 'NOCHANGE')) { + throw new RuntimeException("growpart failed (exit {$result['exitcode']}): {$out}"); + } + } + + /** + * The whole disk behind a partition, and the partition's number. + * + * `/dev/sda1` → `/dev/sda` and `1`; `/dev/nvme0n1p2` → `/dev/nvme0n1` and + * `2`. Null for anything this cannot name with certainty. Deliberately + * narrow: growpart takes a disk and a number, and a pattern loose enough to + * match everything would eventually hand it the wrong disk. + * + * @return array{0: string, 1: string}|null + */ + private function partition(string $source): ?array + { + if (preg_match('#^(/dev/nvme\d+n\d+)p(\d+)$#', $source, $m) === 1 + || preg_match('#^(/dev/[a-z]{2,4})(\d+)$#', $source, $m) === 1) { + return [$m[1], $m[2]]; + } + + return null; + } + + /** The kernel's name for the disk itself — `sda` — or null if it is not one. */ + private function diskName(string $source): ?string + { + $device = $this->partition($source)[0] ?? $source; + + return preg_match('#^/dev/([a-z0-9]+)$#', $device, $m) === 1 ? $m[1] : null; + } +} diff --git a/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php b/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php index a5f2ae1..e2a930c 100644 --- a/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php +++ b/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Customer; use App\Models\Instance; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; +use App\Services\Billing\StorageAllowance; use App\Services\Proxmox\ProxmoxClient; /** @@ -22,6 +23,12 @@ use App\Services\Proxmox\ProxmoxClient; * package includes is on the contract, where it belongs — nothing reads a disk * size as proof of what was sold. * + * Growing it is only the host's half. The guest sees a larger block device and + * nothing more — its partition and its filesystem still end where they did — so + * GrowGuestFilesystem runs immediately after this step in every pipeline that + * can enlarge a disk. Without it, an upgrade delivered a bigger image and not one + * usable byte. + * * **CPU and memory are applied but not made true.** The configuration PUT lands * immediately and a running guest takes the new size at its next cold boot; * nothing in this codebase turns Proxmox's CPU or memory hotplug on, so there is @@ -74,21 +81,41 @@ class ResizeVirtualMachine extends CustomerStep } /** - * Grow the disk to what the contract now includes, and say so out loud when - * the contract asks for less than the machine already has. + * Grow the disk to what the customer is now entitled to, and say so out loud + * when that is less than the machine already has. + * + * The target is the customer's WHOLE allowance — package plus booked storage + * packs — with the package's own system overhead left on top of it. That + * overhead is read off the package and never invented: the catalogue's four + * packages ship 120/100, 540/500, 1050/1000 and 2100/2000 GB, which is 20, + * 40, 50 and 100 GB of room beside the customer's files and is a decision the + * owner makes per package, not a ratio anybody may derive. A pack of extra + * storage is that much more FOR THE CUSTOMER, so it is added to the + * allowance and the package's overhead rides along unchanged. + * + * With no packs booked the sum is exactly the package's own `disk_gb`, which + * is what this step has always used. */ private function growDisk(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void { - $target = (int) ($this->plan($run)['disk_gb'] ?? 0); - $actual = (int) $instance->disk_gb; + $plan = $this->plan($run); + $planDisk = (int) ($plan['disk_gb'] ?? 0); + $planQuota = (int) ($plan['quota_gb'] ?? 0); // No contract behind this run, or a package without a size: nothing to // grow towards, and guessing at one would resize somebody's disk from a // default. - if ($target <= 0) { + if ($planDisk <= 0) { return; } + $allowance = StorageAllowance::for($instance); + + $target = $allowance->totalGb() > 0 + ? $allowance->totalGb() + max(0, $planDisk - $planQuota) + : $planDisk; + $actual = (int) $instance->disk_gb; + if ($target > $actual) { // Absolute target, not '+…', so a retry cannot grow it twice. $pve->resizeDisk($node, $vmid, 'scsi0', $target.'G'); diff --git a/app/Services/Billing/DowngradeCheck.php b/app/Services/Billing/DowngradeCheck.php index 406c9fa..fa1b979 100644 --- a/app/Services/Billing/DowngradeCheck.php +++ b/app/Services/Billing/DowngradeCheck.php @@ -27,6 +27,13 @@ use App\Support\Bytes; * with no reason on it. So a consequence is reported alongside, in the same * shape and in the same place, and it does not make the move impossible: it is * the sentence somebody reads before they agree to it. + * + * A blocker on storage carries the way OUT of it as well, because "no" without a + * way out is where this check used to stop. There are exactly two ways out and + * both are real: buy enough storage packs that the smaller package plus the + * packs covers what is stored — the packs count towards the limit here, on the + * same authority everything else reads — or delete data and ask again against a + * fresh reading rather than last night's. */ final readonly class DowngradeCheck { @@ -36,6 +43,15 @@ final readonly class DowngradeCheck public array $blockers, /** @var array}> */ public array $consequences = [], + /** + * The numbers behind a storage blocker, or null when storage is not what + * is in the way. Real figures, never a shrug: what is stored, what the + * target package plus the packs already booked would allow, how much has + * to go, and how many further packs would cover it instead. + * + * @var array{used: string, limit: string, over: string, packs: int, pack_gb: int}|null + */ + public ?array $storageRemedy = null, ) {} /** @@ -64,32 +80,48 @@ final readonly class DowngradeCheck ]; } - // Storage is checked against the last real reading. Falling back to the - // contractual allowance would refuse a downgrade to anyone who has ever - // bought a large plan, however empty their instance is. - $quotaGb = (int) ($target['quota_gb'] ?? 0); - $metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null; - - if ($quotaGb > 0 && $metric !== null && $metric->disk_used_bytes > $quotaGb * 1024 ** 3) { - $blockers[] = [ - 'key' => 'storage', - 'current' => Bytes::human($metric->disk_used_bytes), - 'limit' => $quotaGb.' GB', - ]; - } - // Asked, never re-derived: which packages may carry an own domain is // CustomDomainAccess's question, and this class only reports what it // answers about the move being considered. $access = app(CustomDomainAccess::class); + $contract = $access->contractOf($customer); + + // Storage is checked against the last real reading. Falling back to the + // contractual allowance would refuse a downgrade to anyone who has ever + // bought a large plan, however empty their instance is. + // + // What it is checked AGAINST is the target package plus the storage packs + // the customer has already booked, because those move with them: a + // customer on 200 GB with three packs may hold 500 GB, and refusing them + // the smaller package would be refusing them storage they are paying for. + $planGb = (int) ($target['quota_gb'] ?? 0); + $allowance = StorageAllowance::forPlan($planGb, $contract); + $metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null; + $storageRemedy = null; + + if ($planGb > 0 && $metric !== null && $metric->disk_used_bytes > $allowance->totalGb() * 1024 ** 3) { + $blockers[] = [ + 'key' => 'storage', + 'current' => Bytes::human($metric->disk_used_bytes), + 'limit' => $allowance->totalGb().' GB', + ]; + + $storageRemedy = [ + 'used' => Bytes::human($metric->disk_used_bytes), + 'limit' => $allowance->totalGb().' GB', + 'over' => Bytes::human($metric->disk_used_bytes - $allowance->totalGb() * 1024 ** 3), + 'packs' => $allowance->packsToCover((int) $metric->disk_used_bytes), + 'pack_gb' => $allowance->packSizeGb, + ]; + } $consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo( - current: $access->contractOf($customer), + current: $contract, instance: $instance, targetPlan: $targetPlan, targetFeatures: array_values((array) ($target['features'] ?? [])), ); - return new self($blockers === [], $blockers, $consequences); + return new self($blockers === [], $blockers, $consequences, $storageRemedy); } } diff --git a/app/Services/Billing/StorageAllowance.php b/app/Services/Billing/StorageAllowance.php new file mode 100644 index 0000000..a8bb049 --- /dev/null +++ b/app/Services/Billing/StorageAllowance.php @@ -0,0 +1,148 @@ +subscription + ?? app(CustomDomainAccess::class)->contractOf($instance->customer); + + return self::forPlan((int) ($instance->quota_gb ?? $contract?->quota_gb ?? 0), $contract); + } + + /** + * The same question about a package the customer is not on yet. + * + * The downgrade check needs it: whether a smaller package is big enough + * depends on the packs the customer has ALREADY paid for, which move with + * them. + */ + public static function forPlan(int $planGb, ?Subscription $subscription): self + { + return new self(max(0, $planGb), self::bookedPacks($subscription), self::packSizeGb()); + } + + /** Everything the booked packs add. */ + public function packGb(): int + { + return $this->packs * $this->packSizeGb; + } + + /** The figure Nextcloud is told, the disk is sized for, and the portal shows. */ + public function totalGb(): int + { + return $this->planGb + $this->packGb(); + } + + public function hasPacks(): bool + { + return $this->packs > 0; + } + + /** + * How many MORE packs it would take to cover this many bytes. + * + * Zero when the allowance already covers it. Rounded up, because half a pack + * is not for sale and an answer that leaves the customer one gigabyte short + * is an answer that sends them back a second time. + */ + public function packsToCover(int $bytes): int + { + $short = $bytes - $this->totalGb() * 1024 ** 3; + + if ($short <= 0 || $this->packSizeGb <= 0) { + return 0; + } + + return (int) ceil($short / ($this->packSizeGb * 1024 ** 3)); + } + + /** + * Every pack still running on this contract, counted by quantity. + * + * Queried rather than read off a loaded relation: this is asked immediately + * after a pack is booked or cancelled, and a relation loaded before that + * would answer with the state the caller has just changed. + */ + private static function bookedPacks(?Subscription $subscription): int + { + if ($subscription === null) { + return 0; + } + + return (int) $subscription->addons()->active() + ->where('addon_key', AddonCatalogue::STORAGE) + ->sum('quantity'); + } + + /** + * What one pack is worth, from the catalogue. + * + * Read live rather than frozen onto the booking, unlike the PRICE. That is + * not an oversight but it is a limit worth naming: `subscription_addons` + * freezes what the customer agreed to PAY, and if the owner ever re-cuts the + * pack from 100 GB to 200 GB, existing bookings would grow with it. Today + * there is one pack size and it has never moved; the day it does, the size + * belongs on the booking beside the price. + */ + private static function packSizeGb(): int + { + return max(0, (int) config('provisioning.storage_addon.gb', 0)); + } +} diff --git a/app/Services/Provisioning/DiskUsageProbe.php b/app/Services/Provisioning/DiskUsageProbe.php new file mode 100644 index 0000000..d16e74c --- /dev/null +++ b/app/Services/Provisioning/DiskUsageProbe.php @@ -0,0 +1,97 @@ +host === null || $instance->vmid === null) { + return null; + } + + $node = $instance->host->node ?? 'pve'; + $client = $this->pve->forHost($instance->host); + + if (! $client->guestAgentPing($node, (int) $instance->vmid)) { + return null; + } + + // -B1 so the numbers are bytes and no locale can reinterpret them. + $result = $client->guestExec($node, (int) $instance->vmid, + 'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /'); + $out = trim((string) ($result['out-data'] ?? $result['output'] ?? '')); + + // Header line, then one line of two integers. Anything else is a shell + // that answered something we did not ask for, and is discarded rather + // than parsed hopefully. + $lines = preg_split('/\r?\n/', $out) ?: []; + $last = trim((string) end($lines)); + + if (preg_match('/^(\d+)\s+(\d+)$/', $last, $m) !== 1) { + return null; + } + + return ['total' => (int) $m[1], 'used' => (int) $m[2]]; + } + + /** + * Take a reading and write it into today's row, so everything that reads the + * fill level sees it. + * + * The same row the sampler accumulates into, and the fill level is + * overwritten rather than added to — it is a state, not a flow. Returns null + * when there was nothing to record, so a caller can say "we could not look" + * instead of "you are fine". + */ + public function record(Instance $instance): ?InstanceMetric + { + $disk = $this->read($instance); + + if ($disk === null) { + return null; + } + + $metric = InstanceMetric::query()->firstOrCreate( + ['instance_id' => $instance->id, 'day' => now()->toDateString()], + ); + + $metric->disk_used_bytes = $disk['used']; + $metric->disk_total_bytes = $disk['total']; + $metric->save(); + + return $metric; + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 2177dc9..bb6776c 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -104,6 +104,12 @@ return [ */ 'plan-change' => [ Customer\ResizeVirtualMachine::class, + // The disk is bigger; nothing inside the guest knows that yet. An + // upgrade used to stop one step short of the customer: Proxmox grew + // the image, the partition and the filesystem stayed where they + // were, and the extra hundreds of gigabytes were unreachable for the + // life of the machine. + Customer\GrowGuestFilesystem::class, Customer\ApplyStorageQuota::class, Customer\ConfigureDnsAndTls::class, Customer\ConfigureNextcloud::class, @@ -146,6 +152,31 @@ return [ 'quota' => [ Customer\ApplyStorageQuota::class, ], + + /* + | A storage pack bought, or given back. + | + | The whole of what "+100 GB" has to mean: the virtual disk grows to + | carry the new allowance, the guest is made to see it — partition and + | filesystem — and only then is Nextcloud told the larger figure. In that + | order, because a quota Nextcloud enforces over a filesystem that never + | grew is a promise of space the machine does not have. + | + | The same three steps the `plan-change` pipeline uses for its storage + | half, reused rather than repeated: booking a pack and moving to a + | bigger package are the same operation on the machine, and two copies of + | it would drift. Started by App\Actions\ApplyStorageAllowance, from + | App\Actions\BookAddon — the one place a pack is booked or cancelled. + | + | Same subject as every other customer pipeline (the instance's own + | purchase Order), so CustomerStep resolves the machine and its contract, + | and so nothing starts a second run beside one already in flight. + */ + 'storage' => [ + Customer\ResizeVirtualMachine::class, + Customer\GrowGuestFilesystem::class, + Customer\ApplyStorageQuota::class, + ], ], // The one currency the catalogue is priced in. Plan prices carry no currency diff --git a/lang/de/billing.php b/lang/de/billing.php index cc98095..1dc5835 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -11,6 +11,14 @@ return [ 'seats' => 'Sie haben :current Benutzer, dieses Paket erlaubt :limit. Entfernen Sie Zugänge unter „Benutzer“.', 'storage' => 'Sie belegen :current, dieses Paket bietet :limit. Löschen Sie Daten oder leeren Sie den Papierkorb.', ], + // Ein „Nein“ ohne Ausweg ist die Sackgasse, an der dieser Test bisher + // endete. Es gibt genau zwei Auswege, und beide stehen hier mit Zahlen. + 'downgrade_escape' => [ + 'intro' => 'Sie belegen :used, mit diesem Paket wären :limit möglich — :over müssen weg. Sie können stattdessen Zusatzspeicher buchen; gebuchte Pakete zählen zu Ihrem Kontingent.', + 'book' => ':count × Zusatzspeicher buchen (+:gb GB)', + 'remeasure' => 'Speicher neu messen', + 'measured_at' => 'Gemessen am :when. Nach dem Löschen von Daten hier neu messen, statt auf die nächtliche Messung zu warten.', + ], // Kein Hindernis, sondern eine Folge: der Wechsel ist möglich, nimmt aber // etwas weg. Steht deshalb über dem Knopf, nicht statt seiner. 'downgrade_consequence_title' => 'Das ändert sich mit dem Wechsel', @@ -94,6 +102,16 @@ return [ 'storage_title' => 'Zusatzspeicher', 'storage_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.', 'storage_cta' => '+:gb GB buchen', + // Zusatzspeicher ist Teil des Kontingents, das die Cloud tatsächlich + // durchsetzt — und nicht nur eine Zeile auf der Rechnung. + 'storage_includes_packs' => 'Darin enthalten: :count × Zusatzspeicher (+:gb GB).', + 'storage_confirm_title' => ':count × Zusatzspeicher buchen?', + 'storage_confirm_body' => 'Damit werden :count Speicherpakete (+:gb GB) vorgemerkt. Das sind :total netto pro Monat, monatlich kündbar.', + 'storage_confirm_cancel' => 'Abbrechen', + 'storage_confirm_cta' => 'Vormerken', + 'storage_remeasured' => 'Ihr Speicherverbrauch wurde neu gemessen.', + 'storage_remeasure_wait' => 'Die Messung läuft bereits — einen Moment bitte.', + 'storage_remeasure_unavailable' => 'Ihre Cloud war für die Messung gerade nicht erreichbar. Bitte später erneut versuchen.', 'addon_cta' => 'Hinzufügen', 'addon_included' => 'In Ihrem Paket enthalten', 'total_with_addons' => 'Gesamt inkl. Module: :total', diff --git a/lang/de/provisioning.php b/lang/de/provisioning.php index e5d32e4..6cc818c 100644 --- a/lang/de/provisioning.php +++ b/lang/de/provisioning.php @@ -18,6 +18,7 @@ return [ 'run_acceptance_checks' => 'Abnahmeprüfung', 'complete_provisioning' => 'Bereitstellung abschließen', 'resize_vm' => 'VM-Ausstattung anpassen', + 'grow_guest_filesystem' => 'Dateisystem im Gast vergrößern', 'apply_storage_quota' => 'Speicherkontingent setzen', 'settle_plan_services' => 'Backup & Monitoring abgleichen', 'shut_down_vm' => 'VM sauber herunterfahren', diff --git a/lang/en/billing.php b/lang/en/billing.php index 1680575..3fa521b 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -11,6 +11,14 @@ return [ 'seats' => 'You have :current users, this package allows :limit. Remove access under "Users".', 'storage' => 'You are using :current, this package offers :limit. Delete data or empty the trash.', ], + // A "no" with no way out of it is the dead end this check used to stop at. + // There are exactly two ways out, and both are stated here with numbers. + 'downgrade_escape' => [ + 'intro' => 'You are using :used, this package would allow :limit — :over has to go. You can book extra storage instead; booked packs count towards your allowance.', + 'book' => 'Add :count × extra storage (+:gb GB)', + 'remeasure' => 'Measure storage again', + 'measured_at' => 'Measured on :when. After deleting data, measure again here rather than waiting for the nightly reading.', + ], // Not an obstacle but a consequence: the move is possible, it just takes // something away. Shown above the button, not instead of it. 'downgrade_consequence_title' => 'What this move changes', @@ -94,6 +102,16 @@ return [ 'storage_title' => 'Extra storage', 'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.', 'storage_cta' => 'Add :gb GB', + // Extra storage is part of the allowance the cloud actually enforces, not + // merely a line on the bill. + 'storage_includes_packs' => 'Includes :count × extra storage (+:gb GB).', + 'storage_confirm_title' => 'Add :count × extra storage?', + 'storage_confirm_body' => 'This places :count storage packs (+:gb GB) in your cart. That is :total net per month, cancellable monthly.', + 'storage_confirm_cancel' => 'Cancel', + 'storage_confirm_cta' => 'Add to cart', + 'storage_remeasured' => 'Your storage usage has been measured again.', + 'storage_remeasure_wait' => 'A measurement is already running — one moment.', + 'storage_remeasure_unavailable' => 'Your cloud could not be reached for the measurement. Please try again later.', 'addon_cta' => 'Add', 'addon_included' => 'Included in your package', 'total_with_addons' => 'Total incl. modules: :total', diff --git a/lang/en/provisioning.php b/lang/en/provisioning.php index 384bebf..2b81849 100644 --- a/lang/en/provisioning.php +++ b/lang/en/provisioning.php @@ -18,6 +18,7 @@ return [ 'run_acceptance_checks' => 'Acceptance checks', 'complete_provisioning' => 'Complete provisioning', 'resize_vm' => 'Resize the VM', + 'grow_guest_filesystem' => 'Grow the filesystem inside the guest', 'apply_storage_quota' => 'Apply storage quota', 'settle_plan_services' => 'Reconcile backup & monitoring', 'shut_down_vm' => 'Shut the VM down cleanly', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index c439608..5b81da7 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -33,8 +33,12 @@
+ {{-- Storage is the figure Nextcloud actually enforces: the package + plus every booked pack. Printing the package alone told a + customer with two packs that they had 500 GB while their own + cloud was giving them 700. --}} @foreach ([ - ['billing.storage', ($current['quota_gb'] ?? 0).' GB'], + ['billing.storage', ($current['storage_gb'] ?? 0).' GB'], ['billing.seats', $current['seats'] ?? '—'], ['billing.performance', __('billing.perf.'.($current['performance'] ?? 'standard'))], ['billing.status', __('billing.status_active')], @@ -46,6 +50,21 @@ @endforeach
+ {{-- Where the storage above comes from, once it is no longer only the + package. A customer who books packs should be able to see them in + the figure they are enforced in, not only as a line on the bill. --}} + @if (($current['storage_packs'] ?? 0) > 0) +
+ +

+ {{ __('billing.storage_includes_packs', [ + 'count' => $current['storage_packs'], + 'gb' => $current['storage_pack_gb'] ?? 0, + ]) }} +

+
+ @endif + {{-- A change already booked, on the card that states what they have. A contract quietly due to shrink at the end of the term is not something a customer should have to remember on their own — and it @@ -231,6 +250,41 @@ @endforeach + + {{-- "No" with a way out of it. A customer blocked + by their own data has exactly two: buy the + room, or delete the files and have us look + again — and being told to wait for tonight's + reading after clearing 40 GB is the same + dead end as a greyed-out button. --}} + @if ($remedy = $plan['check']->storageRemedy) +
+

+ {{ __('billing.downgrade_escape.intro', [ + 'used' => $remedy['used'], + 'limit' => $remedy['limit'], + 'over' => $remedy['over'], + ]) }} +

+
+ @if ($remedy['packs'] > 0) + + {{ __('billing.downgrade_escape.book', ['count' => $remedy['packs'], 'gb' => $remedy['packs'] * $remedy['pack_gb']]) }} + + @endif + + {{ __('billing.downgrade_escape.remeasure') }} + +
+ @if ($storageMeasuredAt) +

+ {{ __('billing.downgrade_escape.measured_at', ['when' => $storageMeasuredAt->local()->isoFormat('LLL')]) }} +

+ @endif +
+ @endif @endif diff --git a/resources/views/livewire/confirm-book-storage.blade.php b/resources/views/livewire/confirm-book-storage.blade.php new file mode 100644 index 0000000..e7007eb --- /dev/null +++ b/resources/views/livewire/confirm-book-storage.blade.php @@ -0,0 +1,28 @@ + +
+ @php + $loc = app()->getLocale(); + $priceCents = (int) config('provisioning.storage_addon.price_cents', 0); + @endphp +
+ + + +
+

{{ __('billing.storage_confirm_title', ['count' => $packs]) }}

+

+ {{ __('billing.storage_confirm_body', [ + 'count' => $packs, + 'gb' => $packs * $packGb, + 'total' => Number::currency($packs * $priceCents / 100, in: 'EUR', locale: $loc), + ]) }} +

+
+
+
+ {{ __('billing.storage_confirm_cancel') }} + + {{ __('billing.storage_confirm_cta') }} + +
+
diff --git a/tests/Feature/Billing/StorageAllowanceTest.php b/tests/Feature/Billing/StorageAllowanceTest.php new file mode 100644 index 0000000..8cbaa3e --- /dev/null +++ b/tests/Feature/Billing/StorageAllowanceTest.php @@ -0,0 +1,414 @@ +active()->create(['datacenter' => 'fsn', 'node' => 'pve']); + $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); + $subscription = $order->subscription; + $customer = $order->customer; + $user = User::factory()->create(['email' => $customer->email]); + + $instance = Instance::factory()->create(array_merge([ + 'order_id' => $order->id, + 'customer_id' => $customer->id, + 'host_id' => $host->id, + 'vmid' => 101, + 'subdomain' => 'berger', + 'plan' => $plan, + 'quota_gb' => $subscription->quota_gb, + 'disk_gb' => $subscription->disk_gb, + 'ram_mb' => $subscription->ram_mb, + 'cores' => $subscription->cores, + 'status' => 'active', + 'route_written' => true, + 'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone()], + 'cert_ok' => true, + ], $instanceAttributes)); + + $subscription->update(['instance_id' => $instance->id]); + + return [ + 'host' => $host, + 'order' => $order, + 'customer' => $customer, + 'user' => $user, + 'subscription' => $subscription->fresh(), + 'instance' => $instance, + ]; +} + +/** A run of the storage pipeline against that machine, ready for a step. */ +function storageRun(array $fixture): ProvisioningRun +{ + return ProvisioningRun::factory()->create([ + 'subject_type' => Order::class, + 'subject_id' => $fixture['order']->id, + 'pipeline' => 'storage', + 'context' => [ + 'instance_id' => $fixture['instance']->id, + 'host_id' => $fixture['host']->id, + 'node' => 'pve', + 'vmid' => 101, + 'subdomain' => $fixture['instance']->subdomain, + ], + ]); +} + +/** What a guest answers when it is asked about its data filesystem. */ +function scriptGuestFilesystem($pve, string $source = '/dev/sda1', string $type = 'ext4', string $target = '/'): void +{ + $pve->guestScript('findmnt', 0, "{$source} {$type} {$target}"); +} + +beforeEach(function () { + fakeServices(); + // The delivery run is created and left for a worker: these tests are about + // what the steps do when they run, not about the queue. + Queue::fake(); +}); + +// ─── A. one authority for the allowance ────────────────────────────────────── + +it('raises the allowance with a booked pack and tells Nextcloud the larger figure', function () { + $fixture = storageFixture('team'); // 500 GB package + $pve = app(ProxmoxClient::class); + + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + + expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600); + + expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance'); + + expect($pve->guestRan("files default_quota --value='600 GB'"))->toBeTrue() + // Recorded as applied, so the sweep can tell an enforced machine from + // one where the figure has only ever been a row in our database. + ->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(600) + ->and($fixture['instance']->fresh()->quotaIsEnforced())->toBeTrue(); +}); + +it('stacks two packs and lowers the allowance again when one is cancelled', function () { + $fixture = storageFixture('team'); + $contract = $fixture['subscription']; + + $first = app(BookAddon::class)($contract, AddonCatalogue::STORAGE); + app(BookAddon::class)($contract, AddonCatalogue::STORAGE); + + expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(700); + + app(BookAddon::class)->cancel($first); + + expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(600) + // Cancelled, never deleted: what somebody was paying, and until when, is + // evidence. + ->and(SubscriptionAddon::query()->count())->toBe(2); +}); + +it('counts a pack booked with a quantity of more than one', function () { + $fixture = storageFixture('team'); + + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 3); + + expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(800); +}); + +it('shows the whole allowance in the portal, not the package alone', function () { + $fixture = storageFixture('team'); + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2); + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + ->assertSee('700 GB') + ->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 200])); +}); + +it('starts one delivery run when a pack is booked, and none while another is in flight', function () { + $fixture = storageFixture('team'); + + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + + expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1); + + // A second booking arrives while the first run is still pending. That run + // reads the allowance when it reaches the quota step, which is this state or + // newer — a second run beside it would resize the same disk twice. + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + + expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1); +}); + +it('starts a delivery run when a pack is cancelled, so the quota comes back down', function () { + $fixture = storageFixture('team'); + $addon = app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + + ProvisioningRun::query()->update(['status' => ProvisioningRun::STATUS_COMPLETED]); + + app(BookAddon::class)->cancel($addon); + + expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(2); +}); + +// ─── B. the machine really gets the space ──────────────────────────────────── + +it('grows the disk to the allowance plus the package overhead, and the guest with it', function () { + // Team is 500 GB sold on a 540 GB disk: 40 GB of overhead the package was + // sized with, and the pack is a hundred gigabytes ON TOP of it. + $fixture = storageFixture('team'); + $pve = app(ProxmoxClient::class); + scriptGuestFilesystem($pve); + + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + $run = storageRun($fixture); + + expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance') + ->and($pve->resizeCalls)->toContain('101:scsi0:640G') + ->and($fixture['instance']->fresh()->disk_gb)->toBe(640); + + expect(app(GrowGuestFilesystem::class)->execute($run)->type)->toBe('advance') + ->and($pve->guestRan('/sys/class/block/sda/device/rescan'))->toBeTrue() + ->and($pve->guestRan("growpart '/dev/sda' '1'"))->toBeTrue() + ->and($pve->guestRan("resize2fs '/dev/sda1'"))->toBeTrue(); +}); + +it('does nothing the second time round', function () { + $fixture = storageFixture('team'); + $pve = app(ProxmoxClient::class); + scriptGuestFilesystem($pve); + + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + $run = storageRun($fixture); + + app(ResizeVirtualMachine::class)->execute($run); + app(GrowGuestFilesystem::class)->execute($run); + + // Everything is already the right size. growpart answers the way it answers + // when the partition already fills the disk: a non-zero exit with NOCHANGE + // on its output, which is not a failure and must not be read as one. + $pve->resizeCalls = []; + $pve->guestCommands = []; + $pve->guestScript('growpart', 1, 'NOCHANGE: partition 1 is size 1342177280. it cannot be grown'); + + expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance') + ->and($pve->resizeCalls)->toBe([]) + ->and($fixture['instance']->fresh()->disk_gb)->toBe(640); + + expect(app(GrowGuestFilesystem::class)->execute($run->fresh())->type)->toBe('advance'); +}); + +it('grows an xfs filesystem with its own tool, pointed at the mount point', function () { + $fixture = storageFixture('team'); + $pve = app(ProxmoxClient::class); + scriptGuestFilesystem($pve, '/dev/sda2', 'xfs', '/'); + + expect(app(GrowGuestFilesystem::class)->execute(storageRun($fixture))->type)->toBe('advance') + ->and($pve->guestRan("growpart '/dev/sda' '2'"))->toBeTrue() + // The mount point, not the device: that is what xfs_growfs takes. + ->and($pve->guestRan("xfs_growfs '/'"))->toBeTrue() + ->and($pve->guestRan('resize2fs'))->toBeFalse(); +}); + +it('fails loudly on a filesystem it does not know how to grow', function () { + $fixture = storageFixture('team'); + $pve = app(ProxmoxClient::class); + scriptGuestFilesystem($pve, '/dev/sda1', 'reiserfs', '/'); + + $result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture)); + + // A failed run somebody can read, never a silent advance: a customer who has + // paid for space that never appeared must not depend on anyone noticing. + expect($result->type)->toBe('fail') + ->and($result->reason)->toContain('reiserfs') + ->and($pve->guestRan('resize2fs'))->toBeFalse() + ->and($pve->guestRan('xfs_growfs'))->toBeFalse(); +}); + +it('waits for a guest that is not answering rather than pretending it grew', function () { + $fixture = storageFixture('team'); + $pve = app(ProxmoxClient::class); + $pve->guestAgentUp = false; + + $result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture)); + + expect($result->type)->toBe('retry') + ->and($pve->guestCommands)->toBe([]); +}); + +// ─── C. a blocked downgrade with a way out ─────────────────────────────────── + +/** A reading of what is actually stored, dated. */ +function storedBytes(Instance $instance, int $gib, ?string $day = null): InstanceMetric +{ + return InstanceMetric::query()->updateOrCreate( + ['instance_id' => $instance->id, 'day' => $day ?? now()->toDateString()], + ['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => 1050 * 1024 ** 3], + ); +} + +it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () { + $fixture = storageFixture('business'); // 1000 GB package + storedBytes($fixture['instance'], 600); + + $team = app(PlanCatalogue::class)->sellable()['team']; // 500 GB + $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team'); + + expect($check->allowed)->toBeFalse() + ->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']) + ->and($check->storageRemedy)->not->toBeNull() + ->and($check->storageRemedy['limit'])->toBe('500 GB') + // One pack covers the hundred gigabytes that are over. + ->and($check->storageRemedy['packs'])->toBe(1) + ->and($check->storageRemedy['pack_gb'])->toBe(100); +}); + +it('lets exactly that downgrade through once the offered packs are booked', function () { + $fixture = storageFixture('business'); + storedBytes($fixture['instance'], 600); + + $team = app(PlanCatalogue::class)->sellable()['team']; + $packs = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->storageRemedy['packs']; + + foreach (range(1, $packs) as $ignored) { + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + } + + $after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team'); + + expect($after->allowed)->toBeTrue() + ->and($after->blockers)->toBe([]); +}); + +it('offers the packs through a modal, and books exactly the number that was offered', function () { + $fixture = storageFixture('business'); + storedBytes($fixture['instance'], 800); // three packs short of Team + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + ->assertSee(__('billing.downgrade_escape.remeasure')) + // R23: the confirmation is this product's own dialog, never the + // browser's — the card only opens it. + ->assertSee('confirm-book-storage') + ->call('bookStoragePacks', 3); + + expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3); +}); + +it('confirms the packs in this product own dialog and raises the event with the number', function () { + Livewire::test(ConfirmBookStorage::class, ['packs' => 3]) + ->assertSee(__('billing.storage_confirm_title', ['count' => 3])) + ->call('proceed') + ->assertDispatched('storage-packs-confirmed', packs: 3); +}); + +it('refuses to put more packs in the cart than the ceiling allows', function () { + $fixture = storageFixture('team'); + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + ->call('bookStoragePacks', 9999); + + expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(50); +}); + +it('passes the check on a fresh reading after data was deleted, without waiting for the night', function () { + $fixture = storageFixture('business'); + $pve = app(ProxmoxClient::class); + + // Last night's reading: 600 GB stored, which does not fit Team. + storedBytes($fixture['instance'], 600, now()->subDay()->toDateString()); + + $team = app(PlanCatalogue::class)->sellable()['team']; + expect(DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->allowed)->toBeFalse(); + + // The customer deletes 300 GB and asks us to look now. + $pve->guestScript('df -B1', 0, "size used\n".(1050 * 1024 ** 3).' '.(300 * 1024 ** 3)); + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + ->call('remeasureStorage') + ->assertDispatched('notify', message: __('billing.storage_remeasured')); + + expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeTrue(); +}); + +it('says it could not look rather than reporting an unreachable cloud as empty', function () { + $fixture = storageFixture('business'); + $pve = app(ProxmoxClient::class); + storedBytes($fixture['instance'], 600, now()->subDay()->toDateString()); + $pve->guestAgentUp = false; + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + ->call('remeasureStorage') + ->assertDispatched('notify', message: __('billing.storage_remeasure_unavailable')); + + $team = app(PlanCatalogue::class)->sellable()['team']; + expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeFalse(); +}); + +// ─── the packs survive a change of package ─────────────────────────────────── + +it('keeps booked packs through a plan change and delivers them on the new package', function () { + $fixture = storageFixture('team'); + $pve = app(ProxmoxClient::class); + scriptGuestFilesystem($pve); + + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2); + + // The packs were paid for separately and have nothing to do with which + // package the customer is on, so nothing cancels them. + app(ApplyPlanChange::class)($fixture['subscription']->fresh(), 'business'); + + $instance = $fixture['instance']->fresh(); + + expect(SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->active()->sum('quantity'))->toBe(2) + // Business is 1000 GB; the two packs are still theirs on top of it. + ->and(StorageAllowance::for($instance)->totalGb())->toBe(1200); + + $run = ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->firstOrFail(); + + app(ResizeVirtualMachine::class)->execute($run); + app(GrowGuestFilesystem::class)->execute($run->fresh()); + app(ApplyStorageQuota::class)->execute($run->fresh()); + + // 1200 GB for the customer, on Business's own 50 GB of overhead. + expect($pve->resizeCalls)->toContain('101:scsi0:1250G') + ->and($pve->guestRan("files default_quota --value='1200 GB'"))->toBeTrue() + ->and($instance->fresh()->quota_applied_gb)->toBe(1200); +});