143 lines
5.4 KiB
PHP
143 lines
5.4 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 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();
|
|
}
|
|
}
|