Apply a bought plan change instead of only pricing it
tests / pest (push) Failing after 8m6s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

PlanChange could say what a move would cost and whether it was allowed, and
that was all it could do. Billing::purchase() wrote an upgrade order and
nothing ever consumed it: same snapshot, same machine, same quota. A customer
could pay for a bigger package and receive nothing.

ApplyPlanChange is now the single place a change lands — it moves the contract
onto the target's current version, writes one register row, settles the custom
domain, and starts a run that resizes the machine. Applying the same order
twice is a no-op, enforced by a unique event key rather than by a check two
callers could both pass.

Two things a plan change must not do, and now does not. A disk is never shrunk
— Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway.
And a live machine is never rebooted as a side effect: cores and RAM are
written, and where they need a restart the instance says so where an operator
and the customer can both see it.

The storage allowance also joins the build pipeline. ApplyStorageQuota was
written for plan changes and ran only there, so a brand-new customer still got
no quota at all — quota_gb reached the instance row and stopped, and every
package delivered the whole disk. The test guarding that pipeline only counted
its steps, which is how a list missing the one step that makes a package's
storage real stayed green for its whole life. It now names the step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-29 17:18:55 +02:00
parent d5b2612e24
commit 34874adec3
26 changed files with 1354 additions and 22 deletions

View File

@ -0,0 +1,292 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The single place a plan change actually happens.
*
* PlanChange is the preview: what a move would cost and whether it is allowed
* yet. Nothing carried one out. A customer could buy an upgrade, see it sit in
* the cart as a paid order, and keep the same contract, the same machine and the
* same quota forever the order was created and never consumed by anything.
*
* This is the consumer. It does the four things a plan change is, in one place
* so they cannot be done by halves:
*
* - the CONTRACT moves onto the target package's current sellable version, so
* the snapshot every step, bill and entitlement check reads says what the
* customer is now on;
* - the REGISTER gets one row, because a change of terms is a commercial event
* and the register is the evidence of what was agreed;
* - the ENTITLEMENTS that hang off the package are settled today that is the
* own domain, which a smaller package can take away;
* - the MACHINE is resized, through a provisioning run like every other piece
* of remote work in this application, so it is retried, logged and visible in
* the console instead of happening in a web request.
*
* Idempotent from both ends. A contract already on the target version is a
* no-op rather than an error the order is consumed and nothing else moves
* and the register row carries the order's own event key, so a duplicate write
* collides in the database rather than in a check that two callers could both
* pass.
*/
class ApplyPlanChange
{
public function __construct(private RecordCommercialEvent $record) {}
/**
* Carry out the change an order bought.
*
* The contract is resolved through CustomDomainAccess::contractOf() rather
* than re-queried here: it is already the one place that answers "which
* contract is this customer on", and a second copy of that query is the one
* that would disagree the day the rule changes.
*/
public function forOrder(Order $order, ?Carbon $at = null): ?ProvisioningRun
{
$subscription = app(CustomDomainAccess::class)->contractOf($order->customer);
if ($subscription === null) {
Log::warning('No contract to move: this plan change has no live subscription behind it.', [
'order_id' => $order->id, 'plan' => $order->plan,
]);
return null;
}
return $this($subscription, (string) $order->plan, $order, $at);
}
/**
* @return ProvisioningRun|null the resize run, or null when the change was
* refused, was a no-op, or there is no machine
* to resize
*/
public function __invoke(Subscription $subscription, string $targetPlan, ?Order $order = null, ?Carbon $at = null): ?ProvisioningRun
{
$at = $at?->copy() ?? now();
try {
// The version on sale RIGHT NOW, which is what someone moving today
// is being sold. Throws for a plan that has been withdrawn or is not
// priced on this term, and that is the correct outcome: a contract
// moved onto a guess is worse than a change that visibly fails.
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
} catch (Throwable $e) {
Log::error('Cannot move a contract onto a package the catalogue will not sell.', [
'subscription' => $subscription->uuid, 'plan' => $targetPlan, 'error' => $e->getMessage(),
]);
return null;
}
// Already there. Not an error — a retried trigger, a command run twice,
// an operator pressing the same thing — but the order is consumed so it
// stops asking.
if ((string) $subscription->plan === $targetPlan
&& (int) $subscription->plan_version_id === (int) $target['plan_version_id']) {
$this->consume($order);
return null;
}
// The gate, and the only one. An upgrade with the period already over,
// a downgrade before the term the customer paid for has run out, and any
// move on a contract that is no longer active all stop here — see
// PlanChange::evaluate() for why each of them is refused.
$change = PlanChange::evaluate($subscription, $targetPlan, $at);
if (! $change->allowedNow) {
return null;
}
$instance = $this->instanceOf($subscription);
// Read BEFORE the snapshot moves: the resize step has to know what the
// machine had, to tell a change of CPU or RAM from a change that only
// touched storage or price.
$before = [
'plan' => (string) $subscription->plan,
'cores' => (int) ($instance?->cores ?? $subscription->cores),
'ram_mb' => (int) ($instance?->ram_mb ?? $subscription->ram_mb),
];
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
$subscription->applyPlanSnapshot($target);
// The register row is written from the contract that has just been
// moved, so the evidence and the contract cannot describe different
// packages — the same order OpenSubscription writes a purchase in.
($this->record)(
event: $change->isUpgrade
? SubscriptionRecord::EVENT_UPGRADE
: SubscriptionRecord::EVENT_DOWNGRADE,
subscription: $subscription,
// What this move is worth: the pro-rata difference for an
// upgrade, nothing for a downgrade. Deliberately NOT the order's
// amount_cents, which is the shop's headline price for the whole
// package — charging or recording that for a move made on the
// sixteenth would state a sum nobody agreed to.
netCents: $change->chargeCents,
at: $at,
extra: ['plan_change' => [
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'remaining_days' => $change->remainingDays,
'term_days' => $change->termDays,
]],
// Nothing has been charged: payment is mocked repo-wide and no
// invoice exists for this move yet. Left null rather than
// reported as zero, which the register would read as a free sale.
chargedGrossCents: null,
order: $order,
// One row per order, enforced by the unique index rather than by
// a check two concurrent triggers could both pass.
eventKey: $order !== null ? 'plan-change:order:'.$order->id : null,
);
$this->resizeInstance($instance, $target, $targetPlan);
$this->consume($order);
return $this->startResize($instance, $targetPlan, $before, $change->isUpgrade, $order);
});
// After the run exists, never before. settleCustomDomain() withdraws a
// domain the new package no longer carries, and withdrawing one asks
// ReapplyInstanceAddress for a run to stop serving it — which finds this
// run already in flight against the same order and stands aside, because
// this pipeline carries the address steps itself. The other way round
// there would be two runs writing one router.
PlanChange::settleCustomDomain($subscription);
if ($run !== null) {
AdvanceRunJob::dispatch($run->uuid); // after commit
Log::info('Applying a plan change.', [
'subscription' => $subscription->uuid,
'from' => $before['plan'],
'to' => $targetPlan,
'upgrade' => $change->isUpgrade,
'run' => $run->uuid,
]);
}
return $run;
}
/**
* The order is spent. Anything but `pending` takes it out of the customer's
* cart, and `applied` says which way it went it was never paid in the sense
* an invoice means, and calling it that would put a charge in the record that
* never happened.
*/
private function consume(?Order $order): void
{
if ($order !== null && $order->status !== 'applied') {
$order->update(['status' => 'applied']);
}
}
/**
* Bring the machine's own row in line with the package it now serves.
*
* Everything except the disk. A disk cannot be shrunk Proxmox grows one
* online and has no way back so a downgrade leaves the guest exactly as
* large as it already is, and writing the smaller figure here would make this
* row claim a size the machine does not have. Two things read it and both
* would be wrong: the host's storage accounting, which would think it had
* space it does not, and the next resize, which would ask Proxmox to shrink.
*
* What actually shrinks is the QUOTA, which Nextcloud enforces and which is
* what was sold see the plan-change pipeline's quota step. The disk is
* infrastructure; the quota is the product. The contract's own `disk_gb` still
* records what the smaller package includes, so nothing has been lost: this
* column is what the machine HAS, and that is all it has ever meant.
*
* @param array<string, mixed> $target
*/
private function resizeInstance(?Instance $instance, array $target, string $targetPlan): void
{
$instance?->update([
'plan' => $targetPlan,
'quota_gb' => $target['quota_gb'],
'ram_mb' => $target['ram_mb'],
'cores' => $target['cores'],
]);
}
/**
* Start the run that makes the machine match the package.
*
* Subject is the instance's own purchase order, exactly as the `address`
* pipeline does it not the upgrade order. That is what CustomerStep::order()
* and ::subscription() resolve from, so the steps read this customer's
* contract rather than nothing; it is what ReapplyInstanceAddress checks for
* a run already in flight, so two runs cannot write one router; and it keeps
* RunRunner from marking a paid, running customer's order failed because a
* resize could not be applied (see ProvisioningSubject::provisioningPipeline).
*
* @param array{plan: string, cores: int, ram_mb: int} $before
*/
private function startResize(?Instance $instance, string $targetPlan, array $before, bool $isUpgrade, ?Order $order): ?ProvisioningRun
{
// A contract with no machine — one still being built, one whose build
// failed, a package granted before provisioning ran — has nothing to
// resize. The contract has still moved, and the machine will be built
// from it whenever it is built.
if ($instance === null || ! $instance->hasLiveMachine()) {
return null;
}
return ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'plan-change',
'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,
// What the machine had before, so the resize step can tell which
// of its changes need the guest to come back round before they
// are true. Kept on the run rather than recomputed: by the time
// the step executes, the instance row already says the new size.
'plan_change' => [
'order_id' => $order?->id,
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'from_cores' => $before['cores'],
'from_ram_mb' => $before['ram_mb'],
'upgrade' => $isUpgrade,
],
],
]);
}
/**
* The machine this contract pays for: the link when provisioning has written
* it, and otherwise the customer's newest instance the same fallback
* CustomDomainAccess makes, for the same reason.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
return $subscription->instance
?? $subscription->customer?->instances()->latest('id')->first();
}
}

View File

@ -107,19 +107,13 @@ class ReapplyInstanceAddress
/**
* Is there a machine here whose address can be re-applied at all?
*
* A reservation with no VM, a failed build and an instance whose order has
* gone all fail the same way: the steps would reach for a guest agent that
* is not there and burn the run's retries doing it. An instance that is
* still `provisioning` is excluded for a different reason its own run
* will apply the address on its way past, and it holds the same lock this
* would.
* The question is not this action's alone a plan change asks exactly the
* same one before it starts a run against a live machine so the rule sits
* on the model and both read it there. See Instance::hasLiveMachine().
*/
private function isReapplyable(Instance $instance): bool
{
return $instance->order !== null
&& $instance->host !== null
&& $instance->vmid !== null
&& in_array($instance->status, ['active', 'cancellation_scheduled'], true);
return $instance->hasLiveMachine();
}
private function hasRunInFlight(Order $order): bool

