From 342e8e765d8b4e27633da70539bdf95f7099713d Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 25 Dec 2025 07:56:19 +0000 Subject: [PATCH 001/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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/100] 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 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 021/100] 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 022/100] 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 023/100] 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 024/100] 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 025/100] 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 026/100] 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 027/100] 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 028/100] 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 029/100] 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 030/100] 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 031/100] 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 032/100] 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 033/100] 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 034/100] 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 035/100] 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 036/100] 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 037/100] 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 038/100] 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 039/100] 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 040/100] 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 041/100] 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 042/100] 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 043/100] 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 044/100] 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 045/100] 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 046/100] 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 047/100] 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 048/100] 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 049/100] 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 050/100] 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 051/100] 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 052/100] 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 053/100] 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 054/100] 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 055/100] 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 056/100] 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 057/100] 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 058/100] 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 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

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 059/100] 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 060/100] 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 061/100] 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 062/100] 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 063/100] 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 064/100] 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 065/100] 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 066/100] 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 067/100] 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 068/100] 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 069/100] 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 070/100] 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 071/100] 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 072/100] 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 073/100] 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 074/100] 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 075/100] 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 @@