<?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,
            // Nach dem Neustart, weil dort ifupdown2 steht (PrepareBaseSystem
            // installiert es) und der Tunnel gerade bewiesen hat, dass er einen
            // Neustart überlebt.
            //
            // Vor ConfigureProxmox, dessen refuseWithoutBridge() unverändert
            // stehen bleibt und damit zur Nachprüfung wird — dasselbe Paar wie
            // BuildVmTemplate → VerifyVmTemplate. Ein Bau, der still danebenging,
            // fällt dann hier auf und nicht erst beim ersten bezahlten Klon.
            Host\EnsureNetworkBridge::class,
            Host\ConfigureProxmox::class,
            Host\CreateAutomationToken::class,
            Host\VerifyProxmoxApi::class,
            // Build first, then check — and both, not one of them. The build
            // ships deploy/bootstrap/lib/template.sh onto the host and runs it
            // there; the check afterwards asks the Proxmox API the same
            // question the customer pipeline will ask, so a script that
            // reported success while the API disagrees is caught here rather
            // than in CloneVirtualMachine.
            //
            // Also before RegisterCapacity, and not only for tidiness: the
            // build is what teaches a Debian-installed Proxmox that its storage
            // may hold disks at all. Counted before that, capacity is 0 — a
            // host that looks finished in the list and can never carry a
            // customer.
            Host\BuildVmTemplate::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,
            // Ohne diesen Schritt verschickt die Kundeninstanz ueberhaupt
            // keine Mail — auch nicht die Einladung an einen Mitarbeiter, die
            // Nextcloud selbst verschickt.
            Customer\ConfigureInstanceMail::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,
        ],

        /*
        | Das Regelwerk eines LAUFENDEN Hosts, und sonst nichts.
        |
        | Ein Host, der vor den Sperrmengen übernommen wurde, trägt ein
        | /etc/nftables.conf ohne `clupilot_blocked`. Dort scheitert jedes
        | `nft add element` — die Sperre steht in Datenbank, Portal, Konsole und
        | in der Mail an den Kunden, in der Firewall aber nie. Der
        | Onboarding-Lauf hilft nicht: `SecureHostFirewall` kürzt sich über den
        | `host_firewall`-Brotkrumen ab, und der hängt am LAUF, nicht am Host.
        |
        | Ein frischer Lauf hat den Brotkrumen nicht, führt den Schritt also
        | wirklich aus — und weil der Schritt die Datei ohnehin vollständig neu
        | schreibt und vorher den Tunnel von der Hostseite aus nachprüft, ist
        | das dieselbe Arbeit wie beim ersten Mal, nicht eine zweite Umsetzung
        | davon.
        |
        | Eigene Pipeline und nicht 'host', damit RunRunner::failRun() den
        | Subjekt-Haken NICHT auslöst: ein gescheiterter Nachzieh-Lauf darf
        | einen laufenden Host nicht auf 'error' stellen. Gestartet von
        | `php artisan clupilot:refresh-host-firewall`, nach dem Muster von
        | `clupilot:apply-quotas` — eine Reparatur, die der Betreiber anstößt
        | und deren Ausgabe er lesen kann, nicht ein Zeitplan, der still für
        | einen kaputten Schritt einspringt.
        */
        'host-firewall' => [
            Host\SecureHostFirewall::class,
        ],

        /*
        | Einer bestehenden Instanz den Mailversand nachtragen. Ein Schritt,
        | derselbe wie im Aufbau — ein zweiter Weg, dieselben Werte zu
        | schreiben, wuerde driften.
        */
        'instance-mail' => [
            Customer\ConfigureInstanceMail::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.
    */
    /*
    | Ein Block Zusatzspeicher: 20 GB für den Kunden, 22 GB auf der Platte.
    |
    | Die zwei Gigabyte Unterschied sind Kopfraum. Ein Paket bringt seinen
    | eigenen mit (max(10 GB, 12 %) seiner Platte); ohne denselben Aufschlag am
    | Block hätte ein gestapeltes Paket weniger Luft als ein gekauftes gleicher
    | Größe, und eine volle Platte legt die Instanz still, nicht nur den Upload.
    |
    | Der Preis folgt einer Regel, nicht einem Gefühl: ein Block muss teurer je
    | GB sein als der Aufstieg in die nächste Stufe (0,75 gegen 0,73 und 0,67),
    | sonst stapeln Kunden Zusätze, statt aufzusteigen, und belegen den
    | knappsten Rohstoff zum niedrigsten Preis. `max` deckelt das zusätzlich
    | dort, wo Aufsteigen billiger UND besser wird.
    |
    | `unavailable_on` nennt die Pakete, für die es das Modul nicht gibt — bei
    | Enterprise, weil dort eine eigene Maschine verkauft wird und ein 20-GB-
    | Block darauf keine Antwort ist.
    */
    'storage_addon' => [
        'gb' => 20,
        'disk_gb' => 22,
        'price_cents' => 1500,
        'sold_as' => 'quantity',
        'max' => 3,
        'unavailable_on' => ['enterprise'],
    ],
    'addons' => [
        'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'],
        'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'],
        // Die PRO-Stufe eines Office, das im Paket schon drin ist — „mehr
        // gleichzeitige Bearbeiter und die erweiterten Funktionen". Sie setzt
        // also voraus, dass überhaupt eines da ist.
        //
        // Ohne `unavailable_on` bot das Portal sie einem Start-Kunden für 22,80 €
        // an, während die Preistafel in derselben Zeile ein „—" zeigte — und die
        // Fußnote dort sagt wörtlich „in diesem Paket nicht verfügbar". Zwei
        // Aussagen über dieselbe Sache auf einer Seite, und die teurere war die
        // falsche. Dieselbe Sperre wie bei `custom_domain`, aus demselben Grund:
        // was ein Paket KAUFEN darf, steht hier und nicht im Katalog.
        'collabora_pro' => [
            'price_cents' => 1900,
            'sold_as' => 'entitlement',
            'unavailable_on' => ['start'],
        ],
        // 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'),

        /*
        | 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'),

        // Ab welcher Stelle im Subnetz HOSTS ihre Tunneladresse bekommen.
        //
        // Personen zählen von unten (10.66.0.2, .3, …), Maschinen ab .100.
        // Fortlaufend vergeben landete der erste Host zwischen zwei Notebooks,
        // und wer eine Adresse in einem Protokoll sah, konnte nicht sagen, ob
        // dahinter ein Mensch oder ein Server steckt. Es ist eine Untergrenze,
        // keine Obergrenze: läuft der Bereich voll, zählt die Vergabe weiter.
        'host_offset' => (int) env('CLUPILOT_WG_HOST_OFFSET', 100),
    ],

    // 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),
    ],

    /*
    | Die zweite SSH-Kennung: die, mit der das Terminal auf den CluPilot-Server
    | SELBST kommt. Getrennt vom Flottenschlüssel darüber, weil dessen
    | öffentliche Hälfte auf jedem übernommenen Host liegt — ein Schlüssel für
    | beides machte aus zwei Türen eine.
    |
    | KEIN env(): dieser Schlüssel wird in der Konsole erzeugt und liegt
    | verschlüsselt im Tresor. Der Eintrag steht hier, weil SecretVault::REGISTRY
    | für jeden Eintrag einen Konfigurationspfad nennt; gelesen wird er nie —
    | `strict` schneidet den Rückfall auf die Serverdatei ab, damit niemand die
    | Kennung für eine Root-Schale über eine Zeile in .env umbiegen kann.
    */
    'server_terminal' => [
        'private_key' => null,
    ],

    // 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.
        |
        | `VM.Monitor` stand hier und ist am 1. August 2026 herausgeflogen: PVE 9
        | kennt es nicht mehr, und `pveum role modify` lehnt die GANZE Liste ab,
        | sobald ein Name darin unbekannt ist — „invalid privilege 'VM.Monitor'".
        | Die Übernahme des ersten PVE-9-Hosts blieb damit in
        | CreateAutomationToken stehen, fünf Wiederholungen lang.
        |
        | Ersatzlos gestrichen, nicht ersetzt: es gab Zugriff auf den
        | QEMU-Monitor, und den benutzt CluPilot nirgends — geprüft, nicht
        | vermutet. Auf PVE 8 fehlt damit ein Recht, das dort ohnehin niemand
        | gebraucht hat, also passt eine Liste weiterhin auf beide Fassungen.
        |
        | Wenn hier je wieder etwas dazukommt: die gültigen Namen stehen auf dem
        | Host selbst, in der Administrator-Rolle aus `pveum role list`.
        */
        '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.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify',
        'user' => 'automation@pve',
        'token_name' => 'clupilot',
    ],
];