View File

@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use Illuminate\Console\Command;
use Throwable;
/**
* Carries out the plan changes whose moment has come.
*
* An upgrade lands when it is paid for see App\Observers\OrderObserver. A
* downgrade cannot: someone on a yearly contract bought a year, so moving them
* down in month three would take away what they have already paid for. It waits
* for the end of the term, and nothing was waiting with it. This is what does.
*
* **How a pending downgrade is known.** By its order, and only by its order.
* `subscriptions.pending_plan` and `pending_effective_at` exist in the schema and
* are written by nothing at all the shop records a plan change as an Order with
* type `downgrade` sitting at `pending`, and that order is the customer's own
* record of the request: it is what the cart shows them, what they remove to
* change their mind, and what App\Livewire\Billing replaces when they pick a
* different package. Mirroring it onto the contract would make two sources of
* truth for one decision, and the second is the one that goes stale.
*
* **Safe to run as often as the scheduler likes.** Nothing here decides whether
* a change is due PlanChange does, from the contract's own period and nothing
* here applies one twice: ApplyPlanChange consumes the order, refuses a contract
* already on the target version, and writes its register row under an event key
* unique to that order.
*/
class ApplyDuePlanChanges extends Command
{
protected $signature = 'clupilot:apply-due-plan-changes';
protected $description = 'Apply scheduled downgrades whose term has run out';
public function handle(ApplyPlanChange $apply, CustomDomainAccess $contracts): int
{
$applied = 0;
$due = 0;
$orders = Order::query()
->where('type', 'downgrade')
->where('status', 'pending')
->with('customer')
->orderBy('id')
->cursor();
foreach ($orders as $order) {
$subscription = $contracts->contractOf($order->customer);
if ($subscription === null) {
continue;
}
try {
// Asked before applying rather than left to ApplyPlanChange's own
// refusal, only so that a downgrade sitting out a yearly term does
// not write a line into the log every quarter of an hour for a year.
if (! PlanChange::evaluate($subscription, (string) $order->plan)->allowedNow) {
continue;
}
$due++;
$apply->forOrder($order);
// Counted from the order rather than from the returned run: a
// contract whose machine is still being built has no resize to
// start, and the change has landed all the same.
if ($order->fresh()?->status === 'applied') {
$applied++;
}
} catch (Throwable $e) {
// One customer's withdrawn package must not stop everybody
// else's downgrade from landing.
$this->warn("Order {$order->uuid}: {$e->getMessage()}");
}
}
$this->info("{$due} downgrade(s) due, {$applied} applied.");
return self::SUCCESS;
}
}

