Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else: priced, frozen, charged every month, and delivered nowhere. Nextcloud's quota came from `instances.quota_gb`, which is the package alone; the disk was sized for the package alone; and on the one occasion a disk did grow — a plan upgrade — the guest never saw it, because nothing stretched the partition or the filesystem over the new space. One authority for the allowance. StorageAllowance adds the package and the booked packs, and everything that needs the figure asks it: the quota step, the downgrade check, the portal, the console, the repair command. It is DERIVED rather than stored — a column would have to be rewritten by booking, cancelling, granting, a grant expiring and a plan change, and the day one of those paths forgot it would be silently wrong in whichever direction costs somebody money. `quota_applied_gb` keeps its own meaning: what the guest was last actually told, which is how a machine with the allowance enforced is told from one where the figure has only ever been a row in our database. Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on booking AND on cancellation; the new `storage` pipeline grows the virtual disk to the allowance plus the package's own overhead (read off the package — 20/40/50/100 GB on the four catalogue packages — never a ratio invented here), then GrowGuestFilesystem makes the guest see it, then the quota is applied. No cold boot is involved: the data disk is scsi0 and Proxmox's resize on a running guest is a qemu block_resize, so the capacity reaches the guest while it runs. The new step rescans, growparts and grows the filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs — and fails loudly on one it does not know rather than handing it to resize2fs and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure, and every tool here exits 0 when there is nothing left to grow. The same step now runs in the plan-change pipeline, where an upgrade used to stop one step short of the customer. A downgrade blocked by data gets a way out. The block stays — it is correct — but the check now measures against the target package PLUS the packs the customer already owns, and reports the numbers behind the refusal: what is stored, what would be allowed, how much has to go, and how many packs would cover it instead. The portal offers both routes: book exactly those packs (confirmed in a modal, R23), or delete data and have the fill level measured on demand rather than waiting for the nightly sampler. That reading is taken by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one notion of "how full is it" and not two. A plan change keeps booked packs. They were paid for separately and have nothing to do with which package the customer is on; the new package's disk and quota are sized with them included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
ec8675861e
commit
5b63fdb86c
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Deliver the storage a customer has just bought — or take back what they have
|
||||
* just cancelled.
|
||||
*
|
||||
* A booked pack was a row in `subscription_addons` and nothing else. It was
|
||||
* priced, frozen, charged for every month and had no effect whatsoever on the
|
||||
* machine: the disk stayed the size the package made it, the filesystem inside
|
||||
* stayed the size the disk had been, and Nextcloud went on enforcing the
|
||||
* package's own allowance. This is the missing half — the moment a booking
|
||||
* becomes space.
|
||||
*
|
||||
* It does not do the work. Growing a disk, growing a guest's filesystem and
|
||||
* writing an occ setting are remote work on a live machine, so they go through
|
||||
* the same run machinery as everything else — see the `storage` pipeline — where
|
||||
* they are retried, logged and visible in the console instead of happening
|
||||
* inside whichever web request happened to book the pack.
|
||||
*
|
||||
* Called from the one place a pack becomes real or stops being real:
|
||||
* App\Actions\BookAddon, on booking and on cancellation. Cancelling matters as
|
||||
* much as booking — the allowance goes back down, and Nextcloud has to be told,
|
||||
* or the customer would keep the storage they have stopped paying for.
|
||||
*/
|
||||
class ApplyStorageAllowance
|
||||
{
|
||||
/** Start a storage run for the machine this contract pays for. */
|
||||
public function __invoke(?Subscription $subscription): ?ProvisioningRun
|
||||
{
|
||||
if ($subscription === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The link when provisioning has written it, and otherwise the
|
||||
// customer's newest instance — the same fallback ApplyPlanChange and
|
||||
// CustomDomainAccess make, for the same reason.
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $catalogue
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before several storage packs go into the cart at once (R23).
|
||||
*
|
||||
* The offer comes from a card that has just told the customer their downgrade is
|
||||
* blocked, and it answers with a recurring charge — three packs is thirty euros
|
||||
* a month, every month, until they cancel them. That is not something a stray
|
||||
* click on a card about SAVING money should do, and the dialog that says so is
|
||||
* drawn by this product rather than by the browser.
|
||||
*
|
||||
* No mutation here. Billing::bookStoragePacks() keeps the work and the customer
|
||||
* resolution it already had; this only says yes, with the number it was opened
|
||||
* for.
|
||||
*/
|
||||
class ConfirmBookStorage extends ModalComponent
|
||||
{
|
||||
public int $packs = 1;
|
||||
|
||||
public int $packGb = 0;
|
||||
|
||||
public function mount(int $packs = 1): void
|
||||
{
|
||||
// Clamped here as well as in the action behind it. A modal is opened
|
||||
// with arguments from the page, and a page is markup — the figure that
|
||||
// arrives is not evidence of anything, and the sentence this dialog
|
||||
// shows must not be able to say something the action would not do.
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
<?php
|
||||
|
||||
namespace App\Provisioning\Steps\Customer;
|
||||
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Provisioning\StepResult;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Make the bigger disk visible INSIDE the guest.
|
||||
*
|
||||
* Growing storage is four things and the product only ever did the first.
|
||||
* Proxmox enlarges the image; the guest kernel has to notice; the partition has
|
||||
* to be stretched over the new space; and the filesystem has to be stretched
|
||||
* over the partition. Without the last three, a customer who bought a hundred
|
||||
* gigabytes got a larger image on the host and a Nextcloud that could not write
|
||||
* one byte more than before — which is precisely what happened to every plan
|
||||
* upgrade this product has ever carried out.
|
||||
*
|
||||
* **No cold boot is involved, and that is not luck.** The data disk is attached
|
||||
* as `scsi0` (see CloneVirtualMachine and ConfigureCloudInit, which sizes that
|
||||
* exact device), and Proxmox's resize on a running guest is a qemu block_resize
|
||||
* on it: the new capacity reaches the guest's SCSI layer while it runs. Current
|
||||
* kernels pick it up by themselves and older ones want to be told to look, which
|
||||
* is what the rescan below is for. The cold boot ResizeVirtualMachine talks
|
||||
* about is about CPU and memory, which have no hotplug configured here — a disk
|
||||
* is not in that sentence, and the `restart` pipeline is not needed to deliver
|
||||
* one.
|
||||
*
|
||||
* Idempotent from end to end, because a run is retried and this step sits in
|
||||
* more than one pipeline: the rescan is a read, growpart says NOCHANGE when the
|
||||
* partition already fills the disk, and every filesystem tool here exits 0 when
|
||||
* there is nothing left to grow. Running it when nothing changed visits the
|
||||
* guest and changes nothing.
|
||||
*/
|
||||
class GrowGuestFilesystem extends CustomerStep
|
||||
{
|
||||
/**
|
||||
* How each filesystem is grown, and what its tool wants pointed at it.
|
||||
*
|
||||
* Detected, never assumed. The images are ext4 today, and a step that
|
||||
* hard-coded resize2fs would go quietly wrong the day one is not: the
|
||||
* command would fail on a filesystem it cannot read, the run would burn its
|
||||
* retries, and the report would say nothing about why. `resize2fs` takes the
|
||||
* block device; xfs and btrfs are grown through their MOUNT POINT, which is
|
||||
* why the target is declared per filesystem instead of guessed at.
|
||||
*
|
||||
* @var array<string, array{0: string, 1: 'source'|'target'}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<int, array{key: string, replace: array<string, string>}> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\Subscription;
|
||||
|
||||
/**
|
||||
* How much a customer may actually store: the package's allowance plus every
|
||||
* storage pack they have booked.
|
||||
*
|
||||
* The packs were sold and never delivered. `AddonCatalogue::STORAGE` reached
|
||||
* `subscription_addons` — priced, frozen, charged for every month — and stopped
|
||||
* there: Nextcloud's quota was written from `instances.quota_gb`, which is the
|
||||
* package alone, so a customer could pay ten euros a month for a hundred
|
||||
* gigabytes that no part of the product ever gave them. This is the one place
|
||||
* that adds the two together, and everything that needs to know the figure —
|
||||
* the quota step, the downgrade check, the portal, the console — asks HERE.
|
||||
* Nothing re-derives it from `quota_gb`.
|
||||
*
|
||||
* **Derived, not stored, and deliberately so.** A column would have to be
|
||||
* rewritten by every path that can move either half of the sum: booking a pack,
|
||||
* cancelling one, an operator's grant, a grant expiring, a plan change, the
|
||||
* contract being replaced. The day one of those paths forgets, the column is
|
||||
* silently wrong in whichever direction is worse — a customer charged for
|
||||
* storage they cannot use, or one still holding storage they have stopped paying
|
||||
* for — and it would be wrong invisibly, because a column looks authoritative.
|
||||
* A sum of two rows that are already the authority cannot drift from them.
|
||||
*
|
||||
* `instances.quota_applied_gb` is NOT the same thing and is not duplicated here.
|
||||
* That column records what the guest was last actually TOLD, which is the only
|
||||
* way to tell a machine whose allowance is enforced from one where the figure
|
||||
* has only ever been a row in our database. This class answers what the customer
|
||||
* is OWED; the two disagreeing is exactly the signal that column exists for.
|
||||
*/
|
||||
final readonly class StorageAllowance
|
||||
{
|
||||
private function __construct(
|
||||
/** What the package itself includes. */
|
||||
public int $planGb,
|
||||
/** How many extra-storage packs are booked and still running. */
|
||||
public int $packs,
|
||||
/** How big one pack is today — see packSizeGb() for why "today". */
|
||||
public int $packSizeGb,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* What the customer behind this machine may store.
|
||||
*
|
||||
* The machine's own row first and the contract only as a fallback: the
|
||||
* instance is what provisioning wrote and what the host's storage accounting
|
||||
* was sized from, while a contract exists before there is a machine at all.
|
||||
*/
|
||||
public static function for(?Instance $instance): self
|
||||
{
|
||||
if ($instance === null) {
|
||||
return self::forPlan(0, null);
|
||||
}
|
||||
|
||||
// The link when provisioning has written it, and otherwise the
|
||||
// customer's live contract — the same resolution the portal and the
|
||||
// console's grant screen make, asked of the one class that answers it.
|
||||
$contract = $instance->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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Provisioning;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
|
||||
/**
|
||||
* How full a customer's cloud actually is, read from inside it.
|
||||
*
|
||||
* Proxmox's own `disk` figure for a VM is the allocated image, not what is used
|
||||
* within it — it would show a customer 500 GB from the first day. `df` in the
|
||||
* guest is the number they recognise, and it is the number the downgrade check
|
||||
* blocks on, so there must be exactly one way of taking it.
|
||||
*
|
||||
* There are two callers and they want it for opposite reasons.
|
||||
* CollectInstanceTraffic takes it on its rounds, for the trend and for the
|
||||
* nightly picture. The portal takes it ON DEMAND, because a customer who has
|
||||
* just deleted forty gigabytes to fit into a smaller package must not be told to
|
||||
* come back tomorrow — the whole value of the answer is that it is current.
|
||||
* Both go through ProxmoxClient, so a test fakes it and nothing needs a real
|
||||
* guest.
|
||||
*/
|
||||
final class DiskUsageProbe
|
||||
{
|
||||
public function __construct(private ProxmoxClient $pve) {}
|
||||
|
||||
/**
|
||||
* A reading, or null when the guest could not give one.
|
||||
*
|
||||
* Null is not zero, and the difference matters everywhere this is used: a
|
||||
* failed read charted as 0 GB tells a customer their data is gone, and a
|
||||
* failed read taken as "empty" would wave a downgrade through that is not
|
||||
* actually possible.
|
||||
*
|
||||
* @return array{used: int, total: int}|null
|
||||
*/
|
||||
public function read(Instance $instance): ?array
|
||||
{
|
||||
if ($instance->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -33,8 +33,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
|
||||
{{-- 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
|
||||
</div>
|
||||
|
||||
{{-- 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)
|
||||
<div class="flex items-start gap-2 border-t border-line px-6 py-3">
|
||||
<x-ui.icon name="database" class="mt-0.5 size-4 shrink-0 text-muted" />
|
||||
<p class="text-sm text-muted">
|
||||
{{ __('billing.storage_includes_packs', [
|
||||
'count' => $current['storage_packs'],
|
||||
'gb' => $current['storage_pack_gb'] ?? 0,
|
||||
]) }}
|
||||
</p>
|
||||
</div>
|
||||
@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 @@
|
|||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
{{-- "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)
|
||||
<div class="mt-3 border-t border-warning-border pt-3">
|
||||
<p class="text-xs leading-relaxed text-ink">
|
||||
{{ __('billing.downgrade_escape.intro', [
|
||||
'used' => $remedy['used'],
|
||||
'limit' => $remedy['limit'],
|
||||
'over' => $remedy['over'],
|
||||
]) }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@if ($remedy['packs'] > 0)
|
||||
<x-ui.button variant="primary" size="sm"
|
||||
x-on:click="$dispatch('openModal', { component: 'confirm-book-storage', arguments: { packs: {{ $remedy['packs'] }} } })">
|
||||
<x-ui.icon name="database" class="size-4" />{{ __('billing.downgrade_escape.book', ['count' => $remedy['packs'], 'gb' => $remedy['packs'] * $remedy['pack_gb']]) }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
<x-ui.button variant="secondary" size="sm"
|
||||
wire:click="remeasureStorage" wire:target="remeasureStorage" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="refresh" class="size-4" />{{ __('billing.downgrade_escape.remeasure') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@if ($storageMeasuredAt)
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ __('billing.downgrade_escape.measured_at', ['when' => $storageMeasuredAt->local()->isoFormat('LLL')]) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php use Illuminate\Support\Number; ?>
|
||||
<div class="rounded-lg bg-surface p-6">
|
||||
@php
|
||||
$loc = app()->getLocale();
|
||||
$priceCents = (int) config('provisioning.storage_addon.price_cents', 0);
|
||||
@endphp
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-active">
|
||||
<x-ui.icon name="database" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('billing.storage_confirm_title', ['count' => $packs]) }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ __('billing.storage_confirm_body', [
|
||||
'count' => $packs,
|
||||
'gb' => $packs * $packGb,
|
||||
'total' => Number::currency($packs * $priceCents / 100, in: 'EUR', locale: $loc),
|
||||
]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('billing.storage_confirm_cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="proceed" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="database" class="size-4" />{{ __('billing.storage_confirm_cta') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\ApplyPlanChange;
|
||||
use App\Actions\BookAddon;
|
||||
use App\Livewire\Billing;
|
||||
use App\Livewire\ConfirmBookStorage;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Models\User;
|
||||
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
|
||||
use App\Provisioning\Steps\Customer\GrowGuestFilesystem;
|
||||
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Billing\StorageAllowance;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Extra storage, from the purchase to the byte.
|
||||
*
|
||||
* A booked pack was a row in `subscription_addons` and nothing else: charged
|
||||
* every month, and delivered nowhere. Nextcloud's quota came from the package
|
||||
* alone, the disk was sized for the package alone, and on the rare occasion a
|
||||
* disk did grow — a plan upgrade — the guest never saw it, because nothing
|
||||
* stretched the partition or the filesystem over the new space.
|
||||
*
|
||||
* These prove the whole chain: the allowance is one figure everything reads, the
|
||||
* machine really gets the space, the guest really sees it, and a customer whose
|
||||
* downgrade is blocked by their own data is offered a way out that works.
|
||||
*/
|
||||
|
||||
/** A customer on a package, with a machine that is built, running and served. */
|
||||
function storageFixture(string $plan = 'team', array $instanceAttributes = []): array
|
||||
{
|
||||
$host = Host::factory()->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);
|
||||
});
|
||||
Loading…
Reference in New Issue