[ 'host' => [ Host\ValidateHostInput::class, Host\EstablishSshTrust::class, Host\PrepareBaseSystem::class, Host\ConfigureWireguard::class, Host\InstallProxmoxVe::class, Host\RebootIntoPveKernel::class, Host\ConfigureProxmox::class, Host\CreateAutomationToken::class, Host\VerifyProxmoxApi::class, Host\RegisterHostDns::class, Host\RegisterCapacity::class, Host\SecureHostFirewall::class, Host\CompleteHostOnboarding::class, ], 'customer' => [ Customer\ValidateOrder::class, Customer\ReserveResources::class, Customer\CloneVirtualMachine::class, Customer\ConfigureCloudInit::class, Customer\StartVirtualMachine::class, Customer\WaitForGuestAgent::class, 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, Customer\RegisterMonitoring::class, Customer\RunAcceptanceChecks::class, Customer\CompleteProvisioning::class, ], /* | Making an existing instance's address real again, without rebuilding | anything. Exactly the two steps an address consists of: the router, | the DNS record and the certificate, then the hostname Nextcloud | itself will answer to. | | Same subject as `customer` (the Order), because that is what | CustomerStep::order()/instance() resolve — a run against any other | subject would need every step in it rewritten. Started by | App\Actions\ReapplyInstanceAddress whenever the address changes: | a domain proven, a domain withdrawn, a package that no longer | carries one. */ 'address' => [ 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, // The disk is bigger; nothing inside the guest knows that yet. An // upgrade used to stop one step short of the customer: Proxmox grew // the image, the partition and the filesystem stayed where they // were, and the extra hundreds of gigabytes were unreachable for the // life of the machine. Customer\GrowGuestFilesystem::class, Customer\ApplyStorageQuota::class, Customer\ConfigureDnsAndTls::class, Customer\ConfigureNextcloud::class, Customer\SettlePlanServices::class, ], /* | Taking a customer's machine round once, on purpose. | | A restart is a shutdown and a start, in that order and never a reboot: | only a cold boot makes qemu read the VM definition again, so only a | cold boot delivers the cores and memory a plan change wrote there. | ResizeVirtualMachine leaves `restart_required_since` set precisely | because nothing in the product could do this, which is how a paid | upgrade could stay invisible forever. | | StartVirtualMachine and WaitForGuestAgent are the customer pipeline's | own steps, reused rather than repeated — starting a machine and waiting | for its agent is the same operation whichever run needs it. Started by | App\Actions\RestartInstance, from the customer's cloud page or the | console's instance list. */ 'restart' => [ Customer\ShutDownVirtualMachine::class, Customer\StartVirtualMachine::class, Customer\WaitForGuestAgent::class, Customer\CompleteRestart::class, ], /* | The storage allowance, and nothing else. | | One step, because that is the whole of the repair: every instance built | before ApplyStorageQuota joined the `customer` pipeline has a quota_gb | in its row that Nextcloud was never told about. Started one run per | instance by `clupilot:apply-quotas`, so the sweep is retried, logged | and visible in the console like every other piece of remote work, | rather than a console command reaching into guests by hand. */ 'quota' => [ Customer\ApplyStorageQuota::class, ], /* | A storage pack bought, or given back. | | The whole of what "+100 GB" has to mean: the virtual disk grows to | carry the new allowance, the guest is made to see it — partition and | filesystem — and only then is Nextcloud told the larger figure. In that | order, because a quota Nextcloud enforces over a filesystem that never | grew is a promise of space the machine does not have. | | The same three steps the `plan-change` pipeline uses for its storage | half, reused rather than repeated: booking a pack and moving to a | bigger package are the same operation on the machine, and two copies of | it would drift. Started by App\Actions\ApplyStorageAllowance, from | App\Actions\BookAddon — the one place a pack is booked or cancelled. | | Same subject as every other customer pipeline (the instance's own | purchase Order), so CustomerStep resolves the machine and its contract, | and so nothing starts a second run beside one already in flight. */ 'storage' => [ Customer\ResizeVirtualMachine::class, Customer\GrowGuestFilesystem::class, Customer\ApplyStorageQuota::class, ], ], // The one currency the catalogue is priced in. Plan prices carry no currency // of their own, so a payment in anything else cannot be frozen onto a // contract without the contract and the payment disagreeing forever. 'currency' => env('CLUPILOT_CURRENCY', 'EUR'), /* | The plan catalogue does NOT live here any more. It lives in | plan_families / plan_versions / plan_prices, because the owner has to be | able to create and schedule plans from the console, and because a config | array cannot hold a version's history. | | Nothing falls back to a plans array here. A fallback would resurrect a | plan the owner had just switched off, and would let commerce read one | source while provisioning read another — the split-brain this replaced. | See App\Services\Billing\PlanCatalogue. | | quota_gb/disk_gb/ram_mb/cores drive infrastructure placement and stay | ADMIN-ONLY. Customers compare seats, storage, performance class and | features — never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*). */ // Default branding applied when a customer has not set their own (resolved by // Customer::brandingResolved(); NULL customer fields fall back to these). 'branding_defaults' => [ 'display_name' => 'CluPilot Cloud', 'logo_path' => null, // null → CluPilot logo used by the provisioner 'primary_color' => '#f97316', 'accent_color' => '#c2560a', ], /* | Traffic policy. Outbound is what counts: inbound is free at our providers, | and it is egress that Hetzner's 20 TB per server applies to. | | Running out throttles rather than blocks — a slow Nextcloud gets a customer | to buy more traffic, a dead one gets a support ticket and a cancellation. */ 'traffic' => [ 'warn_percent' => [80, 95], 'throttle_kbps' => (int) env('CLUPILOT_TRAFFIC_THROTTLE_KBPS', 2048), // ~2 Mbit/s 'addon' => ['gb' => 1000, 'price_cents' => 500], // Sampling interval of the collector, in minutes. Proxmox counters are // cumulative, so a missed run costs accuracy only if the VM restarts // in between. 'sample_minutes' => 15, ], /* | Prices in this config are NET. The rate is configurable because it follows | the seller's country (20 % in Austria), and every price shown to a | customer has to say which it is — a figure without "netto" or "brutto" is | the single most common billing complaint there is. */ 'tax' => [ 'rate_percent' => (float) env('CLUPILOT_TAX_PERCENT', 20), // Ours. A customer whose VAT ID belongs to another EU country is billed // under reverse charge — see App\Services\Billing\TaxTreatment. 'seller_country' => env('CLUPILOT_TAX_COUNTRY', 'AT'), ], /* | Extra storage add-on (per unit) and the add-on catalogue (labels in the | billing translation files). | | `sold_as` says what a SECOND booking of the same module on one contract | means, and it is declared here rather than derived anywhere, because it is | a commercial decision about each module and not a property of its key: | | - `quantity` — the module is sold in packs and buying another one gives | the customer more of it. Storage is the only one today: two 100 GB | packs are 200 GB, and AddonCatalogue sums the bookings for exactly that | reason. | - `entitlement` — the customer either has it or does not, so a second | booking buys them nothing and charges them a second time for the same | thing. Off-site backups are on or off; support is prioritised or it is | not; Collabora Pro is one licence for the instance; and a machine | answers to one own domain. | | Both mistakes cost real money in opposite directions — an entitlement | booked twice is a double charge, a quantity refused twice is a lost sale — | so a module that declares nothing is treated as an entitlement (the | cheaper mistake) and tests/Feature/Billing/AddonEntitlementTest.php | refuses to let one ship undeclared. Read by | App\Services\Billing\AddonCatalogue, enforced by App\Actions\BookAddon. */ 'storage_addon' => ['gb' => 100, 'price_cents' => 1000, 'sold_as' => 'quantity'], 'addons' => [ 'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'], 'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'], 'collabora_pro' => ['price_cents' => 1900, 'sold_as' => 'entitlement'], // The owner's commercial figure, to be adjusted once the first ones are // sold. Listed here because the public sheet has to answer what a plan // without an own domain costs to give one — an unpriced feature can only // be shown as absent, and it was being shown that way to the two plans // we would most like to sell it to. 'custom_domain' => [ 'price_cents' => 900, // One machine, one address: a second booking would charge nine euros // a month for a domain the customer already has. 'sold_as' => 'entitlement', // The packages on which an own domain is not possible AT ALL: not // bookable, not upgradable, not even offered. Named by plan key, // and named HERE, because the catalogue cannot answer this. Plan // versions model what a package HAS — its features and its size — // and nothing in the catalogue models what a package may BUY, so // the only catalogue-derived answers would be proxies, and both // available ones are dishonest: // // - "the lowest tier on sale" quietly hands the right to a // grandfathered Start customer the day the owner stops selling // Start or adds a cheaper package underneath it. // - reading it off `branding` ties two unrelated decisions // together, so letting Start upload a logo would also sell it // a domain. // // A rule this commercial belongs where the owner can see and // change it, next to the price it gates. Read only by // App\Services\Billing\CustomDomainAccess. 'unavailable_on' => ['start'], ], ], // Feature bullets moved onto plan_versions.features, so that what a version // promises is frozen with the version that promised it. Labels still live in // lang/*/billing.php → billing.feature.. 'dns' => [ 'provider' => 'hetzner', 'token' => env('HETZNER_DNS_TOKEN', ''), // Customer instances live on their own registrable domain, NOT on the // one that serves the portal and the console. A Nextcloud is // third-party software that strangers sign in to; on the same // registrable domain as the portal it shares cookie scope, // document.domain and CAA with it. Same reasoning as // googleusercontent.com or vercel.app. 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'), // Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns // container's dnsmasq --hostsdir. NOT the public Hetzner zone above: // a host's tunnel address must never be resolvable outside the tunnel, // see RegisterHostDns. Shared with vpn-dns via the dns-hosts volume. 'hosts_dir' => env('CLUPILOT_DNS_HOSTS_DIR', '/etc/clupilot/dns-hosts'), ], 'traefik' => [ 'dynamic_path' => env('TRAEFIK_DYNAMIC_PATH', '/etc/traefik/dynamic'), ], 'monitoring' => [ // false: a monitoring outage does NOT block delivering a customer's cloud // (the step retries, then continues degraded with a visible event). // true: monitoring registration must succeed or the run fails. 'required' => (bool) env('MONITORING_REQUIRED', false), 'attempts' => (int) env('MONITORING_ATTEMPTS', 2), ], // CluPilot VM acts as the WireGuard hub; hosts join it during onboarding. 'wireguard' => [ // The hub's own address inside the tunnel — what a client uses as its // resolver when the console is reachable there. 'hub_address' => env('CLUPILOT_WG_HUB_ADDRESS', '10.66.0.1'), 'subnet' => $wgSubnet, 'hub_ip' => env('CLUPILOT_WG_HUB_IP', $wgHubDefault), // handshake target 'endpoint' => env('CLUPILOT_WG_ENDPOINT', ''), // host:port reachable by peers 'hub_public_key' => env('CLUPILOT_WG_HUB_PUBKEY', ''), 'config_path' => env('CLUPILOT_WG_CONFIG_PATH', '/etc/wireguard/wg0.conf'), ], // SSH identity CluPilot deploys to each host after first password login. 'ssh' => [ // Prefer *_PATH (a file on disk); fall back to the inline env value. 'public_key' => $secretFrom('CLUPILOT_SSH_PUBLIC_KEY_PATH', 'CLUPILOT_SSH_PUBLIC_KEY'), 'private_key' => $secretFrom('CLUPILOT_SSH_PRIVATE_KEY_PATH', 'CLUPILOT_SSH_PRIVATE_KEY'), // Per-command SSH timeout; below the job timeout (2100s) but far above // the phpseclib ~10s default so apt full-upgrade etc. can finish. 'command_timeout' => (int) env('CLUPILOT_SSH_COMMAND_TIMEOUT', 2000), ], // Proxmox automation role/user created on each host. 'proxmox' => [ 'role_id' => 'CluPilotAutomation', 'role_privs' => 'VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Monitor,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit', 'user' => 'automation@pve', 'token_name' => 'clupilot', ], ];