CluPilotCloud/app/Support/Readiness/OnboardingChecks.php

116 lines
5.8 KiB
PHP

<?php
namespace App\Support\Readiness;
use App\Models\Datacenter;
use App\Services\Secrets\SecretVault;
use App\Services\Wireguard\ConfigVault;
use App\Support\ProvisioningSettings;
/**
* What has to be in place before a single host can be onboarded at all: the
* SSH identity CluPilot deploys to every host, the two encryption keys the
* vault and the VPN config store need to work at all, the WireGuard hub a new
* host joins, and somewhere to onboard it into.
*/
final class OnboardingChecks
{
public const GROUP = 'onboarding';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
// Named after the vault entry it checks (ssh.private_key), not
// a shorthand: ReadinessPageTest's guard walks every
// SecretVault::REGISTRY key and demands a Check key that
// CONTAINS it (dots turned to underscores) — 'onboarding.
// ssh_key' does not contain 'ssh_private_key' and slipped
// through unnoticed until that guard existed (Task 12).
key: 'onboarding.ssh_private_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.ssh_key'),
breaks: __('readiness.onboarding.ssh_key_breaks'),
// The SSH identity card lives on the 'platform' tab
// (integrations.blade.php: "own machines"), beside the
// public-key setting it pairs with — not 'integrations',
// which is not a member of Integrations::TABS at all and
// sent an operator clicking this exact blocking check to the
// wrong tab (Fix-Runde, Befund 1).
tab: 'platform',
// EstablishSshTrust only deploys the PUBLIC half. Every step
// after it (HostStep's own shell helper), and later every
// Traefik write on a host already onboarded (SshTraefikWriter),
// connects with THIS private key.
satisfied: filled(app(SecretVault::class)->get('ssh.private_key')),
),
new Check(
key: 'onboarding.secrets_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.secrets_key'),
breaks: __('readiness.onboarding.secrets_key_breaks'),
// SECRETS_KEY has no field anywhere on Integrations' 'services'
// or 'platform' tabs — the only place an operator can actually
// set it is the raw .env editor on the 'env' tab.
tab: 'env',
// isUsable() rather than a bare filled() on the config value —
// SecretCipher's own docblock warns that a second, shorter
// rule that only checks emptiness lets a malformed-but-non-
// empty key sail through, exactly the gap that once let two
// other call sites reach an uncaught RuntimeException.
satisfied: app(SecretVault::class)->isUsable(),
),
new Check(
key: 'onboarding.vpn_config_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.vpn_config_key'),
breaks: __('readiness.onboarding.vpn_config_key_breaks'),
// Same reasoning as onboarding.secrets_key just above:
// VPN_CONFIG_KEY has no console field either, only the raw
// .env editor.
tab: 'env',
// Same reuse-not-reimplement reasoning as isUsable() above:
// ConfigVault::available() is the one place that already knows
// what counts as a usable key.
satisfied: ConfigVault::available(),
),
new Check(
key: 'onboarding.wg_hub',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.wg_hub'),
breaks: __('readiness.onboarding.wg_hub_breaks'),
// Two of the three values this check reads (wgHubPubkey,
// wgEndpoint) are 'platform'-tab fields; only the third
// (subnet) is .env-only and cannot be changed from the
// console at all (see the docblock on satisfied: below). The
// tab an operator can actually act on is 'platform'.
tab: 'platform',
// Two of three is no half a tunnel, it is none: ConfigureWireguard
// writes the peer config from all three at once. Subnet is read
// straight from config(), never through Settings/ProvisioningSettings
// — that class's own docblock explains why: the compose file
// (vpn-dns, vpn-gateway) reads the same CLUPILOT_WG_SUBNET at
// container boot, so a console-only override would let the
// console show one value while the running containers use another.
satisfied: filled(ProvisioningSettings::wgHubPublicKey())
&& filled(ProvisioningSettings::wgEndpoint())
&& filled((string) config('provisioning.wireguard.subnet')),
),
new Check(
key: 'onboarding.datacenter',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.datacenter'),
breaks: __('readiness.onboarding.datacenter_breaks'),
tab: 'datacenters',
satisfied: Datacenter::query()->exists(),
),
];
}
}