CluPilotCloud/app/Console/Commands/ApplyStorageQuotas.php

176 lines
6.9 KiB
PHP

<?php
namespace App\Console\Commands;
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;
/**
* Give the existing estate the storage allowance it was already sold.
*
* ApplyStorageQuota now runs in the `customer` and `plan-change` pipelines, so
* everything built or changed from here on is enforced on the machine. Every
* instance from before that is not: `quota_gb` reached the row and stopped
* there, Nextcloud was never told, and those customers still have the whole disk
* whatever they are paying for. Nothing in the product could reach them, because
* the two pipelines only run on a build or a change and these machines are
* having neither.
*
* This is the sweep, and it is a REPAIR, not a routine. It is deliberately NOT
* in the scheduler:
*
* - the hole it closes is finite and closes for good. Once an instance's
* allowance is enforced and recorded, both pipelines keep it that way;
* - a nightly sweep would be a second authority writing the same value onto
* live machines on a timer, and the day the step in the pipeline silently
* stopped working the sweep would quietly cover for it — the failure would
* never surface, which is how the original hole survived as long as it did;
* - a repair the owner runs is a repair the owner can read the output of. This
* one prints what it did and what it did not, and refuses to touch anything
* at all under --dry-run.
*
* The work itself goes through the `quota` pipeline rather than reaching into
* guests here: one run per instance, retried, logged and visible in the console
* like every other piece of remote work, and running the exact same step the
* other two pipelines run so a second implementation cannot drift from it.
*/
class ApplyStorageQuotas extends Command
{
protected $signature = 'clupilot:apply-quotas
{--dry-run : list what would happen and change nothing}
{--instance= : one instance uuid, for a single repair}';
protected $description = 'Apply the sold storage allowance to instances whose Nextcloud was never told about it';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$started = 0;
/** @var array<string, int> reason => count */
$skipped = [];
$query = Instance::query()->with(['order', 'host']);
if ($uuid = $this->option('instance')) {
$query->where('uuid', $uuid);
}
foreach ($query->cursor() as $instance) {
$reason = $this->reasonToSkip($instance);
if ($reason !== null) {
$skipped[$reason] = ($skipped[$reason] ?? 0) + 1;
continue;
}
$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}: {$owed} GB "
.'('.($instance->quota_applied_gb === null ? 'noch nie gesetzt' : "zuletzt {$instance->quota_applied_gb} GB").')');
if (! $dryRun) {
$this->startQuotaRun($instance);
}
}
foreach ($skipped as $reason => $count) {
$this->line("übersprungen ({$reason}): {$count}");
}
$this->info($dryRun
? "Probelauf: {$started} Instanz(en) bekämen ihr Kontingent, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.'
: "{$started} Kontingent-Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.');
return self::SUCCESS;
}
/**
* Why this instance is being left alone, or null when it is not.
*
* The reason is the report — a sweep that says "skipped 34" and nothing else
* is a sweep nobody can act on, because "34 machines have no allowance
* enforced" and "34 machines are already fine" look identical from here.
*/
private function reasonToSkip(Instance $instance): ?string
{
// Nothing remote work can reach: a reservation with no VM, a failed
// build, an ended service. The same rule every other maintenance action
// asks — see Instance::hasLiveMachine().
if (! $instance->hasLiveMachine()) {
return 'keine erreichbare Maschine';
}
// No allowance on the row at all. Writing "0 GB" would lock the customer
// out of their own files, which is a far worse answer than leaving what
// is there and letting somebody notice — the same decision the step
// itself makes, made here too so the machine is not visited for nothing.
if ((int) $instance->quota_gb <= 0) {
return 'kein Kontingent hinterlegt';
}
if ($instance->quotaIsEnforced()) {
return 'bereits gesetzt';
}
// Whatever is in flight against this order gets to finish. A quota run
// beside an unfinished build would race the same occ calls, and the
// build applies the quota itself anyway.
if ($this->hasRunInFlight($instance)) {
return 'anderer Lauf aktiv';
}
return null;
}
private function startQuotaRun(Instance $instance): void
{
$run = ProvisioningRun::create([
// The instance's own purchase order, as every customer pipeline
// uses — that is what CustomerStep::order() and ::instance()
// resolve from, and what the in-flight check above looks at.
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'quota',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
}
/**
* ANY run, deliberately — unlike the narrower "will it do this work?" question
* App\Provisioning\WorkInFlight answers for the two actions that must not lose
* a change they were asked to make. This is a repair sweep: it runs again, it
* skips instances whose quota is already enforced, and an instance passed over
* today is picked up on the next pass. Nothing is forgotten by waiting.
*/
private function hasRunInFlight(Instance $instance): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $instance->order_id)
->inFlight()
->exists();
}
}