mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 18:15:49 +00:00
Merge remote-tracking branch 'origin/next' into feat/noindex-domains
This commit is contained in:
commit
003e44d123
1274 files changed
+156179
-25249
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user