[ '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, // Before the host is ever offered to a customer: a node with no // template to clone from cannot serve one, and finding that out in // CloneVirtualMachine means finding it out after somebody paid. Host\VerifyVmTemplate::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 hostname | Nextcloud itself will answer to, then the router, the DNS record and | the certificate. | | In THAT order, which is the order the `customer` pipeline has always | used, and it is not interchangeable. A half-finished address is the | ordinary outcome of a failed run, so the question is which half is safe | to be left holding, and the answer differs by direction: | | - A domain just proven. Router first meant getting the certificate and | then failing at Nextcloud — so the customer's own domain served | Nextcloud's "Zugriff über nicht vertrauenswürdige Domain" error page | under a valid certificate, while `domain_cert_ok` was already true | and the portal was calling that address live. Nextcloud first leaves | a trusted hostname that nothing routes to, which nobody can reach and | nothing announces. | - A domain withdrawn. Nextcloud first stops it being ANSWERED, which is | what withdrawal means; if the router write then fails the name still | reaches us and is refused. Router first stops it being reached while | Nextcloud would still have answered it — the same end state, arrived | at with the trust left standing. | | So Nextcloud first is safe in both directions and router first is safe | in only one. There is no third step to sequence and no state either step | reads from the other. | | 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\ConfigureNextcloud::class, Customer\ConfigureDnsAndTls::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 — in | the same order and for the same reason, spelled out above that pipeline. */ '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\ConfigureNextcloud::class, Customer\ConfigureDnsAndTls::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, WaitForGuestAgent and ConfigureNetwork are the | customer pipeline's own steps, reused rather than repeated — starting a | machine, waiting for its agent and reading the address it came up on 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. | | ConfigureNetwork is here because a cold boot is the one event that can | move the guest: cloud-init hands it `ip=dhcp`, and this step is the only | writer of `instances.guest_ip` — which is the backend the router on the | serving host points at. Without it a restarted machine could come back on | a different address, the router would go on pointing at the old one, and | the customer's cloud would answer 502 for the rest of its life with no | pipeline able to repair it. The step itself asks for the address to be | re-applied when it has actually changed; see ConfigureNetwork. */ 'restart' => [ Customer\ShutDownVirtualMachine::class, Customer\StartVirtualMachine::class, Customer\WaitForGuestAgent::class, Customer\ConfigureNetwork::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'), /* | Die Zone der PLATTFORM — Website, Portal, Konsole und die Namen der | Hosts selbst. Nicht die Kundenzone darüber. | | Sie musste eigens entstehen, weil genau diese Trennung im Code schon | überall behauptet und an einer Stelle gebrochen wurde: der Kopfkommentar | von RegisterHostDns nennt `fsn-01.node.clupilot.com`, die Prüfregel in | Datacenters nennt denselben Namen, und der Test in ServicesTest schreibt | ihn wörtlich — der Schritt selbst baute den Namen aber aus `zone` oben, | und die steht auf der KUNDENZONE. Auf dieser Installation hieß ein Host | damit `fsn-01.node.clupilot.cloud`: in der Zone, die absichtlich von der | Plattform getrennt ist, mit derselben Begründung, die in | OfficialDomains steht. | | Ohne Angabe aus APP_URL abgeleitet, letzte zwei Labels. Das trifft | `app.dev.clupilot.com` → `clupilot.com` und liegt bei mehrteiligen | Endungen wie `co.uk` daneben — dort wird CLUPILOT_PLATFORM_ZONE gesetzt. */ 'platform_zone' => env('CLUPILOT_PLATFORM_ZONE') ?: (function () { $host = parse_url((string) env('APP_URL', ''), PHP_URL_HOST) ?: ''; $labels = array_filter(explode('.', $host)); return count($labels) >= 2 ? implode('.', array_slice($labels, -2)) : 'clupilot.com'; })(), // 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', /* | Sys.Modify is not optional, and its absence broke every customer run. | | HttpProxmoxClient::createBackupJob() POSTs /cluster/backup, and that | endpoint declares `check => ['perm', '/', ['Sys.Modify']]` in | pve-manager's PVE/API2/Backup.pm — verified against the source, not | inferred from a forum post. Without it the POST comes back 403, | RegisterBackup rethrows anything whose body does not contain "already", | and the run failed at register_backup for every single customer. | | Sys.Modify is also what PUT /cluster/firewall/options requires | (pve-firewall's PVE/API2/Firewall/Cluster.pm), so the same privilege | covers the datacenter firewall if that ever moves off SSH and onto the | API. It is granted on `/` because both endpoints check `/` and nothing | narrower would satisfy them. */ '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,Sys.Modify', 'user' => 'automation@pve', 'token_name' => 'clupilot', ], ];