CluPilotCloud/app/Actions/ApplyStorageAllowance.php

132 lines
5.2 KiB
PHP

<?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 App\Provisioning\WorkInFlight;
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;
// Stand aside only for a run that will deliver the whole allowance: the
// disk, the filesystem inside it and the figure Nextcloud enforces. A
// plan change does all three and there is nothing to add. A `quota` run
// does only the last of them, and letting it stand for the booking would
// enforce a quota over a filesystem that never grew — space the machine
// does not have. `address` and `restart` do none of them, and standing
// aside for one of those was how a pack could be charged monthly and
// never delivered. See App\Provisioning\WorkInFlight.
if (app(WorkInFlight::class)->covers($order, 'storage')) {
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;
}
}