174 lines
6.7 KiB
PHP
174 lines
6.7 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);
|
|
}
|
|
|
|
private function hasRunInFlight(Instance $instance): bool
|
|
{
|
|
return ProvisioningRun::query()
|
|
->where('subject_type', Order::class)
|
|
->where('subject_id', $instance->order_id)
|
|
->whereIn('status', [
|
|
ProvisioningRun::STATUS_PENDING,
|
|
ProvisioningRun::STATUS_RUNNING,
|
|
ProvisioningRun::STATUS_WAITING,
|
|
ProvisioningRun::STATUS_PAUSED,
|
|
])
|
|
->exists();
|
|
}
|
|
}
|