View File

@ -40,6 +40,11 @@ class Instances extends Component
'vmid' => $i->vmid ?? '—',
'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—',
'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—',
// A machine running on less CPU or RAM than it has been sold —
// see Instance::restartIsPending(). Shown beside the status
// rather than as one, because the instance is genuinely active
// and the operator needs both facts at once.
'restart' => $i->restartIsPending(),
'status' => $status = $i->status ?? 'provisioning',
// A status the lifecycle adds later must show as itself, never
// as "admin.status.whatever" in front of the owner.

View File

@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\MaintenanceWindow;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
@ -44,7 +45,7 @@ class Cloud extends Component
// printing it here would tell a customer to visit a hostname that does
// not resolve to them.
$domain = $shown?->subdomain
? $shown->address(\App\Support\ProvisioningSettings::dnsZone())
? $shown->address(ProvisioningSettings::dnsZone())
: 'cloud.example.com';
return view('livewire.cloud', [
@ -94,6 +95,12 @@ class Cloud extends Component
],
'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB',
'maintenance' => $maintenance,
// A plan change writes the new CPU and memory into the VM's
// configuration and a running guest only takes them at its next cold
// boot. Nobody restarts somebody's cloud as a side effect of a
// purchase, so the customer is told instead — otherwise they are
// paying for cores they cannot see and have no way of finding out.
'restartPending' => $shown?->restartIsPending() ?? false,
]);
}
}

View File

@ -18,7 +18,8 @@ class Instance extends Model
protected $fillable = [
'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb',
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at',
'ram_mb', 'cores', 'restart_required_since',
'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at',
'route_written', 'routed_hostnames', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at',
'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures',
];
@ -50,9 +51,45 @@ class Instance extends Model
'cores' => 'integer',
'cancel_requested_at' => 'datetime',
'service_ends_at' => 'datetime',
'restart_required_since' => 'datetime',
];
}
/**
* Is this machine running on less CPU or RAM than it has been sold?
*
* Proxmox writes a new `cores`/`memory` into the VM's configuration
* immediately and a running guest picks it up at its next cold boot. A plan
* change therefore leaves an honest gap, and rebooting somebody's cloud as a
* side effect of a purchase is not a way to close it so the gap is stated
* instead, here, and shown to the operator and to the customer.
*/
public function restartIsPending(): bool
{
return $this->restart_required_since !== null;
}
/**
* Is there a machine here that remote work can actually reach?
*
* A reservation with no VM, a failed build and an instance whose order has
* gone all fail the same way: the steps reach for a guest agent that is not
* there and burn a run's retries doing it. An instance that is still
* `provisioning` is excluded for a different reason its own run is already
* writing this machine, and a second run beside it would write the same
* router and race the same occ calls.
*
* Asked by everything that starts a maintenance run against a live instance
* (ReapplyInstanceAddress, ApplyPlanChange), so the rule is one rule.
*/
public function hasLiveMachine(): bool
{
return $this->order !== null
&& $this->host !== null
&& $this->vmid !== null
&& in_array($this->status, ['active', 'cancellation_scheduled'], true);
}
/**
* Instances that are actually occupying storage on their host.
*

View File

@ -3,15 +3,18 @@
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Observers\OrderObserver;
use App\Provisioning\Contracts\ProvisioningSubject;
use App\Services\Billing\TaxTreatment;
use Database\Factories\OrderFactory;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
#[ObservedBy(OrderObserver::class)]
class Order extends Model implements ProvisioningSubject
{
/** @use HasFactory<OrderFactory> */

View File

