CluPilotCloud/tests/Feature/TrafficTest.php

289 lines
11 KiB
PHP

<?php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceTraffic;
use App\Models\User;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;
function trafficSetup(array $overrides = []): array
{
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
$host = App\Models\Host::factory()->active()->create();
$instance = Instance::factory()->create(array_merge([
'customer_id' => $customer->id,
'host_id' => $host->id,
'vmid' => 1042,
'plan' => 'start', // 1000 GB
'status' => 'active',
], $overrides));
// Every running machine answers to a contract, and the allowance is the
// contract's — so the fixture has to have one too.
App\Models\Subscription::factory()->plan($instance->plan)->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
return [$pve, $customer, $instance->fresh()];
}
it('counts the difference between samples, not the raw counter', function () {
[$pve, , $instance] = trafficSetup();
// First sample only establishes the baseline: what the VM did before we
// started counting this month is not the customer's bill.
$pve->counters[1042] = ['netin' => 5_000_000, 'netout' => 9_000_000];
(new CollectInstanceTraffic)->handle($pve);
$row = InstanceTraffic::query()->firstOrFail();
expect($row->tx_bytes)->toBe(0)
->and($row->last_netout)->toBe(9_000_000);
$pve->counters[1042] = ['netin' => 6_000_000, 'netout' => 11_500_000];
(new CollectInstanceTraffic)->handle($pve);
expect($row->fresh()->tx_bytes)->toBe(2_500_000)
->and($row->fresh()->rx_bytes)->toBe(1_000_000);
});
it('treats a counter that went backwards as a restart, not a refund', function () {
[$pve, , $instance] = trafficSetup();
$pve->counters[1042] = ['netin' => 0, 'netout' => 8_000_000];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => 12_000_000];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_000_000);
// VM restarted: Proxmox counts from zero again.
$pve->counters[1042] = ['netin' => 0, 'netout' => 500_000];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_500_000);
});
it('throttles instead of cutting the service off when the allowance is gone', function () {
[$pve, , $instance] = trafficSetup();
$quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1];
(new CollectInstanceTraffic)->handle($pve);
$row = InstanceTraffic::query()->firstOrFail();
expect($row->throttled)->toBeTrue()
// Slowed down, never stopped: a dead Nextcloud gets a cancellation,
// a slow one gets a top-up.
->and($pve->networkRates[1042])->toBeGreaterThan(0)
->and(TrafficMeter::for($instance->fresh())->state())->toBe('exhausted');
});
it('gives the line back when the customer buys more', function () {
[$pve, , $instance] = trafficSetup();
$quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1];
(new CollectInstanceTraffic)->handle($pve);
expect($pve->networkRates[1042])->not->toBeNull();
$instance->update(['traffic_addons' => 1]); // one extra pack
(new CollectInstanceTraffic)->handle($pve);
expect($pve->networkRates[1042])->toBeNull()
->and(InstanceTraffic::query()->first()->throttled)->toBeFalse();
});
it('warns once per threshold, not on every sample', function () {
[$pve, , $instance] = trafficSetup();
$quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.82)];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80);
// Still in the same band a sample later — nothing new to say.
$pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.84)];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80);
// Crossing the next one does warn again.
$pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.96)];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->notified_percent)->toBe(95);
});
it('counts add-on packs towards the allowance', function () {
[, , $instance] = trafficSetup();
expect(TrafficMeter::quotaGb($instance))->toBe(1000);
$instance->update(['traffic_addons' => 2]);
expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000);
});
it('keeps the allowance the customer bought when a leaner version replaces it', function () {
[, , $instance] = trafficSetup();
// The owner replaces the start plan with a version carrying half the
// traffic. New customers get 500 GB; this one bought 1000.
$catalogue = app(App\Services\Billing\PlanCatalogue::class);
$current = $catalogue->currentVersion('start');
$catalogue->schedule($current, $current->available_from, now());
$leaner = App\Models\PlanVersion::query()->create([
...$current->only(['plan_family_id', 'quota_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'traffic_gb' => 500,
'features' => $current->features,
'available_from' => now(),
]);
$leaner->prices()->create([
'term' => 'monthly', 'amount_cents' => 4900, 'currency' => App\Models\Subscription::catalogueCurrency(),
]);
$leaner->prices()->create([
'term' => 'yearly', 'amount_cents' => 58800, 'currency' => App\Models\Subscription::catalogueCurrency(),
]);
$catalogue->publish($leaner, now());
expect($catalogue->sellable()['start']['traffic_gb'])->toBe(500)
->and(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000);
});
it('shows the customer what is left, and offers a top-up when it gets tight', function () {
[, $customer, $instance] = trafficSetup();
$user = User::factory()->create(['email' => $customer->email]);
InstanceTraffic::create([
'instance_id' => $instance->id,
'period' => InstanceTraffic::currentPeriod(),
'tx_bytes' => (int) (1000 * 1000 ** 3 * 0.10),
]);
// Comfortable: no upsell in the customer's face.
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.traffic.label'))
->assertDontSee(__('dashboard.traffic.buy'));
InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.traffic.buy'));
});
it('shows allowances in the units they are sold in', function () {
// A 1000 GB plan must read as 1 TB, not as 931 GB: quotas are computed in
// SI units, so displaying binary ones made the two disagree on screen.
expect(App\Support\Bytes::human(1000 * 1000 ** 3))->toBe('1 TB')
->and(App\Support\Bytes::human(880 * 1000 ** 3))->toBe('880 GB')
->and(App\Support\Bytes::human(1_500_000))->toBe('1,5 MB')
->and(App\Support\Bytes::human(512))->toBe('512 B');
});
it('lets a throttled instance run again when the new month starts', function () {
[$pve, , $instance] = trafficSetup();
// Last month ended with the allowance exhausted and the NIC limited.
InstanceTraffic::create([
'instance_id' => $instance->id,
'period' => now()->subMonth()->format('Y-m'),
'tx_bytes' => 9_999_999_999_999,
'throttled' => true,
'throttled_at' => now()->subDays(3),
]);
$pve->networkRates[1042] = 0.25;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
// The new period starts clean — and the limit must actually come off the
// NIC, not just out of the database.
expect($pve->networkRates[1042])->toBeNull()
->and(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0);
});
it('keeps trying to release a throttle when Proxmox refuses once', function () {
[$pve, , $instance] = trafficSetup();
InstanceTraffic::create([
'instance_id' => $instance->id,
'period' => now()->subMonth()->format('Y-m'),
'tx_bytes' => 9_999_999_999_999,
'throttled' => true,
]);
// A hub that fails the first release and works afterwards — the transient
// error that would otherwise leave the customer throttled for good.
$flaky = new class extends FakeProxmoxClient
{
public int $attempts = 0;
public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void
{
$this->attempts++;
if ($mbytesPerSecond === null && $this->attempts === 1) {
throw new RuntimeException('proxmox unavailable');
}
parent::setNetworkRate($node, $vmid, $mbytesPerSecond);
}
};
$flaky->counters[1042] = ['netin' => 0, 'netout' => 0];
app()->instance(ProxmoxClient::class, $flaky);
(new CollectInstanceTraffic)->handle($flaky);
expect(InstanceTraffic::query()->where('throttled', true)->count())->toBe(1);
// Next run: the current row already exists, so a release tied to row
// creation would never fire again.
(new CollectInstanceTraffic)->handle($flaky);
expect(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0)
->and($flaky->networkRates[1042])->toBeNull();
});
it('ignores a sample that carries no counters', function () {
[$pve, , $instance] = trafficSetup();
$pve->counters[1042] = ['netin' => 0, 'netout' => 4_000_000];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => 10_000_000];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(6_000_000);
// Proxmox answers without telemetry. Treating that as zero would reset the
// baseline and count the whole counter again on the next real sample.
$blind = new class extends FakeProxmoxClient
{
public function vmStatus(string $node, int $vmid): array
{
return ['status' => 'running'];
}
};
app()->instance(ProxmoxClient::class, $blind);
(new CollectInstanceTraffic)->handle($blind);
$row = InstanceTraffic::query()->first();
expect($row->tx_bytes)->toBe(6_000_000)
->and($row->last_netout)->toBe(10_000_000); // baseline untouched
// The next good sample continues from where it left off.
app()->instance(ProxmoxClient::class, $pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => 11_000_000];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(7_000_000);
});