order($run); // Idempotent: an instance already exists for this order. $existing = Instance::query()->where('order_id', $order->id)->first(); if ($existing !== null) { $this->putContext($run, $existing); $this->linkToSubscription($run, $existing); return StepResult::advance(); } $subscription = $this->subscription($run); if ($subscription === null) { return StepResult::fail('no_subscription'); } // Sizes come from the contract, not the catalogue: this customer gets // the machine they paid for even if the plan was edited since. $plan = $this->plan($run); // The operator may have decided where this one goes. A parked queue is // read by a human who knows things the placement rule does not — that // one customer belongs beside another, that a machine is about to be // taken out of service — and "dieser kommt auf Host x" has to survive // the trip from the console to here. $pinned = $this->pinnedHost($run, $order); // Place + create under a per-datacenter lock so concurrent reservations // can't both pick the same host and overcommit its capacity. $instance = Cache::lock('placement:'.$order->datacenter, 30)->block(10, function () use ($order, $plan, $pinned) { // A pin is honoured or it waits — never quietly overridden. Placing // it somewhere else because the chosen host happened to be full // would answer a different question than the one the operator // answered, and they would find out from the finished instance. $host = $pinned !== null ? ($pinned->canTake((int) $plan['disk_gb']) ? $pinned : null) : Host::placeableIn($order->datacenter, (int) $plan['disk_gb']); if ($host === null) { return null; } return Instance::create([ 'customer_id' => $order->customer_id, 'order_id' => $order->id, 'host_id' => $host->id, 'plan' => $order->plan, 'quota_gb' => $plan['quota_gb'], 'disk_gb' => $plan['disk_gb'], 'ram_mb' => $plan['ram_mb'], 'cores' => $plan['cores'], 'subdomain' => $this->uniqueSubdomain($order), 'status' => 'reserving', ]); }); // Named, not just 'no_capacity'. An operator reading a failed run needs // to know WHAT would have fitted — the difference between "buy a // server" and "buy a server with 540 GB free" is the whole decision. // No room: PARK the order, do not fail it. // // The customer has paid. Failing the run turns a sale into an incident // and leaves an operator to work out what to do with the money; waiting // turns it into a delivery date. The estate is finite and thick-booked, // so "no host has room right now" is an ordinary Tuesday, not a fault — // it means somebody has to order a machine, which takes hours, not // seconds. // // poll(), not retry(): retry consumes the run's attempt budget and this // wait is measured in days. The step owns its own deadline instead. if ($instance === null) { $largest = app(HostCapacity::class)->largestPlaceableGb($order->datacenter); // Long enough for a server to be ordered, installed and onboarded // over a weekend; short enough that a forgotten order surfaces as a // failure instead of waiting silently for ever. The console shouts // about it from the first minute either way. if ($run->created_at->diffInDays(now()) >= self::PARK_DAYS) { Log::error('Order '.$order->id.' waited '.self::PARK_DAYS.' days for capacity in '.$order->datacenter.' and is being failed. It needs '.$plan['disk_gb'].' GB; the roomiest host has '.$largest.' GB.'); return StepResult::fail('no_capacity'); } // Two different waits, named differently: one is "nobody has room", // the other is "the host YOU chose has no room". The second one is // a decision to revisit, not a server to buy, and an operator // reading 'awaiting_capacity' would go and buy the machine. return StepResult::poll( self::PARK_POLL_SECONDS, $pinned !== null ? 'awaiting_pinned_host' : 'awaiting_capacity' ); } $this->putContext($run, $instance); $this->linkToSubscription($run, $instance); $this->recordResource($run, $instance->host, 'instance_id', (string) $instance->id); return StepResult::advance(); } /** * Point the contract at the machine that fulfils it, so anything asking * "what is this customer entitled to?" can get there from either end. * instance_id is not part of the frozen snapshot, so this is allowed. */ private function linkToSubscription(ProvisioningRun $run, Instance $instance): void { $subscription = $this->subscription($run); if ($subscription !== null && $subscription->instance_id !== $instance->id) { $subscription->update(['instance_id' => $instance->id]); } } /** * The host an operator assigned this order to, if it is still usable. * * A pin that names a host which has since been removed — or one in another * datacenter — is no pin at all. Falling back to ordinary placement is * right there: the operator's instruction has lost its subject rather than * been overruled. */ private function pinnedHost(ProvisioningRun $run, Order $order): ?Host { $id = $run->context('preferred_host_id'); if ($id === null) { return null; } // Same datacenter, or it is not a pin but a mistake. Placement is a // per-datacenter question — the lock above is taken per datacenter, and // an instance's network, DNS and backups all assume it stayed where it // was ordered. return Host::query() ->where('status', 'active') ->where('datacenter', $order->datacenter) ->find((int) $id); } private function putContext(ProvisioningRun $run, Instance $instance): void { $run->mergeContext([ 'instance_id' => $instance->id, 'host_id' => $instance->host_id, 'node' => $instance->host?->node ?? 'pve', 'subdomain' => $instance->subdomain, 'plan' => $instance->plan, ]); } private function uniqueSubdomain(Order $order): string { $base = Str::slug($order->customer->name) ?: 'cloud'; do { $subdomain = $base.'-'.Str::lower(Str::random(5)); } while (Instance::query()->where('subdomain', $subdomain)->exists()); return $subdomain; } }