@ -192,6 +192,45 @@ class Subscription extends Model
];
}
/**
* Move this contract onto another package the one sanctioned exception to
* the freeze above.
*
* The freeze exists so that nothing can rewrite what a customer is owed
* behind their back: a price rise, an edited plan, a stray `update()`. A
* plan change is the opposite of that. It is the customer asking for
* different terms, it is priced and recorded before it lands, and afterwards
* the contract has to state the package they are actually on otherwise the
* snapshot stops being the authority every step and every bill reads it as.
*
* So the exception is named and lives here rather than being a `saveQuietly()`
* somewhere in an action, where it would read as a way around the guard
* instead of a decision. Exactly one caller: App\Actions\ApplyPlanChange,
* which refuses unless PlanChange says the move is allowed today and writes
* the proof-register row in the same transaction.
*
* Only the snapshot's own columns move. `started_at` stays where it is the
* contract began when it began, and a change of package does not restart it
* and so do the period boundaries, which belong to the billing cycle and
* not to the package.
*
* @param array<string, mixed> $snapshot from snapshotFrom()
*/
public function applyPlanSnapshot(array $snapshot): void
{
$movable = array_diff([...self::FROZEN, 'template_vmid'], ['started_at']);
$this->forceFill(array_intersect_key($snapshot, array_flip($movable)));
// Quietly, because the guard in booted() is what is being excepted here.
$this->saveQuietly();
// The version has moved, so anything already holding the old one is
// holding the wrong package — the proof-register row written straight
// after this reads planVersion for the version number it files under.
$this->unsetRelation('planVersion');
}
public function isYearly(): bool
{
return $this->term === self::TERM_YEARLY;

View File

@ -0,0 +1,57 @@
<?php
namespace App\Observers;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The moment a bought upgrade becomes a bigger machine.
*
* StartCustomerProvisioning turns a paid order into work from the webhook that
* announced the payment. An upgrade has no such webhook: there is no checkout in
* this application yet, payment is mocked repo-wide, and the order sits in the
* cart at `pending` until something marks it paid. So the trigger is hung on the
* fact itself the order becoming paid rather than on whichever call site
* happens to exist today. A real Stripe checkout, an operator settling a
* purchase by hand and a seeder all end at the same `status = paid`, and all
* three have to result in the customer getting what they bought.
*
* Only an UPDATE, never a create. Every order in this codebase is created either
* already-paid by provisioning (a first purchase, which opens a contract rather
* than changing one) or pending by the shop; an upgrade that goes straight in as
* paid is not a state this application produces, and firing on create would make
* every fixture that builds one start a provisioning run.
*
* A downgrade is deliberately not here. It costs nothing, is never paid, and
* lands at the end of the term clupilot:apply-due-plan-changes carries those.
*/
class OrderObserver
{
public function updated(Order $order): void
{
if (! $order->wasChanged('status') || $order->status !== 'paid') {
return;
}
if ($order->type !== 'upgrade') {
return;
}
try {
app(ApplyPlanChange::class)->forOrder($order);
} catch (Throwable $e) {
// Never allowed to fail the write that marked the order paid. That
// write is the record that money changed hands, and undoing it over
// a plan change would erase the payment and leave nothing to retry
// from. A change that did not land is loud in its own right: the
// order stays unconsumed and the contract still says the old
// package, both of which somebody can see.
Log::error('Failed to apply the plan change a paid order bought.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
/**
* The storage a plan change actually delivers.
*
* This is the half of a plan change that a downgrade can carry out in full: a
* disk cannot be shrunk, but a quota can, and the quota is what was sold. The
* customer on a 200 GB package gets 200 GB whatever size the underlying disk
* happens to be, and Nextcloud is what enforces it.
*
* `files default_quota`, not a per-user quota. An account with an explicit quota
* stops following the default, so writing one onto the admin account would
* freeze that account at today's figure and quietly exclude it from the next
* plan change. The default applies to every account on the instance which is
* what a package's storage allowance means here, since a package is a whole
* Nextcloud and not a seat on a shared one.
*/
class ApplyStorageQuota extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'apply_storage_quota';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
if ($instance === null) {
return StepResult::fail('no_instance');
}
$quota = (int) $instance->quota_gb;
// No allowance recorded: setting "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.
if ($quota <= 0) {
return StepResult::advance();
}
$pve = $this->pve->forHost($instance->host);
$occ = 'cd /opt/nextcloud && docker compose exec -T app php occ ';
// Idempotent, like every other occ call in this pipeline: writing the
// same value again is a no-op and exits 0.
$this->guest($pve, $run, $occ.'config:app:set files default_quota --value='.escapeshellarg($quota.' GB'));
return StepResult::advance();
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
/**
* The hardware half of a plan change: CPU, memory and disk.
*
* Two things here are not symmetrical, and both are deliberate.
*
* **A disk only grows.** Proxmox resizes a virtual disk upwards on a running
* guest and has no way back down there is no shrink, online or offline, and
* asking for one is an error, not a slow operation. So a downgrade does not try.
* What shrinks instead is the customer's QUOTA, which Nextcloud enforces and
* which is the thing that was actually sold; the guest keeps the disk it already
* has, and `instances.disk_gb` keeps saying so because that column is what the
* machine HAS and the host's storage accounting reads it. What the smaller
* package includes is on the contract, where it belongs nothing reads a disk
* size as proof of what was sold.
*
* **CPU and memory are applied but not made true.** The configuration PUT lands
* immediately and a running guest takes the new size at its next cold boot;
* nothing in this codebase turns Proxmox's CPU or memory hotplug on, so there is
* no live path. Rebooting a customer's cloud as a side effect of a purchase is
* not something to do quietly, and applying a value that only becomes real
* months later with nobody knowing is not either so the machine is left
* running and the gap is recorded on the instance, where the console's instance
* list and the customer's own cloud page both show it.
*/
class ResizeVirtualMachine extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'resize_vm';
}
public function maxDuration(): int
{
return 300;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
if ($instance === null) {
return StepResult::fail('no_instance');
}
$node = (string) $run->context('node');
$vmid = (int) $run->context('vmid');
$pve = $this->pve->forHost($instance->host);
// setCloudInit() is the generic `PUT /nodes/{node}/qemu/{vmid}/config`
// despite its name — it writes whatever parameters it is given, and
// cloud-init keys are simply what the first caller happened to need.
// cores and memory are ordinary VM configuration and go the same way.
// Idempotent: writing the same values again changes nothing.
$pve->setCloudInit($node, $vmid, [
'cores' => (int) $instance->cores,
'memory' => (int) $instance->ram_mb,
]);
$this->growDisk($run, $pve, $instance, $node, $vmid);
$this->settlePendingRestart($run, $pve, $instance, $node, $vmid);
return StepResult::advance();
}
/**
* Grow the disk to what the contract now includes, and say so out loud when
* the contract asks for less than the machine already has.
*/
private function growDisk(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
{
$target = (int) ($this->plan($run)['disk_gb'] ?? 0);
$actual = (int) $instance->disk_gb;
// No contract behind this run, or a package without a size: nothing to
// grow towards, and guessing at one would resize somebody's disk from a
// default.
if ($target <= 0) {
return;
}
if ($target > $actual) {
// Absolute target, not '+…', so a retry cannot grow it twice.
$pve->resizeDisk($node, $vmid, 'scsi0', $target.'G');
$instance->update(['disk_gb' => $target]);
return;
}
if ($target < $actual) {
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => "Festplatte bleibt bei {$actual} GB: ein kleineres Paket ({$target} GB) verkleinert "
.'keine Platte — das Kontingent des Kunden sinkt stattdessen.',
]);
}
}
/**
* Record or clear the restart this change is waiting on.
*
* Keyed on whether the machine is RUNNING rather than on reading back what
* Proxmox says the guest currently has. Without hotplug the answer is the
* same either way and this one needs no second API shape to be faked in
* tests; and where the two could differ, being pessimistic is the safe
* direction. A "restart pending" that turns out to have been unnecessary is
* a visible nuisance, while a resize that silently never happened is exactly
* the failure this step exists to stop.
*/
private function settlePendingRestart(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
{
$fromCores = (int) $run->context('plan_change.from_cores', $instance->cores);
$fromRam = (int) $run->context('plan_change.from_ram_mb', $instance->ram_mb);
// Nothing about CPU or memory moved, so this change is waiting on
// nothing. An older pending restart is left exactly where it is — it is
// still pending, and clearing it here would hide it.
if ($fromCores === (int) $instance->cores && $fromRam === (int) $instance->ram_mb) {
return;
}
$running = ($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running';
if (! $running) {
// A stopped machine reads its configuration when it starts, so the
// new size is already true for it — and anything pending from an
// earlier change is settled by the same start.
if ($instance->restartIsPending()) {
$instance->update(['restart_required_since' => null]);
}
return;
}
// First pending change keeps its timestamp: how long somebody has been
// paying for cores they do not have is the interesting number, not when
// the most recent change was applied.
if (! $instance->restartIsPending()) {
$instance->update(['restart_required_since' => now()]);
}
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => "Neue Ausstattung ({$instance->cores} vCPU, {$instance->ram_mb} MB RAM) ist in der VM-Konfiguration "
.'hinterlegt und wird beim nächsten Neustart wirksam. Die laufende Maschine wurde nicht neu gestartet.',
]);
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\ProvisioningSettings;
use Throwable;
/**
* Backup and monitoring, after a plan change checked, and almost always left
* alone.
*
* Neither of them depends on the package today. Every instance is backed up
* nightly at 02:00 whatever it costs, and the monitoring check is
* `https://{subdomain}/status.php`, which a plan change does not move. So the
* honest thing for this step to do in the ordinary case is nothing: deleting and
* re-creating a vzdump job to arrive at the same schedule would be remote work
* on a live host in exchange for a second job to keep in step with, and
* re-registering a monitor would leave two checks alerting on one instance.
*
* It is a step rather than an omission for two reasons. It is the named place a
* plan-dependent backup or monitoring rule goes the day there is one the
* alternative being that somebody adds it to whichever step they happen to be
* reading. And there is one real gap it closes: an instance whose build
* registered neither, because monitoring was unreachable and the run went on
* DEGRADED, or because the build was interrupted after these steps. A plan
* change is a reasonable moment to notice that and put it right.
*/
class SettlePlanServices extends CustomerStep
{
public function __construct(
private ProxmoxClient $pve,
private MonitoringClient $monitoring,
) {}
public function key(): string
{
return 'settle_plan_services';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
if ($instance === null) {
return StepResult::fail('no_instance');
}
// Every package, the same nightly window — see the class docblock for
// why this is read from one place rather than derived from the plan.
$schedule = '02:00';
if (! $instance->backups()->exists()) {
$jobId = $this->pve->forHost($instance->host)
->createBackupJob((string) $run->context('node'), (int) $instance->vmid, $schedule);
// Local row before anything else can fail, exactly as RegisterBackup
// does it, so a retry finds the job rather than creating a second.
$instance->backups()->firstOrCreate(
['external_job_id' => $jobId],
['schedule' => $schedule, 'status' => 'scheduled'],
);
}
$this->settleMonitoring($run, $instance);
return StepResult::advance();
}
/**
* Monitoring is observability, not the product.
*
* The same judgement RegisterMonitoring makes during a build applies here
* with more force: a customer's plan change must not fail because Kuma is
* down. The gap is recorded on the run and the change goes through.
*/
private function settleMonitoring(ProvisioningRun $run, Instance $instance): void
{
$url = 'https://'.$instance->subdomain.'.'.ProvisioningSettings::dnsZone().'/status.php';
if ($instance->monitoringTargets()->where('url', $url)->exists()) {
return;
}
try {
$targetId = $this->monitoring->registerTarget($instance->subdomain, $url);
} catch (Throwable $e) {
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => 'Monitoring übersprungen (Dienst nicht erreichbar): '.$e->getMessage(),
]);
return;
}
$instance->monitoringTargets()->firstOrCreate(
['external_id' => $targetId],
// 'unknown', not 'up': registering a check is not passing one.
['url' => $url, 'status' => 'unknown'],
);
}
}

View File

@ -31,7 +31,16 @@ interface ProxmoxClient
/** Clone a template to a new VM; returns the task UPID. */
public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string;
/** @param array<string, mixed> $params */
/**
* The generic `PUT /nodes/{node}/qemu/{vmid}/config`, despite the name.
*
* It writes whatever parameters it is given; cloud-init keys are simply what
* the first caller needed. `cores`, `memory` and the rest of a VM's ordinary
* configuration go through here too see ResizeVirtualMachine, which uses
* it to apply a new plan's CPU and memory.
*
* @param array<string, mixed> $params
*/
public function setCloudInit(string $node, int $vmid, array $params): void;
public function resizeDisk(string $node, int $vmid, string $disk, string $size): void;

View File

@ -54,6 +54,11 @@ return [
Customer\ConfigureNetwork::class,
Customer\DeployApplicationStack::class,
Customer\ConfigureNextcloud::class,
// The allowance the customer paid for, enforced on the machine.
// It was never applied anywhere before: quota_gb reached the
// instance row and stopped there, so every package delivered the
// whole disk and the figure on the price sheet was decoration.
Customer\ApplyStorageQuota::class,
Customer\CreateCustomerAdmin::class,
Customer\ConfigureDnsAndTls::class,
Customer\RegisterBackup::class,
@ -79,6 +84,31 @@ return [
Customer\ConfigureDnsAndTls::class,
Customer\ConfigureNextcloud::class,
],
/*
| Making an existing instance match the package it has just been moved
| onto. Started by App\Actions\ApplyPlanChange once the contract, the
| proof register and the entitlements have already moved this is the
| machine catching up, and nothing here decides whether a change is
| allowed.
|
| Same subject as `customer` and `address` (the instance's own purchase
| Order, NOT the upgrade order), so CustomerStep::order(), ::instance()
| and ::subscription() all resolve, so ReapplyInstanceAddress sees a run
| in flight and does not start a second one against the same machine,
| and so RunRunner's failure hook leaves a paid, running order alone.
|
| The two address steps are the `address` pipeline itself, reused rather
| than repeated: a downgrade onto a package without an own domain has to
| stop serving that domain, and that is exactly what those two do.
*/
'plan-change' => [
Customer\ResizeVirtualMachine::class,
Customer\ApplyStorageQuota::class,
Customer\ConfigureDnsAndTls::class,
Customer\ConfigureNextcloud::class,
Customer\SettlePlanServices::class,
],
],
// The one currency the catalogue is priced in. Plan prices carry no currency

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A machine that is running on less CPU or RAM than its contract now says.
*
* Proxmox takes a new `cores`/`memory` on a running guest into the VM's
* configuration and applies it at the next cold boot nothing in this codebase
* turns CPU or memory hotplug on, so a plan change that grows a live machine
* changes what it WILL have, not what it has. There are only two honest things
* to do with that: reboot the customer's machine as a side effect of a purchase,
* or say so. This column is the saying so.
*
* Set by the plan-change pipeline's resize step, read by the console's instance
* list and by the customer's own cloud page. Cleared by the same step the next
* time it finds the machine stopped, because a stopped machine takes its new
* size the moment it starts.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->timestamp('restart_required_since')->nullable()->after('cores');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('restart_required_since');
});
}
};

