Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Support;
final class ClickhouseBackupCommand
{
/** @return array<int, string> */
public static function make(
string $containerName,
string $database,
string $archiveName,
string $backupDirectory,
): array {
validateShellSafePath($database, 'database name');
validateFilenameSafe($archiveName, 'ClickHouse backup archive');
$backupDirectory = rtrim($backupDirectory, '/');
$containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName;
$backupLocation = $backupDirectory.'/'.$archiveName;
$query = "BACKUP DATABASE `{$database}` TO File('{$archiveName}')";
return [
'mkdir -p '.escapeshellarg($backupDirectory),
'docker exec '.escapeshellarg($containerName).' clickhouse-client --query '.escapeshellarg($query),
'docker cp '.escapeshellarg($containerName.':'.$containerBackupPath).' '.escapeshellarg($backupLocation),
];
}
public static function cleanup(string $containerName, string $archiveName): string
{
validateFilenameSafe($archiveName, 'ClickHouse backup archive');
$containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName;
return 'docker exec '.escapeshellarg($containerName).' rm -f '.escapeshellarg($containerBackupPath);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Support;
/**
* Provider-agnostic DNS records to point hostnames at a Coolify server.
*/
class DnsRecordHints
{
/**
* Build A/AAAA entries for every hostname (deduped).
*
* @param array<int, string|null> $hostnames
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forHostnames(array $hostnames, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$seen = [];
foreach ($hostnames as $hostname) {
if (! is_string($hostname) || trim($hostname) === '') {
continue;
}
foreach (self::forTarget($hostname, $ipv4, $ipv6) as $record) {
$key = strtolower($record['type'].'|'.$record['name'].'|'.$record['value']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$records[] = $record;
}
}
usort($records, function (array $a, array $b): int {
return [$a['name'], $a['type'], $a['value']] <=> [$b['name'], $b['type'], $b['value']];
});
return $records;
}
/**
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forTarget(?string $hostname, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return [];
}
if (filled($ipv4) && filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$records[] = [
'type' => 'A',
'name' => $fqdn,
'value' => $ipv4,
];
}
if (filled($ipv6) && filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$records[] = [
'type' => 'AAAA',
'name' => $fqdn,
'value' => $ipv6,
];
}
return $records;
}
public static function normalizeHostname(?string $hostname): ?string
{
if (blank($hostname)) {
return null;
}
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
if ($hostname === '' || $hostname === '@' || ! str_contains($hostname, '.')) {
return null;
}
// Strip path-like noise; host only.
if (filter_var($hostname, FILTER_VALIDATE_IP)) {
return null;
}
return $hostname;
}
/**
* Relative name for a zone (e.g. app for app.example.com, @ for example.com).
*/
public static function relativeName(?string $hostname): string
{
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return '@';
}
$labels = array_values(array_filter(explode('.', $fqdn), fn (string $p) => $p !== ''));
if (count($labels) <= 2) {
return '@';
}
return implode('.', array_slice($labels, 0, -2));
}
/**
* BIND-compatible zone snippet for clipboard (absolute names with trailing dots).
*
* Example:
* asd.hu. IN A 172.16.0.2
* www.asd.hu. IN A 172.16.0.2
*
* @param array<int, array{type: string, name: string, value: string}> $records
*/
public static function toCopyText(array $records): string
{
if ($records === []) {
return '';
}
$lines = [];
$nameWidth = 0;
foreach ($records as $record) {
$name = self::bindAbsoluteName((string) $record['name']);
$nameWidth = max($nameWidth, strlen($name));
}
foreach ($records as $record) {
$name = self::bindAbsoluteName((string) $record['name']);
$type = strtoupper((string) $record['type']);
$value = (string) $record['value'];
// AAAA values may be IPv6; leave as-is (no quotes needed for A/AAAA).
$format = '%-'.$nameWidth.'s IN %-5s %s';
$lines[] = sprintf($format, $name, $type, $value);
}
return implode("\n", $lines)."\n";
}
/**
* Absolute BIND name (trailing dot). Leaves @ as-is.
*/
public static function bindAbsoluteName(string $name): string
{
$name = trim($name);
if ($name === '' || $name === '@') {
return '@';
}
$name = rtrim($name, '.');
return $name.'.';
}
}
@@ -0,0 +1,338 @@
<?php
namespace App\Support\DomainConnect;
use InvalidArgumentException;
use RuntimeException;
/**
* Builds Cloudflare Domain Connect synchronous "apply template" URLs.
*
* @see https://developers.cloudflare.com/dns/reference/domain-connect/
* @see https://github.com/Domain-Connect/spec/blob/master/Domain%20Connect%20Spec%20Draft.adoc
*/
class CloudflareDomainConnect
{
public const CLOUDFLARE_SYNC_UX = 'https://dash.cloudflare.com/domainconnect';
public function isConfigured(): bool
{
return filled($this->privateKeyPem());
}
/**
* Domain Connect is a Coolify Cloud feature and requires a signing key
* (instance setting or env) to be present at runtime.
*/
public function isAvailable(): bool
{
return isCloud() && $this->isConfigured();
}
public function providerId(): string
{
return (string) config('services.domain_connect.provider_id', 'coolify.io');
}
public function serviceId(): string
{
return (string) config('services.domain_connect.service_id', 'hosting');
}
public function keyId(): string
{
return (string) config('services.domain_connect.key_id', '_dcpubkeyv1');
}
/**
* @param array<string, string|null> $variables Template variables (e.g. ip). Null values become empty strings.
*/
public function buildApplyUrl(
string $domain,
array $variables = [],
?string $host = null,
?string $redirectUri = null,
): string {
if (! $this->isAvailable()) {
throw new RuntimeException(
'Domain Connect is only available on Coolify Cloud when a Domain Connect private key is configured.'
);
}
$domain = strtolower(trim($domain));
$domain = preg_replace('#^https?://#', '', $domain) ?? $domain;
$domain = rtrim(explode('/', $domain)[0] ?? $domain, '.');
if ($domain === '' || ! str_contains($domain, '.')) {
throw new InvalidArgumentException('A valid domain is required for Cloudflare Domain Connect.');
}
$host = $host === null ? '' : strtolower(trim($host));
$host = rtrim($host, '.');
if ($host === '@') {
$host = '';
}
// Signature covers the query string excluding `key` and `sig` (Domain Connect spec).
$params = [
'domain' => $domain,
'host' => $host,
];
foreach ($variables as $name => $value) {
if ($name === 'domain' || $name === 'host' || $name === 'key' || $name === 'sig') {
continue;
}
$params[$name] = $value === null ? '' : (string) $value;
}
if (filled($redirectUri)) {
$params['redirect_uri'] = $redirectUri;
}
$queryToSign = $this->buildQueryString($params);
$signature = $this->sign($queryToSign);
// Cloudflare requires `sig` as the last query parameter; `key` is also required.
$finalQuery = $queryToSign
.'&key='.rawurlencode($this->keyId())
.'&sig='.rawurlencode($signature);
return self::CLOUDFLARE_SYNC_UX
.'/v2/domainTemplates/providers/'.rawurlencode($this->providerId())
.'/services/'.rawurlencode($this->serviceId())
.'/apply?'.$finalQuery;
}
/**
* Point a zone (and optional host) A record at the Coolify server IP.
*/
public function buildHostingApplyUrl(
string $domain,
string $ip,
?string $host = null,
?string $redirectUri = null,
): string {
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
throw new InvalidArgumentException('A valid IPv4 or IPv6 address is required for DNS autoconfigure.');
}
return $this->buildApplyUrl(
domain: $domain,
variables: ['ip' => $ip],
host: $host,
redirectUri: $redirectUri,
);
}
/**
* Split a hostname into its registrable domain and relative host.
*
* Uses a small multi-part public-suffix set for common cases (e.g. co.uk)
* instead of shipping the full Mozilla Public Suffix List (~300KB).
* Unknown multi-part TLDs fall back to last-two-labels; Cloudflare will
* still match the user's zone when the guess is close enough.
*
* @return array{domain: string, host: string}
*/
public static function splitHostname(string $hostname): array
{
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
$labels = array_values(array_filter(explode('.', $hostname), fn (string $p) => $p !== ''));
if (count($labels) < 2) {
throw new InvalidArgumentException('Hostname must include a domain (e.g. example.com or app.example.com).');
}
$suffixLabelCount = self::publicSuffixLabelCount($labels);
$registrableLabelCount = $suffixLabelCount + 1;
if (count($labels) < $registrableLabelCount) {
throw new InvalidArgumentException('Hostname must include a registrable domain.');
}
$domain = implode('.', array_slice($labels, -$registrableLabelCount));
$hostLabels = array_slice($labels, 0, -$registrableLabelCount);
$host = implode('.', $hostLabels);
return ['domain' => $domain, 'host' => $host];
}
/**
* How many trailing labels form the public suffix (effective TLD).
*
* @param list<string> $labels
*/
protected static function publicSuffixLabelCount(array $labels): int
{
$count = count($labels);
// Longest multi-part match first (up to 3 labels: e.g. com.au, co.uk).
for ($length = min(3, $count - 1); $length >= 2; $length--) {
$candidate = implode('.', array_slice($labels, -$length));
if (isset(self::MULTI_PART_PUBLIC_SUFFIXES[$candidate])) {
return $length;
}
}
return 1;
}
/**
* Common multi-label public suffixes. Keys only; values unused.
* Not exhaustive — covers frequent Domain Connect / Cloudflare zones.
*
* @var array<string, true>
*/
private const MULTI_PART_PUBLIC_SUFFIXES = [
// United Kingdom / related
'ac.uk' => true,
'co.uk' => true,
'gov.uk' => true,
'ltd.uk' => true,
'me.uk' => true,
'net.uk' => true,
'org.uk' => true,
'plc.uk' => true,
'sch.uk' => true,
// Australia
'com.au' => true,
'net.au' => true,
'org.au' => true,
'edu.au' => true,
'gov.au' => true,
'asn.au' => true,
'id.au' => true,
// New Zealand
'co.nz' => true,
'net.nz' => true,
'org.nz' => true,
'govt.nz' => true,
'ac.nz' => true,
// Japan
'co.jp' => true,
'or.jp' => true,
'ne.jp' => true,
'ac.jp' => true,
'go.jp' => true,
// Brazil
'com.br' => true,
'net.br' => true,
'org.br' => true,
'gov.br' => true,
// India
'co.in' => true,
'net.in' => true,
'org.in' => true,
'gen.in' => true,
'firm.in' => true,
'ind.in' => true,
// South Africa
'co.za' => true,
'org.za' => true,
'web.za' => true,
'net.za' => true,
// Mexico / LatAm
'com.mx' => true,
'org.mx' => true,
'gob.mx' => true,
'com.ar' => true,
'com.co' => true,
'com.pe' => true,
'com.cl' => true,
// Asia / others
'com.cn' => true,
'net.cn' => true,
'org.cn' => true,
'com.hk' => true,
'com.sg' => true,
'com.tw' => true,
'com.my' => true,
'com.ph' => true,
'com.tr' => true,
'com.ua' => true,
'com.pl' => true,
'com.ru' => true,
'co.kr' => true,
'co.il' => true,
'com.sa' => true,
'com.eg' => true,
'com.ng' => true,
// EU-style
'co.at' => true,
'or.at' => true,
'co.nl' => true,
'com.de' => true,
// Platforms sometimes used as zones
'github.io' => true,
'pages.dev' => true,
];
/**
* @param array<string, string> $params
*/
protected function buildQueryString(array $params): string
{
$parts = [];
foreach ($params as $key => $value) {
// Always include empty values (Resend sends host= for apex).
$parts[] = rawurlencode((string) $key).'='.rawurlencode($value);
}
return implode('&', $parts);
}
protected function sign(string $queryString): string
{
$privateKey = openssl_pkey_get_private($this->privateKeyPem());
if ($privateKey === false) {
throw new RuntimeException('Invalid Domain Connect private key.');
}
$signature = '';
$ok = openssl_sign($queryString, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if (! $ok) {
throw new RuntimeException('Failed to sign Domain Connect apply URL.');
}
return base64_encode($signature);
}
protected function privateKeyPem(): ?string
{
$key = null;
try {
$settingsKey = data_get(instanceSettings(), 'domain_connect_private_key');
if (is_string($settingsKey) && trim($settingsKey) !== '') {
$key = $settingsKey;
}
} catch (\Throwable) {
// Instance settings may be unavailable during early boot/tests.
}
if ($key === null) {
$envKey = config('services.domain_connect.private_key');
if (is_string($envKey) && trim($envKey) !== '') {
$key = $envKey;
}
}
if ($key === null) {
return null;
}
$key = str_replace(["\r\n", "\r"], "\n", $key);
// Allow single-line env values with literal \n
if (! str_contains($key, "\n") && str_contains($key, '\\n')) {
$key = str_replace('\\n', "\n", $key);
}
return $key;
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Support\V5;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
/**
* Single source of truth for the canvas resource payloads served by the
* dashboard Inertia props and broadcast by V5CanvasResourceUpdated — the two
* must stay identical for websocket vs. initial-load parity.
*/
class CanvasResourceSerializer
{
public const CARD_WIDTH = 320;
public const CARD_HEIGHT = 144;
public const CARD_GAP = 32;
/**
* @return array<string, mixed>
*/
public function serializeApplication(V5Application $application): array
{
$application->loadMissing(['server', 'domains', 'project', 'environment']);
$server = $application->server;
$isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server);
return [
'id' => $application->uuid,
'name' => $application->name,
'image' => $application->image,
'containerName' => $application->container_name,
'status' => $application->status,
'statusMessage' => $application->status_message,
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
'effectiveStatusMessage' => $isServerReachable
? $application->status_message
: $this->serverStatusMessage($server),
'runtimeContainerId' => $application->runtime_container_id,
'serverName' => $server?->name,
'serverStatus' => $server?->status,
'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null,
'isServerReachable' => $isServerReachable,
'serverIngressEnabled' => (bool) $server?->isIngress(),
'meshNamespace' => $application->mesh_namespace,
'ingressEnabled' => $application->ingress_enabled,
'internalPort' => $application->internal_port,
'domains' => $application->domains->pluck('domain')->values()->all(),
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
'projectUuid' => $application->project?->uuid,
'environmentUuid' => $application->environment?->uuid,
'canvasX' => $application->canvas_x,
'canvasY' => $application->canvas_y,
];
}
/**
* @return array<string, mixed>
*/
public function serializeCaddyIngress(V5Server $server, int $index = 0): array
{
$isServerReachable = $this->isServerReachable($server);
return [
'id' => $server->uuid,
'name' => $server->name,
'host' => $server->host,
'type' => $server->ingressType(),
'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable',
'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server),
'canvasX' => $server->canvas_x ?? -(self::CARD_WIDTH + self::CARD_GAP),
'canvasY' => $server->canvas_y ?? $index * (self::CARD_HEIGHT + self::CARD_GAP),
];
}
private function isServerReachable(V5Server $server): bool
{
return $server->status !== 'unreachable';
}
private function serverStatusMessage(?V5Server $server): ?string
{
return $server?->last_status_output ?: null;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Support\V5;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\Server as V5Server;
/**
* Single source of truth for the cluster payload served by the Clusters
* Inertia props and broadcast by V5ClusterUpdated — the two must stay
* identical for websocket vs. initial-load parity.
*/
class ClusterSerializer
{
/**
* @return array<string, mixed>
*/
public function serialize(V5Cluster $cluster): array
{
return [
'id' => $cluster->uuid,
'name' => $cluster->name,
'description' => $cluster->description,
'wireguardInterface' => $cluster->wireguard_interface,
'wireguardManagementPool' => $cluster->wireguard_management_pool,
'wireguardListenPort' => $cluster->wireguard_listen_port,
'containerNetworkPool' => $cluster->container_network_pool,
'containerNetworkPrefix' => $cluster->container_network_prefix,
'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES,
'defaultDenyContainers' => $cluster->default_deny_containers,
'cooldVersion' => $cluster->coold_version,
'corrosionVersion' => $cluster->corrosion_version,
'corrosionGossipPort' => $cluster->corrosion_gossip_port,
'corrosionApiPort' => $cluster->corrosion_api_port,
'builderEnabled' => $cluster->builder_enabled,
'builderCapacity' => $cluster->builder_capacity,
'builderCpuQuota' => $cluster->builder_cpu_quota,
'builderMemoryMax' => $cluster->builder_memory_max,
'builderTimeoutSecs' => $cluster->builder_timeout_secs,
'lastCliAction' => $cluster->last_cli_action,
'lastCliStatus' => $cluster->last_cli_status,
'lastCliSummary' => $cluster->last_cli_summary,
'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(),
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
'servers' => $cluster->servers->map(fn (V5Server $server) => [
'id' => $server->uuid,
'name' => $server->name,
'host' => $server->host,
'status' => $server->status,
'capabilities' => $server->capabilities ?? [],
'builderEnabled' => $server->builder_enabled,
'builderCapacity' => $server->builder_capacity,
'builderCpuQuota' => $server->builder_cpu_quota,
'ingressEnabled' => $server->isIngress(),
'ingressType' => $server->ingress_type,
'uuid' => $server->uuid,
'nodeAddress' => $server->node_address,
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
'wireguardManagementIp' => $server->wireguard_management_ip,
'wireguardPublicKey' => $server->wireguard_public_key,
'containerSubnets' => $server->container_subnets ?? [],
'privateKeyName' => $server->privateKey?->name,
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
'lastBootstrapAction' => $server->last_bootstrap_action,
'lastBootstrapStatus' => $server->last_bootstrap_status,
'lastBootstrapOutput' => $server->last_bootstrap_output,
'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(),
'lastStatusOutput' => $server->last_status_output,
'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(),
])->all(),
];
}
/**
* Reload servers (with keys) and counts before serializing so the payload
* always reflects the latest database state.
*
* @return array<string, mixed>
*/
public function serializeFresh(V5Cluster $cluster): array
{
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
$cluster->loadCount('servers');
return $this->serialize($cluster);
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Support\V5;
use App\Exceptions\V5\UnsupportedCooldVerb;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Single source of truth for deriving node firewall rules from a resource
* connection's DB rules and converging them through Flux. Reusable from
* controllers, jobs, and events alike; deterministic rule ids keep repeated
* syncs and compensating rollbacks idempotent.
*/
class ConnectionFirewallSync
{
/**
* @return Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>
*/
public function rulesFor(ResourceConnection $connection): Collection
{
$applicationIds = $connection->rules
->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id])
->unique()
->values();
$applications = V5Application::query()
->whereIn('id', $applicationIds)
->with('server')
->get()
->keyBy('id');
return $connection->rules
->flatMap(function ($rule) use ($applications, $connection): Collection {
$source = $applications->get($rule->source_resource_id);
$target = $applications->get($rule->target_resource_id);
if (! $source instanceof V5Application || ! $target instanceof V5Application) {
return collect();
}
$missingHost = collect([$source, $target])
->first(function (V5Application $application): bool {
$hostId = $application->server?->fluxHostId();
return ! is_string($hostId) || $hostId === '';
});
if ($missingHost instanceof V5Application) {
throw new \RuntimeException("Application {$missingHost->name} has no reachable server host id, so its firewall rules cannot be synced.");
}
$hostIds = collect([$source->server, $target->server])
->map(fn (V5Server $server) => $server->fluxHostId())
->unique()
->values();
$firewallRule = [
'id' => $this->ruleId($connection, $rule),
'namespace' => $target->mesh_namespace ?: 'default',
'src' => $source->container_name,
'dst' => $target->container_name,
'proto' => $rule->protocol ?: 'tcp',
'port' => (int) $rule->port,
];
return $hostIds->map(fn (string $hostId): array => [
'id' => $firewallRule['id'],
'hostId' => $hostId,
'rule' => $firewallRule,
]);
})
->values();
}
/**
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $oldRules
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $newRules
*/
public function sync(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void
{
$newRuleKeys = $newRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
$oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
$oldRules
->reject(fn (array $oldRule): bool => in_array($this->syncKey($oldRule), $newRuleKeys, true))
->each(fn (array $oldRule): ?string => $this->revokeRuleIfPresent($fluxClient, $oldRule['hostId'], $oldRule['id']));
$newRules
->reject(fn (array $newRule): bool => in_array($this->syncKey($newRule), $oldRuleKeys, true))
->each(function (array $newRule) use ($fluxClient): void {
try {
$fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule']);
} catch (UnsupportedCooldVerb $exception) {
Log::warning('V5 resource connection firewall rule skipped: coold verb unsupported', [
'host_id' => $newRule['hostId'],
'rule_id' => $newRule['id'],
'verb' => $exception->verb,
'message' => $exception->getMessage(),
]);
}
});
}
public function revokeRuleIfPresent(FluxClient $fluxClient, string $hostId, string $ruleId): ?string
{
try {
return $fluxClient->revokeFirewallRule($hostId, $ruleId);
} catch (UnsupportedCooldVerb $exception) {
Log::warning('V5 resource connection firewall revoke skipped: coold verb unsupported', [
'host_id' => $hostId,
'rule_id' => $ruleId,
'verb' => $exception->verb,
'message' => $exception->getMessage(),
]);
return null;
} catch (\RuntimeException $exception) {
if (str_contains(Str::lower($exception->getMessage()), 'not found')) {
return null;
}
throw $exception;
}
}
/**
* Deterministic node-side rule id derived only from the connection id and
* the rule's stable attributes — never from the rule row's primary key —
* so rewritten or restored DB rows resolve to the same firewall rule ids
* and compensating re-syncs stay idempotent.
*/
public function ruleId(ResourceConnection $connection, mixed $rule): string
{
return implode(':', [
'v5-resource-connection',
$connection->id,
$rule->source_resource_id,
$rule->target_resource_id,
$rule->protocol ?: 'tcp',
(int) $rule->port,
]);
}
/**
* @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule
*/
private function syncKey(array $rule): string
{
return $rule['hostId'].'|'.$rule['id'];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Support\V5;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
use Illuminate\Support\Collection;
/**
* Single source of truth for the resource connection payloads served by the
* dashboard Inertia props and the connection endpoints — the wire format is
* consumed by resources/js/v5/types.ts and must stay stable.
*/
class ResourceConnectionSerializer
{
/**
* @return array<string, mixed>
*/
public function serialize(ResourceConnection $connection): array
{
$applications = $this->applicationsById($connection);
$resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid;
$resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid;
$applicationsById = $applications;
return [
'id' => $connection->uuid,
'applicationIds' => array_values(array_filter([
$resourceOneUuid,
$resourceTwoUuid,
])),
'fromApplicationId' => $resourceOneUuid,
'toApplicationId' => $resourceTwoUuid,
'portsByDirection' => $connection->rules
->groupBy(function ($rule) use ($applicationsById): string {
$sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid;
$targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid;
return "{$sourceUuid}->{$targetUuid}";
})
->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->'))
->map(fn (Collection $rules) => $rules
->sortBy('port')
->pluck('port')
->map(fn ($port) => (string) $port)
->values()
->all())
->all(),
];
}
/**
* @return Collection<string, V5Application>
*/
public function applicationsByUuid(ResourceConnection $connection): Collection
{
return $this->applicationsById($connection)->keyBy('uuid');
}
/**
* @return Collection<int, V5Application>
*/
public function applicationsById(ResourceConnection $connection): Collection
{
return V5Application::query()
->whereIn('id', [
(int) $connection->resource_one_id,
(int) $connection->resource_two_id,
])
->get()
->keyBy('id');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Support\V5;
use App\Enums\V5\ApplicationStatus;
use App\Enums\V5\ContainerState;
use App\Enums\V5\IngressStatus;
use App\Enums\V5\ServerStatus;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Log;
/**
* Shared status-observation watermarking and enum normalization used by every
* v5 status write path (the flux webhook, the reconcile job, and the manual
* refresh endpoint) so out-of-order updates are dropped and raw coold states
* are normalized identically everywhere.
*/
class StatusObservation
{
/**
* A write whose observation timestamp is older than the one already
* persisted is stale (delivered or computed out of order) and must not
* clobber the newer state.
*
* @param array<string, mixed> $logContext
*/
public static function isStale(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
{
if ($observedAt === null || $currentObservedAt === null || ! $observedAt->lt($currentObservedAt)) {
return false;
}
Log::debug("Dropping stale flux {$context} update.", [
...$logContext,
'observed_at' => $observedAt->toIso8601String(),
'current_status_observed_at' => $currentObservedAt->toIso8601String(),
]);
return true;
}
/**
* Map a raw status string onto the given status enum. Unknown values are
* never written to the database: they fall back to the enum's Unknown case
* and are logged. Returns null only when no raw value is supplied.
*
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
*/
public static function normalize(?string $raw, string $enumClass): ?string
{
if ($raw === null || $raw === '') {
return null;
}
$status = $enumClass::tryFrom(strtolower($raw));
if ($status === null) {
Log::warning('Received unknown flux resource status; falling back to unknown.', [
'raw_status' => $raw,
'status_enum' => $enumClass,
]);
return $enumClass::Unknown->value;
}
return $status->value;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Support\V5;
class V5Feature
{
private const DEVELOPMENT_ENVIRONMENTS = ['local', 'development', 'dev', 'testing'];
public static function enabled(): bool
{
return (bool) config('v5.enabled');
}
public static function enabledForEnvironment(string $environment): bool
{
return in_array($environment, self::DEVELOPMENT_ENVIRONMENTS, true);
}
}