From 519a186e841d0d5c3f56c38af8aeb7cfc7d63cdd Mon Sep 17 00:00:00 2001 From: Tristan Rhodes Date: Thu, 9 Apr 2026 09:38:56 -0600 Subject: [PATCH 01/30] fix: normalize oauth emails before matching users --- app/Http/Controllers/OauthController.php | 9 ++- tests/Feature/OauthControllerTest.php | 79 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/OauthControllerTest.php diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 3a3f18c9c..4038fe63e 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -19,7 +19,12 @@ public function callback(string $provider) { try { $oauthUser = get_socialite_provider($provider)->user(); - $user = User::whereEmail($oauthUser->email)->first(); + $email = trim((string) $oauthUser->email); + if ($email === '') { + abort(403, 'OAuth provider did not return an email address'); + } + $email = strtolower($email); + $user = User::whereEmail($email)->first(); if (! $user) { $settings = instanceSettings(); if (! $settings->is_registration_enabled) { @@ -28,7 +33,7 @@ public function callback(string $provider) $user = User::create([ 'name' => $oauthUser->name, - 'email' => $oauthUser->email, + 'email' => $email, ]); } Auth::login($user); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php new file mode 100644 index 000000000..af5fb0658 --- /dev/null +++ b/tests/Feature/OauthControllerTest.php @@ -0,0 +1,79 @@ + 0, + 'is_registration_enabled' => false, + ]); + + OauthSetting::create([ + 'provider' => 'google', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', + 'tenant' => 'example.com', + ]); +}); + +it('logs in an existing user when the oauth provider returns a mixed-case email', function () { + config()->set('app.maintenance.driver', 'file'); + + $user = User::factory()->create([ + 'email' => 'username@example.edu', + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => 'UserName@example.edu', + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->get(route('auth.callback', 'google')); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($user); + expect(User::count())->toBe(1); +}); + +it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { + config()->set('app.maintenance.driver', 'file'); + InstanceSettings::firstOrCreate([ + 'id' => 0, + ], [ + 'is_registration_enabled' => false, + ])->update([ + 'is_registration_enabled' => true, + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => $providerEmail, + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->from('/login')->get(route('auth.callback', 'google')); + + $response->assertRedirect('/login'); + expect(User::count())->toBe(0); +})->with([ + 'null email' => [null], + 'blank email' => [' '], +]); From f1f53a31ab94227bcac767ec0dac8f9cffca76d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:31:57 +0000 Subject: [PATCH 02/30] build(deps): bump follow-redirects in /docker/coolify-realtime Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docker/coolify-realtime/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 174077562..eae81be6a 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -165,9 +165,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", From 593006be88cefdf34bd3319f98e8ae541630e69f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:27:26 +0530 Subject: [PATCH 03/30] fix(validation): allow decimals for database backups max storage --- app/Http/Controllers/Api/DatabasesController.php | 16 ++++++++-------- .../project/database/backup-edit.blade.php | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index c05af152f..a6056b9f3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -639,10 +639,10 @@ public function update_by_uuid(Request $request) 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -703,10 +703,10 @@ public function create_backup(Request $request) 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); @@ -878,10 +878,10 @@ public function create_backup(Request $request) 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -933,10 +933,10 @@ public function update_backup(Request $request) 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index d5c25916a..898283afd 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -106,7 +106,7 @@ min="0" helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required /> @@ -122,7 +122,7 @@ min="0" helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required /> From 36baf70637d864eb5680e4bb94b6fe10fce4b06d Mon Sep 17 00:00:00 2001 From: nehemiyawicks Date: Sun, 26 Apr 2026 19:30:05 +0530 Subject: [PATCH 04/30] fix: use --network host for Dockerfile buildpack builds Dockerfile buildpack was passing --network {custom_network_name} to docker build, but BuildKit only supports host, none, and default. Every other buildpack already uses --network host with --add-host flags. Aligned the Dockerfile path to match. Fixes #9804 --- app/Jobs/ApplicationDeploymentJob.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..84bb4a09d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3075,29 +3075,28 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - $safeNetwork = escapeshellarg($this->destination->network); if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); From 9a58e0fea25879d14d1b7f1afb7835dd7a15269f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:33:08 +0200 Subject: [PATCH 05/30] fix(logs): disable auto-scroll on user scroll-up, re-enable on scroll-to-bottom Add wheel, touch, and keyboard event handlers to log containers in deployment and get-logs views. Auto-follow disables when user scrolls up; re-enables when user scrolls back to bottom (within 10px threshold). --- .../application/deployment/show.blade.php | 51 +++++++++++- .../project/shared/get-logs.blade.php | 46 ++++++++-- templates/service-templates-latest.json | 83 ++++++++++++++----- templates/service-templates.json | 83 ++++++++++++++----- 4 files changed, 208 insertions(+), 55 deletions(-) diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index d6294f3c8..8618872f5 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -9,6 +9,9 @@ fullscreen: @entangle('fullscreen'), alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, rafId: null, + scrollDebounce: null, + isScrolling: false, + lastTouchY: 0, showTimestamps: true, searchQuery: '', matchCount: 0, @@ -19,9 +22,54 @@ scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { + this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; + setTimeout(() => { this.isScrolling = false; }, 50); } }, + disableFollow() { + if (!this.alwaysScroll) return; + this.alwaysScroll = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }, + handleWheel(event) { + if (this.alwaysScroll && event.deltaY < 0) { + this.disableFollow(); + } + }, + handleTouchStart(event) { + this.lastTouchY = event.touches[0].clientY; + }, + handleTouchMove(event) { + if (!this.alwaysScroll) return; + const currentY = event.touches[0].clientY; + if (currentY > this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, + handleScroll(event) { + if (this.isScrolling) return; + clearTimeout(this.scrollDebounce); + this.scrollDebounce = setTimeout(() => { + const el = event.target; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); + } + }, 150); + }, scheduleScroll() { if (!this.alwaysScroll) return; this.rafId = requestAnimationFrame(() => { @@ -327,7 +375,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text- -
this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { @@ -57,17 +89,14 @@ } }, handleScroll(event) { - if (!this.alwaysScroll || this.isScrolling) return; + if (this.isScrolling) return; clearTimeout(this.scrollDebounce); this.scrollDebounce = setTimeout(() => { const el = event.target; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > 100) { - this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); } }, 150); }, @@ -473,7 +502,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-
@if ($outputs) diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index fdc99ae78..eb667fcb8 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC43JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FMQ09NXzMwMDAKICAgICAgLSBORVhUX1BVQkxJQ19MSUNFTlNFX0NPTlNFTlQ9YWdyZWUKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ05FWFRfUFVCTElDX1dFQkFQUF9VUkw9JHtTRVJWSUNFX1VSTF9DQUxDT019JwogICAgICAtICdORVhUX1BVQkxJQ19BUElfVjJfVVJMPSR7U0VSVklDRV9VUkxfQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0NBTENPTX0vYXBpL2F1dGgnCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTVNFQ1JFVH0nCiAgICAgIC0gJ0NBTEVORFNPX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfQ0FMQ09NS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSBEQVRBQkFTRV9IT1NUPXBvc3RncmVzcWwKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke0RBVEFCQVNFX0hPU1Q6LXBvc3RncmVzcWx9LyR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSAnREFUQUJBU0VfRElSRUNUX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gQ0FMQ09NX1RFTEVNRVRSWV9ESVNBQkxFRD0xCiAgICAgIC0gJ0VNQUlMX0ZST009JHtFTUFJTF9GUk9NfScKICAgICAgLSAnRU1BSUxfRlJPTV9OQU1FPSR7RU1BSUxfRlJPTV9OQU1FfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX0hPU1Q9JHtFTUFJTF9TRVJWRVJfSE9TVH0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QT1JUPSR7RU1BSUxfU0VSVkVSX1BPUlR9JwogICAgICAtICdFTUFJTF9TRVJWRVJfVVNFUj0ke0VNQUlMX1NFUlZFUl9VU0VSfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1BBU1NXT1JEPSR7RU1BSUxfU0VSVkVSX1BBU1NXT1JEfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQVBQX05BTUU9IkNhbC5jb20iJwogICAgICAtICdBTExPV0VEX0hPU1ROQU1FUz1bIiR7U0VSVklDRV9VUkxfQ0FMQ09NfSJdJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3Jlc3FsCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICB2b2x1bWVzOgogICAgICAtICdjYWxjb20tcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FQXzMwMDAKICAgICAgLSBBRE1JTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfQURNSU4KICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYnVuCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcpLnRoZW4ociA9PiB7IGlmICghci5vaykgcHJvY2Vzcy5leGl0KDEpIH0pLmNhdGNoKCgpID0+IHByb2Nlc3MuZXhpdCgxKSkiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgICBkZXBlbmRzX29uOgogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICd2YWxrZXktZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tc2F2ZSA2MCAxIC0tbG9nbGV2ZWwgd2FybmluZyAtLW1heG1lbW9yeS1wb2xpY3kgbm9ldmljdGlvbicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB2YWxrZXktY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9VUkxfSklUU0kKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gRU5BQkxFX0xFVFNFTkNSWVBUPTAKICAgICAgLSBFTkFCTEVfSFRUUF9SRURJUkVDVD0wCiAgICAgIC0gRElTQUJMRV9IVFRQUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ1hNUFBfQk9TSF9VUkxfQkFTRT1odHRwOi8vcHJvc29keTo1MjgwJwogICAgICAtIEpWQl9CUkVXRVJZX01VQz1qdmJicmV3ZXJ5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgICAgLSBqaWNvZm8KICAgICAgLSBqdmIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLXdlYjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6CiAgICAgICAgYWxpYXNlczoKICAgICAgICAgIC0gbWVldC5qaXRzaQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgcHJvc29keToKICAgIGltYWdlOiAnaml0c2kvcHJvc29keTpzdGFibGUtMTA4ODgnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQVVUSF9UWVBFPWludGVybmFsCiAgICAgIC0gRU5BQkxFX0FVVEg9MAogICAgICAtIEVOQUJMRV9HVUVTVFM9MQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfR1VFU1RfRE9NQUlOPWd1ZXN0Lm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtICdKSUNPRk9fQ09NUE9ORU5UX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSklDT0ZPX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pWQl9BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KVkJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWp2YjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQpuZXR3b3JrczoKICBtZWV0LmppdHNpOiBudWxsCg==", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAkU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogM3MKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNi4yLjQuMjMnCiAgICB1c2VyOiAnMTAxOjEwMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX0RCPSR7Q0xJQ0tIT1VTRV9EQjotZGVmYXVsdH0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMzAvYXBpL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogIEJVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCngtYXBwLWVudjoKICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVApzZXJ2aWNlczoKICBwcm94eToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICAtICdTSVRFX0FERFJFU1M9OjgwJwogICAgICAtICdGSUxFX1NJWkVfTElNSVQ9JHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICAtICdCVUNLRVRfTkFNRT0ke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICB2b2x1bWVzOgogICAgICAtICdwcm94eV9jb25maWc6L2NvbmZpZycKICAgICAgLSAncHJveHlfZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWZyb250ZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC1xTy0gaHR0cDovL2Bob3N0bmFtZWA6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHNwYWNlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtc3BhY2U6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd29ya2VyCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFkbWluOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1saXZlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYXBpOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtYXBpLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2FwaTovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYmVhdC13b3JrZXI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtaWdyYXRvcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIHJlc3RhcnQ6ICdubycKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LW1pZ3JhdG9yLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX21pZ3JhdG9yOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD1odHRwczovLyR7U0VSVklDRV9VUkxfUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ1NNVFBfUFdEPSR7U01UUF9QV0R9JwogICAgICAtICdTTVRQX1RMU19FTkFCTEVEPSR7U01UUF9UTFNfRU5BQkxFRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1JBTExMWX0nCiAgICAgIC0gJ0FMTE9XRURfRU1BSUxTPSR7QUxMT1dFRF9FTUFJTFN9JwogICAgICAtICdTVVBQT1JUX0VNQUlMPSR7U1VQUE9SVF9FTUFJTDotc3VwcG9ydEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkU6LWZhbHNlfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", diff --git a/templates/service-templates.json b/templates/service-templates.json index 45e2185ed..cc909dc68 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNycKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBTENPTV8zMDAwCiAgICAgIC0gTkVYVF9QVUJMSUNfTElDRU5TRV9DT05TRU5UPWFncmVlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdORVhUX1BVQkxJQ19XRUJBUFBfVVJMPSR7U0VSVklDRV9GUUROX0NBTENPTX0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQSV9WMl9VUkw9JHtTRVJWSUNFX0ZRRE5fQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9DQUxDT019L2FwaS9hdXRoJwogICAgICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9DQUxDT01TRUNSRVR9JwogICAgICAtICdDQUxFTkRTT19FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTUtFWX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gJ0RBVEFCQVNFX0RJUkVDVF9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7REFUQUJBU0VfSE9TVDotcG9zdGdyZXNxbH0vJHtQT1NUR1JFU19EQjotY2FsZW5kc299JwogICAgICAtIENBTENPTV9URUxFTUVUUllfRElTQUJMRUQ9MQogICAgICAtICdFTUFJTF9GUk9NPSR7RU1BSUxfRlJPTX0nCiAgICAgIC0gJ0VNQUlMX0ZST01fTkFNRT0ke0VNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9IT1NUPSR7RU1BSUxfU0VSVkVSX0hPU1R9JwogICAgICAtICdFTUFJTF9TRVJWRVJfUE9SVD0ke0VNQUlMX1NFUlZFUl9QT1JUfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1VTRVI9JHtFTUFJTF9TRVJWRVJfVVNFUn0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QQVNTV09SRD0ke0VNQUlMX1NFUlZFUl9QQVNTV09SRH0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQUF9OQU1FPSJDYWwuY29tIicKICAgICAgLSAnQUxMT1dFRF9IT1NUTkFNRVM9WyIke1NFUlZJQ0VfRlFETl9DQUxDT019Il0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBvc3RncmVzcWwKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NhbGNvbS1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBUF8zMDAwCiAgICAgIC0gQURNSU5fS0VZPSRTRVJWSUNFX1BBU1NXT1JEX0FETUlOCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJ1bgogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vbG9jYWxob3N0OjMwMDAnKS50aGVuKHIgPT4geyBpZiAoIXIub2spIHByb2Nlc3MuZXhpdCgxKSB9KS5jYXRjaCgoKSA9PiBwcm9jZXNzLmV4aXQoMSkpIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogICAgZGVwZW5kc19vbjoKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjktYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXNhdmUgNjAgMSAtLWxvZ2xldmVsIHdhcm5pbmcgLS1tYXhtZW1vcnktcG9saWN5IG5vZXZpY3Rpb24nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gdmFsa2V5LWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIEVOQUJMRV9BVVRIPTAKICAgICAgLSBFTkFCTEVfR1VFU1RTPTEKICAgICAgLSBFTkFCTEVfTEVUU0VOQ1JZUFQ9MAogICAgICAtIEVOQUJMRV9IVFRQX1JFRElSRUNUPTAKICAgICAgLSBESVNBQkxFX0hUVFBTPTEKICAgICAgLSBYTVBQX0RPTUFJTj1tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9BVVRIX0RPTUFJTj1hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0dVRVNUX0RPTUFJTj1ndWVzdC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9NVUNfRE9NQUlOPWNvbmZlcmVuY2UubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSAnWE1QUF9CT1NIX1VSTF9CQVNFPWh0dHA6Ly9wcm9zb2R5OjUyODAnCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSAnSklDT0ZPX0NPTVBPTkVOVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pJQ09GT19BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKVkJfQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSlZCfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcHJvc29keQogICAgICAtIGppY29mbwogICAgICAtIGp2YgogICAgdm9sdW1lczoKICAgICAgLSAnaml0c2ktd2ViOi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSBtZWV0LmppdHNpCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3QnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBwcm9zb2R5OgogICAgaW1hZ2U6ICdqaXRzaS9wcm9zb2R5OnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9GUUROX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwcm9zb2R5CiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1qdmI6L2NvbmZpZycKICAgIG5ldHdvcmtzOgogICAgICBtZWV0LmppdHNpOiBudWxsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hYm91dC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKbmV0d29ya3M6CiAgbWVldC5qaXRzaTogbnVsbAo=", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAzMC9hcGkvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICBjb21tYW5kOgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAiJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDNzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjYuMi40LjIzJwogICAgdXNlcjogJzEwMToxMDEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfbG9nczovdmFyL2xvZy9jbGlja2hvdXNlLXNlcnZlcicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3Q6ODEyMy9waW5nIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICBTSVRFX0FERFJFU1M6ICcke1NJVEVfQUREUkVTUzotOjgwfScKeC1tcS1lbnY6CiAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCngtbGl2ZS1lbnY6CiAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAp4LWFwcC1lbnY6CiAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgLSAnU0lURV9BRERSRVNTPTo4MCcKICAgICAgLSAnRklMRV9TSVpFX0xJTUlUPSR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgLSAnQlVDS0VUX05BTUU9JHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgdm9sdW1lczoKICAgICAgLSAncHJveHlfY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ3Byb3h5X2RhdGE6L2RhdGEnCiAgICBkZXBlbmRzX29uOgogICAgICAtIHdlYgogICAgICAtIGFwaQogICAgICAtIHNwYWNlCiAgICAgIC0gYWRtaW4KICAgICAgLSBsaXZlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICB3ZWI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXNwYWNlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBhZG1pbjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWFkbWluOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBsaXZlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWFwaS5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19hcGk6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICByZXN0YXJ0OiAnbm8nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1taWdyYXRvci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19taWdyYXRvcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9aHR0cHM6Ly8ke1NFUlZJQ0VfRlFETl9SQUxMTFl9JwogICAgICAtICdBTExPV0VEX0VNQUlMUz0ke0FMTE9XRURfRU1BSUxTfScKICAgICAgLSAnU1VQUE9SVF9FTUFJTD0ke1NVUFBPUlRfRU1BSUw6LXN1cHBvcnRAZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICAgIC0gJ1NNVFBfVExTX0VOQUJMRUQ9JHtTTVRQX1RMU19FTkFCTEVEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdTTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdTTVRQX1BXRD0ke1NNVFBfUFdEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", From 9408620d5f47836241a9b10516296c5311786832 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:35:32 +0200 Subject: [PATCH 06/30] fix(terminal): add WS heartbeat and fix proxy idle disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxies (Cloudflare, nginx) drop idle WebSocket connections before the application notices, leaving clients typing into dead sockets. - Add server-side ping/pong heartbeat (30s) in terminal-server.js; terminate unresponsive clients instead of letting connections go stale - Move client keepAlive interval start to the connect event so it restarts correctly after reconnects - Remove hidden-tab keepalive short-circuit — server pings now own liveness; suppressing client pings while hidden masked proxy drops - Fix clearAllTimers to use clearTimeout for one-shot timers - On visibility resume, probe with a 5s timeout instead of the default 35s so half-open sockets are detected quickly - Bump coolify-realtime to 1.0.14 across all compose files --- config/constants.php | 2 +- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- docker/coolify-realtime/terminal-server.js | 22 +++++++++++ other/nightly/docker-compose.prod.yml | 2 +- other/nightly/docker-compose.windows.yml | 2 +- resources/js/terminal.js | 38 +++++++++++++------ .../Feature/RealtimeTerminalPackagingTest.php | 25 ++++++++++++ 8 files changed, 79 insertions(+), 16 deletions(-) diff --git a/config/constants.php b/config/constants.php index 7504b6ba8..f2f6946fb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,7 +4,7 @@ 'coolify' => [ 'version' => '4.0.0', 'helper_version' => '1.0.13', - 'realtime_version' => '1.0.13', + 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 3ae77857f..470f4af1e 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -105,7 +105,12 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +const HEARTBEAT_INTERVAL_MS = 30000; + wss.on('connection', async (ws, req) => { + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); + const userId = generateUserId(); const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); @@ -167,6 +172,23 @@ wss.on('connection', async (ws, req) => { }); }); +const heartbeat = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + logTerminal('warn', 'Terminating WS due to missed protocol pong.'); + return ws.terminate(); + } + ws.isAlive = false; + try { + ws.ping(); + } catch (_) { + // ignore — close handler will follow + } + }); +}, HEARTBEAT_INTERVAL_MS); + +wss.on('close', () => clearInterval(heartbeat)); + const messageHandlers = { message: (session, data) => session.ptyProcess.write(data), resize: (session, { cols, rows }) => { diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/resources/js/terminal.js b/resources/js/terminal.js index aa5f37353..923d09d86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -75,8 +75,6 @@ export function initializeTerminalComponent() { focusWhenReady(); }); - this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); - this.$watch('terminalActive', (active) => { if (!active && this.keepAliveInterval) { clearInterval(this.keepAliveInterval); @@ -150,8 +148,11 @@ export function initializeTerminalComponent() { }, clearAllTimers() { - [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] - .forEach(timer => timer && clearInterval(timer)); + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + } + [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] + .forEach(timer => timer && clearTimeout(timer)); this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; @@ -282,6 +283,13 @@ export function initializeTerminalComponent() { this.pendingCommand = null; } + // (Re)start application-level keepalive on every successful connect. + // Server-side WebSocket protocol pings are the primary heartbeat; this + // adds a JSON-level ping in case the server-side is older or restarting. + if (!this.keepAliveInterval) { + this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); + } + // Start ping timeout monitoring this.resetPingTimeout(); @@ -494,11 +502,6 @@ export function initializeTerminalComponent() { }, keepAlive() { - // Skip keepalive when document is hidden to prevent unnecessary disconnects - if (!this.isDocumentVisible) { - return; - } - if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); } else if (this.connectionState === 'disconnected') { @@ -524,10 +527,23 @@ export function initializeTerminalComponent() { logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { - // Send immediate ping to verify connection is still alive + // Connection may be half-open after Cloudflare/proxy idle drop while hidden. + // Probe with a short timeout (5s) instead of the default 35s — force a + // reconnect quickly if no pong arrives so the user is not stuck typing + // into a dead socket. this.heartbeatMissed = 0; this.sendMessage({ ping: true }); - this.resetPingTimeout(); + if (this.pingTimeoutId) { + clearTimeout(this.pingTimeoutId); + } + this.pingTimeoutId = setTimeout(() => { + logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.'); + try { + this.socket.close(4000, 'Visibility-resume timeout'); + } catch (_) { + // ignore — close handler will run on its own + } + }, 5000); } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { // Was connected before but now disconnected - attempt reconnection this.reconnectAttempts = 0; diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index e8fa5ff76..8f4da3a62 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -32,3 +32,28 @@ ->toContain('if (!terminalDebugEnabled) {') ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); }); + +it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('ws.isAlive = true;') + ->toContain("ws.on('pong'") + ->toContain('ws.ping();') + ->toContain('ws.terminate();') + ->toContain('HEARTBEAT_INTERVAL_MS'); +}); + +it('removes the keepalive short-circuit that fired when the tab was hidden', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects'); +}); + +it('uses a fast probe timeout when the tab regains visibility', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("'Visibility-resume timeout'"); +}); From cabcd8f699dc3ac400edeb63f3a9ac0c0c2260c6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:26:31 +0200 Subject: [PATCH 07/30] fix(terminal): add idle timeout, reconnect replay, and scrollback preservation - Kill PTY and notify client after 30 min of inactivity (IDLE_TIMEOUT_MS) - Buffer client messages during async auth/IP fetch to prevent race-condition message loss on fast reconnects - Replay last sent command after transient reconnect so PTY respawns without user interaction - Preserve scrollback on disconnect/reconnect; write visible timestamp markers instead of wiping term state - Handle idle-timeout sentinel on client with user-facing error message --- docker/coolify-realtime/terminal-server.js | 81 +++++++++++++++---- resources/js/terminal.js | 49 +++++++++-- .../Feature/RealtimeTerminalPackagingTest.php | 47 +++++++++++ 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 470f4af1e..11695173b 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -106,13 +106,24 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const HEARTBEAT_INTERVAL_MS = 30000; +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; wss.on('connection', async (ws, req) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); const userId = generateUserId(); - const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; + ws.userId = userId; + const userSession = { + ws, + userId, + ptyProcess: null, + isActive: false, + authorizedIPs: [], + lastActivityAt: Date.now(), + authReady: false, + pendingMessages: [], + }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { userId, @@ -122,6 +133,26 @@ wss.on('connection', async (ws, req) => { hasLaravelSession: Boolean(laravelSession), }; + // Register socket handlers up front so messages sent immediately by the client + // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch + // below is still pending. + ws.on('message', (message) => { + if (userSession.authReady) { + handleMessage(userSession, message); + } else { + userSession.pendingMessages.push(message); + } + }); + ws.on('error', (err) => handleError(err, userId)); + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); + // Verify presence of required tokens if (!laravelSession || !xsrfToken) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); @@ -153,23 +184,17 @@ wss.on('connection', async (ws, req) => { } userSessions.set(userId, userSession); + userSession.authReady = true; logTerminal('log', 'Terminal websocket connection established.', { ...connectionContext, authorizedHostCount: userSession.authorizedIPs.length, + bufferedMessages: userSession.pendingMessages.length, }); - ws.on('message', (message) => { - handleMessage(userSession, message); - }); - ws.on('error', (err) => handleError(err, userId)); - ws.on('close', (code, reason) => { - logTerminal('log', 'Terminal websocket connection closed.', { - userId, - code, - reason: reason?.toString(), - }); - handleClose(userId); - }); + // Drain any messages that arrived while we were waiting on the IP auth call. + while (userSession.pendingMessages.length > 0) { + handleMessage(userSession, userSession.pendingMessages.shift()); + } }); const heartbeat = setInterval(() => { @@ -184,14 +209,41 @@ const heartbeat = setInterval(() => { } catch (_) { // ignore — close handler will follow } + + const session = ws.userId ? userSessions.get(ws.userId) : null; + if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { + const idleMs = Date.now() - session.lastActivityAt; + logTerminal('warn', 'Closing terminal session due to idle timeout.', { + userId: ws.userId, + idleMs, + idleTimeoutMs: IDLE_TIMEOUT_MS, + }); + try { + ws.send('idle-timeout'); + } catch (_) { + // ignore — close still attempted below + } + killPtyProcess(ws.userId); + setTimeout(() => { + try { + ws.close(1000, 'Idle timeout'); + } catch (_) { + // ignore — already closed + } + }, 100); + } }); }, HEARTBEAT_INTERVAL_MS); wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { - message: (session, data) => session.ptyProcess.write(data), + message: (session, data) => { + session.lastActivityAt = Date.now(); + session.ptyProcess.write(data); + }, resize: (session, { cols, rows }) => { + session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -323,6 +375,7 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 923d09d86..fe13c1b21 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -42,6 +42,10 @@ export function initializeTerminalComponent() { maxHeartbeatMisses: 3, // Command buffering for race condition prevention pendingCommand: null, + // Last successfully sent SSH command — replayed after a transient reconnect + // so the PTY respawns automatically. Cleared on intentional terminations + // (pty-exited, idle-timeout, unprocessable). + lastSentCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -162,9 +166,17 @@ export function initializeTerminalComponent() { resetTerminal() { if (this.term) { - this.$wire.dispatch('error', 'Terminal websocket connection lost.'); - this.term.reset(); - this.term.clear(); + this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...'); + // Preserve scrollback so the user keeps the context of their previous + // session. Print a visible marker so they know where the disconnect + // happened. Old PTY shell state cannot be restored — this is purely + // a visual carry-over. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`); + } catch (_) { + // ignore — terminal not ready to receive writes + } this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; @@ -277,10 +289,15 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } - // Flush any buffered command from before WebSocket was ready + // Flush any buffered command from before WebSocket was ready, otherwise + // replay the last command so a transient reconnect respawns the PTY + // automatically without requiring the user to click Connect again. if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; + } else if (this.lastSentCommand) { + logTerminal('log', '[Terminal] Replaying last command after reconnect.'); + this.sendMessage(this.lastSentCommand); } // (Re)start application-level keepalive on every successful connect. @@ -362,6 +379,9 @@ export function initializeTerminalComponent() { sendMessage(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); + if (message && message.command) { + this.lastSentCommand = message; + } } else { logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } @@ -395,7 +415,15 @@ export function initializeTerminalComponent() { this.term.open(document.getElementById('terminal')); this.term._initialized = true; } else { - this.term.reset(); + // Already initialized — this is a reconnect or a follow-up command. + // Preserve scrollback so the user keeps context. Write a visible + // separator so the new shell prompt is easy to spot. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`); + } catch (_) { + // ignore — fall through; xterm will render the new prompt anyway + } } this.terminalActive = true; this.term.focus(); @@ -423,6 +451,7 @@ export function initializeTerminalComponent() { } else if (event.data === 'unprocessable') { if (this.term) this.term.reset(); this.terminalActive = false; + this.lastSentCommand = null; this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed @@ -431,9 +460,19 @@ export function initializeTerminalComponent() { this.terminalActive = false; this.term.reset(); this.commandBuffer = ''; + this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if (event.data === 'idle-timeout') { + this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); + this.terminalActive = false; + if (this.term) { + this.term.reset(); + } + this.commandBuffer = ''; + this.lastSentCommand = null; + this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 8f4da3a62..ba01deca5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -57,3 +57,50 @@ expect($terminalClient) ->toContain("'Visibility-resume timeout'"); }); + +it('closes idle terminal sessions after 30 minutes on the server', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') + ->toContain('lastActivityAt') + ->toContain("ws.send('idle-timeout');") + ->toContain("ws.close(1000, 'Idle timeout');"); +}); + +it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("event.data === 'idle-timeout'") + ->toContain('Terminal closed after 30 minutes of inactivity.'); +}); + +it('replays the last command on reconnect so the PTY respawns automatically', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('lastSentCommand') + ->toContain('Replaying last command after reconnect.') + ->toContain('this.lastSentCommand = null;'); +}); + +it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('authReady: false') + ->toContain('pendingMessages: []') + ->toContain('userSession.pendingMessages.push(message)') + ->toContain('userSession.authReady = true'); +}); + +it('preserves terminal scrollback across transient reconnects', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('── Connection lost at') + ->toContain('── Reconnected at') + // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. + ->not->toContain("this.term.reset();\n this.term.clear();"); +}); From 1368026f20d88045601f6d1ad03b53e9aff7d2b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:29:32 +0200 Subject: [PATCH 08/30] fix(terminal): remove verbose websocket message logging --- docker/coolify-realtime/terminal-server.js | 6 ------ resources/js/terminal.js | 2 -- 2 files changed, 8 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 11695173b..f5760279f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -271,12 +271,6 @@ function handleMessage(userSession, message) { return; } - logTerminal('log', 'Received websocket message.', { - userId: userSession.userId, - keys: Object.keys(parsed), - isActive: userSession.isActive, - }); - Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index fe13c1b21..7a7fc8536 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -396,8 +396,6 @@ export function initializeTerminalComponent() { }, handleSocketMessage(event) { - logTerminal('log', '[Terminal] Received WebSocket message:', event.data); - // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; From 81510e5f6bfae1a844a52e4e2ae78ffbbc4601e5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:30 +0200 Subject: [PATCH 09/30] Update versions.json --- openapi.json | 12 ++++++------ openapi.yaml | 12 ++++++------ versions.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openapi.json b/openapi.json index d83b30d80..09232be2f 100644 --- a/openapi.json +++ b/openapi.json @@ -4381,8 +4381,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4393,8 +4393,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4951,7 +4951,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4963,7 +4963,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index aab408098..d6a8d7635 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/versions.json b/versions.json index 3307b7f2e..ef92cfe9e 100644 --- a/versions.json +++ b/versions.json @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.13" + "version": "1.0.14" }, "sentinel": { "version": "0.0.21" From a2096c6f680731247fd667550bde20df62a910b9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:37 +0200 Subject: [PATCH 10/30] feat(observability): add structured audit log channel for API and webhook events Introduce a dedicated `audit` log channel (daily rotation, configurable retention via LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to record state-changing API operations and webhook events. Instrumented: - API mutation endpoints (create / update / delete / start / stop / restart) across applications, services, databases (incl. backups, env vars, storage), servers, projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider tokens, Hetzner server provisioning, instance enable/disable. - Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and Stripe, plus the Sentinel push endpoint. - Authentication and authorization outcomes via the global exception handler and the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied). The helper is wrapped in try/catch so logging failures never affect the request path. Successful operations log at `info`; suspicious/denied requests log at `warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`. Includes a feature test suite covering the helper, the webhook providers and the new auth/authorization log paths. Co-Authored-By: Claude Opus 4.7 --- app/Exceptions/Handler.php | 22 +- .../Api/ApplicationsController.php | 170 +++++++ .../Api/CloudProviderTokensController.php | 30 +- .../Controllers/Api/DatabasesController.php | 197 ++++++++ app/Http/Controllers/Api/DeployController.php | 25 + app/Http/Controllers/Api/GithubController.php | 21 + .../Controllers/Api/HetznerController.php | 14 +- app/Http/Controllers/Api/OtherController.php | 8 + .../Controllers/Api/ProjectController.php | 37 ++ .../Api/ScheduledTasksController.php | 68 ++- .../Controllers/Api/SecurityController.php | 22 + .../Controllers/Api/ServersController.php | 37 +- .../Controllers/Api/ServicesController.php | 106 +++++ app/Http/Controllers/Webhook/Bitbucket.php | 27 ++ app/Http/Controllers/Webhook/Gitea.php | 21 + app/Http/Controllers/Webhook/Github.php | 39 ++ app/Http/Controllers/Webhook/Gitlab.php | 24 + app/Http/Controllers/Webhook/Stripe.php | 10 +- app/Http/Middleware/ApiAbility.php | 14 +- bootstrap/helpers/audit.php | 81 ++++ config/logging.php | 8 + openapi.json | 12 +- openapi.yaml | 12 +- routes/api.php | 30 ++ tests/Feature/Security/AuditLogTest.php | 445 ++++++++++++++++++ 25 files changed, 1438 insertions(+), 42 deletions(-) create mode 100644 bootstrap/helpers/audit.php create mode 100644 tests/Feature/Security/AuditLogTest.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 71de48bcd..58f21c793 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -4,8 +4,10 @@ use App\Models\InstanceSettings; use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Psr\Log\LogLevel; use RuntimeException; use Sentry\Laravel\Integration; use Sentry\State\Scope; @@ -16,7 +18,7 @@ class Handler extends ExceptionHandler /** * A list of exception types with their corresponding custom log levels. * - * @var array, \Psr\Log\LogLevel::*> + * @var array, LogLevel::*> */ protected $levels = [ // @@ -25,7 +27,7 @@ class Handler extends ExceptionHandler /** * A list of the exception types that are not reported. * - * @var array> + * @var array> */ protected $dontReport = [ ProcessException::class, @@ -49,6 +51,13 @@ class Handler extends ExceptionHandler protected function unauthenticated($request, AuthenticationException $exception) { if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) { + if ($request->is('api/*')) { + auditLog('api.auth.unauthenticated', [ + 'reason' => $exception->getMessage(), + 'guards' => $exception->guards(), + ], 'warning'); + } + return response()->json(['message' => $exception->getMessage()], 401); } @@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception) public function render($request, Throwable $e) { // Handle authorization exceptions for API routes - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { if ($request->is('api/*') || $request->expectsJson()) { + if ($request->is('api/*')) { + auditLog('api.auth.policy_denied', [ + 'reason' => $e->getMessage(), + 'route' => $request->route()?->getName() ?? $request->path(), + ], 'warning'); + } + // Get the custom message from the policy if available $message = $e->getMessage(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index eb2e7fc53..bb72ebabe 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1309,6 +1309,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1539,6 +1548,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1739,6 +1757,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1846,6 +1873,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1956,6 +1992,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type) StartService::dispatch($service); } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'service_uuid' => data_get($service, 'uuid'), + 'service_name' => data_get($service, 'name'), + 'application_type' => $type, + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($service, 'uuid'), 'domains' => data_get($service, 'domains'), @@ -2297,6 +2350,12 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.application.deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + return response()->json([ 'message' => 'Application deletion request queued.', ]); @@ -2796,6 +2855,13 @@ public function update_by_uuid(Request $request) } $application->save(); + auditLog('api.application.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + if ($instantDeploy) { $deployment_uuid = new Cuid2; @@ -3048,6 +3114,14 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } else { return response()->json([ @@ -3081,6 +3155,14 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } else { return response()->json([ @@ -3307,6 +3389,12 @@ public function create_bulk_envs(Request $request) $returnedEnvs->push($this->removeSensitiveData($env)); } + auditLog('api.application.env_bulk_upserted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_count' => $returnedEnvs->count(), + ]); + return response()->json($returnedEnvs)->setStatusCode(201); } @@ -3446,6 +3534,14 @@ public function create_env(Request $request) 'resourceable_id' => $application->id, ]); + auditLog('api.application.env_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json([ 'uuid' => $env->uuid, ])->setStatusCode(201); @@ -3471,6 +3567,14 @@ public function create_env(Request $request) 'resourceable_id' => $application->id, ]); + auditLog('api.application.env_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json([ 'uuid' => $env->uuid, ])->setStatusCode(201); @@ -3562,8 +3666,17 @@ public function delete_env_by_uuid(Request $request) 'message' => 'Environment variable not found.', ], 404); } + $envKey = $found_env->key; + $envUuid = $found_env->uuid; $found_env->forceDelete(); + auditLog('api.application.env_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json([ 'message' => 'Environment variable deleted.', ]); @@ -3675,6 +3788,15 @@ public function action_deploy(Request $request) ); } + auditLog('api.application.deployed', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'force_rebuild' => $force, + 'instant_deploy' => $instant_deploy, + ]); + return response()->json( [ 'message' => 'Deployment request queued.', @@ -3763,6 +3885,13 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopApplication::dispatch($application, false, $dockerCleanup); + auditLog('api.application.stopped', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Application stopping request queued.', @@ -3853,6 +3982,13 @@ public function action_restart(Request $request) ], 200); } + auditLog('api.application.restarted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + ]); + return response()->json( [ 'message' => 'Restart request queued.', @@ -4221,6 +4357,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.application.storage_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -4399,6 +4544,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.application.storage_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -4472,8 +4626,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.application.storage_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } @@ -4543,6 +4707,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons $preview->delete(); CleanupPreviewDeployment::run($application, $pullRequestId, $preview); + auditLog('api.application.preview_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $pullRequestId, + ]); + return response()->json(['message' => 'Preview deletion request queued.']); } } diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index 5be82a31c..d652f2ba1 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\CloudProviderToken; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -244,7 +245,7 @@ public function store(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -286,6 +287,13 @@ public function store(Request $request) 'name' => $body['name'], ]); + auditLog('api.cloud_token.created', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $cloudProviderToken->uuid, + 'cloud_token_name' => $cloudProviderToken->name, + 'provider' => $cloudProviderToken->provider, + ]); + return response()->json([ 'uuid' => $cloudProviderToken->uuid, ])->setStatusCode(201); @@ -355,7 +363,7 @@ public function update(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -389,6 +397,14 @@ public function update(Request $request) $token->update(array_intersect_key($body, array_flip($allowedFields))); + auditLog('api.cloud_token.updated', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))), + ]); + return response()->json([ 'uuid' => $token->uuid, ]); @@ -464,8 +480,18 @@ public function destroy(Request $request) return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); } + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $tokenProvider = $token->provider; $token->delete(); + auditLog('api.cloud_token.deleted', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $tokenUuid, + 'cloud_token_name' => $tokenName, + 'provider' => $tokenProvider, + ]); + return response()->json(['message' => 'Cloud provider token deleted.']); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a6056b9f3..dc9b6f5b5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -596,6 +596,14 @@ public function update_by_uuid(Request $request) StopDatabaseProxy::dispatch($database); } + auditLog('api.database.updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'message' => 'Database updated.', ]); @@ -826,6 +834,15 @@ public function create_backup(Request $request) dispatch(new DatabaseBackupJob($backupConfig)); } + auditLog('api.database.backup_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backupConfig->uuid, + 'frequency' => $backupConfig->frequency, + 'save_s3' => (bool) $backupConfig->save_s3, + 'backup_now' => (bool) $request->backup_now, + ]); + return response()->json([ 'uuid' => $backupConfig->uuid, 'message' => 'Backup configuration created successfully.', @@ -1045,6 +1062,14 @@ public function update_backup(Request $request) dispatch(new DatabaseBackupJob($backupConfig)); } + auditLog('api.database.backup_updated', [ + 'team_id' => $teamId, + 'backup_uuid' => $backupConfig->uuid, + 'database_id' => $backupConfig->database_id, + 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))), + 'backup_now' => (bool) $request->backup_now, + ]); + return response()->json([ 'message' => 'Database backup configuration updated', ]); @@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; @@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; @@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; @@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; @@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; @@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; @@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } @@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.database.deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json([ 'message' => 'Database deletion request queued.', ]); @@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request) $backup->delete(); DB::commit(); + auditLog('api.database.backup_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $request->scheduled_backup_uuid, + 'delete_s3' => $deleteS3, + 'executions_deleted' => $executions->count(), + ]); + return response()->json([ 'message' => 'Backup configuration and all executions deleted.', ]); @@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request) $execution->delete(); + auditLog('api.database.backup_execution_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $request->scheduled_backup_uuid, + 'execution_uuid' => $request->execution_uuid, + 'delete_s3' => $deleteS3, + ]); + return response()->json([ 'message' => 'Backup execution deleted.', ]); @@ -2633,6 +2751,13 @@ public function action_deploy(Request $request) } StartDatabase::dispatch($database); + auditLog('api.database.started', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json( [ 'message' => 'Database starting request queued.', @@ -2724,6 +2849,14 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopDatabase::dispatch($database, $dockerCleanup); + auditLog('api.database.stopped', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Database stopping request queued.', @@ -2801,6 +2934,13 @@ public function action_restart(Request $request) RestartDatabase::dispatch($database); + auditLog('api.database.restarted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json( [ 'message' => 'Database restarting request queued.', @@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.database.env_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } @@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request) $updatedEnvs->push($this->removeSensitiveEnvData($env)); } + auditLog('api.database.env_bulk_upserted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_count' => $updatedEnvs->count(), + ]); + return response()->json($updatedEnvs)->setStatusCode(201); } @@ -3266,6 +3419,13 @@ public function create_env(Request $request) 'comment' => $request->comment ?? null, ]); + auditLog('api.database.env_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } @@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } + $envKey = $env->key; + $envUuid = $env->uuid; $env->forceDelete(); + auditLog('api.database.env_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json(['message' => 'Environment variable deleted.']); } @@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.database.storage_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.database.storage_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.database.storage_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 6ff06c10a..c93731d68 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -281,6 +281,14 @@ public function cancel_deployment(Request $request) } } + auditLog('api.deployment.cancelled', [ + 'team_id' => $teamId, + 'deployment_uuid' => $deployment->deployment_uuid, + 'application_id' => $application?->id, + 'application_uuid' => $application?->uuid, + 'server_id' => $deployment->server_id, + ]); + return response()->json([ 'message' => 'Deployment cancelled successfully.', 'deployment_uuid' => $deployment->deployment_uuid, @@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st $message = $result['message']; } else { $message = "Application {$resource->name} deployment queued."; + auditLog('api.deployment.triggered', [ + 'resource_type' => 'application', + 'application_uuid' => $resource->uuid, + 'application_name' => $resource->name, + 'deployment_uuid' => $deployment_uuid?->toString(), + 'force_rebuild' => $force, + 'pull_request_id' => $pr, + ]); } break; case Service::class: @@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st } StartService::run($resource); $message = "Service {$resource->name} started. It could take a while, be patient."; + auditLog('api.service.deployed', [ + 'service_uuid' => $resource->uuid, + 'service_name' => $resource->name, + ]); break; default: // Database resource - check authorization @@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st $resource->save(); $message = "Database {$resource->name} started."; + auditLog('api.database.started', [ + 'database_uuid' => $resource->uuid, + 'database_name' => $resource->name, + 'database_type' => $resource->getMorphClass(), + ]); break; } diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 9a2cf2b9f..651969b97 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -271,6 +271,12 @@ public function create_github_app(Request $request) $githubApp = GithubApp::create($payload); + auditLog('api.github_app.created', [ + 'team_id' => $teamId, + 'github_app_uuid' => $githubApp->uuid, + 'github_app_name' => $githubApp->name, + ]); + return response()->json($githubApp, 201); } catch (\Throwable $e) { return handleError($e); @@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id) // Update the GitHub app $githubApp->update($payload); + auditLog('api.github_app.updated', [ + 'team_id' => $teamId, + 'github_app_uuid' => $githubApp->uuid, + 'github_app_name' => $githubApp->name, + 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])), + ]); + return response()->json([ 'message' => 'GitHub app updated successfully', 'data' => $githubApp, @@ -734,8 +747,16 @@ public function delete_github_app($github_app_id) ], 409); } + $deletedUuid = $githubApp->uuid; + $deletedName = $githubApp->name; $githubApp->delete(); + auditLog('api.github_app.deleted', [ + 'team_id' => $teamId, + 'github_app_uuid' => $deletedUuid, + 'github_app_name' => $deletedName, + ]); + return response()->json([ 'message' => 'GitHub app deleted successfully', ]); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 092c48594..2f35ba576 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; use App\Http\Controllers\Controller; @@ -12,6 +13,7 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -550,7 +552,7 @@ public function createServer(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -717,9 +719,17 @@ public function createServer(Request $request) // Validate server if requested if ($request->instant_validate) { - \App\Actions\Server\ValidateServer::dispatch($server); + ValidateServer::dispatch($server); } + auditLog('api.hetzner_server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'hetzner_server_id' => $hetznerServer['id'], + 'ip' => $ipAddress, + ]); + return response()->json([ 'uuid' => $server->uuid, 'hetzner_server_id' => $hetznerServer['id'], diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 49468b597..5ac274f93 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -85,11 +85,15 @@ public function enable_api(Request $request) return invalidTokenResponse(); } if ($teamId !== '0') { + auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning'); + return response()->json(['message' => 'You are not allowed to enable the API.'], 403); } $settings = instanceSettings(); $settings->update(['is_api_enabled' => true]); + auditLog('api.instance.enabled', ['team_id' => $teamId]); + return response()->json(['message' => 'API enabled.'], 200); } @@ -137,11 +141,15 @@ public function disable_api(Request $request) return invalidTokenResponse(); } if ($teamId !== '0') { + auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning'); + return response()->json(['message' => 'You are not allowed to disable the API.'], 403); } $settings = instanceSettings(); $settings->update(['is_api_enabled' => false]); + auditLog('api.instance.disabled', ['team_id' => $teamId]); + return response()->json(['message' => 'API disabled.'], 200); } diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index ec2e300ff..0e5f6e93b 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -264,6 +264,12 @@ public function create_project(Request $request) 'team_id' => $teamId, ]); + auditLog('api.project.created', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + ]); + return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -382,6 +388,13 @@ public function update_project(Request $request) $project->update($request->only($allowedFields)); + auditLog('api.project.updated', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -460,8 +473,16 @@ public function delete_project(Request $request) return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } + $projectUuid = $project->uuid; + $projectName = $project->name; $project->delete(); + auditLog('api.project.deleted', [ + 'team_id' => $teamId, + 'project_uuid' => $projectUuid, + 'project_name' => $projectName, + ]); + return response()->json(['message' => 'Project deleted.']); } @@ -641,6 +662,13 @@ public function create_environment(Request $request) 'name' => $request->name, ]); + auditLog('api.project.environment_created', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'environment_name' => $environment->name, + ]); + return response()->json([ 'uuid' => $environment->uuid, ])->setStatusCode(201); @@ -723,8 +751,17 @@ public function delete_environment(Request $request) return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); } + $envUuid = $environment->uuid; + $envName = $environment->name; $environment->delete(); + auditLog('api.project.environment_deleted', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $envUuid, + 'environment_name' => $envName, + ]); + return response()->json(['message' => 'Environment deleted.']); } } diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 6245dc2ec..d7b109918 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -6,6 +6,7 @@ use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -33,7 +34,7 @@ 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 + private function listTasks(Application|Service $resource): JsonResponse { $this->authorize('view', $resource); @@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json return response()->json($tasks); } - private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function createTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I $task->save(); + auditLog('api.scheduled_task.created', [ + 'team_id' => $teamId, + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + return response()->json($this->removeSensitiveData($task), 201); } - private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function updateTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I $task->update($request->only($allowedFields)); + auditLog('api.scheduled_task.updated', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json($this->removeSensitiveData($task), 200); } - private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function deleteTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); - $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); - if (! $deleted) { + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { return response()->json(['message' => 'Scheduled task not found.'], 404); } + $taskUuid = $task->uuid; + $taskName = $task->name; + $task->delete(); + + auditLog('api.scheduled_task.deleted', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $taskUuid, + 'task_name' => $taskName, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + return response()->json(['message' => 'Scheduled task deleted.']); } - private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function getExecutions(Request $request, Application|Service $resource): JsonResponse { $this->authorize('view', $resource); @@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource): ), ] )] - public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina ), ] )] - public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function executions_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht ), ] )] - public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H ), ] )] - public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function executions_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 2c62928c2..e59c40866 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -232,6 +232,13 @@ public function create_key(Request $request) 'private_key' => $request->private_key, ]); + auditLog('api.private_key.created', [ + 'team_id' => $teamId, + 'private_key_uuid' => $key->uuid, + 'private_key_name' => $key->name, + 'fingerprint' => $fingerPrint, + ]); + return response()->json(serializeApiResponse([ 'uuid' => $key->uuid, ]))->setStatusCode(201); @@ -333,6 +340,13 @@ public function update_key(Request $request) } $foundKey->update($request->only($allowedFields)); + auditLog('api.private_key.updated', [ + 'team_id' => $teamId, + 'private_key_uuid' => $foundKey->uuid, + 'private_key_name' => $foundKey->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json(serializeApiResponse([ 'uuid' => $foundKey->uuid, ]))->setStatusCode(201); @@ -415,8 +429,16 @@ public function delete_key(Request $request) ], 422); } + $keyUuid = $key->uuid; + $keyName = $key->name; $key->forceDelete(); + auditLog('api.private_key.deleted', [ + 'team_id' => $teamId, + 'private_key_uuid' => $keyUuid, + 'private_key_name' => $keyName, + ]); + return response()->json([ 'message' => 'Private Key deleted.', ]); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index c13c6665c..b33e85c78 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -13,6 +13,7 @@ use App\Models\Project; use App\Models\Server as ModelsServer; use App\Rules\ValidServerIp; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Stringable; @@ -477,7 +478,7 @@ public function create_server(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -564,6 +565,14 @@ public function create_server(Request $request) ValidateServer::dispatch($server); } + auditLog('api.server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'ip' => $server->ip, + 'is_build_server' => (bool) $request->is_build_server, + ]); + return response()->json([ 'uuid' => $server->uuid, ])->setStatusCode(201); @@ -647,7 +656,7 @@ public function update_server(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -718,6 +727,13 @@ public function update_server(Request $request) ValidateServer::dispatch($server); } + auditLog('api.server.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $server->uuid, ])->setStatusCode(201); @@ -807,6 +823,9 @@ public function delete_server(Request $request) } } + $deletedUuid = $server->uuid; + $deletedName = $server->name; + $deletedIp = $server->ip; $server->delete(); DeleteServer::dispatch( $server->id, @@ -816,6 +835,14 @@ public function delete_server(Request $request) $server->team_id ); + auditLog('api.server.deleted', [ + 'team_id' => $teamId, + 'server_uuid' => $deletedUuid, + 'server_name' => $deletedName, + 'ip' => $deletedIp, + 'force' => $force, + ]); + return response()->json(['message' => 'Server deleted.']); } @@ -881,6 +908,12 @@ public function validate_server(Request $request) } ValidateServer::dispatch($server); + auditLog('api.server.validated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + return response()->json(['message' => 'Validation started.'], 201); } } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 20560635e..11a23d46c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -486,6 +486,14 @@ public function create_service(Request $request) StartService::dispatch($service); } + auditLog('api.service.created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'service_type' => $oneClickServiceName ?? null, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -650,6 +658,14 @@ public function create_service(Request $request) StartService::dispatch($service); } + auditLog('api.service.created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'service_type' => 'docker_compose', + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.service.deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + ]); + return response()->json([ 'message' => 'Service deletion request queued.', ]); @@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request) StartService::dispatch($service); } + auditLog('api.service.updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.service.env_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request) $updatedEnvs->push($this->removeSensitiveData($env)); } + auditLog('api.service.env_bulk_upserted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_count' => $updatedEnvs->count(), + ]); + return response()->json($updatedEnvs)->setStatusCode(201); } @@ -1506,6 +1548,13 @@ public function create_env(Request $request) 'comment' => $request->comment ?? null, ]); + auditLog('api.service.env_created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } + $envKey = $env->key; + $envUuid = $env->uuid; $env->forceDelete(); + auditLog('api.service.env_deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json(['message' => 'Environment variable deleted.']); } @@ -1668,6 +1726,12 @@ public function action_deploy(Request $request) } StartService::dispatch($service); + auditLog('api.service.deployed', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + ]); + return response()->json( [ 'message' => 'Service starting request queued.', @@ -1759,6 +1823,13 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopService::dispatch($service, false, $dockerCleanup); + auditLog('api.service.stopped', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Service stopping request queued.', @@ -1846,6 +1917,13 @@ public function action_restart(Request $request) $pullLatest = $request->boolean('latest'); RestartService::dispatch($service, $pullLatest); + auditLog('api.service.restarted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'pull_latest' => $pullLatest, + ]); + return response()->json( [ 'message' => 'Service restarting request queued.', @@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.service.storage_created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.service.storage_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.service.storage_deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ffa71b55a..eda2014a6 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -58,6 +58,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); if (empty($webhook_secret)) { + auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -70,6 +76,12 @@ public function manual(Request $request) $parts = explode('=', $x_bitbucket_token, 2); if (count($parts) !== 2 || $parts[0] !== 'sha256') { + auditLogWebhookFailure('bitbucket', 'malformed_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -81,6 +93,12 @@ public function manual(Request $request) $hash = $parts[1]; $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { + auditLogWebhookFailure('bitbucket', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -118,6 +136,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'bitbucket', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => $commit, + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 62adf5410..c6297905e 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -68,6 +68,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); if (empty($webhook_secret)) { + auditLogWebhookFailure('gitea', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitea_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -78,6 +84,12 @@ public function manual(Request $request) } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + auditLogWebhookFailure('gitea', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitea_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -117,6 +129,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitea', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'status' => 'success', 'message' => 'Deployment queued.', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 4158016d0..7489b433f 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -82,6 +82,12 @@ public function manual(Request $request) foreach ($serverApplications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_github'); if (empty($webhook_secret)) { + auditLogWebhookFailure('github', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'mode' => 'manual', + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -92,6 +98,12 @@ public function manual(Request $request) } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + auditLogWebhookFailure('github', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'mode' => 'manual', + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -131,6 +143,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'github', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', @@ -224,6 +245,13 @@ public function normal(Request $request) $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (config('app.env') !== 'local') { if (! hash_equals($x_hub_signature_256, $hmac)) { + auditLogWebhookFailure('github', 'invalid_signature', [ + 'mode' => 'app', + 'github_app_id' => $github_app->id, + 'github_app_name' => $github_app->name, + 'installation_target_id' => $x_github_hook_installation_target_id, + ]); + return response('Invalid signature.'); } } @@ -311,6 +339,17 @@ public function normal(Request $request) if ($result['status'] === 'queue_full') { return response($result['message'], 429)->header('Retry-After', 60); } + if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) { + auditLog('webhook.deployment.queued', [ + 'provider' => 'github', + 'mode' => 'app', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + 'commit' => data_get($payload, 'after'), + 'github_app_id' => $github_app->id, + ]); + } $return_payloads->push([ 'status' => $result['status'], 'message' => $result['message'], diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 4453a0e7a..99ee4e477 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -32,6 +32,9 @@ public function manual(Request $request) } if (empty($x_gitlab_token)) { + auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'status' => 'failed', 'message' => 'Invalid signature.', @@ -101,6 +104,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); if (empty($webhook_secret)) { + auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -110,6 +119,12 @@ public function manual(Request $request) continue; } if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) { + auditLogWebhookFailure('gitlab', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -150,6 +165,15 @@ public function manual(Request $request) 'application_name' => $application->name, ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitlab', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'status' => 'success', 'message' => 'Deployment queued.', diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index d59adf0ca..41e70b2ce 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,6 +6,8 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; +use Stripe\Exception\SignatureVerificationException; +use Stripe\Webhook; class Stripe extends Controller { @@ -14,7 +16,7 @@ public function events(Request $request) try { $webhookSecret = config('subscription.stripe_webhook_secret'); $signature = $request->header('Stripe-Signature'); - $event = \Stripe\Webhook::constructEvent( + $event = Webhook::constructEvent( $request->getContent(), $signature, $webhookSecret @@ -22,6 +24,12 @@ public function events(Request $request) StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); + } catch (SignatureVerificationException $e) { + auditLogWebhookFailure('stripe', 'invalid_signature', [ + 'error' => $e->getMessage(), + ]); + + return response($e->getMessage(), 400); } catch (Exception $e) { return response($e->getMessage(), 400); } diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 324eeebaa..f81c7d184 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use Illuminate\Auth\AuthenticationException; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class ApiAbility extends CheckForAnyAbility @@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities) } return parent::handle($request, $next, ...$abilities); - } catch (\Illuminate\Auth\AuthenticationException $e) { + } catch (AuthenticationException $e) { + auditLog('api.auth.unauthenticated', [ + 'reason' => $e->getMessage(), + 'required_abilities' => $abilities, + ], 'warning'); + return response()->json([ 'message' => 'Unauthenticated.', ], 401); } catch (\Exception $e) { + auditLog('api.auth.ability_denied', [ + 'required_abilities' => $abilities, + 'token_id' => $request->user()?->currentAccessToken()?->id, + 'reason' => $e->getMessage(), + ], 'warning'); + return response()->json([ 'message' => 'Missing required permissions: '.implode(', ', $abilities), ], 403); diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php new file mode 100644 index 000000000..8477450c4 --- /dev/null +++ b/bootstrap/helpers/audit.php @@ -0,0 +1,81 @@ + $context Identifiers + outcome details. + * @param string $level Log level: info | warning | error. + */ + function auditLog(string $event, array $context = [], string $level = 'info'): void + { + try { + $request = app()->bound('request') ? request() : null; + $user = auth()->check() ? auth()->user() : null; + $token = $user?->currentAccessToken(); + + $base = [ + 'event' => $event, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'team_id' => $token ? data_get($token, 'team_id') : null, + 'token_id' => $token?->id ?? null, + 'token_name' => $token?->name ?? null, + 'method' => $request?->method(), + 'path' => $request?->path(), + ]; + + $payload = array_merge($base, $context); + + Log::channel('audit')->{$level}($event, $payload); + } catch (Throwable $e) { + // Audit logging must never break the request path. + try { + Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); + } catch (Throwable) { + } + } + } +} + +if (! function_exists('auditLogWebhookFailure')) { + /** + * Record a webhook signature/auth verification failure to the `audit` channel. + */ + function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void + { + try { + $request = app()->bound('request') ? request() : null; + + $event = "webhook.{$provider}.signature_failed"; + + $base = [ + 'event' => $event, + 'reason' => $reason, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'method' => $request?->method(), + 'path' => $request?->path(), + 'event_header' => $request?->header('X-GitHub-Event') + ?? $request?->header('X-Gitlab-Event') + ?? $request?->header('X-Gitea-Event') + ?? $request?->header('X-Event-Key'), + ]; + + Log::channel('audit')->warning($event, array_merge($base, $context)); + } catch (Throwable $e) { + try { + Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); + } catch (Throwable) { + } + } + } +} diff --git a/config/logging.php b/config/logging.php index 1dbb1135f..05cf8e13d 100644 --- a/config/logging.php +++ b/config/logging.php @@ -132,6 +132,14 @@ 'level' => 'warning', 'days' => 14, ], + + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => env('LOG_AUDIT_LEVEL', 'info'), + 'days' => env('LOG_AUDIT_DAYS', 90), + 'replace_placeholders' => true, + ], ], ]; diff --git a/openapi.json b/openapi.json index d83b30d80..09232be2f 100644 --- a/openapi.json +++ b/openapi.json @@ -4381,8 +4381,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4393,8 +4393,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4951,7 +4951,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4963,7 +4963,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index aab408098..d6a8d7635 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/routes/api.php b/routes/api.php index 7394d4e16..c944a6ebc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -215,6 +215,8 @@ Route::post('/sentinel/push', function () { $token = request()->header('Authorization'); if (! $token) { + auditLogWebhookFailure('sentinel', 'token_missing'); + return response()->json(['message' => 'Unauthorized'], 401); } $naked_token = str_replace('Bearer ', '', $token); @@ -222,26 +224,49 @@ $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); } catch (Exception $e) { + auditLogWebhookFailure('sentinel', 'decrypt_failed'); + return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); if (! $server_uuid) { + auditLogWebhookFailure('sentinel', 'invalid_token_payload'); + return response()->json(['message' => 'Invalid token'], 401); } $server = Server::where('uuid', $server_uuid)->first(); if (! $server) { + auditLogWebhookFailure('sentinel', 'server_not_found', [ + 'server_uuid' => $server_uuid, + ]); + return response()->json(['message' => 'Server not found'], 404); } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } if ($server->isFunctional() === false) { + auditLogWebhookFailure('sentinel', 'server_not_functional', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Server is not functional'], 401); } if ($server->settings->sentinel_token !== $naked_token) { + auditLogWebhookFailure('sentinel', 'token_mismatch', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } $data = request()->all(); @@ -249,6 +274,11 @@ // \App\Jobs\ServerCheckNewJob::dispatch($server, $data); PushServerUpdateJob::dispatch($server, $data); + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'ok'], 200); }); }); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php new file mode 100644 index 000000000..34e9168ec --- /dev/null +++ b/tests/Feature/Security/AuditLogTest.php @@ -0,0 +1,445 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + test()->actingAs($user); + + return [$team, $user]; +} + +function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string +{ + $token = $user->createToken('audit-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + return $token->plainTextToken; +} + +function makeAuditApplication(string $repo = 'test-org/test-repo'): Application +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'audit-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +describe('audit channel helper', function () { + test('auditLog writes structured payload to audit channel', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('test.event', Mockery::on(function ($context) { + return $context['event'] === 'test.event' + && $context['custom_field'] === 'value' + && array_key_exists('ip', $context) + && array_key_exists('user_id', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.event', ['custom_field' => 'value']); + }); + + test('auditLog warning level routes correctly', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.failed', [], 'warning'); + }); + + test('auditLogWebhookFailure logs warning with provider tag', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->once() + ->with('webhook.github.signature_failed', Mockery::on(function ($context) { + return $context['reason'] === 'invalid_signature' + && $context['event'] === 'webhook.github.signature_failed' + && array_key_exists('ip', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']); + }); + + test('auditLog never includes raw secret keys in context', function () { + $captured = null; + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) { + $captured = $context; + + return true; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.private_key.created', [ + 'team_id' => '1', + 'private_key_uuid' => 'abc', + 'fingerprint' => 'SHA256:xyz', + ]); + + expect($captured)->toBeArray(); + // Helper itself never injects secret-bearing keys. + $disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret']; + foreach (array_keys($captured) as $key) { + expect(in_array(strtolower($key), $disallowed, true))->toBeFalse(); + } + }); +}); + +describe('webhook signature failure logging', function () { + test('GitHub manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.github.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('GitLab manual webhook with bad token logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitlab.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Bitbucket manual webhook with malformed signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.bitbucket.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1=anyvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Gitea manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitea.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); +}); + +describe('API mutation audit logging', function () { + test('private key creation emits api.private_key.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.private_key.created', Mockery::on(function ($context) { + return $context['event'] === 'api.private_key.created' + && ! array_key_exists('private_key', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + // Generate a valid OpenSSH-format private key for the test. + $opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n". + base64_encode(str_repeat('a', 256)). + "\n-----END OPENSSH PRIVATE KEY-----"; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/security/keys', [ + 'name' => 'test-key', + 'description' => 'audit test', + 'private_key' => $opensshKey, + ]); + + // Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201. + expect($response->status())->toBeIn([201, 422]); + }); + + test('enable_api denial for non-root team emits warning audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.instance.enable_denied', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/enable'); + + $response->assertStatus(403); + }); + + test('project creation emits api.project.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.project.created', Mockery::on(function ($context) { + return $context['event'] === 'api.project.created' + && ! empty($context['project_uuid']) + && $context['project_name'] === 'audit-project'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'audit-project', + 'description' => 'audit', + ]); + + $response->assertStatus(201); + }); +}); + +describe('threat-detection audit logging (Phase 2)', function () { + test('missing bearer token logs api.auth.unauthenticated', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('expired bearer token logs api.auth.unauthenticated', function () { + [$team, $user] = makeAuditTeamUser(); + $token = $user->createToken('expired-audit', ['read'], now()->subDay()); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('read-only token hitting write endpoint logs api.auth.ability_denied', function () { + [$team, $user] = makeAuditTeamUser(); + $readToken = makeAuditApiToken($user, $team, ['read']); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.ability_denied', Mockery::on(function ($ctx) { + return in_array('write', $ctx['required_abilities'] ?? [], true); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'should-fail', + ]); + + $response->assertStatus(403); + }); + + test('sentinel push without Authorization logs token_missing', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'token_missing'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); + + test('sentinel push with un-decryptable bearer logs decrypt_failed', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'decrypt_failed'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer not-a-valid-encrypted-payload', + ])->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); +}); From cf13d4017888099ae969f5ee64ec662633f6118f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:27:50 +0200 Subject: [PATCH 11/30] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/constants.php b/config/constants.php index f2f6946fb..8ebf27eed 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0', + 'version' => '4.0.1', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 3307b7f2e..fe7f6418d 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "nightly": { "version": "4.0.0" @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.13" + "version": "1.0.14" }, "sentinel": { "version": "0.0.21" diff --git a/versions.json b/versions.json index ef92cfe9e..fe7f6418d 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "nightly": { "version": "4.0.0" From b8226be774eb2fc5cc735d11bd85e250347a7ac4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:28:22 +0200 Subject: [PATCH 12/30] refactor(server): dispatch event for reachability notifications, drop retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move reachability notification triggering out of isReachableChanged into a dedicated ServerReachabilityChanged event dispatched by ServerConnectionCheckJob. Remove the blocking 3-attempt sleep loop from isReachableChanged — unreachable_count threshold alone now gates the Unreachable notification. Add feature and unit tests covering all notification dispatch paths. --- app/Jobs/ServerConnectionCheckJob.php | 34 ++++++ app/Models/Server.php | 26 +---- .../ServerReachabilityNotificationTest.php | 105 ++++++++++++++++++ tests/Unit/IsReachableChangedTest.php | 61 ++++++++++ tests/Unit/ServerBackoffTest.php | 51 +++++++++ 5 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 tests/Feature/ServerReachabilityNotificationTest.php create mode 100644 tests/Unit/IsReachableChangedTest.php diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 7ce316dcd..98ad60fff 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ServerReachabilityChanged; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ConfigurationRepository; @@ -43,6 +44,9 @@ private function disableSshMux(): void public function handle() { + $wasReachable = (bool) $this->server->settings->is_reachable; + $wasNotified = (bool) $this->server->unreachable_notification_sent; + try { // Check if server is disabled if ($this->server->settings->force_disabled) { @@ -84,6 +88,8 @@ public function handle() 'server_ip' => $this->server->ip, ]); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + return; } @@ -99,6 +105,8 @@ public function handle() $this->server->update(['unreachable_count' => 0]); } + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true); + } catch (\Throwable $e) { Log::error('ServerConnectionCheckJob failed', [ @@ -111,6 +119,8 @@ public function handle() ]); $this->server->increment('unreachable_count'); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + return; } } @@ -118,17 +128,41 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof TimeoutExceededException) { + $wasReachable = (bool) $this->server->settings->is_reachable; + $wasNotified = (bool) $this->server->unreachable_notification_sent; + $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, ]); $this->server->increment('unreachable_count'); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); } } + /** + * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2) + * or when a previously-notified server recovers. Skips noise from single transient flaps. + */ + private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void + { + if ($isReachable) { + if (! $wasReachable || $wasNotified) { + ServerReachabilityChanged::dispatch($this->server); + } + + return; + } + + if ($this->server->unreachable_count >= 2 && ! $wasNotified) { + ServerReachabilityChanged::dispatch($this->server); + } + } + private function checkHetznerStatus(): void { $status = null; diff --git a/app/Models/Server.php b/app/Models/Server.php index 06426f211..74e8ba5b0 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1236,10 +1236,8 @@ public function isReachableChanged() $this->refresh(); $unreachableNotificationSent = (bool) $this->unreachable_notification_sent; $isReachable = (bool) $this->settings->is_reachable; - if ($isReachable === true) { - $this->unreachable_count = 0; - $this->save(); + if ($isReachable === true) { if ($unreachableNotificationSent === true) { $this->sendReachableNotification(); } @@ -1247,28 +1245,8 @@ public function isReachableChanged() return; } - $this->increment('unreachable_count'); - - if ($this->unreachable_count === 1) { - $this->settings->is_reachable = true; - $this->settings->save(); - - return; - } - if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) { - $failedChecks = 0; - for ($i = 0; $i < 3; $i++) { - $status = $this->serverStatus(); - sleep(5); - if (! $status) { - $failedChecks++; - } - } - - if ($failedChecks === 3 && ! $unreachableNotificationSent) { - $this->sendUnreachableNotification(); - } + $this->sendUnreachableNotification(); } } diff --git a/tests/Feature/ServerReachabilityNotificationTest.php b/tests/Feature/ServerReachabilityNotificationTest.php new file mode 100644 index 000000000..e996ba028 --- /dev/null +++ b/tests/Feature/ServerReachabilityNotificationTest.php @@ -0,0 +1,105 @@ +team = Team::factory()->create(); + $this->team->emailNotificationSettings()->update([ + 'use_instance_email_settings' => true, + 'server_unreachable_email_notifications' => true, + 'server_reachable_email_notifications' => true, + ]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + Notification::fake(); +}); + +it('sends Unreachable notification when threshold reached and not yet notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue(); +}); + +it('does not send Unreachable on first transient failure (count=1)', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 1, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('does not send Unreachable when already notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 5, + 'unreachable_notification_sent' => true, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('sends Reachable notification on recovery when previously notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => true, + ])->save(); + + $fresh = $this->server->fresh(); + expect($fresh->unreachable_notification_sent)->toBeTrue(); + expect((bool) $fresh->settings->is_reachable)->toBeTrue(); + + ServerReachabilityChanged::dispatch($fresh); + + Notification::assertSentTo($this->team, Reachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse(); +}); + +it('does not send Reachable when never notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('routes Unreachable notification through EmailChannel when email toggle is on', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) { + return in_array(EmailChannel::class, $channels); + }); +}); diff --git a/tests/Unit/IsReachableChangedTest.php b/tests/Unit/IsReachableChangedTest.php new file mode 100644 index 000000000..76f9863bf --- /dev/null +++ b/tests/Unit/IsReachableChangedTest.php @@ -0,0 +1,61 @@ +is_reachable = $isReachable; + + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('refresh')->andReturnSelf(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + + return $server; +} + +it('sends Reachable notification when reachable and notification was previously sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0); + $server->shouldReceive('sendReachableNotification')->once(); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send any notification when reachable and notification was never sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0); + $server->shouldNotReceive('sendReachableNotification'); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('sends Unreachable notification when count >= 2 and not yet notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2); + $server->shouldReceive('sendUnreachableNotification')->once(); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send Unreachable notification on first transient failure (count=1)', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not double-send Unreachable when already notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php index bdcefb74f..9f1f747d4 100644 --- a/tests/Unit/ServerBackoffTest.php +++ b/tests/Unit/ServerBackoffTest.php @@ -1,11 +1,13 @@ is_reachable = true; $settings->shouldReceive('update') ->with(['is_reachable' => false, 'is_usable' => false]) ->once(); $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false); $server->shouldReceive('increment')->with('unreachable_count')->once(); $server->id = 1; $server->name = 'test-server'; + $server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is $job = new ServerConnectionCheckJob($server); $job->failed(new TimeoutExceededException); @@ -152,6 +159,50 @@ }); }); +describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () { + // ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that + // call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy + // through the event dispatcher (which trips Eloquent static method lookups on the proxy class). + $invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) { + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + if ($expectDispatch) { + $server->shouldReceive('isReachableChanged')->once()->andReturnNull(); + } else { + $server->shouldNotReceive('isReachableChanged'); + } + + $job = new ServerConnectionCheckJob($server); + $method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded'); + $method->invoke($job, $wasReachable, $wasNotified, $isReachable); + }; + + it('dispatches event when count crosses unreachable threshold', function () use ($invoke) { + $invoke(true, false, false, 2, true); + }); + + it('does not dispatch on first transient failure (count=1)', function () use ($invoke) { + $invoke(true, false, false, 1, false); + }); + + it('does not dispatch when already notified and still unreachable', function () use ($invoke) { + $invoke(false, true, false, 5, false); + }); + + it('dispatches recovery event when previously unreachable', function () use ($invoke) { + $invoke(false, false, true, 0, true); + }); + + it('dispatches recovery event when previously notified', function () use ($invoke) { + $invoke(true, true, true, 0, true); + }); + + it('does not dispatch when consistently reachable and never notified', function () use ($invoke) { + $invoke(true, false, true, 0, false); + }); +}); + describe('ServerCheckJob unreachable_count', function () { it('increments unreachable_count on timeout', function () { $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); From 6293b14586b1dd7e23de9a121963a4ff22ea4eb6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:39:36 +0200 Subject: [PATCH 13/30] feat(server): add configurable SSH connection timeout per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `connection_timeout` field to server settings, allowing per-server override of the global SSH connection timeout constant. - Migration adds `connection_timeout` integer column (default 10s) - `ServerSetting` model exposes and casts the new field - `SshMultiplexingHelper::getConnectionTimeout()` resolves per-server value with fallback to `constants.ssh.connection_timeout` - All SSH/SCP command builders use the new resolver instead of the global config directly - Livewire `Show` component binds `connectionTimeout` with validation (1–300 seconds) and syncs to/from the model - UI input added to server settings form with helper text - Feature tests cover default, persistence, resolver, and fallback --- app/Helpers/SshMultiplexingHelper.php | 15 +++++-- app/Livewire/Server/Show.php | 13 +++++- app/Models/ServerSetting.php | 3 ++ ..._connection_timeout_to_server_settings.php | 22 ++++++++++ openapi.json | 4 ++ openapi.yaml | 3 ++ .../views/livewire/server/show.blade.php | 6 +++ tests/Feature/ServerConnectionTimeoutTest.php | 43 +++++++++++++++++++ 8 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php create mode 100644 tests/Feature/ServerConnectionTimeoutTest.php diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index aa9d06996..4629df571 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; $muxSocket = $sshConfig['muxFilename']; - $connectionTimeout = config('constants.ssh.connection_timeout'); + $connectionTimeout = self::getConnectionTimeout($server); $serverInterval = config('constants.ssh.server_interval'); $muxPersistTime = config('constants.ssh.mux_persist_time'); @@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); + $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { @@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; } - $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); + $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); $delimiter = Hash::make($command); $delimiter = base64_encode($delimiter); @@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void } } + public static function getConnectionTimeout(Server $server): int + { + $timeout = data_get($server, 'settings.connection_timeout'); + + return is_numeric($timeout) && (int) $timeout > 0 + ? (int) $timeout + : (int) config('constants.ssh.connection_timeout'); + } + private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string { $options = "-i {$sshKeyLocation} " diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 84cb65ee6..3e05d9306 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -32,6 +32,8 @@ class Show extends Component public string $port; + public int $connectionTimeout; + public ?string $validationLogs = null; public ?string $wildcardDomain = null; @@ -110,6 +112,7 @@ protected function rules(): array 'ip' => ['required', new ValidServerIp], 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', + 'connectionTimeout' => 'required|integer|min:1|max:300', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', @@ -138,6 +141,10 @@ protected function messages(): array 'ip.required' => 'The IP Address field is required.', 'user.required' => 'The User field is required.', 'port.required' => 'The Port field is required.', + 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.', + 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.', + 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.', + 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.', 'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.', 'sentinelToken.required' => 'The Sentinel Token field is required.', 'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.', @@ -210,6 +217,7 @@ public function syncData(bool $toModel = false) $this->server->validation_logs = $this->validationLogs; $this->server->save(); + $this->server->settings->connection_timeout = $this->connectionTimeout; $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; @@ -237,6 +245,7 @@ public function syncData(bool $toModel = false) $this->ip = $this->server->ip; $this->user = $this->server->user; $this->port = $this->server->port; + $this->connectionTimeout = $this->server->settings->connection_timeout; $this->wildcardDomain = $this->server->settings->wildcard_domain; $this->isReachable = $this->server->settings->is_reachable; @@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false) return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $this->hetznerServerStatus = $serverData['status'] ?? null; @@ -471,7 +480,7 @@ public function startHetznerServer() return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $hetznerService->powerOnServer($this->server->hetzner_server_id); $this->hetznerServerStatus = 'starting'; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 30fc1e165..8d85c8932 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -49,6 +49,7 @@ 'updated_at' => ['type' => 'string'], 'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'], 'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'], ] )] class ServerSetting extends Model @@ -97,6 +98,7 @@ class ServerSetting extends Model 'is_terminal_enabled', 'deployment_queue_limit', 'disable_application_image_retention', + 'connection_timeout', ]; protected $casts = [ @@ -108,6 +110,7 @@ class ServerSetting extends Model 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', + 'connection_timeout' => 'integer', ]; protected static function booted() diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php new file mode 100644 index 000000000..1700feebc --- /dev/null +++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php @@ -0,0 +1,22 @@ +integer('connection_timeout')->default(10)->after('deployment_queue_limit'); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('connection_timeout'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 09232be2f..8b3d5b91f 100644 --- a/openapi.json +++ b/openapi.json @@ -13349,6 +13349,10 @@ "delete_unused_networks": { "type": "boolean", "description": "The flag to indicate if the unused networks should be deleted." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index d6a8d7635..07d9b6095 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8538,6 +8538,9 @@ components: delete_unused_networks: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds.' type: object Service: description: 'Service model' diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d694174d5..cfbeccd0c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 label="Port" required :disabled="$isValidating" />
+
+ +
diff --git a/tests/Feature/ServerConnectionTimeoutTest.php b/tests/Feature/ServerConnectionTimeoutTest.php new file mode 100644 index 000000000..b457f3f01 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutTest.php @@ -0,0 +1,43 @@ +create(); + $this->team = $user->teams()->first(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('defaults connection_timeout to 10 seconds for new servers', function () { + expect($this->server->settings->connection_timeout)->toBe(10); +}); + +it('persists a custom connection_timeout value', function () { + $this->server->settings->connection_timeout = 30; + $this->server->settings->save(); + + expect($this->server->settings->fresh()->connection_timeout)->toBe(30); +}); + +it('returns the per-server connection_timeout from getConnectionTimeout', function () { + $this->server->settings->connection_timeout = 45; + $this->server->settings->save(); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45); +}); + +it('falls back to config default when connection_timeout is invalid', function () { + $this->server->settings->connection_timeout = 0; + $this->server->settings->saveQuietly(); + + $expected = (int) config('constants.ssh.connection_timeout'); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected); +}); From 4a2e37e87fd66540e87dba7fada0083f9840fbe0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:41:04 +0200 Subject: [PATCH 14/30] chore(dev): replace minio image with maxio:latest in docker-compose.dev --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f608fe3cb..82b7edb44 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ services: networks: - coolify minio: - image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + image: ghcr.io/coollabsio/maxio:latest pull_policy: always container_name: coolify-minio command: server /data --console-address ":9001" From 9bb819c33ea6d327377038717d402851e9697726 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:43:58 +0200 Subject: [PATCH 15/30] feat(api): expose connection_timeout in servers API Add connection_timeout to create_server docs, update_server allowed fields, validation (integer 1-300), and advanced settings update path. --- app/Http/Controllers/Api/ServersController.php | 6 ++++-- openapi.json | 4 ++++ openapi.yaml | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index b33e85c78..6c3b2da00 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -612,6 +612,7 @@ public function create_server(Request $request) 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'], ], ), ), @@ -648,7 +649,7 @@ public function create_server(Request $request) )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -674,6 +675,7 @@ public function update_server(Request $request) 'deployment_queue_limit' => 'integer|min:1', 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_check_frequency' => 'string', + 'connection_timeout' => 'integer|min:1|max:300', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -718,7 +720,7 @@ public function update_server(Request $request) ], 422); } - $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } diff --git a/openapi.json b/openapi.json index 8b3d5b91f..059b3d911 100644 --- a/openapi.json +++ b/openapi.json @@ -10545,6 +10545,10 @@ "server_disk_usage_check_frequency": { "type": "string", "description": "Cron expression for disk usage check frequency." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds (1-300). Default: 10." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 07d9b6095..83aa30744 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6734,6 +6734,9 @@ paths: server_disk_usage_check_frequency: type: string description: 'Cron expression for disk usage check frequency.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds (1-300). Default: 10.' type: object responses: '201': From e8dc48e058e6c21df89b0262e3fdbd47d60db059 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:06:20 +0200 Subject: [PATCH 16/30] fix(vite): make dev server host/port configurable via env vars Replace hardcoded HMR host with VITE_HOST/VITE_PORT env vars. Set allowedHosts to true and derive origin/HMR config from env, falling back to defaults when vars are absent. --- vite.config.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/vite.config.js b/vite.config.js index fc739c95d..4b967c40e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const viteHost = env.VITE_HOST || null; + const vitePort = Number(env.VITE_PORT || 5173); return { server: { @@ -14,9 +16,11 @@ export default defineConfig(({ mode }) => { ], }, host: "0.0.0.0", - hmr: { - host: env.VITE_HOST || '0.0.0.0' - }, + allowedHosts: true, + origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined, + hmr: viteHost + ? { host: viteHost, clientPort: vitePort } + : true, }, plugins: [ laravel({ From 19994a0a135ab06cd52e4869cfa5aeffd9455a8e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:20:15 +0200 Subject: [PATCH 17/30] test(api): add feature tests for server connection_timeout API Tests cover PATCH update success, out-of-range, above-max, and non-integer validation for the connection_timeout field. --- .../ServerConnectionTimeoutApiTest.php | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/Feature/ServerConnectionTimeoutApiTest.php diff --git a/tests/Feature/ServerConnectionTimeoutApiTest.php b/tests/Feature/ServerConnectionTimeoutApiTest.php new file mode 100644 index 000000000..287122523 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutApiTest.php @@ -0,0 +1,74 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $newToken = $this->user->createToken('write-token', ['write']); + $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $newToken->plainTextToken; +}); + +it('PATCH updates connection_timeout via API', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 45, + ]); + + $response->assertStatus(201); + expect($this->server->settings->fresh()->connection_timeout)->toBe(45); +}); + +it('PATCH rejects connection_timeout out of range', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 0, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects connection_timeout above max', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 999, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects non-integer connection_timeout', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 'fast', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); From eaaf258f25ea1a793885e3027072b49543dd2ac3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:36:56 +0200 Subject: [PATCH 18/30] fix(service): block UI editing of file volumes exceeding 5 MiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large host files mounted via Docker volumes caused the storages page to become unusable — full file content was stored in the encrypted mediumText column and serialised into the Livewire payload, crashing the browser. - Add MAX_CONTENT_SIZE (5 MiB), BINARY_PLACEHOLDER, and TOO_LARGE_PLACEHOLDER constants to LocalFileVolume - Check remote file size via stat/wc before cat in loadStorageOnServer and saveStorageOnServer; store placeholder instead of content when limit exceeded - Expose is_too_large computed attribute (appended for Livewire serialisation) - Guard submit, instantSave, and syncData in FileStorage Livewire component - Truncate oversized content in Storage::refreshStorages to prevent payload bloat - Show distinct warning banner in file-storage blade; mark textarea readonly and hide Save/Convert buttons for too-large files - Add unit tests covering constants, computed flags, and toArray serialisation Fixes #4701 --- app/Livewire/Project/Service/FileStorage.php | 16 ++++- app/Livewire/Project/Service/Storage.php | 6 +- app/Models/LocalFileVolume.php | 47 +++++++++++-- .../project/service/file-storage.blade.php | 12 ++-- tests/Unit/LocalFileVolumeContentSizeTest.php | 66 +++++++++++++++++++ 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/LocalFileVolumeContentSizeTest.php diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 844e37854..2f1a229b4 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -63,13 +63,16 @@ public function mount() $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large; $this->syncData(); } public function syncData(bool $toModel = false): void { if ($toModel) { + if ($this->fileStorage->is_too_large) { + return; + } $this->validate(); // Sync to model @@ -172,6 +175,12 @@ public function submit() { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_too_large) { + $this->dispatch('error', 'File on server is too large to edit from the UI.'); + + return; + } + $original = $this->fileStorage->getOriginal(); try { $this->validate(); @@ -197,6 +206,11 @@ public function submit() public function instantSave(): void { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_too_large) { + $this->dispatch('error', 'File on server is too large to edit from the UI.'); + + return; + } $this->syncData(true); $this->dispatch('success', 'File updated.'); } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 6f43662d5..30655691a 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -69,7 +69,11 @@ public function refreshStoragesFromEvent() public function refreshStorages() { - $this->fileStorage = $this->resource->fileStorages()->get(); + $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) { + if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) { + $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + } + }); $this->resource->load('persistentStorages.resource'); } diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 4b5c602c2..627750232 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -10,6 +10,12 @@ class LocalFileVolume extends BaseModel { + public const MAX_CONTENT_SIZE = 5_242_880; + + public const BINARY_PLACEHOLDER = '[binary file]'; + + public const TOO_LARGE_PLACEHOLDER = '[file too large to display]'; + protected $casts = [ // 'fs_path' => 'encrypted', // 'mount_path' => 'encrypted', @@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel 'is_preview_suffix_enabled', ]; - public $appends = ['is_binary']; + public $appends = ['is_binary', 'is_too_large']; protected static function booted() { @@ -46,9 +52,14 @@ protected static function booted() protected function isBinary(): Attribute { return Attribute::make( - get: function () { - return $this->content === '[binary file]'; - } + get: fn () => $this->content === self::BINARY_PLACEHOLDER + ); + } + + protected function isTooLarge(): Attribute + { + return Attribute::make( + get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER ); } @@ -81,10 +92,17 @@ public function loadStorageOnServer() $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK') { + if ($this->remoteFileExceedsLimit($escapedPath, $server)) { + $this->content = self::TOO_LARGE_PLACEHOLDER; + $this->is_directory = false; + $this->save(); + + return; + } $content = instant_remote_process(["cat {$escapedPath}"], $server, false); // Check if content contains binary data by looking for null bytes or non-printable characters if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { - $content = '[binary file]'; + $content = self::BINARY_PLACEHOLDER; } $this->content = $content; $this->is_directory = false; @@ -92,6 +110,18 @@ public function loadStorageOnServer() } } + protected function remoteFileExceedsLimit(string $escapedPath, $server): bool + { + $sizeOutput = instant_remote_process( + ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"], + $server, + false, + ); + $size = (int) trim((string) $sizeOutput); + + return $size > self::MAX_CONTENT_SIZE; + } + public function deleteStorageOnServer() { $this->load(['service']); @@ -173,9 +203,12 @@ public function saveStorageOnServer() $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK' && $this->is_directory) { - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + if ($this->remoteFileExceedsLimit($escapedPath, $server)) { + $this->content = self::TOO_LARGE_PLACEHOLDER; + } else { + $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + } $this->is_directory = false; - $this->content = $content; $this->save(); FileStorageChanged::dispatch(data_get($server, 'team_id')); throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.'); diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 48eb935ab..b7f68c6ec 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -1,6 +1,10 @@
- @if ($isReadOnly) + @if ($fileStorage->is_too_large) +
+ File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server. +
+ @elseif ($isReadOnly)
@if ($fileStorage->is_directory) This directory is mounted as read-only and cannot be modified from the UI. @@ -44,7 +48,7 @@ confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" shortConfirmationLabel="Filepath" /> @else - @if (!$fileStorage->is_binary) + @if (!$fileStorage->is_binary && !$fileStorage->is_too_large) is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version." rows="20" id="content" - readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"> - @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) + readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"> + @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large) Save @endif @else diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php new file mode 100644 index 000000000..1fd315884 --- /dev/null +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -0,0 +1,66 @@ +toBe(5_242_880); +}); + +it('exposes binary and too-large placeholder constants', function () { + expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]'); + expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]'); +}); + +it('flags is_too_large when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + expect($volume->is_too_large)->toBeTrue(); + expect($volume->is_binary)->toBeFalse(); +}); + +it('flags is_binary when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::BINARY_PLACEHOLDER; + + expect($volume->is_binary)->toBeTrue(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag normal content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = "hello\nworld\n"; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag empty content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = null; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('exposes the too-large flag via toArray for Livewire serialization', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + $array = $volume->toArray(); + + expect($array)->toHaveKey('is_too_large'); + expect($array['is_too_large'])->toBeTrue(); +}); From 33f5cbb7d7bf7bf37dfc98d1ddd79cb69753a30a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:58:45 +0200 Subject: [PATCH 19/30] chore(version): bump version to 4.1.0 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 8ebf27eed..867cc22d9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.1', + 'version' => '4.1.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index fe7f6418d..f8b4ea890 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.1" + "version": "4.1.0" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index fe7f6418d..f8b4ea890 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.1" + "version": "4.1.0" }, "nightly": { "version": "4.0.0" From 46180dbbf9e2d55f8b6a57d417197210cf4f3671 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:12:24 +0200 Subject: [PATCH 20/30] feat(webhook): skip deployment on [skip ci]/[skip cd] commit markers Add DetectsSkipDeployCommits trait with two strategies: shouldSkipDeploy (all commits must contain the marker) for push events, and shouldSkipDeployAny (any single marker triggers skip) for PR/MR titles and latest-commit signals. Apply trait to Bitbucket, Gitea, GitHub, GitLab webhook controllers and ProcessGithubPullRequestWebhook job. PRs pass pullRequestTitle through to the job constructor for evaluation. --- app/Http/Controllers/Webhook/Bitbucket.php | 35 ++++++ .../Concerns/DetectsSkipDeployCommits.php | 55 +++++++++ app/Http/Controllers/Webhook/Gitea.php | 26 ++++ app/Http/Controllers/Webhook/Github.php | 31 +++++ app/Http/Controllers/Webhook/Gitlab.php | 27 ++++ app/Jobs/ProcessGithubPullRequestWebhook.php | 7 ++ docker-compose.dev.yml | 1 - tests/Unit/DetectsSkipDeployCommitsTest.php | 115 ++++++++++++++++++ 8 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php create mode 100644 tests/Unit/DetectsSkipDeployCommitsTest.php diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index eda2014a6..ee7f25431 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -12,6 +13,8 @@ class Bitbucket extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -31,6 +34,16 @@ public function manual(Request $request) $branch = data_get($payload, 'push.changes.0.new.name'); $full_name = data_get($payload, 'repository.full_name'); $commit = data_get($payload, 'push.changes.0.new.target.hash'); + // Bitbucket webhooks ship up to 5 commits per change. Larger pushes + // are evaluated only on the visible 5. + $skip_deploy_commits = self::shouldSkipDeploy( + collect(data_get($payload, 'push.changes', [])) + ->flatMap(fn ($change) => data_get($change, 'commits', [])) + ->pluck('message') + ->filter() + ->values() + ->all() + ); if (! $branch) { return response([ @@ -45,6 +58,8 @@ public function manual(Request $request) $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'pullrequest.id'); $pull_request_html_url = data_get($payload, 'pullrequest.links.html.href'); + $pull_request_title = data_get($payload, 'pullrequest.title'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); } $applications = Application::where('git_repository', 'like', "%$full_name%"); @@ -119,6 +134,17 @@ public function manual(Request $request) } if ($x_bitbucket_event === 'repo:push') { if ($application->isDeployable()) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -161,6 +187,15 @@ public function manual(Request $request) } if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php new file mode 100644 index 000000000..69695e99b --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php @@ -0,0 +1,55 @@ + $messages + */ + public static function shouldSkipDeploy(array $messages): bool + { + $messages = array_values(array_filter($messages, fn ($m) => filled($m))); + + if (empty($messages)) { + return false; + } + + foreach ($messages as $message) { + $lower = strtolower((string) $message); + if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) { + return false; + } + } + + return true; + } + + /** + * Returns true if at least one non-empty message contains [skip cd] or + * [skip ci]. Used for PR/MR title + latest-commit signals where any one + * marker should trigger the skip. + * + * @param array $messages + */ + public static function shouldSkipDeployAny(array $messages): bool + { + foreach ($messages as $message) { + if (! filled($message)) { + continue; + } + $lower = strtolower((string) $message); + if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index c6297905e..64807d694 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -13,6 +14,8 @@ class Gitea extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -40,12 +43,15 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_gitea_event === 'pull_request') { $action = data_get($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); } @@ -112,6 +118,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -170,6 +187,15 @@ public function manual(Request $request) if ($x_gitea_event === 'pull_request') { if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 7489b433f..b0e11f60c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -16,6 +17,8 @@ class Github extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -43,12 +46,14 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); $before_sha = data_get($payload, 'before'); @@ -126,6 +131,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -201,6 +217,7 @@ public function manual(Request $request) action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, + pullRequestTitle: $pull_request_title ?? null, beforeSha: $before_sha, afterSha: $after_sha, commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), @@ -274,12 +291,14 @@ public function normal(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); $id = data_get($payload, 'repository.id'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); $before_sha = data_get($payload, 'before'); @@ -328,6 +347,17 @@ public function normal(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -399,6 +429,7 @@ public function normal(Request $request) action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, + pullRequestTitle: $pull_request_title ?? null, beforeSha: $before_sha, afterSha: $after_sha, commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 99ee4e477..205bede8f 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -13,6 +14,8 @@ class Gitlab extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -61,6 +64,7 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_gitlab_event === 'merge_request') { $action = data_get($payload, 'object_attributes.action'); @@ -69,6 +73,9 @@ public function manual(Request $request) $full_name = data_get($payload, 'project.path_with_namespace'); $pull_request_id = data_get($payload, 'object_attributes.iid'); $pull_request_html_url = data_get($payload, 'object_attributes.url'); + $pull_request_title = data_get($payload, 'object_attributes.title'); + $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); if (! $branch) { $return_payloads->push([ 'status' => 'failed', @@ -147,6 +154,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -206,6 +224,15 @@ public function manual(Request $request) if ($x_gitlab_event === 'merge_request') { if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 041cd812c..54e386676 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Enums\ProcessStatus; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\GithubApp; @@ -17,6 +18,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue { + use DetectsSkipDeployCommits; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; @@ -31,6 +33,7 @@ public function __construct( public string $action, public int $pullRequestId, public string $pullRequestHtmlUrl, + public ?string $pullRequestTitle, public ?string $beforeSha, public ?string $afterSha, public string $commitSha, @@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp return; } + if (self::shouldSkipDeployAny([$this->pullRequestTitle])) { + return; + } + // Check if PR deployments from public contributors are restricted if (! $application->settings->is_pr_deployments_public_enabled) { $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 82b7edb44..50edc140f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -132,7 +132,6 @@ services: image: ghcr.io/coollabsio/maxio:latest 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" diff --git a/tests/Unit/DetectsSkipDeployCommitsTest.php b/tests/Unit/DetectsSkipDeployCommitsTest.php new file mode 100644 index 000000000..5255df5d6 --- /dev/null +++ b/tests/Unit/DetectsSkipDeployCommitsTest.php @@ -0,0 +1,115 @@ +toBeFalse(); + }); + + test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse(); + }); + + test('returns true when all messages contain [skip ci]', function () use ($harnessClass) { + $messages = [ + 'Update docs [skip ci]', + 'Fix typo [skip ci]', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('returns true when single message contains [skip cd]', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue(); + }); + + test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) { + $messages = [ + 'Docs [SKIP CI]', + 'Changelog [Skip Cd]', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('returns false when at least one message has no skip marker', function () use ($harnessClass) { + $messages = [ + 'Update docs [skip ci]', + 'Actual feature change', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse(); + }); + + test('returns false when single message has no skip marker', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse(); + }); + + test('null entries are filtered before evaluation', function () use ($harnessClass) { + $messages = [ + null, + 'Docs [skip ci]', + null, + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('matches PR title scenario (single string)', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue(); + expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse(); + expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse(); + }); +}); + +describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) { + test('returns false when messages array is empty', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse(); + }); + + test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse(); + }); + + test('returns true when any one message contains [skip ci]', function () use ($harnessClass) { + $messages = [ + 'Real feature change', + 'docs: update readme [skip ci]', + ]; + expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue(); + }); + + test('returns true when any one message contains [skip cd]', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue(); + }); + + test('returns true case-insensitively', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue(); + expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue(); + }); + + test('returns false when no message contains a skip marker', function () use ($harnessClass) { + $messages = [ + 'feat: add new endpoint', + 'fix: handle edge case', + ]; + expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse(); + }); + + test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue(); + expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse(); + }); + + test('PR title alone with skip marker triggers skip', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue(); + }); + + test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue(); + }); +}); From ec6407a71f72dfa27fa29831df917aa15f4f67dc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:43:19 +0200 Subject: [PATCH 21/30] docs(design): migrate design system from .ai/ to DESIGN.md Condense verbose 1666-line AI reference into 752-line structured YAML/Markdown spec. Move from .ai/design-system.md to repo-root DESIGN.md for broader visibility. --- .ai/design-system.md | 1666 ------------------------------------------ DESIGN.md | 752 +++++++++++++++++++ 2 files changed, 752 insertions(+), 1666 deletions(-) delete mode 100644 .ai/design-system.md create mode 100644 DESIGN.md diff --git a/.ai/design-system.md b/.ai/design-system.md deleted file mode 100644 index d22adf3c6..000000000 --- a/.ai/design-system.md +++ /dev/null @@ -1,1666 +0,0 @@ -# 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; -} -``` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..9520fdf23 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,752 @@ +--- +version: alpha +name: Coolify +description: Self-hosted PaaS. Dark-first utilitarian UI. Purple (light) / yellow (dark) accent swap. Sharp 2px radii. Inset box-shadow inputs with 4px dirty-bar indicator. +colors: + # Brand + coollabs: "#6b16ed" + coollabs-50: "#f5f0ff" + coollabs-100: "#7317ff" + coollabs-200: "#5a12c7" + coollabs-300: "#4a0fa3" + # Dark-mode accent (warning scale) + warning: "#fcd452" + 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" + # Dark surfaces + base: "#101010" + coolgray-100: "#181818" + coolgray-200: "#202020" + coolgray-300: "#242424" + coolgray-400: "#282828" + coolgray-500: "#323232" + # Light surfaces (Tailwind neutrals) + surface: "#ffffff" + background: "#f9fafb" + border: "#e5e5e5" + text: "#000000" + text-muted: "#737373" + text-placeholder: "#d4d4d4" + # Semantic + success: "#22C55E" + error: "#dc2626" + primary: "{colors.coollabs}" +typography: + h1: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.875rem + fontWeight: 700 + lineHeight: 1.2 + h2: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.25rem + fontWeight: 700 + h3: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.125rem + fontWeight: 700 + h4: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1rem + fontWeight: 700 + body-md: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.875rem + fontWeight: 400 + lineHeight: 1.25rem + label-md: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.875rem + fontWeight: 500 + label-sm: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.75rem + fontWeight: 700 + lineHeight: 1rem + mono: + fontFamily: "'Geist Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace" + fontSize: 0.875rem + fontWeight: 400 +rounded: + sm: 0.125rem # default — inputs, buttons, cards, modals + md: 0.25rem # coolbox + lg: 0.5rem # callouts + full: 9999px # badges, pills +spacing: + xs: 0.25rem + sm: 0.5rem + md: 1rem + lg: 1.5rem + xl: 2rem + section: 3rem + sidebar-width: 14rem + button-height: 2rem + card-min-height: 4rem + input-py: 0.375rem +components: + button: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + height: "{spacing.button-height}" + padding: 0 0.5rem + button-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + button-hover: + backgroundColor: "#f5f5f5" + button-hover-dark: + backgroundColor: "{colors.coolgray-200}" + button-highlighted: + backgroundColor: "{colors.coollabs-50}" + textColor: "{colors.coollabs-200}" + button-highlighted-hover: + backgroundColor: "{colors.coollabs}" + textColor: "#ffffff" + button-error: + backgroundColor: "#fef2f2" + textColor: "#991b1b" + button-error-hover: + backgroundColor: "#fca5a5" + textColor: "#ffffff" + input: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + padding: 0.375rem 0.5rem + input-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + textarea: + typography: "{typography.mono}" + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + box: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + padding: "{spacing.sm}" + height: "{spacing.card-min-height}" + box-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + box-hover: + backgroundColor: "#f5f5f5" + box-hover-dark: + backgroundColor: "{colors.coollabs-100}" + textColor: "#ffffff" + coolbox: + backgroundColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "{spacing.sm}" + height: "{spacing.card-min-height}" + badge-success: + backgroundColor: "{colors.success}" + size: 0.75rem + rounded: "{rounded.full}" + badge-warning: + backgroundColor: "{colors.warning}" + size: 0.75rem + rounded: "{rounded.full}" + badge-error: + backgroundColor: "{colors.error}" + size: 0.75rem + rounded: "{rounded.full}" + deprecated-badge: + backgroundColor: "rgba(252, 212, 82, 0.15)" + textColor: "{colors.warning}" + rounded: "{rounded.full}" + padding: 0.125rem 0.5rem + callout-warning: + backgroundColor: "{colors.warning-50}" + textColor: "{colors.warning-800}" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-danger: + backgroundColor: "#fef2f2" + textColor: "#991b1b" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-info: + backgroundColor: "#eff6ff" + textColor: "#1e40af" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-success: + backgroundColor: "#f0fdf4" + textColor: "#166534" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + dropdown: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + padding: "{spacing.xs}" + dropdown-dark: + backgroundColor: "{colors.coolgray-200}" + dropdown-item-hover: + backgroundColor: "#f5f5f5" + dropdown-item-hover-dark: + backgroundColor: "{colors.coollabs}" + textColor: "#ffffff" + menu-item-active: + backgroundColor: "#e5e5e5" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + menu-item-active-dark: + backgroundColor: "{colors.coolgray-200}" + textColor: "{colors.warning}" + tag: + backgroundColor: "#f5f5f5" + textColor: "{colors.text-muted}" + padding: 0.25rem 0.5rem + kbd: + rounded: "{rounded.sm}" + padding: 0 0.5rem + toast: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + padding: "{spacing.md}" + width: 20rem + modal-input: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + modal-input-dark: + backgroundColor: "{colors.base}" + modal-confirmation: + backgroundColor: "#f5f5f5" + rounded: "{rounded.sm}" + modal-confirmation-dark: + backgroundColor: "{colors.base}" +--- + +# Coolify Design System + +## Overview + +Coolify is a self-hosted PaaS (Heroku/Netlify/Vercel alternative) built with Laravel 12, Livewire 3, and Tailwind CSS v4. UI is **dark-first, dense, utilitarian** — operators want information density over whitespace. + +Brand personality: precise, engineered, no-nonsense. No flourish. No gradients outside a single branded upsell. Flat surfaces differentiated by tonal depth, not shadow. + +Two signature traits define the system: + +1. **Purple/Yellow accent swap.** Light mode uses `coollabs` purple `#6b16ed`. Dark mode swaps to `warning` yellow `#fcd452` for focus rings, active nav items, helper icons, loading spinners, highlighted text, helper links. Never use purple as an accent in dark mode. +2. **Inset box-shadow inputs with a 4px "dirty bar".** Inputs and selects have no border — they use `box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px `. When the field is focused or has unsaved changes (`wire:dirty`), the left 4px becomes the accent color — a live visual indicator of modified state. This is the single most distinctive UI detail in Coolify. + +Sharp geometry everywhere: 2px corner radius by default (`rounded-sm`). 8px only on callouts. Shadows used sparingly — one `shadow-sm` on boxes, one drop-shadow on toasts. The rest is flat tonal layers. + +## Colors + +Source of truth: `resources/css/app.css` `@theme` block (Tailwind v4). + +### Palettes + +- **Primary / Coollabs (`#6b16ed`)** — brand purple. Light-mode accent. Used for focus rings, active states, highlighted buttons, spinners, scrollbar thumb. Scale: `coollabs-50 #f5f0ff` (backgrounds), `coollabs #6b16ed` (base), `coollabs-100 #7317ff` (dark-mode button hover), `coollabs-200 #5a12c7` (light-mode text), `coollabs-300 #4a0fa3` (deepest). +- **Warning (`#fcd452`)** — dark-mode accent + callout palette. Full yellow scale `warning-50` through `warning-900`. Swaps in for coollabs under `.dark`. +- **Coolgray (dark surface ladder)** — five shades building dark-mode depth: `base #101010` (page) → `coolgray-100 #181818` (components) → `-200 #202020` (elevated / active nav) → `-300 #242424` (input borders, button borders) → `-400 #282828` (tooltips) → `-500 #323232` (subtle overlays). +- **Semantic** — `success #22C55E` for running/healthy, `error #dc2626` for stopped/danger. +- **Light surfaces** — `gray-50 #f9fafb` (page), `white` (components), `neutral-200 #e5e5e5` (borders), `neutral-500 #737373` (muted text), `neutral-300 #d4d4d4` (placeholders). + +### Dark-mode heading rule (critical) + +Body default text in dark mode is `neutral-400 #a3a3a3`. Headings and card titles MUST explicitly force `text-white` — otherwise they render near-invisible on `coolgray-100 #181818`. This is enforced globally: `h1–h4` all have `dark:text-white` in `app.css`. + +### Default border override + +Tailwind v4 defaults `border-color` to `currentcolor`. Coolify overrides it in `@layer base`: + +```css +*, ::after, ::before, ::backdrop, ::file-selector-button { + border-color: var(--color-coolgray-200, currentcolor); +} +``` + +So any `border` utility without an explicit color gets `coolgray-200 #202020` in dark mode. + +## Typography + +Fonts loaded in `resources/css/fonts.css` (all `woff2`, `font-display: swap`): + +- **Geist Sans** — primary UI font. Variable weight `100 900`. Inter as fallback (static weights 100–900). +- **Geist Mono** — monospace for code, logs, textareas. Variable weight `100 900`. + +Applied via `@theme`: + +```css +--font-sans: 'Geist Sans', Inter, sans-serif; +--font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +--font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +``` + +### Heading hierarchy (Tailwind utilities) + +| Element | Utility | +|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | +| `h2` | `text-xl font-bold dark:text-white` | +| `h3` | `text-lg font-bold dark:text-white` | +| `h4` | `text-base font-bold dark:text-white` | + +### Body + +| Context | Utility | +|---|---| +| Body default | `text-sm font-sans antialiased` | +| Label | `text-sm font-medium` | +| Badge / status text | `text-xs font-bold` | +| Box description | `text-xs font-bold text-neutral-500` | +| Caption / kbd | `text-xs` | + +## Layout + +Fixed left sidebar layout on desktop. Mobile collapses to a sticky top bar with hamburger menu overlay. + +### Structure + +- **Sidebar** — fixed, `w-56` (14rem / 224px), `hidden lg:flex`. Inner `flex flex-col overflow-y-auto gap-y-5 scrollbar`. Nav `bg-white dark:bg-base border-r`. +- **Main content** — `lg:pl-56` offset. Inner padding `p-4 sm:px-6 lg:px-8 lg:py-6`. +- **Mobile top bar** — `sticky top-0 z-40 lg:hidden` with `bg-white/95 dark:bg-base/95 backdrop-blur-sm`. + +### Spacing scale + +| Token | Value | Use | +|---|---|---| +| `p-2` | 0.5rem | Component internal padding | +| `p-4` | 1rem | Callout padding | +| `py-1.5` | 0.375rem | Input vertical padding | +| `h-8` | 2rem | Button height | +| `px-2` | 0.5rem | Button horizontal padding | +| `gap-2` | 0.5rem | Button gap | +| `px-2 py-1` | 0.25rem / 0.5rem | Menu item padding | +| `gap-3` | 0.75rem | Menu item gap | +| `mb-12` | 3rem | Section margin | +| `min-h-[4rem]` | 4rem | Card min-height | + +No grid system — flex layouts everywhere. + +## Elevation & Depth + +**Flat + tonal.** Hierarchy comes from background color, not shadows. + +### Dark tonal ladder + +``` +#101010 (base) page background + #181818 (coolgray-100) cards, inputs, components + #202020 (coolgray-200) elevated surfaces, borders, nav active + #242424 (coolgray-300) input borders, button borders + #282828 (coolgray-400) tooltips, hover states + #323232 (coolgray-500) subtle overlays +``` + +### Light tonal ladder + +``` +#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 +``` + +### Shadows (used sparingly) + +- Boxes: `shadow-sm` (`0 1px 2px 0 rgba(0,0,0,0.05)`) +- Toasts: `shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]` +- Slide-over: `shadow-lg` +- Modal-input: `drop-shadow-sm` + +### Input inset box-shadow system (distinctive) + +Inputs and selects use `box-shadow` instead of `border` — this enables the 4px left dirty-bar indicator: + +```css +/* default */ box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +/* default dark */ inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +/* focus light */ inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +/* focus dark */ inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +/* dirty (same as focus) — set via wire:dirty.class */ +/* disabled / readonly */ box-shadow: none; +``` + +Variant `input-sticky` uses `1px` outer shadow instead of `2px`. + +### Focus ring (buttons, links, checkboxes, non-input) + +`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` + +## Shapes + +- **Default** — `rounded-sm` (2px). Everything: inputs, buttons, cards, modals, toasts, dropdowns. +- **Coolbox** — `rounded` (4px). Alternate card style with ring-hover. +- **Callouts** — `rounded-lg` (8px). Only exception to the sharp rule. +- **Badges / deprecated badge / pills / avatars** — `rounded-full`. + +Never mix radii within the same view. + +## Components + +All component classes live in `resources/css/utilities.css` as `@utility` blocks, consumed by Blade components under `resources/views/components/`. + +### Forms + +#### Button + +Utility `.button` (`resources/css/utilities.css`): + +``` +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 +``` + +Attribute variants (in `app.css`): + +- `button[isHighlighted]` → `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` +- `button[isError]` → `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` + +Loading: `` — inline `w-4 h-4 dark:text-warning animate-spin` SVG. + +#### Input + +Utility chain `.input-select` → `.input`: + +``` +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 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200 dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 focus-visible:outline-none +``` + +Plus the inset box-shadow system (see Elevation). Password variant: `.input[type="password"]` gets `pr-[2.4rem]` for the eye icon. + +**Dirty indicator.** Livewire sets the focus-colored shadow via `wire:dirty.class`: + +```blade +wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" +``` + +Variant `.input-sticky` — same shape, `1px` outer shadow (thinner border). + +#### Select + +Extends `.input-select` + custom SVG dropdown arrow: + +```css +background-image: url("data:image/svg+xml,...stroke='%23000000'..."); +padding-right: 2.5rem; +``` + +Dark mode swaps the SVG stroke to `%23ffffff`. + +#### Checkbox + +Input class: +``` +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: +``` +form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100 cursor-pointer +``` + +#### Textarea + +Uses the same `input` utility + `font-mono` + dirty-bar via `wire:dirty.class` (identical to input). Optional `@keydown.tab=handleKeydown` inserts 2 spaces on Tab. + +#### Copy-Button + +`resources/views/components/forms/copy-button.blade.php` — readonly `.input` with an absolute-positioned copy icon right-side. Copied state shows a green check (`text-green-500`) for 1 second. Only renders in secure contexts (`window.isSecureContext`). + +### Containers + +#### Box + +Utility `.box`: +``` +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 +``` + +**Critical child text rule.** On dark hover, background becomes purple `#7317ff` — description text `#737373` disappears. Utilities `.box-title` and `.box-description` include `dark:group-hover:text-white group-hover:text-black` to flip text contrast. + +Variants: `.box-boarding`, `.box-without-bg`, `.box-without-bg-without-border`. + +#### Coolbox + +Utility `.coolbox`: +``` +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] +``` + +Distinguished by `rounded` (4px, not 2px) and **ring-hover** instead of background change. + +### Status & Badges + +#### Badge base + +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none border border-neutral-200 dark:border-black +``` + +Fill utilities: `.badge-success` (`bg-success`), `.badge-warning` (`bg-warning`), `.badge-error` (`bg-error`). Dashboard variant `.badge-dashboard` is `absolute top-1 right-1 w-2.5 h-2.5`. + +#### Status indicator pattern + +Badge + label side-by-side. Components in `resources/views/components/status/`: + +| Component | Badge | Text color | Loading? | +|---|---|---|---| +| `status/running` | `badge-success` | `text-success` (`#22C55E`) | Swaps to `badge-warning` while checking proxy | +| `status/degraded` | `badge-warning` | `dark:text-warning` (`#fcd452`) | `` + `wire:loading.delay.longer` | +| `status/restarting` | `badge-warning` | `dark:text-warning` | `` | +| `status/stopped` | `badge-error` | `text-error` (`#dc2626`) | `` | + +Layout: `
` → badge → `
{label}
` → optional `({health})` in same color. + +#### Deprecated Badge + +`resources/views/components/deprecated-badge.blade.php`: +``` +px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30 +``` + +#### Tag + +Utility `.tag`: +``` +px-2 py-1 cursor-pointer box-description dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +### Overlays + +#### Callout + +Four types (`warning`, `danger`, `info`, `success`). Base: `relative p-4 border rounded-lg`. + +| 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` | + +Icon colors (600 light / 400 dark) match type. + +#### Modal (input variant) + +`resources/views/components/modal.blade.php`: +``` +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 +``` + +Backdrop: `bg-black/20 backdrop-blur-xs`. Close button: `w-8 h-8 rounded-full hover:bg-neutral-100 dark:hover:bg-coolgray-300` top-right, 24px `stroke-width=1.5` X icon. + +#### Modal Confirmation + +`resources/views/components/modal-confirmation.blade.php` — destructive-action 2-or-3-step wizard (checkboxes → confirm text → password): +``` +relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col +``` + +Uses `` for warning. Password step hidden for OAuth users. + +#### Confirm Modal + +`resources/views/components/confirm-modal.blade.php` — Livewire-bound simpler confirm dialog. + +#### Popup / Popup-Small + +Fixed bottom-right notification card with title / description / action button. `bg-white dark:bg-coolgray-100 border dark:border-coolgray-300 shadow-lg sm:rounded-sm`. Popup is responsive max-w-4xl, Popup-Small is `max-w-[46rem]`. + +#### Slide-Over + +`resources/views/components/slide-over.blade.php`: + +Outer: `fixed inset-y-0 right-0 flex max-w-full pl-10` + +Panel: `max-w-xl w-screen flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200` + +#### Toast + +`resources/views/components/toast.blade.php` — Alpine-powered stacked toast system. + +- Container: `fixed ... sm:max-w-xs z-9999`, positioned via `position` param (`top-right` / `top-left` / `top-center` / `bottom-right` / `bottom-left` / `bottom-center`). +- Toast shell: `relative flex flex-col items-start shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] w-full dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 rounded-sm sm:max-w-xs`. +- Stacks up to 4 (oldest gets scale 82% then burns). +- Auto-dismiss after 4 s. Hover on container pauses dismissal and expands stack. +- HTML payload sanitized via `window.sanitizeHTML` (XSS guard). +- Per-toast copy-to-clipboard + close buttons. + +Icon colors: + +| Type | Class | +|---|---| +| success | `text-green-500` | +| info | `text-blue-500` | +| warning | `text-orange-400` | +| danger | `text-red-500` | +| default | `text-gray-800` | + +#### Helper / Tooltip + +`resources/views/components/helper.blade.php`. Icon utility `.info-helper`: +``` +cursor-pointer text-coollabs dark:text-warning +``` + +Popup utility `.info-helper-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 +``` + +Shown on parent `.group:hover`. Supports rich HTML (links colored `text-coollabs dark:text-warning underline`). + +### Navigation + +#### Sidebar / Navbar + +Component: `resources/views/components/navbar.blade.php`. + +Root nav: `flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base` + +Menu list: `flex flex-col flex-1 gap-y-7` → inner `flex flex-col h-full space-y-1.5`. + +Utility `.menu-item`: +``` +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 +``` + +Utility `.menu-item-active`: +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +Icon `.menu-item-icon`: `flex-shrink-0 w-6 h-6 dark:hover:text-white`. Sub-items use `gap-2` + `w-4 h-4` icons. + +#### Breadcrumbs + +`resources/views/components/resources/breadcrumbs.blade.php` — project → environment → resource trail. Desktop: `