View File

@ -134,6 +134,8 @@ return [
],
'retry' => 'Wiederholen',
'restart_pending' => 'Neustart offen',
'restart_pending_hint' => 'Neue CPU/RAM sind hinterlegt und werden beim nächsten Neustart der VM wirksam.',
'run_retried' => 'Lauf wird erneut gestartet.',
'run_started' => 'Gestartet',
'run_activity' => 'Letzte Aktivität',

View File

@ -30,6 +30,8 @@ return [
'storage' => 'Speicher',
'storage_growth' => 'Speicherverlauf',
'storage_growth_sub' => 'Belegter Speicher der letzten 12 Wochen.',
'restart_pending_title' => 'Neustart erforderlich',
'restart_pending_body' => 'Ihr neues Paket ist aktiv. Mehr Prozessorkerne und Arbeitsspeicher werden erst beim nächsten Neustart Ihrer Cloud wirksam — Ihre Cloud wurde dafür bewusst nicht von selbst neu gestartet.',
'restart' => 'Neu starten',
'snapshot' => 'Snapshot erstellen',
'logs' => 'Protokolle',

View File

@ -17,6 +17,9 @@ return [
'register_monitoring' => 'Monitoring einrichten',
'run_acceptance_checks' => 'Abnahmeprüfung',
'complete_provisioning' => 'Bereitstellung abschließen',
'resize_vm' => 'VM-Ausstattung anpassen',
'apply_storage_quota' => 'Speicherkontingent setzen',
'settle_plan_services' => 'Backup & Monitoring abgleichen',
],
'mail' => [

View File

@ -134,6 +134,8 @@ return [
],
'retry' => 'Retry',
'restart_pending' => 'Restart pending',
'restart_pending_hint' => 'New CPU/RAM are configured and take effect at the VMs next restart.',
'run_retried' => 'Run is being retried.',
'run_started' => 'Started',
'run_activity' => 'Last activity',

View File

@ -30,6 +30,8 @@ return [
'storage' => 'Storage',
'storage_growth' => 'Storage over time',
'storage_growth_sub' => 'Used storage over the last 12 weeks.',
'restart_pending_title' => 'Restart required',
'restart_pending_body' => 'Your new plan is active. The extra CPU cores and memory only take effect the next time your cloud restarts — we deliberately did not restart it for you.',
'restart' => 'Restart',
'snapshot' => 'Create snapshot',
'logs' => 'Logs',

View File

@ -17,6 +17,9 @@ return [
'register_monitoring' => 'Register monitoring',
'run_acceptance_checks' => 'Acceptance checks',
'complete_provisioning' => 'Complete provisioning',
'resize_vm' => 'Resize the VM',
'apply_storage_quota' => 'Apply storage quota',
'settle_plan_services' => 'Reconcile backup & monitoring',
],
'mail' => [

View File

@ -27,7 +27,17 @@
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['vmid'] }}</td>
<td class="px-4 py-3 text-xs text-muted">{{ $r['plan'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['quota'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ $r['status_label'] }}</x-ui.badge></td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1.5">
<x-ui.badge :status="$r['status']">{{ $r['status_label'] }}</x-ui.badge>
@if ($r['restart'])
<span class="inline-flex items-center gap-1 rounded-pill border border-warning-border bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning"
title="{{ __('admin.restart_pending_hint') }}">
<x-ui.icon name="rotate-ccw" class="size-3.5" />{{ __('admin.restart_pending') }}
</span>
@endif
</div>
</td>
</tr>
@empty
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-muted">{{ __('admin.instances_empty') }}</td></tr>

View File

@ -36,6 +36,16 @@
</div>
@endif
@if ($restartPending)
<div class="mt-4 flex items-start gap-2.5 rounded-lg border border-warning-border bg-warning-bg p-3">
<x-ui.icon name="rotate-ccw" class="mt-0.5 size-4 shrink-0 text-warning" />
<div class="min-w-0 text-sm">
<p class="font-semibold text-ink">{{ __('cloud.restart_pending_title') }}</p>
<p class="text-body">{{ __('cloud.restart_pending_body') }}</p>
</div>
</div>
@endif
<dl class="mt-4 grid grid-cols-1 gap-4 border-t border-line pt-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([
['cloud.plan', $instance['plan']],

View File

@ -1,6 +1,11 @@
<?php
use App\Console\TickProvisioning;
use App\Models\StripePendingEvent;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Provisioning\Jobs\PingHosts;
use App\Provisioning\Jobs\SyncMonitoringStatus;
use App\Provisioning\Jobs\SyncVpnPeers;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@ -18,7 +23,7 @@ Schedule::call(fn () => app(TickProvisioning::class)())
// Refresh the VPN peer state (handshakes, traffic) so the console does not go
// stale while nobody has it open. The job itself runs on the provisioning
// queue — that worker owns wg0.
Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers)
Schedule::job(new SyncVpnPeers)
->everyMinute()
->name('vpn-sync');
@ -26,11 +31,11 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers)
// provisioning queue, which is where the Proxmox credentials are usable.
// Turns the monitoring column from a claim into a measurement. Without it the
// console and the public status page report every instance healthy forever.
Schedule::job(new \App\Provisioning\Jobs\SyncMonitoringStatus)
Schedule::job(new SyncMonitoringStatus)
->everyFiveMinutes()
->withoutOverlapping();
Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic)
Schedule::job(new CollectInstanceTraffic)
->everyFifteenMinutes()
->name('traffic-collect');
@ -40,7 +45,7 @@ Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic)
// made by hand in the dashboard. A week is far longer than the moment a
// checkout takes to become a contract, and short enough that the table stays
// a holding area rather than a second copy of Stripe's event log.
Schedule::call(fn () => \App\Models\StripePendingEvent::query()
Schedule::call(fn () => StripePendingEvent::query()
->where('created_at', '<', now()->subWeek())
->delete())
->daily()
@ -99,6 +104,20 @@ Schedule::command('clupilot:auto-update')
// once, at onboarding — so every host read "offline" half an hour later,
// permanently. Nothing was measuring host reachability at all, which is also
// why it could not appear on the status page.
Schedule::job(new \App\Provisioning\Jobs\PingHosts)
Schedule::job(new PingHosts)
->everyMinute()
->name('host-ping');
// Carry out the downgrades whose term has run out.
//
// An upgrade lands the moment it is paid for; a downgrade waits, because
// somebody on a yearly contract bought a year. Nothing was waiting with it — the
// order sat in the cart forever and the customer stayed on the bigger package.
//
// Every quarter of an hour rather than nightly: a term ends at the second it
// ends, and a customer who has asked to pay less should not spend another
// afternoon on the old price. Nothing is taken away that they did not ask to
// give up, so there is no bad hour for it.
Schedule::command('clupilot:apply-due-plan-changes')
->everyFifteenMinutes()
->withoutOverlapping();

View File

@ -0,0 +1,334 @@
<?php
use App\Actions\ApplyPlanChange;
use App\Livewire\Admin\Instances;
use App\Livewire\Cloud;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
use App\Services\Billing\CustomDomainAccess;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* A plan change that actually happens.
*
* PlanChange could price a move and say whether it was allowed; nothing carried
* one out. A customer could buy an upgrade, watch the order sit there paid, and
* keep the same contract, the same machine and the same quota the order was
* created by the shop and consumed by nobody.
*
* These prove the four halves of a change landing (contract, register,
* entitlements, machine), both triggers, and the two things that cannot be done
* naively to a live customer: shrinking a disk, and rebooting their cloud.
*/
/** A customer on a package, with a machine that is built, running and served. */
function planChangeFixture(string $plan = 'business', array $instanceAttributes = []): array
{
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
$subscription = $order->subscription;
$instance = Instance::factory()->create(array_merge([
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'host_id' => $host->id,
'vmid' => 101,
'guest_ip' => '10.20.0.7',
'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]);
return ['host' => $host, 'order' => $order, 'subscription' => $subscription->fresh(), 'instance' => $instance];
}
/** What the shop writes when somebody picks another package. */
function planChangeOrder(array $fixture, string $plan, string $type): Order
{
return Order::create([
'customer_id' => $fixture['order']->customer_id,
'plan' => $plan,
'type' => $type,
'amount_cents' => 1000,
'currency' => 'EUR',
'datacenter' => 'fsn',
'status' => 'pending',
]);
}
/** The run the change started, whatever step it is on. */
function planChangeRun(): ?ProvisioningRun
{
return ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->first();
}
it('moves the contract onto the new package, records it once, and starts one run when the upgrade is paid', function () {
fakeServices();
Queue::fake();
$fixture = planChangeFixture('team');
$order = planChangeOrder($fixture, 'business', 'upgrade');
$periodEnd = $fixture['subscription']->current_period_end;
// The trigger: the order becoming paid, which is all a mocked payment is.
$order->update(['status' => 'paid']);
$business = Subscription::snapshotFrom('business');
$contract = $fixture['subscription']->fresh();
expect($contract->plan)->toBe('business')
->and($contract->plan_version_id)->toBe($business['plan_version_id'])
->and($contract->quota_gb)->toBe($business['quota_gb'])
->and($contract->price_cents)->toBe($business['price_cents'])
// Term and billing period are the customer's, not the package's.
->and($contract->term)->toBe(Subscription::TERM_MONTHLY)
->and($contract->current_period_end->eq($periodEnd))->toBeTrue()
->and($contract->started_at->eq($fixture['subscription']->started_at))->toBeTrue();
expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1)
->and($order->fresh()->status)->toBe('applied')
->and($fixture['instance']->fresh()->plan)->toBe('business');
// The run hangs off the machine's own purchase order, like `address` does,
// so the steps can find this customer's contract and no second run can be
// started against the same machine beside it.
expect(planChangeRun()->subject_id)->toBe($fixture['order']->id);
});
it('changes nothing the second time the same order is applied', function () {
fakeServices();
Queue::fake();
$fixture = planChangeFixture('team');
$order = planChangeOrder($fixture, 'business', 'upgrade');
$order->update(['status' => 'paid']);
$versionBefore = $fixture['subscription']->fresh()->plan_version_id;
// Straight back through the action, as a retried trigger or a second
// scheduler tick would come.
expect(app(ApplyPlanChange::class)->forOrder($order->fresh()))->toBeNull();
expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1)
->and($fixture['subscription']->fresh()->plan_version_id)->toBe($versionBefore);
});
it('holds a downgrade until the term is over, then applies it from the scheduler', function () {
fakeServices();
Queue::fake();
$fixture = planChangeFixture('business');
$order = planChangeOrder($fixture, 'team', 'downgrade');
$end = $fixture['subscription']->current_period_end;
// Mid-term: the customer bought this month, and a month is a month.
Carbon::setTestNow($end->copy()->subDays(3));
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
expect($fixture['subscription']->fresh()->plan)->toBe('business')
->and($order->fresh()->status)->toBe('pending')
->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0)
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(0);
// Once the term has run out it lands, and running the command again does
// not land it a second time.
Carbon::setTestNow($end->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
expect($fixture['subscription']->fresh()->plan)->toBe('team')
->and($order->fresh()->status)->toBe('applied')
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(1)
->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1);
Carbon::setTestNow();
});
it('stops serving a domain the smaller package cannot carry', function () {
$services = fakeServices();
Queue::fake();
$domain = 'cloud.berger.at';
$fixture = planChangeFixture('business', [
'custom_domain' => $domain,
'domain_token' => 'tok',
'domain_verified_at' => now(),
'domain_cert_ok' => true,
'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone(), $domain],
]);
$customer = $fixture['order']->customer;
$access = app(CustomDomainAccess::class);
// Business carries an own domain; Start cannot have one at all.
expect($access->allowsCustomer($customer))->toBeTrue();
$order = planChangeOrder($fixture, 'start', 'downgrade');
Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
expect($access->allowsCustomer($customer->fresh()))->toBeFalse();
// And the machine is told, by this run rather than by a second one racing
// it: the address steps are part of the plan-change pipeline.
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0);
$run = planChangeRun();
app(ConfigureDnsAndTls::class)->execute($run);
app(ConfigureNextcloud::class)->execute($run);
expect($services['traefik']->serves('berger', $domain))->toBeFalse()
->and($services['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue()
->and($order->fresh()->status)->toBe('applied');
Carbon::setTestNow();
});
it('shrinks the quota on a downgrade and never the disk', function () {
$services = fakeServices();
Queue::fake();
$fixture = planChangeFixture('business');
$diskBefore = (int) $fixture['instance']->disk_gb; // 1050 GB, and it stays
$order = planChangeOrder($fixture, 'start', 'downgrade');
Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
$run = planChangeRun();
app(ResizeVirtualMachine::class)->execute($run);
app(ApplyStorageQuota::class)->execute($run);
$start = Subscription::snapshotFrom('start');
$instance = $fixture['instance']->fresh();
// Proxmox grows a disk and cannot shrink one, so nothing asked it to — and
// the row still states the size the machine actually holds, because the
// host's storage accounting reads it.
expect($services['pve']->resizeCalls)->toBe([])
->and($instance->disk_gb)->toBe($diskBefore)
->and($instance->disk_gb)->toBeGreaterThan((int) $start['disk_gb']);
// What was sold does shrink, and Nextcloud is what enforces it.
expect($instance->quota_gb)->toBe($start['quota_gb'])
->and($services['pve']->guestRan("config:app:set files default_quota --value='100 GB'"))->toBeTrue();
Carbon::setTestNow();
});
it('grows the disk on an upgrade', function () {
$services = fakeServices();
Queue::fake();
$fixture = planChangeFixture('team');
planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']);
app(ResizeVirtualMachine::class)->execute(planChangeRun());
expect($services['pve']->resizeCalls)->toBe(['101:scsi0:1050G'])
->and($fixture['instance']->fresh()->disk_gb)->toBe(1050);
});
it('refuses an upgrade on a contract that is no longer active', function () {
fakeServices();
Queue::fake();
$fixture = planChangeFixture('team');
$fixture['subscription']->update(['status' => 'cancelled', 'cancelled_at' => now()]);
$order = planChangeOrder($fixture, 'business', 'upgrade');
$order->update(['status' => 'paid']);
// Nothing at all: a customer who has left is not upgraded, billed or built.
expect($fixture['subscription']->fresh()->plan)->toBe('team')
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(0)
->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0)
// Still the customer's to remove from the cart, rather than silently
// marked as though it had been carried out.
->and($order->fresh()->status)->toBe('paid');
});
it('never reboots a live machine, and says so where a human looks', function () {
$services = fakeServices();
Queue::fake();
$fixture = planChangeFixture('team');
$services['pve']->runningVmids = [101];
planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']);
app(ResizeVirtualMachine::class)->execute($run = planChangeRun());
$instance = $fixture['instance']->fresh();
// The configuration carries the bigger machine…
expect($services['pve']->cloudInitParams[101]['cores'])->toBe(8)
->and($services['pve']->cloudInitParams[101]['memory'])->toBe(16384)
// …the guest was left running, untouched: startVm appends to this list,
// so an unchanged list is a machine that was never stopped and started.
->and($services['pve']->runningVmids)->toBe([101])
// …and the gap is recorded rather than left for somebody to discover.
->and($instance->restartIsPending())->toBeTrue()
->and($run->events()->where('outcome', 'info')->where('step', 'resize_vm')->exists())->toBeTrue();
// The customer is told on the page that shows them their cloud.
$user = User::factory()->create(['email_verified_at' => now()]);
$fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]);
Livewire::actingAs($user)->test(Cloud::class)
->assertSee(__('cloud.restart_pending_title'));
// And the console's instance list carries it beside the status.
Livewire::actingAs(admin(), 'operator')->test(Instances::class)
->assertSee(__('admin.restart_pending'));
});
it('has nothing pending when the machine is stopped, because starting it applies the new size', function () {
$services = fakeServices();
Queue::fake();
// Stopped, and carrying a pending restart from an earlier change.
$fixture = planChangeFixture('team', ['restart_required_since' => now()->subDay()]);
$services['pve']->runningVmids = [];
planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']);
app(ResizeVirtualMachine::class)->execute(planChangeRun());
expect($fixture['instance']->fresh()->restartIsPending())->toBeFalse();
});
it('refuses to be moved onto a package the catalogue will not sell', function () {
fakeServices();
Queue::fake();
$fixture = planChangeFixture('team');
expect(app(ApplyPlanChange::class)($fixture['subscription'], 'gibtsnicht'))->toBeNull()
->and($fixture['subscription']->fresh()->plan)->toBe('team')
->and(SubscriptionRecord::query()->whereIn('event', [
SubscriptionRecord::EVENT_UPGRADE, SubscriptionRecord::EVENT_DOWNGRADE,
])->count())->toBe(0);
});

View File

@ -3,6 +3,8 @@
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Services\Billing\PlanCatalogue;
use Tests\Support\Steps\ProbeCustomerStep;
it('resolves order, instance and plan from the run', function () {
@ -23,11 +25,16 @@ it('resolves order, instance and plan from the run', function () {
->and($step->label())->toBe('provisioning.step.probe');
});
it('registers the 15-step customer pipeline in config', function () {
expect(config('provisioning.pipelines.customer'))->toHaveCount(15);
it('builds every customer machine with the storage allowance that was sold', function () {
// A count alone only says the list is as long as it was. What matters is
// that the quota step is IN it: the allowance was applied nowhere for the
// whole life of the pipeline — quota_gb reached the instance row and
// stopped — so every package handed out the whole disk.
expect(config('provisioning.pipelines.customer'))->toHaveCount(16)
->toContain(ApplyStorageQuota::class);
});
it('takes the blueprint from the catalogue, which no longer lives in config', function () {
expect(config('provisioning.plans'))->toBeNull()
->and(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('start')->template_vmid)->toBe(9000);
->and(app(PlanCatalogue::class)->currentVersion('start')->template_vmid)->toBe(9000);
});