289 lines
11 KiB
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);
|
|
});
|