368 lines
18 KiB
PHP
368 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Provisioning\Steps\Customer;
|
|
use App\Provisioning\Steps\Host;
|
|
|
|
$wgSubnet = env('CLUPILOT_WG_SUBNET', '10.66.0.0/24');
|
|
[$wgNetwork, $wgBits] = array_pad(explode('/', $wgSubnet), 2, '24');
|
|
$wgBase = ip2long($wgNetwork) & (0xFFFFFFFF << (32 - (int) $wgBits)) & 0xFFFFFFFF;
|
|
$wgHubDefault = long2ip($wgBase + 1); // first usable address in the CIDR
|
|
|
|
/**
|
|
* Read a secret from a FILE path if given, else from the inline env var.
|
|
* SSH keys are multi-line PEM, which a plain .env cannot hold — pointing at a
|
|
* file keeps the key out of .env entirely (and off the environment).
|
|
* Note: after `config:cache`, changing the file needs `php artisan config:clear`.
|
|
*/
|
|
$secretFrom = function (string $pathVar, string $inlineVar): string {
|
|
$path = (string) env($pathVar, '');
|
|
if ($path !== '' && is_readable($path)) {
|
|
return (string) file_get_contents($path);
|
|
}
|
|
|
|
return (string) env($inlineVar, '');
|
|
};
|
|
|
|
return [
|
|
/*
|
|
| Ordered step classes per pipeline. ::class is textual — listing steps that
|
|
| are built incrementally does not autoload them until the pipeline runs.
|
|
*/
|
|
'pipelines' => [
|
|
'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.<key>.
|
|
|
|
'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',
|
|
],
|
|
];
|