From 575bfaa08564f90031dc7883e917ff72914b14b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:35:22 +0200 Subject: [PATCH 1/2] feat(analytics): add configurable client IP storage modes Allow full, anonymized, or disabled IP recording per server through the UI and API, and pass the selected mode to Sentinel. Adapt traffic breakdowns to the effective IP mode, including views that aggregate multiple servers. --- .ai/lessons.md | 1 + app/Actions/Server/StartSentinel.php | 2 + app/Enums/TrafficIpMode.php | 72 +++++++++++++++++++ .../Api/ServerSentinelController.php | 6 ++ app/Livewire/Analytics.php | 12 +++- .../Shared/ResourceTrafficAnalytics.php | 12 +++- .../Server/TrafficAnalyticsSettings.php | 8 ++- app/Models/ServerSetting.php | 7 +- ...add_traffic_ip_mode_to_server_settings.php | 28 ++++++++ openapi.json | 55 ++++++++++++-- openapi.yaml | 34 +++++++-- resources/views/livewire/analytics.blade.php | 15 ++-- .../project/application/analytics.blade.php | 15 ++-- .../traffic-analytics-settings.blade.php | 10 ++- tests/Feature/Api/ServerSubsystemsApiTest.php | 29 +++++++- .../ApplicationAnalyticsTest.php | 18 +++++ .../TrafficAnalytics/GlobalAnalyticsTest.php | 50 +++++++++++++ .../StartSentinelTrafficTest.php | 10 +++ .../ToggleTrafficAnalyticsTest.php | 46 ++++++++++++ 19 files changed, 399 insertions(+), 31 deletions(-) create mode 100644 app/Enums/TrafficIpMode.php create mode 100644 database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 3d2c6a1e68..84d6bc1992 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -58,6 +58,7 @@ ## Trace infrastructure changes end to end - For container image changes, inspect Compose services and every relevant Dockerfile build stage. - Pin a stable release tag instead of using a floating `latest` tag. +- Never give a test build an official image tag (for example `coollabsio/sentinel:`) in the host Docker. Dev instances that use `testing-host` share the host Docker socket and run that tag without a pull. Load such a tag only inside a dev VM, and give the build the same version string, or Coolify's Sentinel check restarts it every minute. - A successful image pull does not prove that the complete application build no longer uses the old image. - Do not use `docker compose up --wait` for a stack with one-shot services; wait for the required long-running service's health instead. diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index f58c995c96..3dcc2650ac 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Enums\TrafficIpMode; use App\Events\SentinelRestarted; use App\Models\Server; use Illuminate\Contracts\Cache\LockTimeoutException; @@ -58,6 +59,7 @@ class StartSentinel 'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395), 'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false', 'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30), + 'TRAFFIC_IP_MODE' => TrafficIpMode::forServer($server)->value, ]; $license = data_get($settings, 'geoip_maxmind_license_key'); if ($settings->is_geoip_enabled && filled($license)) { diff --git a/app/Enums/TrafficIpMode.php b/app/Enums/TrafficIpMode.php new file mode 100644 index 0000000000..6f2b09d89a --- /dev/null +++ b/app/Enums/TrafficIpMode.php @@ -0,0 +1,72 @@ +settings?->traffic_ip_mode ?? self::Full; + } + + /** + * Effective mode for a view that merges several servers: Off only when every server is off, + * Anonymized when every server that records IPs anonymizes them, otherwise Full. + * + * @param iterable $servers + */ + public static function forServers(iterable $servers): self + { + $modes = collect($servers)->map(fn (Server $server): self => self::forServer($server)); + + if ($modes->isEmpty()) { + return self::Full; + } + + $recording = $modes->reject(fn (self $mode): bool => $mode === self::Off); + + if ($recording->isEmpty()) { + return self::Off; + } + + return $recording->every(fn (self $mode): bool => $mode === self::Anonymized) + ? self::Anonymized + : self::Full; + } + + /** + * Drop the `ip` dimension when Sentinel does not record it. + * + * @param array $dimensions + * @return array + */ + public function breakdownDimensions(array $dimensions): array + { + if ($this !== self::Off) { + return $dimensions; + } + + return array_values(array_filter($dimensions, fn (string $dimension): bool => $dimension !== 'ip')); + } + + public function breakdownLabel(): string + { + return $this === self::Anonymized ? 'Top networks' : 'Top IPs'; + } + + public function breakdownHelper(): string + { + return $this === self::Anonymized + ? 'Busiest client networks. IPs are anonymized to /24 (IPv4) and /48 (IPv6).' + : 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).'; + } +} diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index f3e15b5c14..04076c7ce7 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Enums\TrafficIpMode; use App\Http\Controllers\Controller; use App\Models\Server; use App\Models\ServerSetting; @@ -26,6 +27,7 @@ class ServerSentinelController extends Controller 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', + 'traffic_ip_mode', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -55,6 +57,7 @@ class ServerSentinelController extends Controller 'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days, 'is_geoip_enabled' => (bool) $settings->is_geoip_enabled, 'geoip_refresh_days' => (int) $settings->geoip_refresh_days, + 'traffic_ip_mode' => TrafficIpMode::forServer($server)->value, ]; if ($this->canReadSensitive()) { @@ -97,6 +100,7 @@ class ServerSentinelController extends Controller new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'), new OA\Property(property: 'is_geoip_enabled', type: 'boolean'), new OA\Property(property: 'geoip_refresh_days', type: 'integer'), + new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs for the Top IPs breakdown.'), new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'), ], type: 'object', @@ -152,6 +156,7 @@ class ServerSentinelController extends Controller new OA\Property(property: 'is_geoip_enabled', type: 'boolean'), new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1), new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true), + new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.'), ], type: 'object', ), @@ -198,6 +203,7 @@ class ServerSentinelController extends Controller 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer|min:1', 'geoip_maxmind_license_key' => 'nullable|string|max:255', + 'traffic_ip_mode' => 'string|in:full,anonymized,off', ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 00b0e0d23f..e4400f58ec 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Enums\TrafficIpMode; use App\Livewire\Concerns\BuildsTrafficChartPayload; use App\Models\Application; use App\Models\Server; @@ -79,6 +80,10 @@ class Analytics extends Component public ?string $attribution = null; + /** Effective TrafficIpMode of the queried servers; decides how the IP breakdown renders. */ + #[Locked] + public string $ipMode = 'full'; + /** * Per-bucket status-class time series for the stacked area chart, summed across * target servers and sorted by bucket. @@ -342,8 +347,11 @@ class Analytics extends Component $resource = $this->selectedResource(); $servers = $this->targetServers(); $domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain']; + $ipMode = TrafficIpMode::forServers($servers); + $this->ipMode = $ipMode->value; + $dimensions = $ipMode->breakdownDimensions($this->breakdownDimensions); - $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); + $aggregator = new TrafficAnalyticsAggregator($dimensions); $appRows = []; $hostTotals = []; @@ -362,7 +370,7 @@ class Analytics extends Component // Warm every server-wide endpoint, including the leaderboard's per-key // overviews, in one dashboard fetch; the per-call methods below then read // from cache. - $leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); + $leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $dimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) { Log::warning('Traffic analytics leaderboard truncated', [ diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php index cbc1caac1a..d936333a92 100644 --- a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Enums\TrafficIpMode; use App\Livewire\Concerns\BuildsTrafficChartPayload; use App\Models\Application; use App\Models\Service; @@ -52,6 +53,10 @@ abstract class ResourceTrafficAnalytics extends Component public ?string $attribution = null; + /** TrafficIpMode of the resource's server; decides how the IP breakdown renders. */ + #[Locked] + public string $ipMode = 'full'; + /** * Per-bucket status-class time series for the stacked area chart. * @@ -131,11 +136,14 @@ abstract class ResourceTrafficAnalytics extends Component $resource = $this->trafficResource(); [$from, $to] = SentinelTrafficClient::rangeWindow($this->range); - $client = app(SentinelTrafficClient::class, ['server' => $resource->server()]); + $server = $resource->server(); + $client = app(SentinelTrafficClient::class, ['server' => $server]); + $ipMode = $server ? TrafficIpMode::forServer($server) : TrafficIpMode::Full; + $this->ipMode = $ipMode->value; // Sentinel's resource scope merges every key exactly in one docker exec. A Sentinel // before 1.0.2 falls back to all keys of the resource, merged here (approximate). - $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); + $aggregator = new TrafficAnalyticsAggregator($ipMode->breakdownDimensions($this->breakdownDimensions)); $aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey)); $result = $aggregator->overview(); diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index 6c782cff6e..bbfc5d8204 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -4,6 +4,7 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureTrafficAnalytics; use App\Enums\ProxyTypes; +use App\Enums\TrafficIpMode; use App\Livewire\Analytics; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -39,6 +40,9 @@ class TrafficAnalyticsSettings extends Component #[Validate(['nullable', 'string', 'max:255'])] public ?string $geoipMaxmindLicenseKey = null; + #[Validate(['required', 'string', 'in:full,anonymized,off'])] + public string $trafficIpMode = 'full'; + public function mount(): void { $this->authorize('update', $this->server); @@ -56,6 +60,7 @@ class TrafficAnalyticsSettings extends Component $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled; $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays; $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey; + $this->server->settings->traffic_ip_mode = TrafficIpMode::from($this->trafficIpMode); $this->server->settings->save(); return; @@ -69,6 +74,7 @@ class TrafficAnalyticsSettings extends Component $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled; $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days; $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key; + $this->trafficIpMode = TrafficIpMode::forServer($this->server)->value; } public function toggleTrafficAnalytics(): void @@ -99,7 +105,7 @@ class TrafficAnalyticsSettings extends Component $this->authorize('update', $this->server); $this->syncData(true); auditLog('ui.server.traffic_analytics.updated', $this->auditContext([ - 'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key'], + 'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', 'traffic_ip_mode'], ])); $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index fab0d406ec..c7b28fb4ef 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ServerRole; +use App\Enums\TrafficIpMode; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -36,6 +37,7 @@ use OpenApi\Attributes as OA; 'traffic_retention_1d_days' => ['type' => 'integer'], 'is_geoip_enabled' => ['type' => 'boolean'], 'geoip_refresh_days' => ['type' => 'integer'], + 'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']], 'is_reachable' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], @@ -131,6 +133,7 @@ class ServerSetting extends Model 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', + 'traffic_ip_mode', 'docker_version', 'docker_version_checked_at', 'compose_version', @@ -167,6 +170,7 @@ class ServerSetting extends Model 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer', 'geoip_maxmind_license_key' => 'encrypted', + 'traffic_ip_mode' => TrafficIpMode::class, 'docker_version_checked_at' => 'datetime', 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', @@ -218,7 +222,8 @@ class ServerSetting extends Model $settings->wasChanged('traffic_retention_1d_days') || $settings->wasChanged('is_geoip_enabled') || $settings->wasChanged('geoip_refresh_days') || - $settings->wasChanged('geoip_maxmind_license_key') + $settings->wasChanged('geoip_maxmind_license_key') || + $settings->wasChanged('traffic_ip_mode') ) { // Only recreate Sentinel when it is already enabled. Otherwise a change to a // traffic/geoip tuning knob would turn Sentinel on as a side effect, because diff --git a/database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php b/database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php new file mode 100644 index 0000000000..85d7c69e12 --- /dev/null +++ b/database/migrations/2026_10_07_092441_add_traffic_ip_mode_to_server_settings.php @@ -0,0 +1,28 @@ +string('traffic_ip_mode')->default('full'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('traffic_ip_mode'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 9c89dcf413..99587429fa 100644 --- a/openapi.json +++ b/openapi.json @@ -991,7 +991,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -1582,7 +1582,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -2173,7 +2173,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -2659,7 +2659,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -3125,7 +3125,7 @@ "autogenerate_domain": { "type": "boolean", "default": true, - "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." }, "is_container_label_escape_enabled": { "type": "boolean", @@ -9161,6 +9161,9 @@ }, "409": { "description": "Another start, restart or import of this database is already in progress." + }, + "422": { + "description": "The database cannot start yet, for example because a CA certificate is missing." } }, "security": [ @@ -9279,6 +9282,9 @@ }, "409": { "description": "Another start, restart or import of this database is already in progress." + }, + "422": { + "description": "The database cannot start yet, for example because a CA certificate is missing." } }, "security": [ @@ -17045,6 +17051,15 @@ "geoip_refresh_days": { "type": "integer" }, + "traffic_ip_mode": { + "description": "How Sentinel stores client IPs for the Top IPs breakdown.", + "type": "string", + "enum": [ + "full", + "anonymized", + "off" + ] + }, "geoip_maxmind_license_key": { "description": "Only present with read:sensitive.", "type": "string" @@ -17153,6 +17168,15 @@ "string", "null" ] + }, + "traffic_ip_mode": { + "description": "How Sentinel stores client IPs: full, anonymized (IPv4 \/24, IPv6 \/48), or off.", + "type": "string", + "enum": [ + "full", + "anonymized", + "off" + ] } }, "type": "object" @@ -23224,6 +23248,13 @@ "default": 36000, "maximum": 36000, "minimum": 60 + }, + "missing_backup_notification_days": { + "description": "Alert after this many days without an execution; 0 disables alerts", + "type": "integer", + "default": 0, + "maximum": 365, + "minimum": 0 } }, "type": "object", @@ -23246,7 +23277,8 @@ "retention_amount_s3", "retention_days_s3", "retention_max_storage_s3", - "timeout" + "timeout", + "missing_backup_notification_days" ], "properties": { "uuid": { @@ -23308,6 +23340,9 @@ }, "timeout": { "type": "integer" + }, + "missing_backup_notification_days": { + "type": "integer" } }, "type": "object" @@ -24344,6 +24379,14 @@ "geoip_refresh_days": { "type": "integer" }, + "traffic_ip_mode": { + "type": "string", + "enum": [ + "full", + "anonymized", + "off" + ] + }, "is_reachable": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 802aee71a7..e298549e80 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -655,7 +655,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -1041,7 +1041,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -1427,7 +1427,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -1754,7 +1754,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -2067,7 +2067,7 @@ paths: autogenerate_domain: type: boolean default: true - description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." is_container_label_escape_enabled: type: boolean default: true @@ -5985,6 +5985,8 @@ paths: $ref: '#/components/responses/404' '409': description: 'Another start, restart or import of this database is already in progress.' + '422': + description: 'The database cannot start yet, for example because a CA certificate is missing.' security: - bearerAuth: [] @@ -6060,6 +6062,8 @@ paths: $ref: '#/components/responses/404' '409': description: 'Another start, restart or import of this database is already in progress.' + '422': + description: 'The database cannot start yet, for example because a CA certificate is missing.' security: - bearerAuth: [] @@ -10758,6 +10762,7 @@ paths: traffic_retention_1d_days: { type: integer } is_geoip_enabled: { type: boolean } geoip_refresh_days: { type: integer } + traffic_ip_mode: { description: 'How Sentinel stores client IPs for the Top IPs breakdown.', type: string, enum: [full, anonymized, 'off'] } geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string } type: object '401': @@ -10828,6 +10833,10 @@ paths: minimum: 1 geoip_maxmind_license_key: type: [string, 'null'] + traffic_ip_mode: + description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.' + type: string + enum: [full, anonymized, 'off'] type: object responses: '200': @@ -14741,6 +14750,12 @@ components: default: 36000 maximum: 36000 minimum: 60 + missing_backup_notification_days: + description: 'Alert after this many days without an execution; 0 disables alerts' + type: integer + default: 0 + maximum: 365 + minimum: 0 type: object additionalProperties: false VolumeBackupScheduleResponse: @@ -14761,6 +14776,7 @@ components: - retention_days_s3 - retention_max_storage_s3 - timeout + - missing_backup_notification_days properties: uuid: type: string @@ -14803,6 +14819,8 @@ components: format: float timeout: type: integer + missing_backup_notification_days: + type: integer type: object Application: description: 'Application model' @@ -15562,6 +15580,12 @@ components: type: boolean geoip_refresh_days: type: integer + traffic_ip_mode: + type: string + enum: + - full + - anonymized + - 'off' is_reachable: type: boolean is_sentinel_enabled: diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index cfcb6e1594..c326ac29d5 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -370,12 +370,15 @@ $appListboxOptions = array_merge( 'rows' => data_get($breakdowns, 'agent', []), 'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.', ]) - @include('livewire.traffic._breakdown-section', [ - 'dimension' => 'ip', - 'label' => 'Top IPs', - 'rows' => data_get($breakdowns, 'ip', []), - 'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).', - ]) + @php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full) + @if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off) + @include('livewire.traffic._breakdown-section', [ + 'dimension' => 'ip', + 'label' => $ipModeEnum->breakdownLabel(), + 'rows' => data_get($breakdowns, 'ip', []), + 'helper' => $ipModeEnum->breakdownHelper(), + ]) + @endif {{-- User agents (full width — raw UA strings are long). --}} diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php index 692d3fda82..b2458f4cf8 100644 --- a/resources/views/livewire/project/application/analytics.blade.php +++ b/resources/views/livewire/project/application/analytics.blade.php @@ -214,12 +214,15 @@ $approxBadge = fn (string $tooltip) => ' @if ($isTrafficAnalyticsEnabled) + targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey,trafficIpMode" /> @endif false, 'label' => 'Disabled'], ]" helper="Country enrichment from visitor IPs. Disable to skip GeoIP lookups." /> + diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index 37699bff1f..1f24abd97e 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -221,7 +221,8 @@ describe('Sentinel API', function () { ->assertJsonPath('is_sentinel_enabled', true) ->assertJsonPath('is_metrics_enabled', true) ->assertJsonPath('traffic_topn', 50) - ->assertJsonPath('is_geoip_enabled', true); + ->assertJsonPath('is_geoip_enabled', true) + ->assertJsonPath('traffic_ip_mode', 'full'); expect($response->json())->not->toHaveKey('sentinel_token') ->and($response->json())->not->toHaveKey('sentinel_custom_url'); @@ -247,6 +248,32 @@ describe('Sentinel API', function () { ->toHaveCount(1); }); + test('PATCH updates the client IP mode and restarts Sentinel', function (string $mode) { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ + 'traffic_ip_mode' => $mode, + ]) + ->assertOk() + ->assertJsonPath('traffic_ip_mode', $mode); + + expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe($mode); + expect(Queue::pushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel)) + ->toHaveCount(1); + })->with(['anonymized', 'off']); + + test('PATCH rejects an unknown client IP mode', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ + 'traffic_ip_mode' => 'partial', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('traffic_ip_mode'); + + expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe('full'); + }); + test('PATCH rejects disabling mandatory Sentinel', function () { $this->withHeaders(serverSubsystemsHeaders()) ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ diff --git a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php index f347165646..3c4bf8f49f 100644 --- a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php @@ -200,6 +200,24 @@ it('decorates per-app paths with the app domain and surfaces AI agents', functio expect($component->instance()->topPaths[0]['domain'])->toBe('api.example.com'); }); +it('hides the IP breakdown when the server does not record client IPs', function () { + $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true); + $server = $application->destination->server; + $server->settings->traffic_ip_mode = 'off'; + // Saved quietly: a mode change restarts Sentinel, which needs SSH. + $server->settings->saveQuietly(); + + $fake = new FakeAnalyticsTrafficClient($server); + $fake->responses = fakeAnalyticsResponses(); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class, ['application' => $application->fresh()])) + ->assertOk() + ->assertSee('Top user agents') + ->assertDontSee('Top IPs') + ->assertDontSee('198.51.100.42'); +}); + it('shows an empty state when traffic analytics is disabled for the server', function () { $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false); diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index f6007d7e00..58add72c10 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -1,5 +1,6 @@ and($fake->urls)->not->toBeEmpty() ->and(array_values(array_diff($fake->urls, $firstUrls)))->toBe([]); }); + +it('labels the IP breakdown by the client IP mode of the queried servers', function (string $mode, ?string $label) { + $server = bootEnabledGlobalServer(); + $server->settings->traffic_ip_mode = $mode; + // Saved quietly: a mode change restarts Sentinel, which needs SSH. + $server->settings->saveQuietly(); + + $fake = new FakeGlobalAnalyticsTrafficClient($server); + $fake->responses = fakeGlobalAnalyticsResponses(); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + $component = loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSet('ipMode', $mode) + ->assertSee('Top user agents'); + + if ($label === null) { + $component->assertDontSee('Top IPs') + ->assertDontSee('Top networks') + ->assertDontSee('203.0.113.7'); + expect($component->instance()->breakdowns)->not->toHaveKey('ip'); + + return; + } + + $component->assertSee($label)->assertSee('203.0.113.7'); +})->with([ + 'full' => ['full', 'Top IPs'], + 'anonymized' => ['anonymized', 'Top networks'], + 'off' => ['off', null], +]); + +it('combines the client IP mode of several servers', function (array $modes, string $expected) { + $servers = collect($modes)->map(function (string $mode) { + $server = bootEnabledGlobalServer(); + $server->settings->traffic_ip_mode = $mode; + // Saved quietly: a mode change restarts Sentinel, which needs SSH. + $server->settings->saveQuietly(); + + return $server->fresh(); + }); + + expect(TrafficIpMode::forServers($servers))->toBe(TrafficIpMode::from($expected)); +})->with([ + 'no servers' => [[], 'full'], + 'all off' => [['off', 'off'], 'off'], + 'anonymized and off' => [['anonymized', 'off'], 'anonymized'], + 'full and anonymized' => [['full', 'anonymized'], 'full'], +]); diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php index 0f23a64cbf..9d53564fb7 100644 --- a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php @@ -44,9 +44,19 @@ it('produces traffic + geoip env when enabled', function () { expect($env['GEOIP_ENABLED'])->toBe('true'); expect($env['GEOIP_REFRESH_DAYS'])->toBe('30'); expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('lic'); + expect($env['TRAFFIC_IP_MODE'])->toBe('full'); expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH'); }); +it('passes the saved client IP mode as sentinel env', function (string $mode) { + $server = trafficProxyServer($this); + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->traffic_ip_mode = $mode; + $server->settings->save(); + + expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_IP_MODE'])->toBe($mode); +})->with(['anonymized', 'off']); + it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) { config()->set('app.env', 'local'); $server = trafficProxyServer($this); diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php index 964942eb71..17bc84d4ef 100644 --- a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php @@ -2,6 +2,7 @@ use App\Actions\Server\ConfigureTrafficAnalytics; use App\Enums\ServerRole; +use App\Enums\TrafficIpMode; use App\Livewire\Analytics; use App\Livewire\Server\TrafficAnalyticsSettings; use App\Models\InstanceSettings; @@ -105,6 +106,51 @@ it('saves traffic analytics settings from the sentinel form', function () { ->and($settings->geoip_refresh_days)->toBe(7); }); +it('saves the client IP mode from the sentinel form', function () { + Queue::fake(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) + ->assertSet('trafficIpMode', 'full') + ->assertSee('Client IP addresses') + ->set('trafficIpMode', 'anonymized') + ->call('saveTrafficAnalyticsSettings') + ->assertHasNoErrors(); + + expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Anonymized); +}); + +it('rejects an unknown client IP mode', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) + ->set('trafficIpMode', 'partial') + ->call('saveTrafficAnalyticsSettings') + ->assertHasErrors(['trafficIpMode']); + + expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full); +}); + +it('does not let a team member change the client IP mode', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + $component = Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]); + + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + $component->set('trafficIpMode', 'off') + ->call('saveTrafficAnalyticsSettings'); + + expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full); +}); + it('rejects a zero top-n cap', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); From 38861e1394074714ec6e119c90f10b6be7b54b4f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:45:08 +0200 Subject: [PATCH 2/2] feat(github-runners): support deployment and build servers (#12109) --- .../Controllers/Api/ServersController.php | 4 +- app/Jobs/ProvisionGithubRunnerJob.php | 4 +- app/Jobs/ReconcileGithubRunnersJob.php | 4 +- app/Livewire/Server/GithubRunners.php | 8 +-- app/Livewire/Server/Show.php | 4 +- .../GithubRunner/GithubRunnerContainer.php | 2 +- .../livewire/server/github-runners.blade.php | 51 ++++++++++---- .../views/livewire/server/new/by-ip.blade.php | 6 +- .../views/livewire/server/show.blade.php | 6 +- .../livewire/source/github/change.blade.php | 2 +- .../livewire/source/github/create.blade.php | 2 +- .../source/github/permissions.blade.php | 2 +- tests/Feature/GithubRunnerFlowTest.php | 8 +-- tests/Feature/GithubRunnerSettingsTest.php | 66 ++++++++++++++++--- 14 files changed, 121 insertions(+), 48 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index c55eb6c8f8..5fa1801a5e 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -816,10 +816,10 @@ class ServersController extends Controller ], 422); } - if ($serverRole !== null && $serverRole !== ServerRole::BUILD && $server->hasEnabledGithubRunners()) { + if ($serverRole === ServerRole::DEPLOYMENT && $server->hasEnabledGithubRunners()) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['server_role' => ['Disable the GitHub runners before you change the role of this server.']], + 'errors' => ['server_role' => ['Disable the GitHub runners before you set this server to deployments only.']], ], 422); } diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index f170c34c3b..c5b1148448 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -19,7 +19,7 @@ use Illuminate\Support\Sleep; use RuntimeException; /** - * Starts one ephemeral runner for a queued execution on the least busy matching build server. + * Starts one ephemeral runner for a queued execution on the least busy matching server. * When no server has free capacity, the execution stays queued and is retried by * CleanupGithubRunnerJob or ReconcileGithubRunnersJob. A runner that fails to start is removed and * started again, possibly on another server, up to MAX_ATTEMPTS times. @@ -191,7 +191,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ->filter(fn (GithubRunnerConfig $config) => $config->server !== null && $config->matchesLabels($execution->labels ?? []) && ($config->allow_pull_requests || ! $execution->is_pull_request) - && $config->server->isBuildServer() + && $config->server->canBuildApplications() && ! $config->server->isLocalhost() && $config->server->isFunctional()) ->map(fn (GithubRunnerConfig $config) => ['config' => $config, 'occupied' => $config->occupiedRunnerCount()]) diff --git a/app/Jobs/ReconcileGithubRunnersJob.php b/app/Jobs/ReconcileGithubRunnersJob.php index 6ec70a26b2..f3adb513c4 100644 --- a/app/Jobs/ReconcileGithubRunnersJob.php +++ b/app/Jobs/ReconcileGithubRunnersJob.php @@ -17,7 +17,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; /** - * Compares runner executions with the runner containers on each build server. It cleans up + * Compares runner executions with the runner containers on each runner server. It cleans up * stopped and orphaned runners, applies the wait, idle, and job timeouts, and restarts * provisioning for queued executions whose dispatch was lost. */ @@ -161,7 +161,7 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue foreach ($executions as $execution) { if (($execution->queued_at ?? $execution->created_at)->lt(now()->subMinutes($timeout))) { - $execution->finish(GithubRunnerStatus::TimedOut, "No build server had free capacity within {$timeout} minutes."); + $execution->finish(GithubRunnerStatus::TimedOut, "No runner server had free capacity within {$timeout} minutes."); } } diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php index 3613339f28..598a8485fc 100644 --- a/app/Livewire/Server/GithubRunners.php +++ b/app/Livewire/Server/GithubRunners.php @@ -183,8 +183,8 @@ class GithubRunners extends Component } try { - if (! $this->server->isBuildServer()) { - throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.'); + if (! $this->server->canBuildApplications()) { + throw new \RuntimeException('GitHub Actions runners need a server with the Builds only or Deployments and builds role.'); } if ($this->config?->is_enabled ?? true) { @@ -242,8 +242,8 @@ class GithubRunners extends Component }); $this->dispatch('success', 'GitHub runners disabled. Running jobs continue until they finish.'); } else { - if (! $this->server->isBuildServer()) { - throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.'); + if (! $this->server->canBuildApplications()) { + throw new \RuntimeException('GitHub Actions runners need a server with the Builds only or Deployments and builds role.'); } $this->authorize('update', $config->githubApp); $this->prepareRunnerGroup($config->githubApp); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cea2c3f465..8357e89257 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -476,9 +476,9 @@ class Show extends Component $newRole = ServerRole::from($this->serverRole); $currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole(); - if ($newRole !== ServerRole::BUILD && $this->server->hasEnabledGithubRunners()) { + if ($newRole === ServerRole::DEPLOYMENT && $this->server->hasEnabledGithubRunners()) { $this->serverRole = $currentRole->value; - $this->dispatch('error', 'Disable the GitHub runners before you change the role of this server.'); + $this->dispatch('error', 'Disable the GitHub runners before you set this server to deployments only.'); return; } diff --git a/app/Services/GithubRunner/GithubRunnerContainer.php b/app/Services/GithubRunner/GithubRunnerContainer.php index c970490be7..09839249a5 100644 --- a/app/Services/GithubRunner/GithubRunnerContainer.php +++ b/app/Services/GithubRunner/GithubRunnerContainer.php @@ -162,7 +162,7 @@ class GithubRunnerContainer #!/bin/bash case "\${GITHUB_EVENT_NAME:-}" in {$events}|"") - echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the build server to run them." + echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the server to run them." exit 1 ;; esac diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php index b91886484e..96b918b28a 100644 --- a/resources/views/livewire/server/github-runners.blade.php +++ b/resources/views/livewire/server/github-runners.blade.php @@ -10,7 +10,7 @@
- @if (!$server->isBuildServer()) + @if (! $server->canBuildApplications()) @@ -22,13 +22,13 @@ @elseif ($this->githubApps->isEmpty()) Runners are registered at organization level. Add a GitHub App that belongs to an organization - under Sources, and select "Run workflow jobs on build servers" when you register it. + under Sources, and select "Run workflow jobs on your servers" when you register it. @elseif (! $this->config?->is_enabled) - - Enable runners - + @if ($server->isBuildServer()) + + Enable runners + + @else + + + + Enable runners + + + + @endif @@ -63,6 +77,13 @@ @endcan + @if (! $server->isBuildServer()) + + Workflow jobs use the same CPU, memory, and disk as the resources on this server. A heavy + job can slow down or stop them. Use a server with the Builds only role for runners when + you can. + + @endif
In the privileged Docker mode, a malicious workflow can take control of this server. @@ -99,12 +120,14 @@ - + @if ($server->isBuildServer()) + + @endif diff --git a/resources/views/livewire/server/new/by-ip.blade.php b/resources/views/livewire/server/new/by-ip.blade.php index 772ede2ac5..628f6656b3 100644 --- a/resources/views/livewire/server/new/by-ip.blade.php +++ b/resources/views/livewire/server/new/by-ip.blade.php @@ -84,11 +84,11 @@
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index ff159bdf1a..565fda89a3 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -238,11 +238,11 @@
@endif diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 38cef644d9..383d54bc6a 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -314,7 +314,7 @@ :disabled="blank($github_app->organization)" :options="[ ['value' => false, 'label' => 'Do not run workflow jobs'], - ['value' => true, 'label' => 'Run workflow jobs on build servers'], + ['value' => true, 'label' => 'Run workflow jobs on your servers'], ]" :helper="filled($github_app->organization) ? 'Adds the organization Self-hosted runners (write) and Actions (read) permissions and the Workflow job webhook event.' diff --git a/resources/views/livewire/source/github/create.blade.php b/resources/views/livewire/source/github/create.blade.php index e61a029f37..d60517d4c7 100644 --- a/resources/views/livewire/source/github/create.blade.php +++ b/resources/views/livewire/source/github/create.blade.php @@ -37,7 +37,7 @@ + helper="Runs workflow jobs on your servers. Needs an organization." />
diff --git a/resources/views/livewire/source/github/permissions.blade.php b/resources/views/livewire/source/github/permissions.blade.php index 6f6884ceed..ef2162e902 100644 --- a/resources/views/livewire/source/github/permissions.blade.php +++ b/resources/views/livewire/source/github/permissions.blade.php @@ -27,7 +27,7 @@ @php($missingRunnerRequirements = $github_app->missingRunnerRequirements()) + description="Your servers can run the organization's workflow jobs in Docker containers. Configure them in the GitHub Runners menu of a server.">
githubApp); Process::fake(['*' => Process::result(output: '29.8.0')]); $busyServer = runnerTestServer($this->team); $busyConfig = runnerTestConfig($busyServer, $this->githubApp); runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $busyServer->id, 'github_runner_config_id' => $busyConfig->id]); - $freeServer = runnerTestServer($this->team); + $freeServer = runnerTestServer($this->team, 'both'); runnerTestConfig($freeServer, $this->githubApp); $execution = runnerTestExecution($this->githubApp); @@ -615,7 +615,7 @@ describe('provisioning', function () { Process::assertNothingRan(); }); - it('does not use servers without the build role, disabled configs, or other labels', function (string $role, array $configAttributes) { + it('does not use deployments only servers, disabled configs, or other labels', function (string $role, array $configAttributes) { Queue::fake(); Process::fake(); $server = runnerTestServer($this->team, $role); @@ -627,7 +627,7 @@ describe('provisioning', function () { expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued); Process::assertNothingRan(); })->with([ - 'combined role' => ['both', []], + 'deployments only role' => ['deployment', []], 'disabled config' => ['build', ['is_enabled' => false]], 'other labels' => ['build', ['labels' => ['gpu']]], ]); diff --git a/tests/Feature/GithubRunnerSettingsTest.php b/tests/Feature/GithubRunnerSettingsTest.php index 65052a0fcb..e06a072e8e 100644 --- a/tests/Feature/GithubRunnerSettingsTest.php +++ b/tests/Feature/GithubRunnerSettingsTest.php @@ -268,21 +268,47 @@ describe('runner settings page', function () { ->assertRedirect(route('server.index')); }); - it('refuses to save runners on a server without the build role', function () { + it('asks for confirmation before it enables runners on a server that also runs resources', function () { $server = settingsTestServer($this->team, 'both'); + fakeRunnerGroupApi($this->githubApp); + + Livewire::actingAs($this->owner) + ->test(GithubRunners::class, ['server_uuid' => $server->uuid]) + ->assertSee('Enable runners on this server?') + ->assertDontSee('Application builds') + ->call('toggleEnabled') + ->assertDispatched('success') + ->assertSee('Runners share this server with your resources'); + + expect(GithubRunnerConfig::sole())->server_id->toBe($server->id)->is_enabled->toBeTrue(); + }); + + it('refuses runners on a deployments only server', function () { + $server = settingsTestServer($this->team, 'deployment'); Http::fake(); Livewire::actingAs($this->owner) ->test(GithubRunners::class, ['server_uuid' => $server->uuid]) ->assertSee('This server is not enabled for builds') - ->assertSee(route('server.show', ['server_uuid' => $server->uuid])) - ->assertSee(route('server.github-runners', ['server_uuid' => $server->uuid])) ->assertDontSee('Recent runners') ->set('githubAppId', $this->githubApp->id) - ->call('submit') + ->call('toggleEnabled') ->assertDispatched('error'); expect(GithubRunnerConfig::count())->toBe(0); + Http::assertNothingSent(); + }); + + it('enables runners on a build server without a confirmation', function () { + fakeRunnerGroupApi($this->githubApp); + + Livewire::actingAs($this->owner) + ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Enable runners on this server?') + ->call('toggleEnabled') + ->assertDispatched('success') + ->assertSee('Application builds') + ->assertDontSee('Runners share this server with your resources'); }); it('disables runners and removes only the idle ones', function () { @@ -539,25 +565,49 @@ describe('build server role', function () { GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]); }); - it('blocks a role change in the UI while runners are enabled', function () { + it('changes the role in the UI while runners are enabled', function () { Livewire::actingAs($this->owner) ->test(Show::class, ['server_uuid' => $this->server->uuid]) ->set('serverRole', 'both') ->call('requestServerRoleChange') + ->assertNotDispatched('error') + ->assertDispatched('open-server-role-confirmation') + ->call('confirmServerRoleChange'); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); + }); + + it('blocks a change to deployments only in the UI while runners are enabled', function () { + Livewire::actingAs($this->owner) + ->test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('serverRole', 'deployment') + ->call('requestServerRoleChange') ->assertSet('serverRole', 'build') ->assertDispatched('error'); expect($this->server->settings->fresh()->server_role->value)->toBe('build'); }); - it('blocks a role change through the API while runners are enabled', function () { + it('blocks a change to deployments only through the API while runners are enabled', function () { + $token = $this->owner->createToken('runner-test', ['*']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + settingsTestServer($this->team, 'build'); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken]) + ->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'deployment']) + ->assertStatus(422) + ->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you set this server to deployments only.'); + }); + + it('changes the role through the API while runners are enabled', function () { $token = $this->owner->createToken('runner-test', ['*']); $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken]) ->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'both']) - ->assertStatus(422) - ->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you change the role of this server.'); + ->assertSuccessful(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); }); it('leaves dedicated runner servers out of application builds', function () {