420 lines
18 KiB
PHP
420 lines
18 KiB
PHP
<?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]);
|
|
|
|
// Some of these tests book storage packs through Billing::purchase(),
|
|
// which since Task 5 refuses outright without a Stripe key for the
|
|
// active mode.
|
|
withStripeSecret();
|
|
|
|
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);
|
|
});
|