[
[
'type' => 'bind',
- 'source' => "$volume_configuration_dir/nginx.conf",
+ 'source' => "$configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
@@ -116,18 +115,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([
"mkdir -p $configuration_dir",
- [
- 'transfer_file' => [
- 'content' => base64_decode($nginxconf_base64),
- 'destination' => "$configuration_dir/nginx.conf",
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($dockercompose_base64),
- 'destination' => "$configuration_dir/docker-compose.yaml",
- ],
- ],
+ "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
+ "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index 579c6841d..38ad99d2e 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -183,12 +183,8 @@ public function handle(StandaloneDragonfly $database)
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index e1d4e43c1..59bcd4123 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -199,12 +199,8 @@ public function handle(StandaloneKeydb $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 3f7d22245..13dba4b43 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -203,12 +203,8 @@ public function handle(StandaloneMariadb $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -288,11 +284,7 @@ private function add_custom_mysql()
}
$filename = 'custom-config.cnf';
$content = $this->database->mariadb_conf;
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 7135f1c70..870b5b7e5 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -28,6 +28,9 @@ public function handle(StandaloneMongodb $database)
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
+ if (isDev()) {
+ $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
+ }
$this->commands = [
"echo 'Starting database.'",
@@ -251,12 +254,8 @@ public function handle(StandaloneMongodb $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -333,22 +332,15 @@ private function add_custom_mongo_conf()
}
$filename = 'mongod.conf';
$content = $this->database->mongo_conf;
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
private function add_default_database()
{
$content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
}
}
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 5f453f80a..5d5611e07 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -204,12 +204,8 @@ public function handle(StandaloneMysql $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -291,11 +287,7 @@ private function add_custom_mysql()
}
$filename = 'custom-config.cnf';
$content = $this->database->mysql_conf;
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 75ca8ef10..38d46b3c1 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -27,6 +27,9 @@ public function handle(StandalonePostgresql $database)
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
+ if (isDev()) {
+ $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
+ }
$this->commands = [
"echo 'Starting database.'",
@@ -214,12 +217,8 @@ public function handle(StandalonePostgresql $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -305,12 +304,8 @@ private function generate_init_scripts()
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
}
}
@@ -332,11 +327,7 @@ private function add_custom_conf()
$this->database->postgres_conf = $content;
$this->database->save();
}
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => $config_file_path,
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null";
}
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index b5962b165..68a1f3fe3 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -196,12 +196,8 @@ public function handle(StandaloneRedis $database)
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index c3268ec07..f5d5f82b6 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -26,6 +26,8 @@ class GetContainersStatus
public $server;
+ protected ?Collection $applicationContainerStatuses;
+
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@@ -94,7 +96,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
- $containerStatus = "$containerStatus ($containerHealth)";
+ if ($containerStatus === 'restarting') {
+ $containerStatus = "restarting ($containerHealth)";
+ } else {
+ $containerStatus = "$containerStatus ($containerHealth)";
+ }
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) {
@@ -119,11 +125,16 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$application = $this->applications->where('id', $applicationId)->first();
if ($application) {
$foundApplications[] = $application->id;
- $statusFromDb = $application->status;
- if ($statusFromDb !== $containerStatus) {
- $application->update(['status' => $containerStatus]);
- } else {
- $application->update(['last_online_at' => now()]);
+ // Store container status for aggregation
+ if (! isset($this->applicationContainerStatuses)) {
+ $this->applicationContainerStatuses = collect();
+ }
+ if (! $this->applicationContainerStatuses->has($applicationId)) {
+ $this->applicationContainerStatuses->put($applicationId, collect());
+ }
+ $containerName = data_get($labels, 'com.docker.compose.service');
+ if ($containerName) {
+ $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else {
// Notify user that this container should not be there.
@@ -320,6 +331,97 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
}
+
+ // Aggregate multi-container application statuses
+ if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
+ foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
+ $application = $this->applications->where('id', $applicationId)->first();
+ if (! $application) {
+ continue;
+ }
+
+ $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
+ if ($aggregatedStatus) {
+ $statusFromDb = $application->status;
+ if ($statusFromDb !== $aggregatedStatus) {
+ $application->update(['status' => $aggregatedStatus]);
+ } else {
+ $application->update(['last_online_at' => now()]);
+ }
+ }
+ }
+ }
+
ServiceChecked::dispatch($this->server->team->id);
}
+
+ private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
+ {
+ // Parse docker compose to check for excluded containers
+ $dockerComposeRaw = data_get($application, 'docker_compose_raw');
+ $excludedContainers = collect();
+
+ if ($dockerComposeRaw) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $services = data_get($dockerCompose, 'services', []);
+
+ foreach ($services as $serviceName => $serviceConfig) {
+ // Check if container should be excluded
+ $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
+ $restartPolicy = data_get($serviceConfig, 'restart', 'always');
+
+ if ($excludeFromHc || $restartPolicy === 'no') {
+ $excludedContainers->push($serviceName);
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse, treat all containers as included
+ }
+ }
+
+ // Filter out excluded containers
+ $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
+ return ! $excludedContainers->contains($containerName);
+ });
+
+ // If all containers are excluded, don't update status
+ if ($relevantStatuses->isEmpty()) {
+ return null;
+ }
+
+ $hasRunning = false;
+ $hasRestarting = false;
+ $hasUnhealthy = false;
+ $hasExited = false;
+
+ foreach ($relevantStatuses as $status) {
+ if (str($status)->contains('restarting')) {
+ $hasRestarting = true;
+ } elseif (str($status)->contains('running')) {
+ $hasRunning = true;
+ if (str($status)->contains('unhealthy')) {
+ $hasUnhealthy = true;
+ }
+ } elseif (str($status)->contains('exited')) {
+ $hasExited = true;
+ $hasUnhealthy = true;
+ }
+ }
+
+ if ($hasRestarting) {
+ return 'degraded (unhealthy)';
+ }
+
+ if ($hasRunning && $hasExited) {
+ return 'degraded (unhealthy)';
+ }
+
+ if ($hasRunning) {
+ return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
+ }
+
+ // All containers are exited
+ return 'exited (unhealthy)';
+ }
}
diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php
index 38c9c8def..53fbecce2 100644
--- a/app/Actions/Proxy/SaveProxyConfiguration.php
+++ b/app/Actions/Proxy/SaveProxyConfiguration.php
@@ -21,12 +21,7 @@ public function handle(Server $server, string $configuration): void
// Transfer the configuration file to the server
instant_remote_process([
"mkdir -p $proxy_path",
- [
- 'transfer_file' => [
- 'content' => base64_decode($docker_compose_yml_base64),
- 'destination' => "$proxy_path/docker-compose.yml",
- ],
- ],
+ "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
], $server);
}
}
diff --git a/app/Actions/Server/ConfigureCloudflared.php b/app/Actions/Server/ConfigureCloudflared.php
index e66e7eecb..d21622bc5 100644
--- a/app/Actions/Server/ConfigureCloudflared.php
+++ b/app/Actions/Server/ConfigureCloudflared.php
@@ -40,12 +40,7 @@ public function handle(Server $server, string $cloudflare_token, string $ssh_dom
$commands = collect([
'mkdir -p /tmp/cloudflared',
'cd /tmp/cloudflared',
- [
- 'transfer_file' => [
- 'content' => base64_decode($docker_compose_yml_base64),
- 'destination' => '/tmp/cloudflared/docker-compose.yml',
- ],
- ],
+ "echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null",
'echo Pulling latest Cloudflare Tunnel image.',
'docker compose pull',
'echo Stopping existing Cloudflare Tunnel container.',
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index 33c22b484..5410b1cbd 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -14,7 +14,6 @@ class InstallDocker
public function handle(Server $server)
{
- ray('install docker');
$dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
@@ -104,15 +103,8 @@ public function handle(Server $server)
"curl https://releases.rancher.com/install-docker/{$dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$dockerVersion}",
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
'test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json "/etc/docker/daemon.json.original-$(date +"%Y%m%d-%H%M%S")"',
- [
- 'transfer_file' => [
- 'content' => base64_decode($config),
- 'destination' => '/tmp/daemon.json.new',
- ],
- ],
- 'test ! -s /etc/docker/daemon.json && cp /tmp/daemon.json.new /etc/docker/daemon.json',
- 'cp /tmp/daemon.json.new /etc/docker/daemon.json.coolify',
- 'rm -f /tmp/daemon.json.new',
+ "test ! -s /etc/docker/daemon.json && echo '{$config}' | base64 -d | tee /etc/docker/daemon.json > /dev/null",
+ "echo '{$config}' | base64 -d | tee /etc/docker/daemon.json.coolify > /dev/null",
'jq . /etc/docker/daemon.json.coolify | tee /etc/docker/daemon.json.coolify.pretty > /dev/null',
'mv /etc/docker/daemon.json.coolify.pretty /etc/docker/daemon.json.coolify',
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
diff --git a/app/Actions/Server/ServerCheck.php b/app/Actions/Server/ServerCheck.php
deleted file mode 100644
index 6ac87f1f0..000000000
--- a/app/Actions/Server/ServerCheck.php
+++ /dev/null
@@ -1,268 +0,0 @@
-server = $server;
- try {
- if ($this->server->isFunctional() === false) {
- return 'Server is not functional.';
- }
-
- if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
-
- if (isset($data)) {
- $data = collect($data);
-
- $this->server->sentinelHeartbeat();
-
- $this->containers = collect(data_get($data, 'containers'));
-
- $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
- ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
-
- $containerReplicates = null;
- $this->isSentinel = true;
- } else {
- ['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
- // ServerStorageCheckJob::dispatch($this->server);
- }
-
- if (is_null($this->containers)) {
- return 'No containers found.';
- }
-
- if (isset($containerReplicates)) {
- foreach ($containerReplicates as $containerReplica) {
- $name = data_get($containerReplica, 'Name');
- $this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
- if (data_get($container, 'Spec.Name') === $name) {
- $replicas = data_get($containerReplica, 'Replicas');
- $running = str($replicas)->explode('/')[0];
- $total = str($replicas)->explode('/')[1];
- if ($running === $total) {
- data_set($container, 'State.Status', 'running');
- data_set($container, 'State.Health.Status', 'healthy');
- } else {
- data_set($container, 'State.Status', 'starting');
- data_set($container, 'State.Health.Status', 'unhealthy');
- }
- }
-
- return $container;
- });
- }
- }
- $this->checkContainers();
-
- if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
- CheckAndStartSentinelJob::dispatch($this->server);
- }
-
- if ($this->server->isLogDrainEnabled()) {
- $this->checkLogDrainContainer();
- }
-
- if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
- $foundProxyContainer = $this->containers->filter(function ($value, $key) {
- if ($this->server->isSwarm()) {
- return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
- } else {
- return data_get($value, 'Name') === '/coolify-proxy';
- }
- })->first();
- $proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
- if (! $foundProxyContainer || $proxyStatus !== 'running') {
- try {
- $shouldStart = CheckProxy::run($this->server);
- if ($shouldStart) {
- StartProxy::run($this->server, async: false);
- $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
- }
- } catch (\Throwable $e) {
- }
- } else {
- $this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
- $this->server->save();
- $connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
- instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
- }
- }
- }
- } catch (\Throwable $e) {
- return handleError($e);
- }
- }
-
- private function checkLogDrainContainer()
- {
- $foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
- return data_get($value, 'Name') === '/coolify-log-drain';
- })->first();
- if ($foundLogDrainContainer) {
- $status = data_get($foundLogDrainContainer, 'State.Status');
- if ($status !== 'running') {
- StartLogDrain::dispatch($this->server);
- }
- } else {
- StartLogDrain::dispatch($this->server);
- }
- }
-
- private function checkContainers()
- {
- foreach ($this->containers as $container) {
- if ($this->isSentinel) {
- $labels = Arr::undot(data_get($container, 'labels'));
- } else {
- if ($this->server->isSwarm()) {
- $labels = Arr::undot(data_get($container, 'Spec.Labels'));
- } else {
- $labels = Arr::undot(data_get($container, 'Config.Labels'));
- }
- }
- $managed = data_get($labels, 'coolify.managed');
- if (! $managed) {
- continue;
- }
- $uuid = data_get($labels, 'coolify.name');
- if (! $uuid) {
- $uuid = data_get($labels, 'com.docker.compose.service');
- }
-
- if ($this->isSentinel) {
- $containerStatus = data_get($container, 'state');
- $containerHealth = data_get($container, 'health_status');
- } else {
- $containerStatus = data_get($container, 'State.Status');
- $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
- }
- $containerStatus = "$containerStatus ($containerHealth)";
-
- $applicationId = data_get($labels, 'coolify.applicationId');
- $serviceId = data_get($labels, 'coolify.serviceId');
- $databaseId = data_get($labels, 'coolify.databaseId');
- $pullRequestId = data_get($labels, 'coolify.pullRequestId');
-
- if ($applicationId) {
- // Application
- if ($pullRequestId != 0) {
- if (str($applicationId)->contains('-')) {
- $applicationId = str($applicationId)->before('-');
- }
- $preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
- if ($preview) {
- $preview->update(['status' => $containerStatus]);
- }
- } else {
- $application = Application::where('id', $applicationId)->first();
- if ($application) {
- $application->update([
- 'status' => $containerStatus,
- 'last_online_at' => now(),
- ]);
- }
- }
- } elseif (isset($serviceId)) {
- // Service
- $subType = data_get($labels, 'coolify.service.subType');
- $subId = data_get($labels, 'coolify.service.subId');
- $service = Service::where('id', $serviceId)->first();
- if (! $service) {
- continue;
- }
- if ($subType === 'application') {
- $service = ServiceApplication::where('id', $subId)->first();
- } else {
- $service = ServiceDatabase::where('id', $subId)->first();
- }
- if ($service) {
- $service->update([
- 'status' => $containerStatus,
- 'last_online_at' => now(),
- ]);
- if ($subType === 'database') {
- $isPublic = data_get($service, 'is_public');
- if ($isPublic) {
- $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
- if ($this->isSentinel) {
- return data_get($value, 'name') === $uuid.'-proxy';
- } else {
-
- if ($this->server->isSwarm()) {
- return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
- } else {
- return data_get($value, 'Name') === "/$uuid-proxy";
- }
- }
- })->first();
- if (! $foundTcpProxy) {
- StartDatabaseProxy::run($service);
- }
- }
- }
- }
- } else {
- // Database
- if (is_null($this->databases)) {
- $this->databases = $this->server->databases();
- }
- $database = $this->databases->where('uuid', $uuid)->first();
- if ($database) {
- $database->update([
- 'status' => $containerStatus,
- 'last_online_at' => now(),
- ]);
-
- $isPublic = data_get($database, 'is_public');
- if ($isPublic) {
- $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
- if ($this->isSentinel) {
- return data_get($value, 'name') === $uuid.'-proxy';
- } else {
- if ($this->server->isSwarm()) {
- return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
- } else {
-
- return data_get($value, 'Name') === "/$uuid-proxy";
- }
- }
- })->first();
- if (! $foundTcpProxy) {
- StartDatabaseProxy::run($database);
- // $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php
index 3e1dad1c2..f72f23696 100644
--- a/app/Actions/Server/StartLogDrain.php
+++ b/app/Actions/Server/StartLogDrain.php
@@ -180,30 +180,10 @@ public function handle(Server $server)
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
- [
- 'transfer_file' => [
- 'content' => base64_decode($parsers),
- 'destination' => $parsers_config,
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($config),
- 'destination' => $fluent_bit_config,
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($compose),
- 'destination' => $compose_path,
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($readme),
- 'destination' => $readme_path,
- ],
- ],
+ "echo '{$parsers}' | base64 -d | tee $parsers_config > /dev/null",
+ "echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
+ "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
+ "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"test -f $config_path/.env && rm $config_path/.env",
];
if ($type === 'newrelic') {
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index dd1a7ed53..1f248aec1 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -10,7 +10,7 @@ class StartSentinel
{
use AsAction;
- public function handle(Server $server, bool $restart = false, ?string $latestVersion = null)
+ public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
return;
@@ -44,7 +44,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
];
if (isDev()) {
// data_set($environments, 'DEBUG', 'true');
- // $image = 'sentinel';
+ if ($customImage && ! empty($customImage)) {
+ $image = $customImage;
+ }
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
}
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php
index 5a7ba6637..e06136e3c 100644
--- a/app/Actions/Shared/ComplexStatusCheck.php
+++ b/app/Actions/Shared/ComplexStatusCheck.php
@@ -26,22 +26,22 @@ public function handle(Application $application)
continue;
}
}
- $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
- $container = format_docker_command_output_to_json($container);
- if ($container->count() === 1) {
- $container = $container->first();
- $containerStatus = data_get($container, 'State.Status');
- $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
+ $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
+ $containers = format_docker_command_output_to_json($containers);
+
+ if ($containers->count() > 0) {
+ $statusToSet = $this->aggregateContainerStatuses($application, $containers);
+
if ($is_main_server) {
$statusFromDb = $application->status;
- if ($statusFromDb !== $containerStatus) {
- $application->update(['status' => "$containerStatus:$containerHealth"]);
+ if ($statusFromDb !== $statusToSet) {
+ $application->update(['status' => $statusToSet]);
}
} else {
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
$statusFromDb = $additional_server->first()->pivot->status;
- if ($statusFromDb !== $containerStatus) {
- $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
+ if ($statusFromDb !== $statusToSet) {
+ $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
}
}
} else {
@@ -57,4 +57,78 @@ public function handle(Application $application)
}
}
}
+
+ private function aggregateContainerStatuses($application, $containers)
+ {
+ $dockerComposeRaw = data_get($application, 'docker_compose_raw');
+ $excludedContainers = collect();
+
+ if ($dockerComposeRaw) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $services = data_get($dockerCompose, 'services', []);
+
+ foreach ($services as $serviceName => $serviceConfig) {
+ $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
+ $restartPolicy = data_get($serviceConfig, 'restart', 'always');
+
+ if ($excludeFromHc || $restartPolicy === 'no') {
+ $excludedContainers->push($serviceName);
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse, treat all containers as included
+ }
+ }
+
+ $hasRunning = false;
+ $hasRestarting = false;
+ $hasUnhealthy = false;
+ $hasExited = false;
+ $relevantContainerCount = 0;
+
+ foreach ($containers as $container) {
+ $labels = data_get($container, 'Config.Labels', []);
+ $serviceName = data_get($labels, 'com.docker.compose.service');
+
+ if ($serviceName && $excludedContainers->contains($serviceName)) {
+ continue;
+ }
+
+ $relevantContainerCount++;
+ $containerStatus = data_get($container, 'State.Status');
+ $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
+
+ if ($containerStatus === 'restarting') {
+ $hasRestarting = true;
+ $hasUnhealthy = true;
+ } elseif ($containerStatus === 'running') {
+ $hasRunning = true;
+ if ($containerHealth === 'unhealthy') {
+ $hasUnhealthy = true;
+ }
+ } elseif ($containerStatus === 'exited') {
+ $hasExited = true;
+ $hasUnhealthy = true;
+ }
+ }
+
+ if ($relevantContainerCount === 0) {
+ return 'running:healthy';
+ }
+
+ if ($hasRestarting) {
+ return 'degraded:unhealthy';
+ }
+
+ if ($hasRunning && $hasExited) {
+ return 'degraded:unhealthy';
+ }
+
+ if ($hasRunning) {
+ return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
+ }
+
+ return 'exited:unhealthy';
+ }
}
diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php
new file mode 100644
index 000000000..859aec6f6
--- /dev/null
+++ b/app/Actions/Stripe/CancelSubscription.php
@@ -0,0 +1,151 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+
+ if (! $isDryRun && isCloud()) {
+ $this->stripe = new StripeClient(config('subscription.stripe_api_key'));
+ }
+ }
+
+ public function getSubscriptionsPreview(): Collection
+ {
+ $subscriptions = collect();
+
+ // Get all teams the user belongs to
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Only include subscriptions from teams where user is owner
+ $userRole = $team->pivot->role;
+ if ($userRole === 'owner' && $team->subscription) {
+ $subscription = $team->subscription;
+
+ // Only include active subscriptions
+ if ($subscription->stripe_subscription_id &&
+ $subscription->stripe_invoice_paid) {
+ $subscriptions->push($subscription);
+ }
+ }
+ }
+
+ return $subscriptions;
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'cancelled' => 0,
+ 'failed' => 0,
+ 'errors' => [],
+ ];
+ }
+
+ $cancelledCount = 0;
+ $failedCount = 0;
+ $errors = [];
+
+ $subscriptions = $this->getSubscriptionsPreview();
+
+ foreach ($subscriptions as $subscription) {
+ try {
+ $this->cancelSingleSubscription($subscription);
+ $cancelledCount++;
+ } catch (\Exception $e) {
+ $failedCount++;
+ $errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
+ $errors[] = $errorMessage;
+ \Log::error($errorMessage);
+ }
+ }
+
+ return [
+ 'cancelled' => $cancelledCount,
+ 'failed' => $failedCount,
+ 'errors' => $errors,
+ ];
+ }
+
+ private function cancelSingleSubscription(Subscription $subscription): void
+ {
+ if (! $this->stripe) {
+ throw new \Exception('Stripe client not initialized');
+ }
+
+ $subscriptionId = $subscription->stripe_subscription_id;
+
+ // Cancel the subscription immediately (not at period end)
+ $this->stripe->subscriptions->cancel($subscriptionId, []);
+
+ // Update local database
+ $subscription->update([
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_invoice_paid' => false,
+ 'stripe_trial_already_ended' => false,
+ 'stripe_past_due' => false,
+ 'stripe_feedback' => 'User account deleted',
+ 'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
+ ]);
+
+ // Call the team's subscription ended method to handle cleanup
+ if ($subscription->team) {
+ $subscription->team->subscriptionEnded();
+ }
+
+ \Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
+ }
+
+ /**
+ * Cancel a single subscription by ID (helper method for external use)
+ */
+ public static function cancelById(string $subscriptionId): bool
+ {
+ try {
+ if (! isCloud()) {
+ return false;
+ }
+
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
+ $stripe->subscriptions->cancel($subscriptionId, []);
+
+ // Update local record if exists
+ $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
+ if ($subscription) {
+ $subscription->update([
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_invoice_paid' => false,
+ 'stripe_trial_already_ended' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ if ($subscription->team) {
+ $subscription->team->subscriptionEnded();
+ }
+ }
+
+ return true;
+ } catch (\Exception $e) {
+ \Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+}
diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php
new file mode 100644
index 000000000..7b2e7318d
--- /dev/null
+++ b/app/Actions/User/DeleteUserResources.php
@@ -0,0 +1,125 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+ }
+
+ public function getResourcesPreview(): array
+ {
+ $applications = collect();
+ $databases = collect();
+ $services = collect();
+
+ // Get all teams the user belongs to
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Get all servers for this team
+ $servers = $team->servers;
+
+ foreach ($servers as $server) {
+ // Get applications
+ $serverApplications = $server->applications;
+ $applications = $applications->merge($serverApplications);
+
+ // Get databases
+ $serverDatabases = $this->getAllDatabasesForServer($server);
+ $databases = $databases->merge($serverDatabases);
+
+ // Get services
+ $serverServices = $server->services;
+ $services = $services->merge($serverServices);
+ }
+ }
+
+ return [
+ 'applications' => $applications->unique('id'),
+ 'databases' => $databases->unique('id'),
+ 'services' => $services->unique('id'),
+ ];
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'applications' => 0,
+ 'databases' => 0,
+ 'services' => 0,
+ ];
+ }
+
+ $deletedCounts = [
+ 'applications' => 0,
+ 'databases' => 0,
+ 'services' => 0,
+ ];
+
+ $resources = $this->getResourcesPreview();
+
+ // Delete applications
+ foreach ($resources['applications'] as $application) {
+ try {
+ $application->forceDelete();
+ $deletedCounts['applications']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Delete databases
+ foreach ($resources['databases'] as $database) {
+ try {
+ $database->forceDelete();
+ $deletedCounts['databases']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Delete services
+ foreach ($resources['services'] as $service) {
+ try {
+ $service->forceDelete();
+ $deletedCounts['services']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ return $deletedCounts;
+ }
+
+ private function getAllDatabasesForServer($server): Collection
+ {
+ $databases = collect();
+
+ // Get all standalone database types
+ $databases = $databases->merge($server->postgresqls);
+ $databases = $databases->merge($server->mysqls);
+ $databases = $databases->merge($server->mariadbs);
+ $databases = $databases->merge($server->mongodbs);
+ $databases = $databases->merge($server->redis);
+ $databases = $databases->merge($server->keydbs);
+ $databases = $databases->merge($server->dragonflies);
+ $databases = $databases->merge($server->clickhouses);
+
+ return $databases;
+ }
+}
diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php
new file mode 100644
index 000000000..d8caae54d
--- /dev/null
+++ b/app/Actions/User/DeleteUserServers.php
@@ -0,0 +1,77 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+ }
+
+ public function getServersPreview(): Collection
+ {
+ $servers = collect();
+
+ // Get all teams the user belongs to
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Only include servers from teams where user is owner or admin
+ $userRole = $team->pivot->role;
+ if ($userRole === 'owner' || $userRole === 'admin') {
+ $teamServers = $team->servers;
+ $servers = $servers->merge($teamServers);
+ }
+ }
+
+ // Return unique servers (in case same server is in multiple teams)
+ return $servers->unique('id');
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'servers' => 0,
+ ];
+ }
+
+ $deletedCount = 0;
+
+ $servers = $this->getServersPreview();
+
+ foreach ($servers as $server) {
+ try {
+ // Skip the default server (ID 0) which is the Coolify host
+ if ($server->id === 0) {
+ \Log::info('Skipping deletion of Coolify host server (ID: 0)');
+
+ continue;
+ }
+
+ // The Server model's forceDeleting event will handle cleanup of:
+ // - destinations
+ // - settings
+ $server->forceDelete();
+ $deletedCount++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ return [
+ 'servers' => $deletedCount,
+ ];
+ }
+}
diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php
new file mode 100644
index 000000000..d572db9e7
--- /dev/null
+++ b/app/Actions/User/DeleteUserTeams.php
@@ -0,0 +1,202 @@
+user = $user;
+ $this->isDryRun = $isDryRun;
+ }
+
+ public function getTeamsPreview(): array
+ {
+ $teamsToDelete = collect();
+ $teamsToTransfer = collect();
+ $teamsToLeave = collect();
+ $edgeCases = collect();
+
+ $teams = $this->user->teams;
+
+ foreach ($teams as $team) {
+ // Skip root team (ID 0)
+ if ($team->id === 0) {
+ continue;
+ }
+
+ $userRole = $team->pivot->role;
+ $memberCount = $team->members->count();
+
+ if ($memberCount === 1) {
+ // User is alone in the team - delete it
+ $teamsToDelete->push($team);
+ } elseif ($userRole === 'owner') {
+ // Check if there are other owners
+ $otherOwners = $team->members
+ ->where('id', '!=', $this->user->id)
+ ->filter(function ($member) {
+ return $member->pivot->role === 'owner';
+ });
+
+ if ($otherOwners->isNotEmpty()) {
+ // There are other owners, but check if this user is paying for the subscription
+ if ($this->isUserPayingForTeamSubscription($team)) {
+ // User is paying for the subscription - this is an edge case
+ $edgeCases->push([
+ 'team' => $team,
+ 'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
+ ]);
+ } else {
+ // There are other owners and user is not paying, just remove this user
+ $teamsToLeave->push($team);
+ }
+ } else {
+ // User is the only owner, check for replacement
+ $newOwner = $this->findNewOwner($team);
+ if ($newOwner) {
+ $teamsToTransfer->push([
+ 'team' => $team,
+ 'new_owner' => $newOwner,
+ ]);
+ } else {
+ // No suitable replacement found - this is an edge case
+ $edgeCases->push([
+ 'team' => $team,
+ 'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
+ ]);
+ }
+ }
+ } else {
+ // User is just a member - remove them from the team
+ $teamsToLeave->push($team);
+ }
+ }
+
+ return [
+ 'to_delete' => $teamsToDelete,
+ 'to_transfer' => $teamsToTransfer,
+ 'to_leave' => $teamsToLeave,
+ 'edge_cases' => $edgeCases,
+ ];
+ }
+
+ public function execute(): array
+ {
+ if ($this->isDryRun) {
+ return [
+ 'deleted' => 0,
+ 'transferred' => 0,
+ 'left' => 0,
+ ];
+ }
+
+ $counts = [
+ 'deleted' => 0,
+ 'transferred' => 0,
+ 'left' => 0,
+ ];
+
+ $preview = $this->getTeamsPreview();
+
+ // Check for edge cases - should not happen here as we check earlier, but be safe
+ if ($preview['edge_cases']->isNotEmpty()) {
+ throw new \Exception('Edge cases detected during execution. This should not happen.');
+ }
+
+ // Delete teams where user is alone
+ foreach ($preview['to_delete'] as $team) {
+ try {
+ // The Team model's deleting event will handle cleanup of:
+ // - private keys
+ // - sources
+ // - tags
+ // - environment variables
+ // - s3 storages
+ // - notification settings
+ $team->delete();
+ $counts['deleted']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Transfer ownership for teams where user is owner but not alone
+ foreach ($preview['to_transfer'] as $item) {
+ try {
+ $team = $item['team'];
+ $newOwner = $item['new_owner'];
+
+ // Update the new owner's role to owner
+ $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
+
+ // Remove the current user from the team
+ $team->members()->detach($this->user->id);
+
+ $counts['transferred']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ // Remove user from teams where they're just a member
+ foreach ($preview['to_leave'] as $team) {
+ try {
+ $team->members()->detach($this->user->id);
+ $counts['left']++;
+ } catch (\Exception $e) {
+ \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
+ throw $e; // Re-throw to trigger rollback
+ }
+ }
+
+ return $counts;
+ }
+
+ private function findNewOwner(Team $team): ?User
+ {
+ // Only look for admins as potential new owners
+ // We don't promote regular members automatically
+ $otherAdmin = $team->members
+ ->where('id', '!=', $this->user->id)
+ ->filter(function ($member) {
+ return $member->pivot->role === 'admin';
+ })
+ ->first();
+
+ return $otherAdmin;
+ }
+
+ private function isUserPayingForTeamSubscription(Team $team): bool
+ {
+ if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
+ return false;
+ }
+
+ // In Stripe, we need to check if the customer email matches the user's email
+ // This would require a Stripe API call to get customer details
+ // For now, we'll check if the subscription was created by this user
+
+ // Alternative approach: Check if user is the one who initiated the subscription
+ // We could store this information when the subscription is created
+ // For safety, we'll assume if there's an active subscription and multiple owners,
+ // we should treat it as an edge case that needs manual review
+
+ if ($team->subscription->stripe_subscription_id &&
+ $team->subscription->stripe_invoice_paid) {
+ // Active subscription exists - we should be cautious
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/CloudDeleteUser.php
new file mode 100644
index 000000000..6928eb97b
--- /dev/null
+++ b/app/Console/Commands/CloudDeleteUser.php
@@ -0,0 +1,722 @@
+error('This command is only available on cloud instances.');
+
+ return 1;
+ }
+
+ $email = $this->argument('email');
+ $this->isDryRun = $this->option('dry-run');
+ $this->skipStripe = $this->option('skip-stripe');
+ $this->skipResources = $this->option('skip-resources');
+
+ if ($this->isDryRun) {
+ $this->info('🔍 DRY RUN MODE - No data will be deleted');
+ $this->newLine();
+ }
+
+ try {
+ $this->user = User::whereEmail($email)->firstOrFail();
+ } catch (\Exception $e) {
+ $this->error("User with email '{$email}' not found.");
+
+ return 1;
+ }
+
+ $this->logAction("Starting user deletion process for: {$email}");
+
+ // Phase 1: Show User Overview (outside transaction)
+ if (! $this->showUserOverview()) {
+ $this->info('User deletion cancelled.');
+
+ return 0;
+ }
+
+ // If not dry run, wrap everything in a transaction
+ if (! $this->isDryRun) {
+ try {
+ DB::beginTransaction();
+
+ // Phase 2: Delete Resources
+ if (! $this->skipResources) {
+ if (! $this->deleteResources()) {
+ DB::rollBack();
+ $this->error('User deletion failed at resource deletion phase. All changes rolled back.');
+
+ return 1;
+ }
+ }
+
+ // Phase 3: Delete Servers
+ if (! $this->deleteServers()) {
+ DB::rollBack();
+ $this->error('User deletion failed at server deletion phase. All changes rolled back.');
+
+ return 1;
+ }
+
+ // Phase 4: Handle Teams
+ if (! $this->handleTeams()) {
+ DB::rollBack();
+ $this->error('User deletion failed at team handling phase. All changes rolled back.');
+
+ return 1;
+ }
+
+ // Phase 5: Cancel Stripe Subscriptions
+ if (! $this->skipStripe && isCloud()) {
+ if (! $this->cancelStripeSubscriptions()) {
+ DB::rollBack();
+ $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
+
+ return 1;
+ }
+ }
+
+ // Phase 6: Delete User Profile
+ if (! $this->deleteUserProfile()) {
+ DB::rollBack();
+ $this->error('User deletion failed at final phase. All changes rolled back.');
+
+ return 1;
+ }
+
+ // Commit the transaction
+ DB::commit();
+
+ $this->newLine();
+ $this->info('✅ User deletion completed successfully!');
+ $this->logAction("User deletion completed for: {$email}");
+
+ } catch (\Exception $e) {
+ DB::rollBack();
+ $this->error('An error occurred during user deletion: '.$e->getMessage());
+ $this->logAction("User deletion failed for {$email}: ".$e->getMessage());
+
+ return 1;
+ }
+ } else {
+ // Dry run mode - just run through the phases without transaction
+ // Phase 2: Delete Resources
+ if (! $this->skipResources) {
+ if (! $this->deleteResources()) {
+ $this->info('User deletion would be cancelled at resource deletion phase.');
+
+ return 0;
+ }
+ }
+
+ // Phase 3: Delete Servers
+ if (! $this->deleteServers()) {
+ $this->info('User deletion would be cancelled at server deletion phase.');
+
+ return 0;
+ }
+
+ // Phase 4: Handle Teams
+ if (! $this->handleTeams()) {
+ $this->info('User deletion would be cancelled at team handling phase.');
+
+ return 0;
+ }
+
+ // Phase 5: Cancel Stripe Subscriptions
+ if (! $this->skipStripe && isCloud()) {
+ if (! $this->cancelStripeSubscriptions()) {
+ $this->info('User deletion would be cancelled at Stripe cancellation phase.');
+
+ return 0;
+ }
+ }
+
+ // Phase 6: Delete User Profile
+ if (! $this->deleteUserProfile()) {
+ $this->info('User deletion would be cancelled at final phase.');
+
+ return 0;
+ }
+
+ $this->newLine();
+ $this->info('✅ DRY RUN completed successfully! No data was deleted.');
+ }
+
+ return 0;
+ }
+
+ private function showUserOverview(): bool
+ {
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 1: USER OVERVIEW');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $teams = $this->user->teams;
+ $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
+ $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
+
+ // Collect all servers from all teams
+ $allServers = collect();
+ $allApplications = collect();
+ $allDatabases = collect();
+ $allServices = collect();
+ $activeSubscriptions = collect();
+
+ foreach ($teams as $team) {
+ $servers = $team->servers;
+ $allServers = $allServers->merge($servers);
+
+ foreach ($servers as $server) {
+ $resources = $server->definedResources();
+ foreach ($resources as $resource) {
+ if ($resource instanceof \App\Models\Application) {
+ $allApplications->push($resource);
+ } elseif ($resource instanceof \App\Models\Service) {
+ $allServices->push($resource);
+ } else {
+ $allDatabases->push($resource);
+ }
+ }
+ }
+
+ if ($team->subscription && $team->subscription->stripe_subscription_id) {
+ $activeSubscriptions->push($team->subscription);
+ }
+ }
+
+ $this->table(
+ ['Property', 'Value'],
+ [
+ ['User', $this->user->email],
+ ['User ID', $this->user->id],
+ ['Created', $this->user->created_at->format('Y-m-d H:i:s')],
+ ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
+ ['Teams (Total)', $teams->count()],
+ ['Teams (Owner)', $ownedTeams->count()],
+ ['Teams (Member)', $memberTeams->count()],
+ ['Servers', $allServers->unique('id')->count()],
+ ['Applications', $allApplications->count()],
+ ['Databases', $allDatabases->count()],
+ ['Services', $allServices->count()],
+ ['Active Stripe Subscriptions', $activeSubscriptions->count()],
+ ]
+ );
+
+ $this->newLine();
+
+ $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
+ $this->newLine();
+
+ if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private function deleteResources(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 2: DELETE RESOURCES');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new DeleteUserResources($this->user, $this->isDryRun);
+ $resources = $action->getResourcesPreview();
+
+ if ($resources['applications']->isEmpty() &&
+ $resources['databases']->isEmpty() &&
+ $resources['services']->isEmpty()) {
+ $this->info('No resources to delete.');
+
+ return true;
+ }
+
+ $this->info('Resources to be deleted:');
+ $this->newLine();
+
+ if ($resources['applications']->isNotEmpty()) {
+ $this->warn("Applications to be deleted ({$resources['applications']->count()}):");
+ $this->table(
+ ['Name', 'UUID', 'Server', 'Status'],
+ $resources['applications']->map(function ($app) {
+ return [
+ $app->name,
+ $app->uuid,
+ $app->destination->server->name,
+ $app->status ?? 'unknown',
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($resources['databases']->isNotEmpty()) {
+ $this->warn("Databases to be deleted ({$resources['databases']->count()}):");
+ $this->table(
+ ['Name', 'Type', 'UUID', 'Server'],
+ $resources['databases']->map(function ($db) {
+ return [
+ $db->name,
+ class_basename($db),
+ $db->uuid,
+ $db->destination->server->name,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($resources['services']->isNotEmpty()) {
+ $this->warn("Services to be deleted ({$resources['services']->count()}):");
+ $this->table(
+ ['Name', 'UUID', 'Server'],
+ $resources['services']->map(function ($service) {
+ return [
+ $service->name,
+ $service->uuid,
+ $service->server->name,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
+ if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Deleting resources...');
+ $result = $action->execute();
+ $this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
+ $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
+ }
+
+ return true;
+ }
+
+ private function deleteServers(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 3: DELETE SERVERS');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new DeleteUserServers($this->user, $this->isDryRun);
+ $servers = $action->getServersPreview();
+
+ if ($servers->isEmpty()) {
+ $this->info('No servers to delete.');
+
+ return true;
+ }
+
+ $this->warn("Servers to be deleted ({$servers->count()}):");
+ $this->table(
+ ['ID', 'Name', 'IP', 'Description', 'Resources Count'],
+ $servers->map(function ($server) {
+ $resourceCount = $server->definedResources()->count();
+
+ return [
+ $server->id,
+ $server->name,
+ $server->ip,
+ $server->description ?? '-',
+ $resourceCount,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+
+ $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
+ if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Deleting servers...');
+ $result = $action->execute();
+ $this->info("Deleted {$result['servers']} servers");
+ $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
+ }
+
+ return true;
+ }
+
+ private function handleTeams(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 4: HANDLE TEAMS');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new DeleteUserTeams($this->user, $this->isDryRun);
+ $preview = $action->getTeamsPreview();
+
+ // Check for edge cases first - EXIT IMMEDIATELY if found
+ if ($preview['edge_cases']->isNotEmpty()) {
+ $this->error('═══════════════════════════════════════');
+ $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
+ $this->error('═══════════════════════════════════════');
+ $this->newLine();
+
+ foreach ($preview['edge_cases'] as $edgeCase) {
+ $team = $edgeCase['team'];
+ $reason = $edgeCase['reason'];
+ $this->error("Team: {$team->name} (ID: {$team->id})");
+ $this->error("Issue: {$reason}");
+
+ // Show team members for context
+ $this->info('Current members:');
+ foreach ($team->members as $member) {
+ $role = $member->pivot->role;
+ $this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
+ }
+
+ // Check for active resources
+ $resourceCount = 0;
+ foreach ($team->servers as $server) {
+ $resources = $server->definedResources();
+ $resourceCount += $resources->count();
+ }
+
+ if ($resourceCount > 0) {
+ $this->warn(" ⚠️ This team has {$resourceCount} active resources!");
+ }
+
+ // Show subscription details if relevant
+ if ($team->subscription && $team->subscription->stripe_subscription_id) {
+ $this->warn(' ⚠️ Active Stripe subscription details:');
+ $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
+ $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
+
+ // Show other owners who could potentially take over
+ $otherOwners = $team->members
+ ->where('id', '!=', $this->user->id)
+ ->filter(function ($member) {
+ return $member->pivot->role === 'owner';
+ });
+
+ if ($otherOwners->isNotEmpty()) {
+ $this->info(' Other owners who could take over billing:');
+ foreach ($otherOwners as $owner) {
+ $this->line(" - {$owner->name} ({$owner->email})");
+ }
+ }
+ }
+
+ $this->newLine();
+ }
+
+ $this->error('Please resolve these issues manually before retrying:');
+
+ // Check if any edge case involves subscription payment issues
+ $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
+ return str_contains($edgeCase['reason'], 'Stripe subscription');
+ });
+
+ if ($hasSubscriptionIssue) {
+ $this->info('For teams with subscription payment issues:');
+ $this->info('1. Cancel the subscription through Stripe dashboard, OR');
+ $this->info('2. Transfer the subscription to another owner\'s payment method, OR');
+ $this->info('3. Have the other owner create a new subscription after cancelling this one');
+ $this->newLine();
+ }
+
+ $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
+ return str_contains($edgeCase['reason'], 'No suitable owner replacement');
+ });
+
+ if ($hasNoOwnerReplacement) {
+ $this->info('For teams with no suitable owner replacement:');
+ $this->info('1. Assign an admin role to a trusted member, OR');
+ $this->info('2. Transfer team resources to another team, OR');
+ $this->info('3. Delete the team manually if no longer needed');
+ $this->newLine();
+ }
+
+ $this->error('USER DELETION ABORTED DUE TO EDGE CASES');
+ $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
+
+ // Exit immediately - don't proceed with deletion
+ if (! $this->isDryRun) {
+ DB::rollBack();
+ }
+ exit(1);
+ }
+
+ if ($preview['to_delete']->isEmpty() &&
+ $preview['to_transfer']->isEmpty() &&
+ $preview['to_leave']->isEmpty()) {
+ $this->info('No team changes needed.');
+
+ return true;
+ }
+
+ if ($preview['to_delete']->isNotEmpty()) {
+ $this->warn('Teams to be DELETED (user is the only member):');
+ $this->table(
+ ['ID', 'Name', 'Resources', 'Subscription'],
+ $preview['to_delete']->map(function ($team) {
+ $resourceCount = 0;
+ foreach ($team->servers as $server) {
+ $resourceCount += $server->definedResources()->count();
+ }
+ $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
+ ? '⚠️ YES - '.$team->subscription->stripe_subscription_id
+ : 'No';
+
+ return [
+ $team->id,
+ $team->name,
+ $resourceCount,
+ $hasSubscription,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($preview['to_transfer']->isNotEmpty()) {
+ $this->warn('Teams where ownership will be TRANSFERRED:');
+ $this->table(
+ ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
+ $preview['to_transfer']->map(function ($item) {
+ return [
+ $item['team']->id,
+ $item['team']->name,
+ $item['new_owner']->name,
+ $item['new_owner']->email,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ if ($preview['to_leave']->isNotEmpty()) {
+ $this->warn('Teams where user will be REMOVED (other owners/admins exist):');
+ $userId = $this->user->id;
+ $this->table(
+ ['ID', 'Name', 'User Role', 'Other Members'],
+ $preview['to_leave']->map(function ($team) use ($userId) {
+ $userRole = $team->members->where('id', $userId)->first()->pivot->role;
+ $otherMembers = $team->members->count() - 1;
+
+ return [
+ $team->id,
+ $team->name,
+ $userRole,
+ $otherMembers,
+ ];
+ })->toArray()
+ );
+ $this->newLine();
+ }
+
+ $this->error('⚠️ WARNING: Team changes affect access control and ownership!');
+ if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Processing team changes...');
+ $result = $action->execute();
+ $this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
+ $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
+ }
+
+ return true;
+ }
+
+ private function cancelStripeSubscriptions(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $action = new CancelSubscription($this->user, $this->isDryRun);
+ $subscriptions = $action->getSubscriptionsPreview();
+
+ if ($subscriptions->isEmpty()) {
+ $this->info('No Stripe subscriptions to cancel.');
+
+ return true;
+ }
+
+ $this->info('Stripe subscriptions to cancel:');
+ $this->newLine();
+
+ $totalMonthlyValue = 0;
+ foreach ($subscriptions as $subscription) {
+ $team = $subscription->team;
+ $planId = $subscription->stripe_plan_id;
+
+ // Try to get the price from config
+ $monthlyValue = $this->getSubscriptionMonthlyValue($planId);
+ $totalMonthlyValue += $monthlyValue;
+
+ $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
+ if ($monthlyValue > 0) {
+ $this->line(" Monthly value: \${$monthlyValue}");
+ }
+ if ($subscription->stripe_cancel_at_period_end) {
+ $this->line(' ⚠️ Already set to cancel at period end');
+ }
+ }
+
+ if ($totalMonthlyValue > 0) {
+ $this->newLine();
+ $this->warn("Total monthly value: \${$totalMonthlyValue}");
+ }
+ $this->newLine();
+
+ $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
+ if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Cancelling subscriptions...');
+ $result = $action->execute();
+ $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
+ if ($result['failed'] > 0 && ! empty($result['errors'])) {
+ $this->error('Failed subscriptions:');
+ foreach ($result['errors'] as $error) {
+ $this->error(" - {$error}");
+ }
+ }
+ $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
+ }
+
+ return true;
+ }
+
+ private function deleteUserProfile(): bool
+ {
+ $this->newLine();
+ $this->info('═══════════════════════════════════════');
+ $this->info('PHASE 6: DELETE USER PROFILE');
+ $this->info('═══════════════════════════════════════');
+ $this->newLine();
+
+ $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
+ $this->newLine();
+
+ $this->info('User profile to be deleted:');
+ $this->table(
+ ['Property', 'Value'],
+ [
+ ['Email', $this->user->email],
+ ['Name', $this->user->name],
+ ['User ID', $this->user->id],
+ ['Created', $this->user->created_at->format('Y-m-d H:i:s')],
+ ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
+ ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
+ ]
+ );
+
+ $this->newLine();
+
+ $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
+ $confirmation = $this->ask('Confirmation');
+
+ if ($confirmation !== "DELETE {$this->user->email}") {
+ $this->error('Confirmation text does not match. Deletion cancelled.');
+
+ return false;
+ }
+
+ if (! $this->isDryRun) {
+ $this->info('Deleting user profile...');
+
+ try {
+ $this->user->delete();
+ $this->info('User profile deleted successfully.');
+ $this->logAction("User profile deleted: {$this->user->email}");
+ } catch (\Exception $e) {
+ $this->error('Failed to delete user profile: '.$e->getMessage());
+ $this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function getSubscriptionMonthlyValue(string $planId): int
+ {
+ // Map plan IDs to monthly values based on config
+ $subscriptionConfigs = config('subscription');
+
+ foreach ($subscriptionConfigs as $key => $value) {
+ if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
+ // Extract price from key pattern: stripe_price_id_basic_monthly -> basic
+ $planType = str($key)->after('stripe_price_id_')->before('_')->toString();
+
+ // Map to known prices (you may need to adjust these based on your actual pricing)
+ return match ($planType) {
+ 'basic' => 29,
+ 'pro' => 49,
+ 'ultimate' => 99,
+ default => 0
+ };
+ }
+ }
+
+ return 0;
+ }
+
+ private function logAction(string $message): void
+ {
+ $logMessage = "[CloudDeleteUser] {$message}";
+
+ if ($this->isDryRun) {
+ $logMessage = "[DRY RUN] {$logMessage}";
+ }
+
+ Log::channel('single')->info($logMessage);
+
+ // Also log to a dedicated user deletion log file
+ $logFile = storage_path('logs/user-deletions.log');
+ $timestamp = now()->format('Y-m-d H:i:s');
+ file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
+ }
+}
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 7ef1c3506..cd640df17 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2429,7 +2429,6 @@ public function envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
- 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -2470,7 +2469,7 @@ public function envs(Request $request)
)]
public function update_env_by_uuid(Request $request)
{
- $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
+ $allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -2495,7 +2494,6 @@ public function update_env_by_uuid(Request $request)
'key' => 'string|required',
'value' => 'string|nullable',
'is_preview' => 'boolean',
- 'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@@ -2516,16 +2514,12 @@ public function update_env_by_uuid(Request $request)
], 422);
}
$is_preview = $request->is_preview ?? false;
- $is_build_time = $request->is_build_time ?? false;
$is_literal = $request->is_literal ?? false;
$key = str($request->key)->trim()->replace(' ', '_')->value;
if ($is_preview) {
$env = $application->environment_variables_preview->where('key', $key)->first();
if ($env) {
$env->value = $request->value;
- if ($env->is_build_time != $is_build_time) {
- $env->is_build_time = $is_build_time;
- }
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@@ -2538,6 +2532,12 @@ public function update_env_by_uuid(Request $request)
if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once;
}
+ if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
+ $env->is_runtime = $request->is_runtime;
+ }
+ if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
+ $env->is_buildtime = $request->is_buildtime;
+ }
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -2550,9 +2550,6 @@ public function update_env_by_uuid(Request $request)
$env = $application->environment_variables->where('key', $key)->first();
if ($env) {
$env->value = $request->value;
- if ($env->is_build_time != $is_build_time) {
- $env->is_build_time = $is_build_time;
- }
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@@ -2565,6 +2562,12 @@ public function update_env_by_uuid(Request $request)
if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once;
}
+ if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
+ $env->is_runtime = $request->is_runtime;
+ }
+ if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
+ $env->is_buildtime = $request->is_buildtime;
+ }
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -2619,7 +2622,6 @@ public function update_env_by_uuid(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
- 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -2690,7 +2692,7 @@ public function create_bulk_envs(Request $request)
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
- return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']);
+ return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@@ -2698,7 +2700,6 @@ public function create_bulk_envs(Request $request)
'key' => 'string|required',
'value' => 'string|nullable',
'is_preview' => 'boolean',
- 'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@@ -2710,7 +2711,6 @@ public function create_bulk_envs(Request $request)
], 422);
}
$is_preview = $item->get('is_preview') ?? false;
- $is_build_time = $item->get('is_build_time') ?? false;
$is_literal = $item->get('is_literal') ?? false;
$is_multi_line = $item->get('is_multiline') ?? false;
$is_shown_once = $item->get('is_shown_once') ?? false;
@@ -2719,9 +2719,7 @@ public function create_bulk_envs(Request $request)
$env = $application->environment_variables_preview->where('key', $key)->first();
if ($env) {
$env->value = $item->get('value');
- if ($env->is_build_time != $is_build_time) {
- $env->is_build_time = $is_build_time;
- }
+
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@@ -2731,16 +2729,23 @@ public function create_bulk_envs(Request $request)
if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once');
}
+ if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
+ $env->is_runtime = $item->get('is_runtime');
+ }
+ if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
+ $env->is_buildtime = $item->get('is_buildtime');
+ }
$env->save();
} else {
$env = $application->environment_variables()->create([
'key' => $item->get('key'),
'value' => $item->get('value'),
'is_preview' => $is_preview,
- 'is_build_time' => $is_build_time,
'is_literal' => $is_literal,
'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once,
+ 'is_runtime' => $item->get('is_runtime', true),
+ 'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -2749,9 +2754,6 @@ public function create_bulk_envs(Request $request)
$env = $application->environment_variables->where('key', $key)->first();
if ($env) {
$env->value = $item->get('value');
- if ($env->is_build_time != $is_build_time) {
- $env->is_build_time = $is_build_time;
- }
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@@ -2761,16 +2763,23 @@ public function create_bulk_envs(Request $request)
if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once');
}
+ if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
+ $env->is_runtime = $item->get('is_runtime');
+ }
+ if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
+ $env->is_buildtime = $item->get('is_buildtime');
+ }
$env->save();
} else {
$env = $application->environment_variables()->create([
'key' => $item->get('key'),
'value' => $item->get('value'),
'is_preview' => $is_preview,
- 'is_build_time' => $is_build_time,
'is_literal' => $is_literal,
'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once,
+ 'is_runtime' => $item->get('is_runtime', true),
+ 'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -2814,7 +2823,6 @@ public function create_bulk_envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
- 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -2854,7 +2862,7 @@ public function create_bulk_envs(Request $request)
)]
public function create_env(Request $request)
{
- $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
+ $allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -2874,7 +2882,6 @@ public function create_env(Request $request)
'key' => 'string|required',
'value' => 'string|nullable',
'is_preview' => 'boolean',
- 'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@@ -2908,10 +2915,11 @@ public function create_env(Request $request)
'key' => $request->key,
'value' => $request->value,
'is_preview' => $request->is_preview ?? false,
- 'is_build_time' => $request->is_build_time ?? false,
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
+ 'is_runtime' => $request->is_runtime ?? true,
+ 'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -2931,10 +2939,11 @@ public function create_env(Request $request)
'key' => $request->key,
'value' => $request->value,
'is_preview' => $request->is_preview ?? false,
- 'is_build_time' => $request->is_build_time ?? false,
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
+ 'is_runtime' => $request->is_runtime ?? true,
+ 'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 162f637c5..e240e326e 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -353,7 +353,6 @@ public function create_service(Request $request)
'value' => $generatedValue,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),
- 'is_build_time' => false,
'is_preview' => false,
]);
});
@@ -919,7 +918,6 @@ public function envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
- 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -975,7 +973,6 @@ public function update_env_by_uuid(Request $request)
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'value' => 'string|nullable',
- 'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@@ -1039,7 +1036,6 @@ public function update_env_by_uuid(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
- 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -1105,7 +1101,6 @@ public function create_bulk_envs(Request $request)
$validator = customApiValidator($item, [
'key' => 'string|required',
'value' => 'string|nullable',
- 'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@@ -1161,7 +1156,6 @@ public function create_bulk_envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
- 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -1216,7 +1210,6 @@ public function create_env(Request $request)
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'value' => 'string|nullable',
- 'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 35e479ff4..c880057e5 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -167,6 +167,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $preserveRepository = false;
+ private bool $dockerBuildkitSupported = false;
+
+ private bool $skip_build = false;
+
+ private Collection|string $build_secrets;
+
public function tags()
{
// Do not remove this one, it needs to properly identify which worker is running the job
@@ -183,6 +189,7 @@ public function __construct(public int $application_deployment_queue_id)
$this->application = Application::find($this->application_deployment_queue->application_id);
$this->build_pack = data_get($this->application, 'build_pack');
$this->build_args = collect([]);
+ $this->build_secrets = '';
$this->deployment_uuid = $this->application_deployment_queue->deployment_uuid;
$this->pull_request_id = $this->application_deployment_queue->pull_request_id;
@@ -221,7 +228,7 @@ public function __construct(public int $application_deployment_queue_id)
if ($this->pull_request_id === 0) {
$this->container_name = $this->application->settings->custom_internal_name;
} else {
- $this->container_name = "{$this->application->settings->custom_internal_name}-pr-{$this->pull_request_id}";
+ $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
}
}
@@ -250,6 +257,14 @@ public function __construct(public int $application_deployment_queue_id)
public function handle(): void
{
+ // Check if deployment was cancelled before we even started
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.');
+
+ return;
+ }
+
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'horizon_job_worker' => gethostname(),
@@ -263,7 +278,6 @@ public function handle(): void
try {
// Make sure the private key is stored in the filesystem
$this->server->privateKey->storeInFileSystem();
-
// Generate custom host<->ip mapping
$allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server);
@@ -319,6 +333,7 @@ public function handle(): void
$this->build_server = $this->server;
$this->original_server = $this->server;
}
+ $this->detectBuildKitCapabilities();
$this->decide_what_to_do();
} catch (Exception $e) {
if ($this->pull_request_id !== 0 && $this->application->is_github_based()) {
@@ -336,6 +351,7 @@ public function handle(): void
} else {
$this->write_deployment_configurations();
}
+
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
$this->graceful_shutdown_container($this->deployment_uuid);
@@ -343,6 +359,80 @@ public function handle(): void
}
}
+ private function detectBuildKitCapabilities(): void
+ {
+ // If build secrets are not enabled, skip detection and use traditional args
+ if (! $this->application->settings->use_build_secrets) {
+ $this->dockerBuildkitSupported = false;
+
+ return;
+ }
+
+ $serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
+ $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
+
+ try {
+ $dockerVersion = instant_remote_process(
+ ["docker version --format '{{.Server.Version}}'"],
+ $serverToCheck
+ );
+
+ $versionParts = explode('.', $dockerVersion);
+ $majorVersion = (int) $versionParts[0];
+ $minorVersion = (int) ($versionParts[1] ?? 0);
+
+ if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
+
+ return;
+ }
+
+ $buildkitEnabled = instant_remote_process(
+ ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
+ $serverToCheck
+ );
+
+ if (trim($buildkitEnabled) !== 'available') {
+ $buildkitTest = instant_remote_process(
+ ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
+ $serverToCheck
+ );
+
+ if (trim($buildkitTest) === 'supported') {
+ $this->dockerBuildkitSupported = true;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
+ $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
+ } else {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
+ $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
+ }
+ } else {
+ // Buildx is available, which means BuildKit is available
+ // Now specifically test for secrets support
+ $secretsTest = instant_remote_process(
+ ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
+ $serverToCheck
+ );
+
+ if (trim($secretsTest) === 'supported') {
+ $this->dockerBuildkitSupported = true;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
+ $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
+ } else {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
+ $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
+ }
+ }
+ } catch (\Exception $e) {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
+ $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
+ }
+ }
+
private function decide_what_to_do()
{
if ($this->restart_only) {
@@ -388,8 +478,11 @@ private function deploy_simple_dockerfile()
$dockerfile_base64 = base64_encode($this->application->dockerfile);
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name} to {$this->server->name}.");
$this->prepare_builder_image();
- $dockerfile_content = base64_decode($dockerfile_base64);
- transfer_file_to_container($dockerfile_content, "{$this->workdir}{$this->dockerfile_location}", $this->deployment_uuid, $this->server);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$dockerfile_base64' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
+ ],
+ );
$this->generate_image_names();
$this->generate_compose_file();
$this->generate_build_env_variables();
@@ -468,14 +561,23 @@ private function deploy_docker_compose_buildpack()
}
$this->generate_image_names();
$this->cleanup_git();
+
+ $this->generate_build_env_variables();
+
$this->application->loadComposeFile(isInit: false);
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser();
$yaml = $composeFile = $this->application->docker_compose_raw;
- $this->save_environment_variables();
+ $this->generate_runtime_environment_variables();
+
+ // For raw compose, we cannot automatically add secrets configuration
+ // User must define it manually in their docker-compose file
+ if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
+ }
} else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
- $this->save_environment_variables();
+ $this->generate_runtime_environment_variables();
if (filled($this->env_filename)) {
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
@@ -491,20 +593,41 @@ private function deploy_docker_compose_buildpack()
return;
}
+
+ // Add build secrets to compose file if enabled and BuildKit is supported
+ if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ $composeFile = $this->add_build_secrets_to_compose($composeFile);
+ }
+
$yaml = Yaml::dump(convertToArray($composeFile), 10);
}
$this->docker_compose_base64 = base64_encode($yaml);
- transfer_file_to_container($yaml, "{$this->workdir}{$this->docker_compose_location}", $this->deployment_uuid, $this->server);
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"),
+ 'hidden' => true,
+ ]);
+
+ // Modify Dockerfiles for ARGs and build secrets
+ $this->modify_dockerfiles_for_compose($composeFile);
// Build new container to limit downtime.
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
if ($this->docker_compose_custom_build_command) {
+ // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
+ $build_command = $this->docker_compose_custom_build_command;
+ if ($this->dockerBuildkitSupported) {
+ $build_command = "DOCKER_BUILDKIT=1 {$build_command}";
+ }
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
} else {
$command = "{$this->coolify_variables} docker compose";
- if ($this->env_filename) {
+ // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
+ if ($this->dockerBuildkitSupported) {
+ $command = "DOCKER_BUILDKIT=1 {$command}";
+ }
+ if (filled($this->env_filename)) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
if ($this->force_rebuild) {
@@ -512,6 +635,13 @@ private function deploy_docker_compose_buildpack()
} else {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
}
+
+ if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
+ $build_args_string = $this->build_args->implode(' ');
+ $command .= " {$build_args_string}";
+ $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
+ }
+
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
);
@@ -550,7 +680,7 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = '/docker-compose.yaml';
$command = "{$this->coolify_variables} docker compose";
- if ($this->env_filename) {
+ if (filled($this->env_filename)) {
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
@@ -567,7 +697,7 @@ private function deploy_docker_compose_buildpack()
} else {
$command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) {
- if ($this->env_filename) {
+ if (filled($this->env_filename)) {
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
@@ -577,7 +707,7 @@ private function deploy_docker_compose_buildpack()
['command' => $command, 'hidden' => true],
);
} else {
- if ($this->env_filename) {
+ if (filled($this->env_filename)) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
@@ -641,6 +771,10 @@ private function deploy_nixpacks_buildpack()
$this->generate_compose_file();
$this->generate_build_env_variables();
$this->build_image();
+
+ // For Nixpacks, save runtime environment variables AFTER the build
+ // to prevent them from being accessible during the build process
+ $this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -663,7 +797,7 @@ private function deploy_static_buildpack()
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
- $this->build_image();
+ $this->build_static_image();
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -706,15 +840,16 @@ private function write_deployment_configurations()
if ($this->pull_request_id === 0) {
$composeFileName = "$mainDir/docker-compose.yaml";
} else {
- $composeFileName = "$mainDir/docker-compose-pr-{$this->pull_request_id}.yaml";
- $this->docker_compose_location = "/docker-compose-pr-{$this->pull_request_id}.yaml";
+ $composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
+ $this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
}
- $this->execute_remote_command([
- "mkdir -p $mainDir",
- ]);
- $docker_compose_content = base64_decode($this->docker_compose_base64);
- transfer_file_to_server($docker_compose_content, $composeFileName, $this->server);
$this->execute_remote_command(
+ [
+ "mkdir -p $mainDir",
+ ],
+ [
+ "echo '{$this->docker_compose_base64}' | base64 -d | tee $composeFileName > /dev/null",
+ ],
[
"echo '{$readme}' > $mainDir/README.md",
]
@@ -833,18 +968,17 @@ private function should_skip_build()
{
if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) {
if ($this->is_this_additional_server) {
+ $this->skip_build = true;
$this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->generate_compose_file();
$this->push_to_docker_registry();
$this->rolling_update();
- if ($this->restart_only) {
- $this->post_deployment();
- }
return true;
}
if (! $this->application->isConfigurationChanged()) {
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
+ $this->skip_build = true;
$this->generate_compose_file();
$this->push_to_docker_registry();
$this->rolling_update();
@@ -885,7 +1019,7 @@ private function check_image_locally_or_remotely()
}
}
- private function save_environment_variables()
+ private function generate_runtime_environment_variables()
{
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
@@ -898,10 +1032,10 @@ private function save_environment_variables()
}
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
+ return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
});
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
+ return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
});
}
$ports = $this->application->main_port();
@@ -912,20 +1046,7 @@ private function save_environment_variables()
if ($this->pull_request_id === 0) {
$this->env_filename = '.env';
- foreach ($sorted_environment_variables as $env) {
- $envs->push($env->key.'='.$env->real_value);
- }
- // Add PORT if not exists, use the first port as default
- if ($this->build_pack !== 'dockercompose') {
- if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
- $envs->push("PORT={$ports[0]}");
- }
- }
- // Add HOST if not exists
- if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
- $envs->push('HOST=0.0.0.0');
- }
-
+ // Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
@@ -942,23 +1063,50 @@ private function save_environment_variables()
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
+
+ // Generate SERVICE_NAME for dockercompose services from processed compose
+ if ($this->application->settings->is_raw_compose_deployment_enabled) {
+ $dockerCompose = Yaml::parse($this->application->docker_compose_raw);
+ } else {
+ $dockerCompose = Yaml::parse($this->application->docker_compose);
+ }
+ $services = data_get($dockerCompose, 'services', []);
+ foreach ($services as $serviceName => $_) {
+ $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
+ }
}
- } else {
- $this->env_filename = ".env-pr-$this->pull_request_id";
- foreach ($sorted_environment_variables_preview as $env) {
+
+ // Filter runtime variables (only include variables that are available at runtime)
+ $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) {
+ return $env->is_runtime;
+ });
+
+ // Sort runtime environment variables: those referencing SERVICE_ variables come after others
+ $runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) {
+ if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) {
+ return 2;
+ }
+
+ return 1;
+ });
+
+ foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
- if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
+ if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
$envs->push("PORT={$ports[0]}");
}
}
// Add HOST if not exists
- if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) {
+ if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
$envs->push('HOST=0.0.0.0');
}
+ } else {
+ $this->env_filename = '.env';
+ // Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
@@ -975,51 +1123,139 @@ private function save_environment_variables()
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
+
+ // Generate SERVICE_NAME for dockercompose services
+ $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
+ $rawServices = data_get($rawDockerCompose, 'services', []);
+ foreach ($rawServices as $rawServiceName => $_) {
+ $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
+ }
+ }
+
+ // Filter runtime variables for preview (only include variables that are available at runtime)
+ $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
+ return $env->is_runtime;
+ });
+
+ // Sort runtime environment variables: those referencing SERVICE_ variables come after others
+ $runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) {
+ if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) {
+ return 2;
+ }
+
+ return 1;
+ });
+
+ foreach ($runtime_environment_variables_preview as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+ // Add PORT if not exists, use the first port as default
+ if ($this->build_pack !== 'dockercompose') {
+ if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
+ $envs->push("PORT={$ports[0]}");
+ }
+ }
+ // Add HOST if not exists
+ if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) {
+ $envs->push('HOST=0.0.0.0');
}
}
if ($envs->isEmpty()) {
- $this->env_filename = null;
- if ($this->use_build_server) {
- $this->server = $this->original_server;
- $this->execute_remote_command(
- [
- 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
- 'hidden' => true,
- 'ignore_errors' => true,
- ]
- );
- $this->server = $this->build_server;
- $this->execute_remote_command(
- [
- 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
- 'hidden' => true,
- 'ignore_errors' => true,
- ]
- );
- } else {
- $this->execute_remote_command(
- [
- 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
- 'hidden' => true,
- 'ignore_errors' => true,
- ]
- );
+ if ($this->env_filename) {
+ if ($this->use_build_server) {
+ $this->server = $this->original_server;
+ $this->execute_remote_command(
+ [
+ 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ ]
+ );
+ $this->server = $this->build_server;
+ $this->execute_remote_command(
+ [
+ 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ ]
+ );
+ } else {
+ $this->execute_remote_command(
+ [
+ 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ ]
+ );
+ }
}
+ $this->env_filename = null;
} else {
- $envs_content = $envs->implode("\n");
- transfer_file_to_container($envs_content, "$this->workdir/{$this->env_filename}", $this->deployment_uuid, $this->server);
+ // For Nixpacks builds, we save the .env file AFTER the build to prevent
+ // runtime-only variables from being accessible during the build process
+ if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) {
+ $envs_base64 = base64_encode($envs->implode("\n"));
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
+ ],
- if ($this->use_build_server) {
- $this->server = $this->original_server;
- transfer_file_to_server($envs_content, "$this->configuration_dir/{$this->env_filename}", $this->server);
- $this->server = $this->build_server;
- } else {
- transfer_file_to_server($envs_content, "$this->configuration_dir/{$this->env_filename}", $this->server);
+ );
+ if ($this->use_build_server) {
+ $this->server = $this->original_server;
+ $this->execute_remote_command(
+ [
+ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
+ ]
+ );
+ $this->server = $this->build_server;
+ } else {
+ $this->execute_remote_command(
+ [
+ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
+ ]
+ );
+ }
}
}
$this->environment_variables = $envs;
}
+ private function save_runtime_environment_variables()
+ {
+ // This method saves the .env file with runtime variables
+ // It should be called AFTER the build for Nixpacks to prevent runtime-only variables
+ // from being accessible during the build process
+
+ if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) {
+ $envs_base64 = base64_encode($this->environment_variables->implode("\n"));
+
+ // Write .env file to workdir (for container runtime)
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
+ ],
+ );
+
+ // Write .env file to configuration directory
+ if ($this->use_build_server) {
+ $this->server = $this->original_server;
+ $this->execute_remote_command(
+ [
+ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
+ ]
+ );
+ $this->server = $this->build_server;
+ } else {
+ $this->execute_remote_command(
+ [
+ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
+ ]
+ );
+ }
+ }
+ }
+
private function elixir_finetunes()
{
if ($this->pull_request_id === 0) {
@@ -1028,32 +1264,17 @@ private function elixir_finetunes()
$envType = 'environment_variables_preview';
}
$mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first();
- if ($mix_env) {
- if ($mix_env->is_build_time === false) {
- $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable is not set as build time.', type: 'error');
- $this->application_deployment_queue->addLogEntry('Please set MIX_ENV environment variable to be build time variable if you facing any issues with the deployment.', type: 'error');
- }
- } else {
+ if (! $mix_env) {
$this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error');
$this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
}
$secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first();
- if ($secret_key_base) {
- if ($secret_key_base->is_build_time === false) {
- $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable is not set as build time.', type: 'error');
- $this->application_deployment_queue->addLogEntry('Please set SECRET_KEY_BASE environment variable to be build time variable if you facing any issues with the deployment.', type: 'error');
- }
- } else {
+ if (! $secret_key_base) {
$this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error');
$this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
}
$database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first();
- if ($database_url) {
- if ($database_url->is_build_time === false) {
- $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable is not set as build time.', type: 'error');
- $this->application_deployment_queue->addLogEntry('Please set DATABASE_URL environment variable to be build time variable if you facing any issues with the deployment.', type: 'error');
- }
- } else {
+ if (! $database_url) {
$this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error');
$this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
}
@@ -1073,7 +1294,6 @@ private function laravel_finetunes()
$nixpacks_php_fallback_path = new EnvironmentVariable;
$nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
$nixpacks_php_fallback_path->value = '/index.php';
- $nixpacks_php_fallback_path->is_build_time = false;
$nixpacks_php_fallback_path->resourceable_id = $this->application->id;
$nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
$nixpacks_php_fallback_path->save();
@@ -1082,7 +1302,6 @@ private function laravel_finetunes()
$nixpacks_php_root_dir = new EnvironmentVariable;
$nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
$nixpacks_php_root_dir->value = '/app/public';
- $nixpacks_php_root_dir->is_build_time = false;
$nixpacks_php_root_dir->resourceable_id = $this->application->id;
$nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
$nixpacks_php_root_dir->save();
@@ -1093,6 +1312,7 @@ private function laravel_finetunes()
private function rolling_update()
{
+ $this->checkForCancellation();
if ($this->server->isSwarm()) {
$this->application_deployment_queue->addLogEntry('Rolling update started.');
$this->execute_remote_command(
@@ -1252,8 +1472,11 @@ private function deploy_pull_request()
$this->add_build_env_variables_to_dockerfile();
}
$this->build_image();
+ // For Nixpacks, save runtime environment variables AFTER the build
+ if ($this->application->build_pack === 'nixpacks') {
+ $this->save_runtime_environment_variables();
+ }
$this->push_to_docker_registry();
- // $this->stop_running_container();
$this->rolling_update();
}
@@ -1289,22 +1512,26 @@ private function create_workdir()
private function prepare_builder_image()
{
+ $this->checkForCancellation();
$settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
$helperImage = "{$helperImage}:{$settings->helper_version}";
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
+
+ $env_flags = $this->generate_docker_env_flags_for_secrets();
+
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
}
- $runCommand = "docker run -d --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
if ($this->dockerConfigFileExists === 'OK') {
- $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
- $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
@@ -1424,11 +1651,14 @@ private function check_git_if_build_needed()
}
$private_key = data_get($this->application, 'private_key.private_key');
if ($private_key) {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
- ]);
- transfer_file_to_container($private_key, '/root/.ssh/id_rsa', $this->deployment_uuid, $this->server);
+ $private_key = base64_encode($private_key);
$this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
+ ],
[
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
@@ -1509,6 +1739,7 @@ private function generate_nixpacks_confs()
{
$nixpacks_command = $this->nixpacks_build_cmd();
$this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
+
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
[executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true],
@@ -1528,6 +1759,7 @@ private function generate_nixpacks_confs()
$parsed = Toml::Parse($this->nixpacks_plan);
// Do any modifications here
+ // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile
$this->generate_env_variables();
$merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args);
$aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []);
@@ -1699,8 +1931,16 @@ private function generate_env_variables()
$this->env_args = collect([]);
$this->env_args->put('SOURCE_COMMIT', $this->commit);
$coolify_envs = $this->generate_coolify_env_variables();
+
+ // For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
- foreach ($this->application->build_environment_variables as $env) {
+ // Get environment variables that are marked as available during build
+ $envs = $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+
+ foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
@@ -1720,7 +1960,13 @@ private function generate_env_variables()
}
}
} else {
- foreach ($this->application->build_environment_variables_preview as $env) {
+ // Get preview environment variables that are marked as available during build
+ $envs = $this->application->environment_variables_preview()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+
+ foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
@@ -1744,13 +1990,13 @@ private function generate_env_variables()
private function generate_compose_file()
{
+ $this->checkForCancellation();
$this->create_workdir();
$ports = $this->application->main_port();
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
- // $environment_variables = $this->generate_environment_variables($ports);
- $this->save_environment_variables();
+ $this->generate_runtime_environment_variables();
if (data_get($this->application, 'custom_labels')) {
$this->application->parseContainerLabels();
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
@@ -1819,7 +2065,7 @@ private function generate_compose_file()
],
],
];
- if (! is_null($this->env_filename)) {
+ if (filled($this->env_filename)) {
$docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename];
}
$docker_compose['services'][$this->container_name]['healthcheck'] = [
@@ -1977,7 +2223,7 @@ private function generate_compose_file()
$this->docker_compose = Yaml::dump($docker_compose, 10);
$this->docker_compose_base64 = base64_encode($this->docker_compose);
- transfer_file_to_container(base64_decode($this->docker_compose_base64), "{$this->workdir}/docker-compose.yaml", $this->deployment_uuid, $this->server);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true]);
}
private function generate_local_persistent_volumes()
@@ -1990,7 +2236,7 @@ private function generate_local_persistent_volumes()
$volume_name = $persistentStorage->name;
}
if ($this->pull_request_id !== 0) {
- $volume_name = $volume_name.'-pr-'.$this->pull_request_id;
+ $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
}
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
@@ -2008,7 +2254,7 @@ private function generate_local_persistent_volumes_only_volume_names()
$name = $persistentStorage->name;
if ($this->pull_request_id !== 0) {
- $name = $name.'-pr-'.$this->pull_request_id;
+ $name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
}
$local_persistent_volumes_names[$name] = [
@@ -2056,16 +2302,74 @@ private function pull_latest_image($image)
);
}
+ private function build_static_image()
+ {
+ $this->application_deployment_queue->addLogEntry('----------------------------------------');
+ $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.');
+ if ($this->application->static_image) {
+ $this->pull_latest_image($this->application->static_image);
+ }
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
+ WORKDIR /usr/share/nginx/html/
+ LABEL coolify.deploymentId={$this->deployment_uuid}
+ COPY . .
+ RUN rm -f /usr/share/nginx/html/nginx.conf
+ RUN rm -f /usr/share/nginx/html/Dockerfile
+ RUN rm -f /usr/share/nginx/html/docker-compose.yaml
+ RUN rm -f /usr/share/nginx/html/.env
+ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ if ($this->application->settings->is_spa) {
+ $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
+ } else {
+ $nginx_config = base64_encode(defaultNginxConfiguration());
+ }
+ }
+ $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ 'hidden' => true,
+ ]
+ );
+ $this->application_deployment_queue->addLogEntry('Building docker image completed.');
+ }
+
private function build_image()
{
- // Add Coolify related variables to the build args
- $this->environment_variables->filter(function ($key, $value) {
- return str($key)->startsWith('COOLIFY_');
- })->each(function ($key, $value) {
- $this->build_args->push("--build-arg '{$key}'");
- });
+ // Add Coolify related variables to the build args/secrets
+ if ($this->dockerBuildkitSupported) {
+ // Coolify variables are already included in the secrets from generate_build_env_variables
+ // build_secrets is already a string at this point
+ } else {
+ // Traditional build args approach
+ $this->environment_variables->filter(function ($key, $value) {
+ return str($key)->startsWith('COOLIFY_');
+ })->each(function ($key, $value) {
+ $this->build_args->push("--build-arg '{$key}'");
+ });
- $this->build_args = $this->build_args->implode(' ');
+ $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
+ ? $this->build_args->implode(' ')
+ : (string) $this->build_args;
+ }
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if ($this->disableBuildCache) {
@@ -2078,114 +2382,127 @@ private function build_image()
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
}
- if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
+ if ($this->application->settings->is_static) {
if ($this->application->static_image) {
$this->pull_latest_image($this->application->static_image);
$this->application_deployment_queue->addLogEntry('Continuing with the building process.');
}
- if ($this->application->build_pack === 'static') {
- $dockerfile = base64_encode("FROM {$this->application->static_image}
-WORKDIR /usr/share/nginx/html/
-LABEL coolify.deploymentId={$this->deployment_uuid}
-COPY . .
-RUN rm -f /usr/share/nginx/html/nginx.conf
-RUN rm -f /usr/share/nginx/html/Dockerfile
-RUN rm -f /usr/share/nginx/html/docker-compose.yaml
-RUN rm -f /usr/share/nginx/html/.env
-COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
- if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
- $nginx_config = base64_encode($this->application->custom_nginx_configuration);
- } else {
- if ($this->application->settings->is_spa) {
- $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
+ if ($this->application->build_pack === 'nixpacks') {
+ $this->nixpacks_plan = base64_encode($this->nixpacks_plan);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
+ if ($this->force_rebuild) {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
+ 'hidden' => true,
+ ], [
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
+ 'hidden' => true,
+ ]);
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ // Modify the nixpacks Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
} else {
- $nginx_config = base64_encode(defaultNginxConfiguration());
+ $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
+ }
+ } else {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
+ 'hidden' => true,
+ ], [
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
+ 'hidden' => true,
+ ]);
+ if ($this->dockerBuildkitSupported) {
+ // Modify the nixpacks Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
+ } else {
+ $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
}
}
- } else {
- if ($this->application->build_pack === 'nixpacks') {
- $this->nixpacks_plan = base64_encode($this->nixpacks_plan);
- $nixpacks_content = base64_decode($this->nixpacks_plan);
- transfer_file_to_container($nixpacks_content, '/artifacts/thegameplan.json', $this->deployment_uuid, $this->server);
- if ($this->force_rebuild) {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
- 'hidden' => true,
- ]);
- $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}";
- } else {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
- 'hidden' => true,
- ]);
- $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}";
- }
- $base64_build_command = base64_encode($build_command);
- $this->execute_remote_command(
- [
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
- 'hidden' => true,
- ]
- );
- $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ 'hidden' => true,
+ ]
+ );
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
+ } else {
+ // Dockerfile buildpack
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ // Modify the Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ if ($this->force_rebuild) {
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
+ } else {
+ $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
+ }
} else {
+ // Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
- $base64_build_command = base64_encode($build_command);
} else {
$build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
- $base64_build_command = base64_encode($build_command);
}
- $this->execute_remote_command(
- [
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
- 'hidden' => true,
- ]
- );
}
- $dockerfile = base64_encode("FROM {$this->application->static_image}
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ 'hidden' => true,
+ ]
+ );
+ }
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
WORKDIR /usr/share/nginx/html/
LABEL coolify.deploymentId={$this->deployment_uuid}
COPY --from=$this->build_image_name /app/{$this->application->publish_directory} .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
- if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
- $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ if ($this->application->settings->is_spa) {
+ $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
} else {
- if ($this->application->settings->is_spa) {
- $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
- } else {
- $nginx_config = base64_encode(defaultNginxConfiguration());
- }
+ $nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($dockerfile), "{$this->workdir}/Dockerfile", $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
],
[
- transfer_file_to_container(base64_decode($nginx_config), "{$this->workdir}/nginx.conf", $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
],
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2200,15 +2517,27 @@ private function build_image()
} else {
// Pure Dockerfile based deployment
if ($this->application->dockerfile) {
- if ($this->force_rebuild) {
- $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ // Modify the Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ if ($this->force_rebuild) {
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ }
} else {
- $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ // Traditional build with args
+ if ($this->force_rebuild) {
+ $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ }
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2223,25 +2552,44 @@ private function build_image()
} else {
if ($this->application->build_pack === 'nixpacks') {
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
- $nixpacks_content = base64_decode($this->nixpacks_plan);
- transfer_file_to_container($nixpacks_content, '/artifacts/thegameplan.json', $this->deployment_uuid, $this->server);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
if ($this->force_rebuild) {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
'hidden' => true,
+ ], [
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
+ 'hidden' => true,
]);
- $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ if ($this->dockerBuildkitSupported) {
+ // Modify the nixpacks Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
+ }
} else {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
'hidden' => true,
+ ], [
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
+ 'hidden' => true,
]);
- $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ if ($this->dockerBuildkitSupported) {
+ // Modify the nixpacks Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
+ }
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2255,16 +2603,27 @@ private function build_image()
);
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
} else {
- if ($this->force_rebuild) {
- $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
- $base64_build_command = base64_encode($build_command);
+ // Dockerfile buildpack
+ if ($this->dockerBuildkitSupported) {
+ // Use BuildKit with secrets
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ if ($this->force_rebuild) {
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ }
} else {
- $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
- $base64_build_command = base64_encode($build_command);
+ // Traditional build with args
+ if ($this->force_rebuild) {
+ $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ }
}
+ $base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2305,7 +2664,7 @@ private function stop_running_container(bool $force = false)
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($this->pull_request_id === 0) {
$containers = $containers->filter(function ($container) {
- return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id;
+ return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
});
}
$containers->each(function ($container) {
@@ -2353,47 +2712,377 @@ private function generate_build_env_variables()
if ($this->application->build_pack === 'nixpacks') {
$variables = collect($this->nixpacks_plan_json->get('variables'));
} else {
+ // Generate environment variables for build process (filters by is_buildtime = true)
$this->generate_env_variables();
$variables = collect([])->merge($this->env_args);
}
- $this->build_args = $variables->map(function ($value, $key) {
- $value = escapeshellarg($value);
+ // Check if build secrets are enabled and BuildKit is supported
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ $this->generate_build_secrets($variables);
+ $this->build_args = '';
+ } else {
+ // Fall back to traditional build args
+ $this->build_args = $variables->map(function ($value, $key) {
+ $value = escapeshellarg($value);
- return "--build-arg {$key}={$value}";
- });
+ return "--build-arg {$key}={$value}";
+ });
+ }
+ }
+
+ private function generate_docker_env_flags_for_secrets()
+ {
+ // Only generate env flags if build secrets are enabled
+ if (! $this->application->settings->use_build_secrets) {
+ return '';
+ }
+
+ $variables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
+
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return $variables
+ ->map(function ($env) {
+ $escaped_value = escapeshellarg($env->real_value);
+
+ return "-e {$env->key}={$escaped_value}";
+ })
+ ->implode(' ');
+ }
+
+ private function generate_build_secrets(Collection $variables)
+ {
+ if ($variables->isEmpty()) {
+ $this->build_secrets = '';
+
+ return;
+ }
+
+ $this->build_secrets = $variables
+ ->map(function ($value, $key) {
+ return "--secret id={$key},env={$key}";
+ })
+ ->implode(' ');
}
private function add_build_env_variables_to_dockerfile()
{
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
- 'hidden' => true,
- 'save' => 'dockerfile',
- ]);
- $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
- if ($this->pull_request_id === 0) {
- foreach ($this->application->build_environment_variables as $env) {
- if (data_get($env, 'is_multiline') === true) {
- $dockerfile->splice(1, 0, "ARG {$env->key}");
- } else {
- $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}");
+ if ($this->dockerBuildkitSupported) {
+ // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
+ } else {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
+ 'hidden' => true,
+ 'save' => 'dockerfile',
+ ]);
+ $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
+
+ if ($this->pull_request_id === 0) {
+ // Only add environment variables that are available during build
+ $envs = $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+ foreach ($envs as $env) {
+ if (data_get($env, 'is_multiline') === true) {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
+ } else {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ }
+ }
+ } else {
+ // Only add preview environment variables that are available during build
+ $envs = $this->application->environment_variables_preview()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+ foreach ($envs as $env) {
+ if (data_get($env, 'is_multiline') === true) {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
+ } else {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ }
}
}
- } else {
- foreach ($this->application->build_environment_variables_preview as $env) {
- if (data_get($env, 'is_multiline') === true) {
- $dockerfile->splice(1, 0, "ARG {$env->key}");
- } else {
- $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}");
+ $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
+ 'hidden' => true,
+ ]);
+ }
+ }
+
+ private function modify_dockerfile_for_secrets($dockerfile_path)
+ {
+ // Only process if build secrets are enabled and we have secrets to mount
+ if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) {
+ return;
+ }
+
+ // Read the Dockerfile
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"),
+ 'hidden' => true,
+ 'save' => 'dockerfile_content',
+ ]);
+
+ $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n");
+
+ // Add BuildKit syntax directive if not present
+ if (! str_starts_with($dockerfile->first(), '# syntax=')) {
+ $dockerfile->prepend('# syntax=docker/dockerfile:1');
+ }
+
+ // Get environment variables for secrets
+ $variables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
+
+ if ($variables->isEmpty()) {
+ return;
+ }
+
+ // Generate mount strings for all secrets
+ $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' ');
+
+ $modified = false;
+ $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
+ $trimmed = ltrim($line);
+
+ // Skip lines that already have secret mounts or are not RUN commands
+ if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) {
+ return $line;
+ }
+
+ // Add mount strings to RUN command
+ $originalCommand = trim(substr($trimmed, 3));
+ $modified = true;
+
+ return "RUN {$mountStrings} {$originalCommand}";
+ });
+
+ if ($modified) {
+ // Write the modified Dockerfile back
+ $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"),
+ 'hidden' => true,
+ ]);
+
+ $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.');
+ }
+ }
+
+ private function modify_dockerfiles_for_compose($composeFile)
+ {
+ if ($this->application->build_pack !== 'dockercompose') {
+ return;
+ }
+
+ $variables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get()
+ : $this->application->environment_variables_preview()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+
+ if ($variables->isEmpty()) {
+ $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.');
+
+ return;
+ }
+
+ $services = data_get($composeFile, 'services', []);
+
+ foreach ($services as $serviceName => $service) {
+ if (! isset($service['build'])) {
+ continue;
+ }
+
+ $context = '.';
+ $dockerfile = 'Dockerfile';
+
+ if (is_string($service['build'])) {
+ $context = $service['build'];
+ } elseif (is_array($service['build'])) {
+ $context = data_get($service['build'], 'context', '.');
+ $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile');
+ }
+
+ $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/');
+ if (str_starts_with($dockerfilePath, './')) {
+ $dockerfilePath = substr($dockerfilePath, 2);
+ }
+ if (str_starts_with($dockerfilePath, '/')) {
+ $dockerfilePath = substr($dockerfilePath, 1);
+ }
+
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"),
+ 'hidden' => true,
+ 'save' => 'dockerfile_check_'.$serviceName,
+ ]);
+
+ if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') {
+ $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection.");
+
+ continue;
+ }
+
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"),
+ 'hidden' => true,
+ 'save' => 'dockerfile_content_'.$serviceName,
+ ]);
+
+ $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName);
+ if (! $dockerfileContent) {
+ continue;
+ }
+
+ $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n"));
+
+ $fromIndices = [];
+ $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) {
+ if (str($line)->trim()->startsWith('FROM')) {
+ $fromIndices[] = $index;
+ }
+ });
+
+ if (empty($fromIndices)) {
+ $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping.");
+
+ continue;
+ }
+
+ $isMultiStage = count($fromIndices) > 1;
+
+ $argsToAdd = collect([]);
+ foreach ($variables as $env) {
+ $argsToAdd->push("ARG {$env->key}");
+ }
+
+ ray($argsToAdd);
+ if ($argsToAdd->isEmpty()) {
+ $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");
+
+ continue;
+ }
+
+ $totalAdded = 0;
+ $offset = 0;
+
+ foreach ($fromIndices as $stageIndex => $fromIndex) {
+ $adjustedIndex = $fromIndex + $offset;
+
+ $stageStart = $adjustedIndex + 1;
+ $stageEnd = isset($fromIndices[$stageIndex + 1])
+ ? $fromIndices[$stageIndex + 1] + $offset
+ : $dockerfile_lines->count();
+
+ $existingStageArgs = collect([]);
+ for ($i = $stageStart; $i < $stageEnd; $i++) {
+ $line = $dockerfile_lines->get($i);
+ if (! $line || ! str($line)->trim()->startsWith('ARG')) {
+ break;
+ }
+ $parts = explode(' ', trim($line), 2);
+ if (count($parts) >= 2) {
+ $argPart = $parts[1];
+ $keyValue = explode('=', $argPart, 2);
+ $existingStageArgs->push($keyValue[0]);
+ }
+ }
+
+ $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) {
+ $key = str($arg)->after('ARG ')->trim()->toString();
+
+ return ! $existingStageArgs->contains($key);
+ });
+
+ if ($stageArgsToAdd->isNotEmpty()) {
+ $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray());
+ $totalAdded += $stageArgsToAdd->count();
+ $offset += $stageArgsToAdd->count();
+ }
+ }
+
+ if ($totalAdded > 0) {
+ $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n"));
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"),
+ 'hidden' => true,
+ ]);
+
+ $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : '';
+ $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}.");
+ } else {
+ $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
+ }
+
+ if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
+ $this->modify_dockerfile_for_secrets($fullDockerfilePath);
+ $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
+ }
+ }
+ }
+
+ private function add_build_secrets_to_compose($composeFile)
+ {
+ // Get environment variables for secrets
+ $variables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get()
+ : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
+
+ if ($variables->isEmpty()) {
+ return $composeFile;
+ }
+
+ $secrets = [];
+ foreach ($variables as $env) {
+ $secrets[$env->key] = [
+ 'environment' => $env->key,
+ ];
+ }
+
+ $services = data_get($composeFile, 'services', []);
+ foreach ($services as $serviceName => &$service) {
+ if (isset($service['build'])) {
+ if (is_string($service['build'])) {
+ $service['build'] = [
+ 'context' => $service['build'],
+ ];
+ }
+ if (! isset($service['build']['secrets'])) {
+ $service['build']['secrets'] = [];
+ }
+ foreach ($variables as $env) {
+ if (! in_array($env->key, $service['build']['secrets'])) {
+ $service['build']['secrets'][] = $env->key;
+ }
}
}
}
- $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
- $this->execute_remote_command([
- transfer_file_to_container(base64_decode($dockerfile_base64), "{$this->workdir}{$this->dockerfile_location}", $this->deployment_uuid, $this->server),
- 'hidden' => true,
- ]);
+
+ $composeFile['services'] = $services;
+ $existingSecrets = data_get($composeFile, 'secrets', []);
+ if ($existingSecrets instanceof \Illuminate\Support\Collection) {
+ $existingSecrets = $existingSecrets->toArray();
+ }
+ $composeFile['secrets'] = array_replace($existingSecrets, $secrets);
+
+ $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).');
+
+ return $composeFile;
}
private function run_pre_deployment_command()
@@ -2461,8 +3150,23 @@ private function run_post_deployment_command()
throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?');
}
+ /**
+ * Check if the deployment was cancelled and abort if it was
+ */
+ private function checkForCancellation(): void
+ {
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
+ }
+ }
+
private function next(string $status)
{
+ // Refresh to get latest status
+ $this->application_deployment_queue->refresh();
+
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
@@ -2470,7 +3174,9 @@ private function next(string $status)
return;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
- return;
+ // Job was cancelled, stop execution
+ $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
}
$this->application_deployment_queue->update([
@@ -2499,8 +3205,8 @@ public function failed(Throwable $exception): void
$code = $exception->getCode();
if ($code !== 69420) {
// 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one
- if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
- // do not remove already running container
+ if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0) {
+ // do not remove already running container for PR deployments
} else {
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
$this->execute_remote_command(
diff --git a/app/Jobs/DEPRECATEDContainerStatusJob.php b/app/Jobs/DEPRECATEDContainerStatusJob.php
deleted file mode 100644
index df6dec7fe..000000000
--- a/app/Jobs/DEPRECATEDContainerStatusJob.php
+++ /dev/null
@@ -1,31 +0,0 @@
-server);
- }
-}
diff --git a/app/Jobs/DEPRECATEDServerCheckNewJob.php b/app/Jobs/DEPRECATEDServerCheckNewJob.php
deleted file mode 100644
index 1118366fe..000000000
--- a/app/Jobs/DEPRECATEDServerCheckNewJob.php
+++ /dev/null
@@ -1,34 +0,0 @@
-server);
- ResourcesCheck::dispatch($this->server);
- } catch (\Throwable $e) {
- return handleError($e);
- }
- }
-}
diff --git a/app/Jobs/DEPRECATEDServerResourceManager.php b/app/Jobs/DEPRECATEDServerResourceManager.php
deleted file mode 100644
index c50567a01..000000000
--- a/app/Jobs/DEPRECATEDServerResourceManager.php
+++ /dev/null
@@ -1,162 +0,0 @@
-onQueue('high');
- }
-
- /**
- * Get the middleware the job should pass through.
- */
- public function middleware(): array
- {
- return [
- (new WithoutOverlapping('server-resource-manager'))
- ->releaseAfter(60),
- ];
- }
-
- public function handle(): void
- {
- // Freeze the execution time at the start of the job
- $this->executionTime = Carbon::now();
-
- $this->settings = instanceSettings();
- $this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
-
- if (validate_timezone($this->instanceTimezone) === false) {
- $this->instanceTimezone = config('app.timezone');
- }
-
- // Process server checks - don't let failures stop the job
- try {
- $this->processServerChecks();
- } catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Failed to process server checks', [
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]);
- }
- }
-
- private function processServerChecks(): void
- {
- $servers = $this->getServers();
-
- foreach ($servers as $server) {
- try {
- $this->processServer($server);
- } catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing server', [
- 'server_id' => $server->id,
- 'server_name' => $server->name,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- }
-
- private function getServers()
- {
- $allServers = Server::where('ip', '!=', '1.2.3.4');
-
- if (isCloud()) {
- $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
- $own = Team::find(0)->servers;
-
- return $servers->merge($own);
- } else {
- return $allServers->get();
- }
- }
-
- private function processServer(Server $server): void
- {
- $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
- }
-
- // Sentinel check
- $lastSentinelUpdate = $server->sentinel_updated_at;
- if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) {
- // Dispatch ServerCheckJob if due
- $checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
- if ($this->shouldRunNow($checkFrequency, $serverTimezone)) {
- ServerCheckJob::dispatch($server);
- }
-
- // Dispatch ServerStorageCheckJob if due
- $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
- if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
- $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
- }
- if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) {
- ServerStorageCheckJob::dispatch($server);
- }
- }
-
- // Dispatch DockerCleanupJob if due
- $dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
- if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
- $dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
- }
- if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
- DockerCleanupJob::dispatch($server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks);
- }
-
- // Dispatch ServerPatchCheckJob if due (weekly)
- if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight
- ServerPatchCheckJob::dispatch($server);
- }
-
- // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
- if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) {
- dispatch(function () use ($server) {
- $server->restartContainer('coolify-sentinel');
- });
- }
- }
-
- private function shouldRunNow(string $frequency, string $timezone): bool
- {
- $cron = new CronExpression($frequency);
-
- // Use the frozen execution time, not the current time
- $baseTime = $this->executionTime ?? Carbon::now();
- $executionTime = $baseTime->copy()->setTimezone($timezone);
-
- return $cron->isDue($executionTime);
- }
-}
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index 3e3aa1eb7..7726c2c73 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $foundApplicationPreviewsIds;
+ public Collection $applicationContainerStatuses;
+
public bool $foundProxy = false;
public bool $foundLogDrainContainer = false;
@@ -87,6 +89,7 @@ public function __construct(public Server $server, public $data)
$this->foundServiceApplicationIds = collect();
$this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect();
+ $this->applicationContainerStatuses = collect();
$this->allApplicationIds = collect();
$this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect();
@@ -155,7 +158,14 @@ public function handle()
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
$this->foundApplicationIds->push($applicationId);
}
- $this->updateApplicationStatus($applicationId, $containerStatus);
+ // Store container status for aggregation
+ if (! $this->applicationContainerStatuses->has($applicationId)) {
+ $this->applicationContainerStatuses->put($applicationId, collect());
+ }
+ $containerName = $labels->get('com.docker.compose.service');
+ if ($containerName) {
+ $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
+ }
} else {
$previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
@@ -205,9 +215,86 @@ public function handle()
$this->updateAdditionalServersStatus();
+ // Aggregate multi-container application statuses
+ $this->aggregateMultiContainerStatuses();
+
$this->checkLogDrainContainer();
}
+ private function aggregateMultiContainerStatuses()
+ {
+ if ($this->applicationContainerStatuses->isEmpty()) {
+ return;
+ }
+
+ foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
+ $application = $this->applications->where('id', $applicationId)->first();
+ if (! $application) {
+ continue;
+ }
+
+ // Parse docker compose to check for excluded containers
+ $dockerComposeRaw = data_get($application, 'docker_compose_raw');
+ $excludedContainers = collect();
+
+ if ($dockerComposeRaw) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $services = data_get($dockerCompose, 'services', []);
+
+ foreach ($services as $serviceName => $serviceConfig) {
+ // Check if container should be excluded
+ $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
+ $restartPolicy = data_get($serviceConfig, 'restart', 'always');
+
+ if ($excludeFromHc || $restartPolicy === 'no') {
+ $excludedContainers->push($serviceName);
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse, treat all containers as included
+ }
+ }
+
+ // Filter out excluded containers
+ $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
+ return ! $excludedContainers->contains($containerName);
+ });
+
+ // If all containers are excluded, don't update status
+ if ($relevantStatuses->isEmpty()) {
+ continue;
+ }
+
+ // Aggregate status: if any container is running, app is running
+ $hasRunning = false;
+ $hasUnhealthy = false;
+
+ foreach ($relevantStatuses as $status) {
+ if (str($status)->contains('running')) {
+ $hasRunning = true;
+ if (str($status)->contains('unhealthy')) {
+ $hasUnhealthy = true;
+ }
+ }
+ }
+
+ $aggregatedStatus = null;
+ if ($hasRunning) {
+ $aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
+ } else {
+ // All containers are exited
+ $aggregatedStatus = 'exited (unhealthy)';
+ }
+
+ // Update application status with aggregated result
+ if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
+ $application->status = $aggregatedStatus;
+ $application->save();
+ }
+ }
+ }
+
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
$application = $this->applications->where('id', $applicationId)->first();
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 167bcea38..8b55434f6 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -78,11 +78,11 @@ public function handle()
}
// Server is reachable, check if Docker is available
- // $isUsable = $this->checkDockerAvailability();
+ $isUsable = $this->checkDockerAvailability();
$this->server->settings->update([
'is_reachable' => true,
- 'is_usable' => true,
+ 'is_usable' => $isUsable,
]);
} catch (\Throwable $e) {
diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php
index f1c5bc1a8..088b6c67d 100644
--- a/app/Jobs/StripeProcessJob.php
+++ b/app/Jobs/StripeProcessJob.php
@@ -58,7 +58,7 @@ public function handle(): void
case 'checkout.session.completed':
$clientReferenceId = data_get($data, 'client_reference_id');
if (is_null($clientReferenceId)) {
- send_internal_notification('Checkout session completed without client reference id.');
+ // send_internal_notification('Checkout session completed without client reference id.');
break;
}
$userId = Str::before($clientReferenceId, ':');
@@ -68,7 +68,7 @@ public function handle(): void
$team = Team::find($teamId);
$found = $team->members->where('id', $userId)->first();
if (! $found->isAdmin()) {
- send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
+ // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
@@ -95,7 +95,7 @@ public function handle(): void
$customerId = data_get($data, 'customer');
$planId = data_get($data, 'lines.data.0.plan.id');
if (Str::contains($excludedPlans, $planId)) {
- send_internal_notification('Subscription excluded.');
+ // send_internal_notification('Subscription excluded.');
break;
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
@@ -110,16 +110,38 @@ public function handle(): void
break;
case 'invoice.payment_failed':
$customerId = data_get($data, 'customer');
+ $invoiceId = data_get($data, 'id');
+ $paymentIntentId = data_get($data, 'payment_intent');
+
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
- send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
+ // send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found for customer: {$customerId}");
}
$team = data_get($subscription, 'team');
if (! $team) {
- send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
+ // send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
+
+ // Verify payment status with Stripe API before sending failure notification
+ if ($paymentIntentId) {
+ try {
+ $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
+
+ if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
+ break;
+ }
+
+ if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) {
+ SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60));
+ break;
+ }
+ } catch (\Exception $e) {
+ }
+ }
+
if (! $subscription->stripe_invoice_paid) {
SubscriptionInvoiceFailedJob::dispatch($team);
// send_internal_notification('Invoice payment failed: '.$customerId);
@@ -129,11 +151,11 @@ public function handle(): void
$customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
- send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
+ // send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
}
if ($subscription->stripe_invoice_paid) {
- send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
+ // send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
return;
}
@@ -154,7 +176,7 @@ public function handle(): void
$team = Team::find($teamId);
$found = $team->members->where('id', $userId)->first();
if (! $found->isAdmin()) {
- send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
+ // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
@@ -177,7 +199,7 @@ public function handle(): void
$subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id');
$planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id');
if (Str::contains($excludedPlans, $planId)) {
- send_internal_notification('Subscription excluded.');
+ // send_internal_notification('Subscription excluded.');
break;
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
@@ -194,7 +216,7 @@ public function handle(): void
'stripe_invoice_paid' => false,
]);
} else {
- send_internal_notification('No subscription and team id found');
+ // send_internal_notification('No subscription and team id found');
throw new \RuntimeException('No subscription and team id found');
}
}
@@ -230,7 +252,7 @@ public function handle(): void
$subscription->update([
'stripe_past_due' => true,
]);
- send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
+ // send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
}
}
if ($status === 'unpaid') {
@@ -238,13 +260,13 @@ public function handle(): void
$subscription->update([
'stripe_invoice_paid' => false,
]);
- send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
+ // send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
}
$team = data_get($subscription, 'team');
if ($team) {
$team->subscriptionEnded();
} else {
- send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
+ // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
}
@@ -273,11 +295,11 @@ public function handle(): void
if ($team) {
$team->subscriptionEnded();
} else {
- send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
+ // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
} else {
- send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
+ // send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
}
break;
diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php
index dc511f445..927d50467 100755
--- a/app/Jobs/SubscriptionInvoiceFailedJob.php
+++ b/app/Jobs/SubscriptionInvoiceFailedJob.php
@@ -23,6 +23,47 @@ public function __construct(protected Team $team)
public function handle()
{
try {
+ // Double-check subscription status before sending failure notification
+ $subscription = $this->team->subscription;
+ if ($subscription && $subscription->stripe_customer_id) {
+ try {
+ $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+
+ if ($subscription->stripe_subscription_id) {
+ $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
+
+ if (in_array($stripeSubscription->status, ['active', 'trialing'])) {
+ if (! $subscription->stripe_invoice_paid) {
+ $subscription->update([
+ 'stripe_invoice_paid' => true,
+ 'stripe_past_due' => false,
+ ]);
+ }
+
+ return;
+ }
+ }
+
+ $invoices = $stripe->invoices->all([
+ 'customer' => $subscription->stripe_customer_id,
+ 'limit' => 3,
+ ]);
+
+ foreach ($invoices->data as $invoice) {
+ if ($invoice->paid && $invoice->created > (time() - 3600)) {
+ $subscription->update([
+ 'stripe_invoice_paid' => true,
+ 'stripe_past_due' => false,
+ ]);
+
+ return;
+ }
+ }
+ } catch (\Exception $e) {
+ }
+ }
+
+ // If we reach here, payment genuinely failed
$session = getStripeCustomerPortalSession($this->team);
$mail = new MailMessage;
$mail->view('emails.subscription-invoice-failed', [
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
new file mode 100644
index 000000000..dacc0d4db
--- /dev/null
+++ b/app/Livewire/GlobalSearch.php
@@ -0,0 +1,372 @@
+searchQuery = '';
+ $this->isModalOpen = false;
+ $this->searchResults = [];
+ $this->allSearchableItems = [];
+ }
+
+ public function openSearchModal()
+ {
+ $this->isModalOpen = true;
+ $this->loadSearchableItems();
+ $this->dispatch('search-modal-opened');
+ }
+
+ public function closeSearchModal()
+ {
+ $this->isModalOpen = false;
+ $this->searchQuery = '';
+ $this->searchResults = [];
+ }
+
+ public static function getCacheKey($teamId)
+ {
+ return 'global_search_items_'.$teamId;
+ }
+
+ public static function clearTeamCache($teamId)
+ {
+ Cache::forget(self::getCacheKey($teamId));
+ }
+
+ public function updatedSearchQuery()
+ {
+ $this->search();
+ }
+
+ private function loadSearchableItems()
+ {
+ // Try to get from Redis cache first
+ $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
+
+ $this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
+ ray()->showQueries();
+ $items = collect();
+ $team = auth()->user()->currentTeam();
+
+ // Get all applications
+ $applications = Application::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($app) {
+ // Collect all FQDNs from the application
+ $fqdns = collect([]);
+
+ // For regular applications
+ if ($app->fqdn) {
+ $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
+ }
+
+ // For docker compose based applications
+ if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
+ try {
+ $composeDomains = json_decode($app->docker_compose_domains, true);
+ if (is_array($composeDomains)) {
+ foreach ($composeDomains as $serviceName => $domains) {
+ if (is_array($domains)) {
+ $fqdns = $fqdns->merge($domains);
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ // Ignore JSON parsing errors
+ }
+ }
+
+ $fqdnsString = $fqdns->implode(' ');
+
+ return [
+ 'id' => $app->id,
+ 'name' => $app->name,
+ 'type' => 'application',
+ 'uuid' => $app->uuid,
+ 'description' => $app->description,
+ 'link' => $app->link(),
+ 'project' => $app->environment->project->name ?? null,
+ 'environment' => $app->environment->name ?? null,
+ 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
+ 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
+ ];
+ });
+
+ // Get all services
+ $services = Service::ownedByCurrentTeam()
+ ->with(['environment.project', 'applications'])
+ ->get()
+ ->map(function ($service) {
+ // Collect all FQDNs from service applications
+ $fqdns = collect([]);
+ foreach ($service->applications as $app) {
+ if ($app->fqdn) {
+ $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
+ $fqdns = $fqdns->merge($appFqdns);
+ }
+ }
+ $fqdnsString = $fqdns->implode(' ');
+
+ return [
+ 'id' => $service->id,
+ 'name' => $service->name,
+ 'type' => 'service',
+ 'uuid' => $service->uuid,
+ 'description' => $service->description,
+ 'link' => $service->link(),
+ 'project' => $service->environment->project->name ?? null,
+ 'environment' => $service->environment->name ?? null,
+ 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
+ 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
+ ];
+ });
+
+ // Get all standalone databases
+ $databases = collect();
+
+ // PostgreSQL
+ $databases = $databases->merge(
+ StandalonePostgresql::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'postgresql',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' postgresql '.$db->description),
+ ];
+ })
+ );
+
+ // MySQL
+ $databases = $databases->merge(
+ StandaloneMysql::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'mysql',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' mysql '.$db->description),
+ ];
+ })
+ );
+
+ // MariaDB
+ $databases = $databases->merge(
+ StandaloneMariadb::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'mariadb',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' mariadb '.$db->description),
+ ];
+ })
+ );
+
+ // MongoDB
+ $databases = $databases->merge(
+ StandaloneMongodb::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'mongodb',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' mongodb '.$db->description),
+ ];
+ })
+ );
+
+ // Redis
+ $databases = $databases->merge(
+ StandaloneRedis::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'redis',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' redis '.$db->description),
+ ];
+ })
+ );
+
+ // KeyDB
+ $databases = $databases->merge(
+ StandaloneKeydb::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'keydb',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' keydb '.$db->description),
+ ];
+ })
+ );
+
+ // Dragonfly
+ $databases = $databases->merge(
+ StandaloneDragonfly::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'dragonfly',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' dragonfly '.$db->description),
+ ];
+ })
+ );
+
+ // Clickhouse
+ $databases = $databases->merge(
+ StandaloneClickhouse::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'clickhouse',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' clickhouse '.$db->description),
+ ];
+ })
+ );
+
+ // Get all servers
+ $servers = Server::ownedByCurrentTeam()
+ ->get()
+ ->map(function ($server) {
+ return [
+ 'id' => $server->id,
+ 'name' => $server->name,
+ 'type' => 'server',
+ 'uuid' => $server->uuid,
+ 'description' => $server->description,
+ 'link' => $server->url(),
+ 'project' => null,
+ 'environment' => null,
+ 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
+ ];
+ });
+
+ // Merge all collections
+ $items = $items->merge($applications)
+ ->merge($services)
+ ->merge($databases)
+ ->merge($servers);
+
+ return $items->toArray();
+ });
+ }
+
+ private function search()
+ {
+ if (strlen($this->searchQuery) < 2) {
+ $this->searchResults = [];
+
+ return;
+ }
+
+ $query = strtolower($this->searchQuery);
+
+ // Case-insensitive search in the items
+ $this->searchResults = collect($this->allSearchableItems)
+ ->filter(function ($item) use ($query) {
+ return str_contains($item['search_text'], $query);
+ })
+ ->take(20)
+ ->values()
+ ->toArray();
+ }
+
+ public function render()
+ {
+ return view('livewire.global-search');
+ }
+}
diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php
index 913710588..490515875 100644
--- a/app/Livewire/Help.php
+++ b/app/Livewire/Help.php
@@ -42,7 +42,7 @@ public function submit()
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
]);
} else {
- send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io');
+ send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
}
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
$this->reset('description', 'subject');
diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index 66f387fcf..dccd1e499 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -52,15 +52,24 @@ public function force_start()
public function cancel()
{
- $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}";
+ $deployment_uuid = $this->application_deployment_queue->deployment_uuid;
+ $kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
$server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id;
+
+ // First, mark the deployment as cancelled to prevent further processing
+ $this->application_deployment_queue->update([
+ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
try {
if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id);
} else {
$server = Server::ownedByCurrentTeam()->find($server_id);
}
+
+ // Add cancellation log entry
if ($this->application_deployment_queue->logs) {
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
@@ -77,13 +86,35 @@ public function cancel()
'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR),
]);
}
- instant_remote_process([$kill_command], $server);
+
+ // Try to stop the helper container if it exists
+ // Check if container exists first
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ // Container exists, kill it
+ instant_remote_process([$kill_command], $server);
+ } else {
+ // Container hasn't started yet
+ $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
+ }
+
+ // Also try to kill any running process if we have a process ID
+ if ($this->application_deployment_queue->current_process_id) {
+ try {
+ $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}";
+ instant_remote_process([$processKillCommand], $server);
+ } catch (\Throwable $e) {
+ // Process might already be gone, that's ok
+ }
+ }
} catch (\Throwable $e) {
+ // Still mark as cancelled even if cleanup fails
return handleError($e, $this);
} finally {
$this->application_deployment_queue->update([
'current_process_id' => null,
- 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
next_after_cancel($server);
}
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 76aa909c8..c77d050cb 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -671,7 +671,7 @@ private function updateServiceEnvironmentVariables()
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
- $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
+ $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
@@ -703,7 +703,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
@@ -712,7 +711,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
@@ -721,7 +719,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
@@ -729,7 +726,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php
index a4f50ee06..3b3e42619 100644
--- a/app/Livewire/Project/CloneMe.php
+++ b/app/Livewire/Project/CloneMe.php
@@ -127,7 +127,7 @@ public function clone(string $type)
$databases = $this->environment->databases();
$services = $this->environment->services;
foreach ($applications as $application) {
- $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations)->where('id', $this->selectedDestination)->first();
+ $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
clone_application($application, $selectedDestination, [
'environment_id' => $environment->id,
], $this->cloneVolumeData);
diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php
index 706c6c0cd..3f974f63d 100644
--- a/app/Livewire/Project/Database/Import.php
+++ b/app/Livewire/Project/Database/Import.php
@@ -232,12 +232,8 @@ public function runImport()
break;
}
- $this->importCommands[] = [
- 'transfer_file' => [
- 'content' => $restoreCommand,
- 'destination' => $scriptPath,
- ],
- ];
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php
index 7c81e810c..5cda1dedd 100644
--- a/app/Livewire/Project/New/DockerCompose.php
+++ b/app/Livewire/Project/New/DockerCompose.php
@@ -63,7 +63,6 @@ public function submit()
EnvironmentVariable::create([
'key' => $key,
'value' => $variable,
- 'is_build_time' => false,
'is_preview' => false,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),
diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php
index 3dbe4230c..73960d288 100644
--- a/app/Livewire/Project/Resource/Create.php
+++ b/app/Livewire/Project/Resource/Create.php
@@ -97,7 +97,6 @@ public function mount()
'value' => $value,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),
- 'is_build_time' => false,
'is_preview' => false,
]);
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index cf7843f84..23a2cd59d 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -19,28 +19,32 @@ class Add extends Component
public ?string $value = null;
- public bool $is_build_time = false;
-
public bool $is_multiline = false;
public bool $is_literal = false;
+ public bool $is_runtime = true;
+
+ public bool $is_buildtime = true;
+
protected $listeners = ['clearAddEnv' => 'clear'];
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
- 'is_build_time' => 'required|boolean',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
];
protected $validationAttributes = [
'key' => 'key',
'value' => 'value',
- 'is_build_time' => 'build',
'is_multiline' => 'multiline',
'is_literal' => 'literal',
+ 'is_runtime' => 'runtime',
+ 'is_buildtime' => 'buildtime',
];
public function mount()
@@ -54,9 +58,10 @@ public function submit()
$this->dispatch('saveKey', [
'key' => $this->key,
'value' => $this->value,
- 'is_build_time' => $this->is_build_time,
'is_multiline' => $this->is_multiline,
'is_literal' => $this->is_literal,
+ 'is_runtime' => $this->is_runtime,
+ 'is_buildtime' => $this->is_buildtime,
'is_preview' => $this->is_preview,
]);
$this->clear();
@@ -66,8 +71,9 @@ public function clear()
{
$this->key = '';
$this->value = '';
- $this->is_build_time = false;
$this->is_multiline = false;
$this->is_literal = false;
+ $this->is_runtime = true;
+ $this->is_buildtime = true;
}
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index 3631a43c8..639c025c7 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -25,6 +25,8 @@ class All extends Component
public bool $is_env_sorting_enabled = false;
+ public bool $use_build_secrets = false;
+
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -34,13 +36,14 @@ class All extends Component
public function mount()
{
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
+ $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [\App\Models\Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
}
- $this->sortEnvironmentVariables();
+ $this->getDevView();
}
public function instantSave()
@@ -49,34 +52,38 @@ public function instantSave()
$this->authorize('manageEnvironment', $this->resource);
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
+ $this->resource->settings->use_build_secrets = $this->use_build_secrets;
$this->resource->settings->save();
- $this->sortEnvironmentVariables();
+ $this->getDevView();
$this->dispatch('success', 'Environment variable settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
- public function sortEnvironmentVariables()
+ public function getEnvironmentVariablesProperty()
{
if ($this->is_env_sorting_enabled === false) {
- if ($this->resource->environment_variables) {
- $this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values();
- }
-
- if ($this->resource->environment_variables_preview) {
- $this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values();
- }
+ return $this->resource->environment_variables()->orderBy('order')->get();
}
- $this->getDevView();
+ return $this->resource->environment_variables;
+ }
+
+ public function getEnvironmentVariablesPreviewProperty()
+ {
+ if ($this->is_env_sorting_enabled === false) {
+ return $this->resource->environment_variables_preview()->orderBy('order')->get();
+ }
+
+ return $this->resource->environment_variables_preview;
}
public function getDevView()
{
- $this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables);
+ $this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
if ($this->showPreview) {
- $this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview);
+ $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
}
}
@@ -97,7 +104,7 @@ private function formatEnvironmentVariables($variables)
public function switch()
{
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
- $this->sortEnvironmentVariables();
+ $this->getDevView();
}
public function submit($data = null)
@@ -111,7 +118,7 @@ public function submit($data = null)
}
$this->updateOrder();
- $this->sortEnvironmentVariables();
+ $this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@@ -212,9 +219,10 @@ private function createEnvironmentVariable($data)
$environment = new EnvironmentVariable;
$environment->key = $data['key'];
$environment->value = $data['value'];
- $environment->is_build_time = $data['is_build_time'] ?? false;
$environment->is_multiline = $data['is_multiline'] ?? false;
$environment->is_literal = $data['is_literal'] ?? false;
+ $environment->is_runtime = $data['is_runtime'] ?? true;
+ $environment->is_buildtime = $data['is_buildtime'] ?? true;
$environment->is_preview = $data['is_preview'] ?? false;
$environment->resourceable_id = $this->resource->id;
$environment->resourceable_type = $this->resource->getMorphClass();
@@ -257,7 +265,7 @@ private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;
foreach ($variables as $key => $value) {
- if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) {
+ if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
continue;
}
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
@@ -276,7 +284,6 @@ private function updateOrCreateVariables($isPreview, $variables)
$environment = new EnvironmentVariable;
$environment->key = $key;
$environment->value = $value;
- $environment->is_build_time = false;
$environment->is_multiline = false;
$environment->is_preview = $isPreview;
$environment->resourceable_id = $this->resource->id;
@@ -293,7 +300,6 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs()
{
$this->resource->refresh();
- $this->sortEnvironmentVariables();
$this->getDevView();
}
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 1a9daf77b..0d0467c13 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -32,14 +32,16 @@ class Show extends Component
public bool $is_shared = false;
- public bool $is_build_time = false;
-
public bool $is_multiline = false;
public bool $is_literal = false;
public bool $is_shown_once = false;
+ public bool $is_runtime = true;
+
+ public bool $is_buildtime = true;
+
public bool $is_required = false;
public bool $is_really_required = false;
@@ -55,10 +57,11 @@ class Show extends Component
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
- 'is_build_time' => 'required|boolean',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
@@ -101,8 +104,9 @@ public function syncData(bool $toModel = false)
]);
} else {
$this->validate();
- $this->env->is_build_time = $this->is_build_time;
$this->env->is_required = $this->is_required;
+ $this->env->is_runtime = $this->is_runtime;
+ $this->env->is_buildtime = $this->is_buildtime;
$this->env->is_shared = $this->is_shared;
}
$this->env->key = $this->key;
@@ -114,10 +118,11 @@ public function syncData(bool $toModel = false)
} else {
$this->key = $this->env->key;
$this->value = $this->env->value;
- $this->is_build_time = $this->env->is_build_time ?? false;
$this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once;
+ $this->is_runtime = $this->env->is_runtime ?? true;
+ $this->is_buildtime = $this->env->is_buildtime ?? true;
$this->is_required = $this->env->is_required ?? false;
$this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false;
@@ -128,7 +133,7 @@ public function syncData(bool $toModel = false)
public function checkEnvs()
{
$this->isDisabled = false;
- if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL')) {
+ if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) {
$this->isDisabled = true;
}
if ($this->env->is_shown_once) {
@@ -139,9 +144,6 @@ public function checkEnvs()
public function serialize()
{
data_forget($this->env, 'real_value');
- if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
- data_forget($this->env, 'is_build_time');
- }
}
public function lock()
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php
index 6f62a5b5b..ca2bbd9b4 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php
@@ -105,6 +105,19 @@ public function loadMoreLogs()
$this->currentPage++;
}
+ public function loadAllLogs()
+ {
+ if (! $this->selectedExecution || ! $this->selectedExecution->message) {
+ return;
+ }
+
+ $lines = collect(explode("\n", $this->selectedExecution->message));
+ $totalLines = $lines->count();
+ $totalPages = ceil($totalLines / $this->logsPerPage);
+
+ $this->currentPage = $totalPages;
+ }
+
public function getLogLinesProperty()
{
if (! $this->selectedExecution) {
diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php
index c26315d3b..63fc06a36 100644
--- a/app/Livewire/Project/Shared/Storages/All.php
+++ b/app/Livewire/Project/Shared/Storages/All.php
@@ -9,4 +9,15 @@ class All extends Component
public $resource;
protected $listeners = ['refreshStorages' => '$refresh'];
+
+ public function getFirstStorageIdProperty()
+ {
+ if ($this->resource->persistentStorages->isEmpty()) {
+ return null;
+ }
+
+ // Use the storage with the smallest ID as the "first" one
+ // This ensures stability even when storages are deleted
+ return $this->resource->persistentStorages->sortBy('id')->first()->id;
+ }
}
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index 055290580..beefed12a 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -32,7 +32,7 @@ public function getListeners()
$teamId = auth()->user()->currentTeam()->id;
return [
- 'refreshServerShow' => '$refresh',
+ 'refreshServerShow' => 'refreshServer',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
];
}
@@ -134,6 +134,12 @@ public function showNotification()
}
+ public function refreshServer()
+ {
+ $this->server->refresh();
+ $this->server->load('settings');
+ }
+
public function render()
{
return view('livewire.server.navbar');
diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
index b564e208b..eb2db1cbb 100644
--- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
+++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
@@ -78,7 +78,10 @@ public function addDynamicConfiguration()
$yaml = Yaml::dump($yaml, 10, 2);
$this->value = $yaml;
}
- transfer_file_to_server($this->value, $file, $this->server);
+ $base64_value = base64_encode($this->value);
+ instant_remote_process([
+ "echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
+ ], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index f4ae6dd7e..473e0b60e 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -63,6 +63,8 @@ class Show extends Component
public bool $isSentinelDebugEnabled;
+ public ?string $sentinelCustomDockerImage = null;
+
public string $serverTimezone;
public function getListeners()
@@ -267,7 +269,8 @@ public function restartSentinel()
{
try {
$this->authorize('manageSentinel', $this->server);
- $this->server->restartSentinel();
+ $customImage = isDev() ? $this->sentinelCustomDockerImage : null;
+ $this->server->restartSentinel($customImage);
$this->dispatch('success', 'Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -295,12 +298,38 @@ public function updatedIsMetricsEnabled($value)
}
}
+ public function updatedIsBuildServer($value)
+ {
+ try {
+ $this->authorize('update', $this->server);
+ if ($value === true && $this->isSentinelEnabled) {
+ $this->isSentinelEnabled = false;
+ $this->isMetricsEnabled = false;
+ $this->isSentinelDebugEnabled = false;
+ StopSentinel::dispatch($this->server);
+ $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
+ }
+ $this->submit();
+ // Dispatch event to refresh the navbar
+ $this->dispatch('refreshServerShow');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function updatedIsSentinelEnabled($value)
{
try {
$this->authorize('manageSentinel', $this->server);
if ($value === true) {
- StartSentinel::run($this->server, true);
+ if ($this->isBuildServer) {
+ $this->isSentinelEnabled = false;
+ $this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
+
+ return;
+ }
+ $customImage = isDev() ? $this->sentinelCustomDockerImage : null;
+ StartSentinel::run($this->server, true, null, $customImage);
} else {
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 11755b16e..094e5c82b 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -4,6 +4,7 @@
use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -110,7 +111,7 @@
class Application extends BaseModel
{
- use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@@ -123,66 +124,6 @@ class Application extends BaseModel
'http_basic_auth_password' => 'encrypted',
];
- public function customNetworkAliases(): Attribute
- {
- return Attribute::make(
- set: function ($value) {
- if (is_null($value) || $value === '') {
- return null;
- }
-
- // If it's already a JSON string, decode it
- if (is_string($value) && $this->isJson($value)) {
- $value = json_decode($value, true);
- }
-
- // If it's a string but not JSON, treat it as a comma-separated list
- if (is_string($value) && ! is_array($value)) {
- $value = explode(',', $value);
- }
-
- $value = collect($value)
- ->map(function ($alias) {
- if (is_string($alias)) {
- return str_replace(' ', '-', trim($alias));
- }
-
- return null;
- })
- ->filter()
- ->unique() // Remove duplicate values
- ->values()
- ->toArray();
-
- return empty($value) ? null : json_encode($value);
- },
- get: function ($value) {
- if (is_null($value)) {
- return null;
- }
-
- if (is_string($value) && $this->isJson($value)) {
- return json_decode($value, true);
- }
-
- return is_array($value) ? $value : [];
- }
- );
- }
-
- /**
- * Check if a string is a valid JSON
- */
- private function isJson($string)
- {
- if (! is_string($string)) {
- return false;
- }
- json_decode($string);
-
- return json_last_error() === JSON_ERROR_NONE;
- }
-
protected static function booted()
{
static::addGlobalScope('withRelations', function ($builder) {
@@ -250,6 +191,66 @@ protected static function booted()
});
}
+ public function customNetworkAliases(): Attribute
+ {
+ return Attribute::make(
+ set: function ($value) {
+ if (is_null($value) || $value === '') {
+ return null;
+ }
+
+ // If it's already a JSON string, decode it
+ if (is_string($value) && $this->isJson($value)) {
+ $value = json_decode($value, true);
+ }
+
+ // If it's a string but not JSON, treat it as a comma-separated list
+ if (is_string($value) && ! is_array($value)) {
+ $value = explode(',', $value);
+ }
+
+ $value = collect($value)
+ ->map(function ($alias) {
+ if (is_string($alias)) {
+ return str_replace(' ', '-', trim($alias));
+ }
+
+ return null;
+ })
+ ->filter()
+ ->unique() // Remove duplicate values
+ ->values()
+ ->toArray();
+
+ return empty($value) ? null : json_encode($value);
+ },
+ get: function ($value) {
+ if (is_null($value)) {
+ return null;
+ }
+
+ if (is_string($value) && $this->isJson($value)) {
+ return json_decode($value, true);
+ }
+
+ return is_array($value) ? $value : [];
+ }
+ );
+ }
+
+ /**
+ * Check if a string is a valid JSON
+ */
+ private function isJson($string)
+ {
+ if (! is_string($string)) {
+ return false;
+ }
+ json_decode($string);
+
+ return json_last_error() === JSON_ERROR_NONE;
+ }
+
public static function ownedByCurrentTeamAPI(int $teamId)
{
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
@@ -728,7 +729,14 @@ public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
public function runtime_environment_variables()
@@ -738,14 +746,6 @@ public function runtime_environment_variables()
->where('key', 'not like', 'NIXPACKS_%');
}
- public function build_environment_variables()
- {
- return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->where('is_preview', false)
- ->where('is_build_time', true)
- ->where('key', 'not like', 'NIXPACKS_%');
- }
-
public function nixpacks_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -757,7 +757,14 @@ public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
- ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
public function runtime_environment_variables_preview()
@@ -767,14 +774,6 @@ public function runtime_environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%');
}
- public function build_environment_variables_preview()
- {
- return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->where('is_preview', true)
- ->where('is_build_time', true)
- ->where('key', 'not like', 'NIXPACKS_%');
- }
-
public function nixpacks_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -934,11 +933,11 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false)
{
- $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels);
+ $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
- $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_build_time', 'is_multiline', 'is_literal'])->sort());
+ $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
- $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_build_time', 'is_multiline', 'is_literal'])->sort());
+ $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
}
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
@@ -1075,20 +1074,26 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
if (is_null($private_key)) {
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
+ $private_key = base64_encode($private_key);
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
- $commands = collect([]);
+ if ($exec_in_docker) {
+ $commands = collect([
+ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
+ executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
+ executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
+ ]);
+ } else {
+ $commands = collect([
+ 'mkdir -p /root/.ssh',
+ "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
+ 'chmod 600 /root/.ssh/id_rsa',
+ ]);
+ }
if ($exec_in_docker) {
- $commands->push(executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'));
- // SSH key transfer handled by ApplicationDeploymentJob, assume key is already in container
- $commands->push(executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'));
$commands->push(executeInDocker($deployment_uuid, $base_comamnd));
} else {
- $server = $this->destination->server;
- $commands->push('mkdir -p /root/.ssh');
- transfer_file_to_server($private_key, '/root/.ssh/id_rsa', $server);
- $commands->push('chmod 600 /root/.ssh/id_rsa');
$commands->push($base_comamnd);
}
@@ -1214,6 +1219,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
if (is_null($private_key)) {
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
+ $private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) {
@@ -1221,18 +1227,18 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base);
}
-
- $commands = collect([]);
-
if ($exec_in_docker) {
- $commands->push(executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'));
- // SSH key transfer handled by ApplicationDeploymentJob, assume key is already in container
- $commands->push(executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'));
+ $commands = collect([
+ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
+ executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
+ executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
+ ]);
} else {
- $server = $this->destination->server;
- $commands->push('mkdir -p /root/.ssh');
- transfer_file_to_server($private_key, '/root/.ssh/id_rsa', $server);
- $commands->push('chmod 600 /root/.ssh/id_rsa');
+ $commands = collect([
+ 'mkdir -p /root/.ssh',
+ "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
+ 'chmod 600 /root/.ssh/id_rsa',
+ ]);
}
if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') {
@@ -1474,14 +1480,14 @@ public function loadComposeFile($isInit = false)
$json = collect(json_decode($this->docker_compose_domains));
foreach ($json as $key => $value) {
if (str($key)->contains('-')) {
- $key = str($key)->replace('-', '_');
+ $key = str($key)->replace('-', '_')->replace('.', '_');
}
$json->put((string) $key, $value);
}
$services = collect(data_get($parsedServices, 'services', []));
foreach ($services as $name => $service) {
if (str($name)->contains('-')) {
- $replacedName = str($name)->replace('-', '_');
+ $replacedName = str($name)->replace('-', '_')->replace('.', '_');
$services->put((string) $replacedName, $service);
$services->forget((string) $name);
}
@@ -1565,7 +1571,19 @@ public function isWatchPathsTriggered(Collection $modified_files): bool
if (is_null($this->watch_paths)) {
return false;
}
- $watch_paths = collect(explode("\n", $this->watch_paths));
+ $watch_paths = collect(explode("\n", $this->watch_paths))
+ ->map(function (string $path): string {
+ return trim($path);
+ })
+ ->filter(function (string $path): bool {
+ return strlen($path) > 0;
+ });
+
+ // If no valid patterns after filtering, don't trigger
+ if ($watch_paths->isEmpty()) {
+ return false;
+ }
+
$matches = $modified_files->filter(function ($file) use ($watch_paths) {
return $watch_paths->contains(function ($glob) use ($file) {
return fnmatch($glob, $file);
diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php
index 2a9bea67a..8df6877ab 100644
--- a/app/Models/ApplicationDeploymentQueue.php
+++ b/app/Models/ApplicationDeploymentQueue.php
@@ -85,6 +85,47 @@ public function commitMessage()
return str($this->commit_message)->value();
}
+ private function redactSensitiveInfo($text)
+ {
+ $text = remove_iip($text);
+
+ $app = $this->application;
+ if (! $app) {
+ return $text;
+ }
+
+ $lockedVars = collect([]);
+
+ if ($app->environment_variables) {
+ $lockedVars = $lockedVars->merge(
+ $app->environment_variables
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter()
+ );
+ }
+
+ if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
+ $lockedVars = $lockedVars->merge(
+ $app->environment_variables_preview
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter()
+ );
+ }
+
+ foreach ($lockedVars as $key => $value) {
+ $escapedValue = preg_quote($value, '/');
+ $text = preg_replace(
+ '/'.$escapedValue.'/',
+ REDACTED,
+ $text
+ );
+ }
+
+ return $text;
+ }
+
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
{
if ($type === 'error') {
@@ -96,7 +137,7 @@ public function addLogEntry(string $message, string $type = 'stdout', bool $hidd
}
$newLogEntry = [
'command' => null,
- 'output' => remove_iip($message),
+ 'output' => $this->redactSensitiveInfo($message),
'type' => $type,
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index b8bde5c84..80399a16b 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -14,10 +14,11 @@
'uuid' => ['type' => 'string'],
'resourceable_type' => ['type' => 'string'],
'resourceable_id' => ['type' => 'integer'],
- 'is_build_time' => ['type' => 'boolean'],
'is_literal' => ['type' => 'boolean'],
'is_multiline' => ['type' => 'boolean'],
'is_preview' => ['type' => 'boolean'],
+ 'is_runtime' => ['type' => 'boolean'],
+ 'is_buildtime' => ['type' => 'boolean'],
'is_shared' => ['type' => 'boolean'],
'is_shown_once' => ['type' => 'boolean'],
'key' => ['type' => 'string'],
@@ -35,15 +36,16 @@ class EnvironmentVariable extends BaseModel
protected $casts = [
'key' => 'string',
'value' => 'encrypted',
- 'is_build_time' => 'boolean',
'is_multiline' => 'boolean',
'is_preview' => 'boolean',
+ 'is_runtime' => 'boolean',
+ 'is_buildtime' => 'boolean',
'version' => 'string',
'resourceable_type' => 'string',
'resourceable_id' => 'integer',
];
- protected $appends = ['real_value', 'is_shared', 'is_really_required'];
+ protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
protected static function booted()
{
@@ -61,8 +63,8 @@ protected static function booted()
ModelsEnvironmentVariable::create([
'key' => $environment_variable->key,
'value' => $environment_variable->value,
- 'is_build_time' => $environment_variable->is_build_time,
'is_multiline' => $environment_variable->is_multiline ?? false,
+ 'is_literal' => $environment_variable->is_literal ?? false,
'resourceable_type' => Application::class,
'resourceable_id' => $environment_variable->resourceable_id,
'is_preview' => true,
@@ -137,6 +139,32 @@ protected function isReallyRequired(): Attribute
);
}
+ protected function isNixpacks(): Attribute
+ {
+ return Attribute::make(
+ get: function () {
+ if (str($this->key)->startsWith('NIXPACKS_')) {
+ return true;
+ }
+
+ return false;
+ }
+ );
+ }
+
+ protected function isCoolify(): Attribute
+ {
+ return Attribute::make(
+ get: function () {
+ if (str($this->key)->startsWith('SERVICE_')) {
+ return true;
+ }
+
+ return false;
+ }
+ );
+ }
+
protected function isShared(): Attribute
{
return Attribute::make(
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index b19b6aa42..b3e71d75d 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -159,7 +159,8 @@ public function saveStorageOnServer()
$chmod = data_get($this, 'chmod');
$chown = data_get($this, 'chown');
if ($content) {
- transfer_file_to_server($content, $path, $server);
+ $content = base64_encode($content);
+ $commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
} else {
$commands->push("touch $path");
}
@@ -174,9 +175,7 @@ public function saveStorageOnServer()
$commands->push("mkdir -p $path > /dev/null 2>&1 || true");
}
- if ($commands->count() > 0) {
- return instant_remote_process($commands, $server);
- }
+ return instant_remote_process($commands, $server);
}
// Accessor for convenient access
diff --git a/app/Models/Server.php b/app/Models/Server.php
index b417cea49..829a4b5aa 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -13,6 +13,7 @@
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -55,7 +56,7 @@
class Server extends BaseModel
{
- use HasFactory, SchemalessAttributesTrait, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
public static $batch_counter = 0;
@@ -309,7 +310,10 @@ public function setupDefaultRedirect()
$conf = Yaml::dump($dynamic_conf, 12, 2);
}
$conf = $banner.$conf;
- transfer_file_to_server($conf, $default_redirect_file, $this);
+ $base64 = base64_encode($conf);
+ instant_remote_process([
+ "echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null",
+ ], $this);
}
if ($proxy_type === 'CADDY') {
@@ -443,10 +447,11 @@ public function setupDynamicProxyConfiguration()
"# Do not edit it manually (only if you know what are you doing).\n\n".
$yaml;
+ $base64 = base64_encode($yaml);
instant_remote_process([
"mkdir -p $dynamic_config_path",
+ "echo '$base64' | base64 -d | tee $file > /dev/null",
], $this);
- transfer_file_to_server($yaml, $file, $this);
}
} elseif ($this->proxyType() === 'CADDY') {
$file = "$dynamic_config_path/coolify.caddy";
@@ -469,7 +474,10 @@ public function setupDynamicProxyConfiguration()
}
reverse_proxy coolify:8080
}";
- transfer_file_to_server($caddy_file, $file, $this);
+ $base64 = base64_encode($caddy_file);
+ instant_remote_process([
+ "echo '$base64' | base64 -d | tee $file > /dev/null",
+ ], $this);
$this->reloadCaddy();
}
}
@@ -1252,13 +1260,13 @@ public function isIpv6(): bool
return str($this->ip)->contains(':');
}
- public function restartSentinel(bool $async = true)
+ public function restartSentinel(?string $customImage = null, bool $async = true)
{
try {
if ($async) {
- StartSentinel::dispatch($this, true);
+ StartSentinel::dispatch($this, true, null, $customImage);
} else {
- StartSentinel::run($this, true);
+ StartSentinel::run($this, true, null, $customImage);
}
} catch (\Throwable $e) {
return handleError($e);
@@ -1312,6 +1320,7 @@ private function disableSshMux(): void
public function generateCaCertificate()
{
try {
+ ray('Generating CA certificate for server', $this->id);
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->id,
@@ -1319,6 +1328,7 @@ public function generateCaCertificate()
validityDays: 10 * 365
);
$caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first();
+ ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
diff --git a/app/Models/Service.php b/app/Models/Service.php
index bd185b355..d42d471c6 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ProcessStatus;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -41,7 +42,7 @@
)]
class Service extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@@ -1113,7 +1114,6 @@ public function saveExtraFields($fields)
$this->environment_variables()->create([
'key' => $key,
'value' => $value,
- 'is_build_time' => false,
'resourceable_id' => $this->id,
'resourceable_type' => $this->getMorphClass(),
'is_preview' => false,
@@ -1230,14 +1230,14 @@ public function scheduled_tasks(): HasMany
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
- }
-
- public function environment_variables_preview()
- {
- return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->where('is_preview', true)
- ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
public function workdir()
@@ -1281,10 +1281,8 @@ public function saveComposeConfigs()
if ($envs->count() === 0) {
$commands[] = 'touch .env';
} else {
- $envs_content = $envs->implode("\n");
- transfer_file_to_server($envs_content, $this->workdir().'/.env', $this->server);
-
- return;
+ $envs_base64 = base64_encode($envs->implode("\n"));
+ $commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null";
}
instant_remote_process($commands, $this->server);
diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 88142066f..146ee0a2d 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandaloneClickhouse extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -43,6 +44,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -266,7 +272,14 @@ public function destination()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
public function runtime_environment_variables()
diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index b7d22a2ce..90e7304f1 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandaloneDragonfly extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -43,6 +44,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -341,6 +347,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
}
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index 807728a36..ad0cabf7e 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandaloneKeydb extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -43,6 +44,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -341,6 +347,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
}
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index 8d602c27d..3d9e38147 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,7 +11,7 @@
class StandaloneMariadb extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -44,6 +45,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -262,7 +268,14 @@ public function destination(): MorphTo
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
public function runtime_environment_variables()
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index f222b0e5c..7cccd332a 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandaloneMongodb extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -46,6 +47,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -363,6 +369,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
}
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index e4693c76a..80269972f 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandaloneMysql extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -44,6 +45,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -345,6 +351,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
}
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index 47c984ff7..acde7a20c 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandalonePostgresql extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -44,6 +45,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
public function workdir()
{
return database_configuration_dir()."/{$this->uuid}";
@@ -296,7 +302,14 @@ public function scheduledBackups()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
public function isBackupSolutionAvailable()
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index 79c6572ab..001ebe36a 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@
class StandaloneRedis extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@@ -45,6 +46,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
@@ -388,6 +394,13 @@ public function redisUsername(): Attribute
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
- ->orderBy('key', 'asc');
+ ->orderByRaw("
+ CASE
+ WHEN LOWER(key) LIKE 'service_%' THEN 1
+ WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ ELSE 3
+ END,
+ LOWER(key) ASC
+ ");
}
}
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
index ed27a158a..30d909388 100644
--- a/app/Providers/FortifyServiceProvider.php
+++ b/app/Providers/FortifyServiceProvider.php
@@ -80,9 +80,23 @@ public function boot(): void
) {
$user->updated_at = now();
$user->save();
- $user->currentTeam = $user->teams->firstWhere('personal_team', true);
- if (! $user->currentTeam) {
- $user->currentTeam = $user->recreate_personal_team();
+
+ // Check if user has a pending invitation they haven't accepted yet
+ $invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
+ if ($invitation && $invitation->isValid()) {
+ // User is logging in for the first time after being invited
+ // Attach them to the invited team if not already attached
+ if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) {
+ $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
+ }
+ $user->currentTeam = $invitation->team;
+ $invitation->delete();
+ } else {
+ // Normal login - use personal team
+ $user->currentTeam = $user->teams->firstWhere('personal_team', true);
+ if (! $user->currentTeam) {
+ $user->currentTeam = $user->recreate_personal_team();
+ }
}
session(['currentTeam' => $user->currentTeam]);
diff --git a/app/Services/ConfigurationGenerator.php b/app/Services/ConfigurationGenerator.php
index a7e4b31be..320e3f32a 100644
--- a/app/Services/ConfigurationGenerator.php
+++ b/app/Services/ConfigurationGenerator.php
@@ -129,7 +129,6 @@ protected function getEnvironmentVariables(): array
$variables->push([
'key' => $env->key,
'value' => $env->value,
- 'is_build_time' => $env->is_build_time,
'is_preview' => $env->is_preview,
'is_multiline' => $env->is_multiline,
]);
@@ -145,7 +144,6 @@ protected function getPreviewEnvironmentVariables(): array
$variables->push([
'key' => $env->key,
'value' => $env->value,
- 'is_build_time' => $env->is_build_time,
'is_preview' => $env->is_preview,
'is_multiline' => $env->is_multiline,
]);
diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php
new file mode 100644
index 000000000..0bcc5d319
--- /dev/null
+++ b/app/Traits/ClearsGlobalSearchCache.php
@@ -0,0 +1,81 @@
+hasSearchableChanges()) {
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ }
+ });
+
+ static::created(function ($model) {
+ // Always clear cache when model is created
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ });
+
+ static::deleted(function ($model) {
+ // Always clear cache when model is deleted
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ });
+ }
+
+ private function hasSearchableChanges(): bool
+ {
+ // Define searchable fields based on model type
+ $searchableFields = ['name', 'description'];
+
+ // Add model-specific searchable fields
+ if ($this instanceof \App\Models\Application) {
+ $searchableFields[] = 'fqdn';
+ $searchableFields[] = 'docker_compose_domains';
+ } elseif ($this instanceof \App\Models\Server) {
+ $searchableFields[] = 'ip';
+ } elseif ($this instanceof \App\Models\Service) {
+ // Services don't have direct fqdn, but name and description are covered
+ }
+ // Database models only have name and description as searchable
+
+ // Check if any searchable field is dirty
+ foreach ($searchableFields as $field) {
+ if ($this->isDirty($field)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function getTeamIdForCache()
+ {
+ // For database models, team is accessed through environment.project.team
+ if (method_exists($this, 'team')) {
+ $team = $this->team();
+ if (filled($team)) {
+ return is_object($team) ? $team->id : null;
+ }
+ }
+
+ // For models with direct team_id property
+ if (property_exists($this, 'team_id') || isset($this->team_id)) {
+ return $this->team_id;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Traits/EnvironmentVariableProtection.php b/app/Traits/EnvironmentVariableProtection.php
index b6b8d2687..ecc484966 100644
--- a/app/Traits/EnvironmentVariableProtection.php
+++ b/app/Traits/EnvironmentVariableProtection.php
@@ -14,7 +14,7 @@ trait EnvironmentVariableProtection
*/
protected function isProtectedEnvironmentVariable(string $key): bool
{
- return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
+ return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_');
}
/**
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index 0e7961368..f9df19c16 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -17,6 +17,46 @@ trait ExecuteRemoteCommand
public static int $batch_counter = 0;
+ private function redact_sensitive_info($text)
+ {
+ $text = remove_iip($text);
+
+ if (! isset($this->application)) {
+ return $text;
+ }
+
+ $lockedVars = collect([]);
+
+ if (isset($this->application->environment_variables)) {
+ $lockedVars = $lockedVars->merge(
+ $this->application->environment_variables
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter()
+ );
+ }
+
+ if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
+ $lockedVars = $lockedVars->merge(
+ $this->application->environment_variables_preview
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter()
+ );
+ }
+
+ foreach ($lockedVars as $key => $value) {
+ $escapedValue = preg_quote($value, '/');
+ $text = preg_replace(
+ '/'.$escapedValue.'/',
+ REDACTED,
+ $text
+ );
+ }
+
+ return $text;
+ }
+
public function execute_remote_command(...$commands)
{
static::$batch_counter++;
@@ -46,6 +86,14 @@ public function execute_remote_command(...$commands)
}
}
+ // Check for cancellation before executing commands
+ if (isset($this->application_deployment_queue)) {
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
+ }
+ }
+
$maxRetries = config('constants.ssh.max_retries');
$attempt = 0;
$lastError = null;
@@ -66,13 +114,19 @@ public function execute_remote_command(...$commands)
// Track SSH retry event in Sentry
$this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
- 'command' => remove_iip($command),
+ 'command' => $this->redact_sensitive_info($command),
'trait' => 'ExecuteRemoteCommand',
]);
// Add log entry for the retry
if (isset($this->application_deployment_queue)) {
$this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
+
+ // Check for cancellation during retry wait
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
+ }
}
sleep($delay);
@@ -85,6 +139,11 @@ public function execute_remote_command(...$commands)
// If we exhausted all retries and still failed
if (! $commandExecuted && $lastError) {
+ // Now we can set the status to FAILED since all retries have been exhausted
+ if (isset($this->application_deployment_queue)) {
+ $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
+ $this->application_deployment_queue->save();
+ }
throw $lastError;
}
});
@@ -106,8 +165,8 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$sanitized_output = sanitize_utf8_text($output);
$new_log_entry = [
- 'command' => remove_iip($command),
- 'output' => remove_iip($sanitized_output),
+ 'command' => $this->redact_sensitive_info($command),
+ 'output' => $this->redact_sensitive_info($sanitized_output),
'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
@@ -160,8 +219,8 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$process_result = $process->wait();
if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) {
- $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
- $this->application_deployment_queue->save();
+ // Don't immediately set to FAILED - let the retry logic handle it
+ // This prevents premature status changes during retryable SSH errors
throw new \RuntimeException($process_result->errorOutput());
}
}
@@ -175,7 +234,7 @@ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, str
$retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
$new_log_entry = [
- 'output' => remove_iip($retryMessage),
+ 'output' => $this->redact_sensitive_info($retryMessage),
'type' => 'stdout',
'timestamp' => Carbon::now('UTC'),
'hidden' => false,
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index ca34875d8..db7767c1e 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -317,18 +317,38 @@ function clone_application(Application $source, $destination, array $overrides =
$newStorage->save();
}
- // Clone environment variables
+ // Clone production environment variables without triggering the created hook
$environmentVariables = $source->environment_variables()->get();
foreach ($environmentVariables as $environmentVariable) {
- $newEnvironmentVariable = $environmentVariable->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'resourceable_id' => $newApplication->id,
- 'resourceable_type' => $newApplication->getMorphClass(),
- ]);
- $newEnvironmentVariable->save();
+ \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
+ $newEnvironmentVariable = $environmentVariable->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'resourceable_id' => $newApplication->id,
+ 'resourceable_type' => $newApplication->getMorphClass(),
+ 'is_preview' => false,
+ ]);
+ $newEnvironmentVariable->save();
+ });
+ }
+
+ // Clone preview environment variables
+ $previewEnvironmentVariables = $source->environment_variables_preview()->get();
+ foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
+ \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
+ $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'resourceable_id' => $newApplication->id,
+ 'resourceable_type' => $newApplication->getMorphClass(),
+ 'is_preview' => true,
+ ]);
+ $newPreviewEnvironmentVariable->save();
+ });
}
return $newApplication;
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 5cfddc599..1491e4712 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -1069,9 +1069,9 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
}
}
- $compose_content = Yaml::dump($yaml_compose);
- transfer_file_to_server($compose_content, "/tmp/{$uuid}.yml", $server);
+ $base64_compose = base64_encode(Yaml::dump($yaml_compose));
instant_remote_process([
+ "echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
"chmod 600 /tmp/{$uuid}.yml",
"docker compose -f /tmp/{$uuid}.yml config --no-interpolate --no-path-resolution -q",
"rm /tmp/{$uuid}.yml",
@@ -1093,11 +1093,11 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
{
if ($server->isSwarm()) {
$output = instant_remote_process([
- "docker service logs -n {$lines} {$container_id}",
+ "docker service logs -n {$lines} {$container_id} 2>&1",
], $server);
} else {
$output = instant_remote_process([
- "docker logs -n {$lines} {$container_id}",
+ "docker logs -n {$lines} {$container_id} 2>&1",
], $server);
}
@@ -1105,7 +1105,6 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
return $output;
}
-
function escapeEnvVariables($value)
{
$search = ['\\', "\r", "\t", "\x0", '"', "'"];
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index f7041c3da..d4701d251 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -342,7 +342,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
@@ -355,7 +354,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
@@ -373,7 +371,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
$originalFqdnFor = str($fqdnFor)->replace('_', '-');
if (str($fqdnFor)->contains('-')) {
- $fqdnFor = str($fqdnFor)->replace('-', '_');
+ $fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
}
// Generated FQDN & URL
$fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
@@ -384,7 +382,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
if ($resource->build_pack === 'dockercompose') {
@@ -409,7 +406,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
$originalUrlFor = str($urlFor)->replace('_', '-');
if (str($urlFor)->contains('-')) {
- $urlFor = str($urlFor)->replace('-', '_');
+ $urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
}
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
$resource->environment_variables()->firstOrCreate([
@@ -418,7 +415,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $url,
- 'is_build_time' => false,
'is_preview' => false,
]);
if ($resource->build_pack === 'dockercompose') {
@@ -446,7 +442,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
@@ -454,6 +449,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
+ // generate SERVICE_NAME variables for docker compose services
+ $serviceNameEnvironments = collect([]);
+ if ($resource->build_pack === 'dockercompose') {
+ $serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
+ }
+
// Parse the rest of the services
foreach ($services as $serviceName => $service) {
$image = data_get_str($service, 'image');
@@ -567,7 +568,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
$source = replaceLocalSource($source, $mainDirectory);
if ($isPullRequest) {
- $source = $source."-pr-$pullRequestId";
+ $source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
LocalFileVolume::updateOrCreate(
[
@@ -610,7 +611,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$name = "{$uuid}_{$slugWithoutUuid}";
if ($isPullRequest) {
- $name = "{$name}-pr-$pullRequestId";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
if (is_string($volume)) {
$parsed = parseDockerVolumeString($volume);
@@ -651,11 +652,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$newDependsOn = collect([]);
$depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
if (is_numeric($condition)) {
- $dependency = "$dependency-pr-$pullRequestId";
+ $dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
$newDependsOn->put($condition, $dependency);
} else {
- $condition = "$condition-pr-$pullRequestId";
+ $condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
$newDependsOn->put($condition, $dependency);
}
});
@@ -754,7 +755,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
]);
@@ -771,7 +771,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
]);
} else {
@@ -807,7 +806,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
- 'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
@@ -822,7 +820,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
@@ -858,13 +855,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($resource->build_pack !== 'dockercompose') {
$domains = collect([]);
}
- $changedServiceName = str($serviceName)->replace('-', '_')->value();
+ $changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
$fqdns = data_get($domains, "$changedServiceName.domain");
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
- $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
+ $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
@@ -872,24 +869,22 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
- $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyUrl->__toString());
- $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyFqdn);
+ $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
+ $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
- 'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'),
+ 'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
], [
'value' => $coolifyUrl->__toString(),
- 'is_build_time' => false,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
- 'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'),
+ 'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
], [
'value' => $coolifyFqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
} else {
@@ -1082,7 +1077,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$payload['volumes'] = $volumesParsed;
}
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
- $payload['environment'] = $environment->merge($coolifyEnvironments);
+ $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
}
if ($logging) {
$payload['logging'] = $logging;
@@ -1091,7 +1086,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$payload['depends_on'] = $depends_on;
}
if ($isPullRequest) {
- $serviceName = "{$serviceName}-pr-{$pullRequestId}";
+ $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
$parsedServices->put($serviceName, $payload);
@@ -1337,7 +1332,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
@@ -1346,7 +1340,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $url,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
@@ -1358,7 +1351,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
@@ -1367,7 +1359,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $url,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
@@ -1397,7 +1388,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
- 'is_build_time' => false,
'is_preview' => false,
]);
@@ -1417,7 +1407,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $url,
- 'is_build_time' => false,
'is_preview' => false,
]);
@@ -1429,7 +1418,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
@@ -1748,7 +1736,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
]);
@@ -1765,7 +1752,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
]);
} else {
@@ -1801,7 +1787,6 @@ function serviceParser(Service $resource): Collection
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
- 'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
@@ -1816,7 +1801,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
- 'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index 7fa9671e3..56386a55f 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -29,31 +29,11 @@ function remote_process(
$type = $type ?? ActivityTypes::INLINE->value;
$command = $command instanceof Collection ? $command->toArray() : $command;
- // Process commands and handle file transfers
- $processed_commands = [];
- foreach ($command as $cmd) {
- if (is_array($cmd) && isset($cmd['transfer_file'])) {
- // Handle file transfer command
- $transfer_data = $cmd['transfer_file'];
- $content = $transfer_data['content'];
- $destination = $transfer_data['destination'];
-
- // Execute file transfer immediately
- transfer_file_to_server($content, $destination, $server, ! $ignore_errors);
-
- // Add a comment to the command log for visibility
- $processed_commands[] = "# File transferred via SCP: $destination";
- } else {
- // Regular string command
- $processed_commands[] = $cmd;
- }
- }
-
if ($server->isNonRoot()) {
- $processed_commands = parseCommandsByLineForSudo(collect($processed_commands), $server);
+ $command = parseCommandsByLineForSudo(collect($command), $server);
}
- $command_string = implode("\n", $processed_commands);
+ $command_string = implode("\n", $command);
if (Auth::check()) {
$teams = Auth::user()->teams->pluck('id');
@@ -200,30 +180,10 @@ function instant_remote_process(Collection|array $command, Server $server, bool
{
$command = $command instanceof Collection ? $command->toArray() : $command;
- // Process commands and handle file transfers
- $processed_commands = [];
- foreach ($command as $cmd) {
- if (is_array($cmd) && isset($cmd['transfer_file'])) {
- // Handle file transfer command
- $transfer_data = $cmd['transfer_file'];
- $content = $transfer_data['content'];
- $destination = $transfer_data['destination'];
-
- // Execute file transfer immediately
- transfer_file_to_server($content, $destination, $server, $throwError);
-
- // Add a comment to the command log for visibility
- $processed_commands[] = "# File transferred via SCP: $destination";
- } else {
- // Regular string command
- $processed_commands[] = $cmd;
- }
- }
-
if ($server->isNonRoot() && ! $no_sudo) {
- $processed_commands = parseCommandsByLineForSudo(collect($processed_commands), $server);
+ $command = parseCommandsByLineForSudo(collect($command), $server);
}
- $command_string = implode("\n", $processed_commands);
+ $command_string = implode("\n", $command);
return \App\Helpers\SshRetryHandler::retry(
function () use ($server, $command_string) {
diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
index 7b53c538e..a124272a2 100644
--- a/bootstrap/helpers/services.php
+++ b/bootstrap/helpers/services.php
@@ -69,11 +69,12 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$fileVolume->content = $content;
$fileVolume->is_directory = false;
$fileVolume->save();
+ $content = base64_encode($content);
$dir = str($fileLocation)->dirname();
instant_remote_process([
"mkdir -p $dir",
+ "echo '$content' | base64 -d | tee $fileLocation",
], $server);
- transfer_file_to_server($content, $fileLocation, $server);
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) {
// Does not exists (no dir or file), flagged as directory, is init
$fileVolume->content = null;
@@ -114,14 +115,14 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$resource->save();
}
- $serviceName = str($resource->name)->upper()->replace('-', '_');
+ $serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
$resourceFqdns = $resourceFqdns->first();
- $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
+ $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$url = Url::fromString($resourceFqdns);
$port = $url->getPort();
$path = $url->getPath();
@@ -133,7 +134,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $urlValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
@@ -144,11 +144,10 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $urlValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
- $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
+ $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
@@ -163,7 +162,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $fqdnValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
@@ -174,7 +172,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $fqdnValue,
- 'is_build_time' => false,
'is_preview' => false,
]);
}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index be509d546..a0ab5a704 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1125,30 +1125,77 @@ function get_public_ips()
function isAnyDeploymentInprogress()
{
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
- $basicDetails = $runningJobs->map(function ($job) {
- return [
- 'id' => $job->id,
- 'created_at' => $job->created_at,
- 'application_id' => $job->application_id,
- 'server_id' => $job->server_id,
- 'horizon_job_id' => $job->horizon_job_id,
- 'status' => $job->status,
- ];
- });
- echo 'Running jobs: '.json_encode($basicDetails)."\n";
+
+ if ($runningJobs->isEmpty()) {
+ echo "No deployments in progress.\n";
+ exit(0);
+ }
+
$horizonJobIds = [];
+ $deploymentDetails = [];
+
foreach ($runningJobs as $runningJob) {
$horizonJobStatus = getJobStatus($runningJob->horizon_job_id);
if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') {
$horizonJobIds[] = $runningJob->horizon_job_id;
+
+ // Get application and team information
+ $application = Application::find($runningJob->application_id);
+ $teamMembers = [];
+ $deploymentUrl = '';
+
+ if ($application) {
+ // Get team members through the application's project
+ $team = $application->team();
+ if ($team) {
+ $teamMembers = $team->members()->pluck('email')->toArray();
+ }
+
+ // Construct the full deployment URL
+ if ($runningJob->deployment_url) {
+ $baseUrl = base_url();
+ $deploymentUrl = $baseUrl.$runningJob->deployment_url;
+ }
+ }
+
+ $deploymentDetails[] = [
+ 'id' => $runningJob->id,
+ 'application_name' => $runningJob->application_name ?? 'Unknown',
+ 'server_name' => $runningJob->server_name ?? 'Unknown',
+ 'deployment_url' => $deploymentUrl,
+ 'team_members' => $teamMembers,
+ 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'),
+ 'horizon_job_id' => $runningJob->horizon_job_id,
+ ];
}
}
+
if (count($horizonJobIds) === 0) {
- echo "No deployments in progress.\n";
+ echo "No active deployments in progress (all jobs completed or failed).\n";
exit(0);
}
- $horizonJobIds = collect($horizonJobIds)->unique()->toArray();
- echo 'There are '.count($horizonJobIds)." deployments in progress.\n";
+
+ // Display enhanced deployment information
+ echo "\n=== Running Deployments ===\n";
+ echo 'Total active deployments: '.count($horizonJobIds)."\n\n";
+
+ foreach ($deploymentDetails as $index => $deployment) {
+ echo 'Deployment #'.($index + 1).":\n";
+ echo ' Application: '.$deployment['application_name']."\n";
+ echo ' Server: '.$deployment['server_name']."\n";
+ echo ' Started: '.$deployment['created_at']."\n";
+ if ($deployment['deployment_url']) {
+ echo ' URL: '.$deployment['deployment_url']."\n";
+ }
+ if (! empty($deployment['team_members'])) {
+ echo ' Team members: '.implode(', ', $deployment['team_members'])."\n";
+ } else {
+ echo " Team members: No team members found\n";
+ }
+ echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n";
+ echo "\n";
+ }
+
exit(1);
}
@@ -1564,7 +1611,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -1644,7 +1690,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -1683,7 +1728,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $generatedValue,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -1722,7 +1766,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -1986,12 +2029,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $name->replaceFirst('~', $dir);
}
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
} else {
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
$volume = str("$name:$mount");
if ($topLevelVolumes->has($name)) {
$v = $topLevelVolumes->get($name);
@@ -2030,7 +2073,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $volume->before(':');
$mount = $volume->after(':');
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
}
@@ -2049,7 +2092,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$source = str($source)->replaceFirst('~', $dir);
}
if ($pull_request_id !== 0) {
- $source = $source."-pr-$pull_request_id";
+ $source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
if ($read_only) {
data_set($volume, 'source', $source.':'.$target.':ro');
@@ -2058,7 +2101,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} else {
if ($pull_request_id !== 0) {
- $source = $source."-pr-$pull_request_id";
+ $source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
if ($read_only) {
data_set($volume, 'source', $source.':'.$target.':ro');
@@ -2110,13 +2153,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $name->replaceFirst('~', $dir);
}
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
} else {
if ($pull_request_id !== 0) {
$uuid = $resource->uuid;
- $name = $uuid."-$name-pr-$pull_request_id";
+ $name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id);
$volume = str("$name:$mount");
if ($topLevelVolumes->has($name)) {
$v = $topLevelVolumes->get($name);
@@ -2158,7 +2201,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = $volume->before(':');
$mount = $volume->after(':');
if ($pull_request_id !== 0) {
- $name = $name."-pr-$pull_request_id";
+ $name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
}
@@ -2186,7 +2229,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($pull_request_id === 0) {
$source = $uuid."-$source";
} else {
- $source = $uuid."-$source-pr-$pull_request_id";
+ $source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id);
}
if ($read_only) {
data_set($volume, 'source', $source.':'.$target.':ro');
@@ -2226,7 +2269,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($pull_request_id !== 0 && count($serviceDependencies) > 0) {
$serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) {
- return $dependency."-pr-$pull_request_id";
+ return addPreviewDeploymentSuffix($dependency, $pull_request_id);
});
data_set($service, 'depends_on', $serviceDependencies->toArray());
}
@@ -2413,7 +2456,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2425,7 +2467,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $generatedValue,
- 'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2459,20 +2500,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($foundEnv) {
$defaultValue = data_get($foundEnv, 'value');
}
- $isBuildTime = data_get($foundEnv, 'is_build_time', false);
if ($foundEnv) {
$foundEnv->update([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
- 'is_build_time' => $isBuildTime,
'value' => $defaultValue,
]);
} else {
EnvironmentVariable::create([
'key' => $key,
'value' => $defaultValue,
- 'is_build_time' => $isBuildTime,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@@ -2620,7 +2658,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
});
if ($pull_request_id !== 0) {
$services->each(function ($service, $serviceName) use ($pull_request_id, $services) {
- $services[$serviceName."-pr-$pull_request_id"] = $service;
+ $services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service;
data_forget($services, $serviceName);
});
}
@@ -3000,3 +3038,18 @@ function parseDockerfileInterval(string $something)
return $seconds;
}
+
+function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string
+{
+ return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id;
+}
+
+function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection
+{
+ $collection = collect([]);
+ foreach ($services as $serviceName => $_) {
+ $collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId));
+ }
+
+ return $collection;
+}
diff --git a/composer.json b/composer.json
index 38756edf9..ea466049d 100644
--- a/composer.json
+++ b/composer.json
@@ -62,6 +62,7 @@
"barryvdh/laravel-debugbar": "^3.15.4",
"driftingly/rector-laravel": "^2.0.5",
"fakerphp/faker": "^1.24.1",
+ "laravel/boost": "^1.1",
"laravel/dusk": "^8.3.3",
"laravel/pint": "^1.24",
"laravel/telescope": "^5.10",
diff --git a/composer.lock b/composer.lock
index c7de9ad34..6320db071 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a78cf8fdfec25eac43de77c05640dc91",
+ "content-hash": "a993799242581bd06b5939005ee458d9",
"packages": [
{
"name": "amphp/amp",
@@ -12747,6 +12747,71 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
+ {
+ "name": "laravel/boost",
+ "version": "v1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/boost.git",
+ "reference": "70f909465bf73dad7e791fad8b7716b3b2712076"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076",
+ "reference": "70f909465bf73dad7e791fad8b7716b3b2712076",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/guzzle": "^7.9",
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "laravel/mcp": "^0.1.1",
+ "laravel/prompts": "^0.1.9|^0.3",
+ "laravel/roster": "^0.2.5",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Boost\\BoostServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Boost\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.",
+ "homepage": "https://github.com/laravel/boost",
+ "keywords": [
+ "ai",
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/boost/issues",
+ "source": "https://github.com/laravel/boost"
+ },
+ "time": "2025-09-04T12:16:09+00:00"
+ },
{
"name": "laravel/dusk",
"version": "v8.3.3",
@@ -12821,6 +12886,70 @@
},
"time": "2025-06-10T13:59:27+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713",
+ "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/http": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "illuminate/validation": "^10.0|^11.0|^12.0",
+ "php": "^8.1|^8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Workbench\\App\\": "workbench/app/",
+ "Laravel\\Mcp\\Tests\\": "tests/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The easiest way to add MCP servers to your Laravel app.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "dev",
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2025-08-16T09:50:43+00:00"
+ },
{
"name": "laravel/pint",
"version": "v1.24.0",
@@ -12890,6 +13019,67 @@
},
"time": "2025-07-10T18:09:32+00:00"
},
+ {
+ "name": "laravel/roster",
+ "version": "v0.2.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/roster.git",
+ "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514",
+ "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "php": "^8.1|^8.2",
+ "symfony/yaml": "^6.4|^7.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Roster\\RosterServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Roster\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Detect packages & approaches in use within a Laravel project",
+ "homepage": "https://github.com/laravel/roster",
+ "keywords": [
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/roster/issues",
+ "source": "https://github.com/laravel/roster"
+ },
+ "time": "2025-09-04T07:31:39+00:00"
+ },
{
"name": "laravel/telescope",
"version": "v5.10.2",
diff --git a/config/constants.php b/config/constants.php
index 0d29c997e..224f2dfb5 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,8 +2,8 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.427',
- 'helper_version' => '1.0.10',
+ 'version' => '4.0.0-beta.429',
+ 'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
diff --git a/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php
new file mode 100644
index 000000000..076ee8e09
--- /dev/null
+++ b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php
@@ -0,0 +1,38 @@
+dropColumn('is_build_time');
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ // Re-add the is_build_time column
+ if (! Schema::hasColumn('environment_variables', 'is_build_time')) {
+ $table->boolean('is_build_time')->default(false)->after('value');
+ }
+ });
+ }
+};
diff --git a/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php
new file mode 100644
index 000000000..d95f351d5
--- /dev/null
+++ b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php
@@ -0,0 +1,28 @@
+boolean('is_buildtime_only')->default(false)->after('is_preview');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn('is_buildtime_only');
+ });
+ }
+};
diff --git a/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php
new file mode 100644
index 000000000..b78f391fc
--- /dev/null
+++ b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php
@@ -0,0 +1,28 @@
+boolean('use_build_secrets')->default(false)->after('is_build_server_enabled');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('use_build_secrets');
+ });
+ }
+};
diff --git a/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php
new file mode 100644
index 000000000..6fd4bfed6
--- /dev/null
+++ b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php
@@ -0,0 +1,67 @@
+boolean('is_runtime')->default(true)->after('is_buildtime_only');
+ $table->boolean('is_buildtime')->default(true)->after('is_runtime');
+ });
+
+ // Migrate existing data from is_buildtime_only to new fields
+ DB::table('environment_variables')
+ ->where('is_buildtime_only', true)
+ ->update([
+ 'is_runtime' => false,
+ 'is_buildtime' => true,
+ ]);
+
+ DB::table('environment_variables')
+ ->where('is_buildtime_only', false)
+ ->update([
+ 'is_runtime' => true,
+ 'is_buildtime' => true,
+ ]);
+
+ // Remove the old is_buildtime_only column
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn('is_buildtime_only');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ // Re-add the is_buildtime_only column
+ $table->boolean('is_buildtime_only')->default(false)->after('is_preview');
+ });
+
+ // Restore data to is_buildtime_only based on new fields
+ DB::table('environment_variables')
+ ->where('is_runtime', false)
+ ->where('is_buildtime', true)
+ ->update(['is_buildtime_only' => true]);
+
+ DB::table('environment_variables')
+ ->where('is_runtime', true)
+ ->update(['is_buildtime_only' => false]);
+
+ // Remove new columns
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn(['is_runtime', 'is_buildtime']);
+ });
+ }
+};
diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile
index 3ea3d8793..212703798 100644
--- a/docker/coolify-helper/Dockerfile
+++ b/docker/coolify-helper/Dockerfile
@@ -14,6 +14,7 @@ ARG NIXPACKS_VERSION=1.40.0
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
+
FROM minio/mc:${MINIO_VERSION} AS minio-client
FROM ${BASE_IMAGE} AS base
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 49907cbd4..c445c972c 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -7,7 +7,7 @@
"dependencies": {
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
- "axios": "1.8.4",
+ "axios": "1.12.0",
"cookie": "1.0.2",
"dotenv": "16.5.0",
"node-pty": "1.0.0",
@@ -36,13 +36,13 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.8.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
- "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
+ "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
+ "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json
index 7851d7f4d..aec3dbe3d 100644
--- a/docker/coolify-realtime/package.json
+++ b/docker/coolify-realtime/package.json
@@ -5,7 +5,7 @@
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"cookie": "1.0.2",
- "axios": "1.8.4",
+ "axios": "1.12.0",
"dotenv": "16.5.0",
"node-pty": "1.0.0",
"ws": "8.18.1"
diff --git a/hooks/pre-commit b/hooks/pre-commit
index 029f67917..fc96e9766 100644
--- a/hooks/pre-commit
+++ b/hooks/pre-commit
@@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
exec Coolify
+
+
+
@@ -278,7 +281,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}
Teams
- @if (isCloud())
+ @if (isCloud() && auth()->user()->isAdmin())
+
+
diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php
index 6dd75aa9a..104cb8003 100644
--- a/resources/views/livewire/project/shared/environment-variable/add.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php
@@ -3,17 +3,19 @@
- @if (data_get($parameters, 'application_uuid'))
-
- @endif
-
- @if (!$shared)
+ @if (!$shared || $isNixpacks)
+
+
@endif
+
+
Save
diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php
index c75407179..cee6b291d 100644
--- a/resources/views/livewire/project/shared/environment-variable/all.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php
@@ -13,17 +13,32 @@
@endcan
Environment variables (secrets) for this resource.
- @if ($resourceClass === 'App\Models\Application' && data_get($resource, 'build_pack') !== 'dockercompose')
-
- @can('manageEnvironment', $resource)
-
- @else
-
- @endcan
+ @if ($resourceClass === 'App\Models\Application')
+
+ @if (data_get($resource, 'build_pack') !== 'dockercompose')
+
+ @can('manageEnvironment', $resource)
+
+ @else
+
+ @endcan
+
+ @endif
+
+ @can('manageEnvironment', $resource)
+
+ @else
+
+ @endcan
+
@endif
@if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
@@ -45,14 +60,7 @@
Production Environment Variables
Environment (secrets) variables for Production.
- @php
- $requiredEmptyVars = $resource->environment_variables->filter(function ($env) {
- return $env->is_required && empty($env->value);
- });
-
- $otherVars = $resource->environment_variables->diff($requiredEmptyVars);
- @endphp
- @forelse ($requiredEmptyVars->merge($otherVars) as $env)
+ @forelse ($this->environmentVariables as $env)
@empty
@@ -63,7 +71,7 @@
Preview Deployments Environment Variables
Environment (secrets) variables for Preview Deployments.
- @foreach ($resource->environment_variables_preview as $env)
+ @foreach ($this->environmentVariablesPreview as $env)
@endforeach
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index 258c65219..a04b477d5 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -21,6 +21,95 @@
step2ButtonText="Permanently Delete" />
@endcan
+ @can('update', $this->env)
+
+
+ @if (!$is_redis_credential)
+ @if ($type === 'service')
+
+
+
+
+ @else
+ @if ($is_shared)
+
+ @else
+ @if ($isSharedVariable)
+
+ @else
+ @if (!$env->is_nixpacks)
+
+ @endif
+
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
+ @endif
+ @endif
+ @endif
+ @endif
+
+
+ @else
+
+
+ @if (!$is_redis_credential)
+ @if ($type === 'service')
+
+
+
+
+ @else
+ @if ($is_shared)
+
+ @else
+ @if ($isSharedVariable)
+
+ @else
+
+
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
+ @endif
+ @endif
+ @endif
+
+
+ @endcan
@else
@can('update', $this->env)
@if ($isDisabled)
@@ -55,107 +144,113 @@
@endcan
@can('update', $this->env)
-
- @if (!$is_redis_credential)
- @if ($type === 'service')
-
-
-
- @else
- @if ($is_shared)
-
+
+
+ @if (!$is_redis_credential)
+ @if ($type === 'service')
+
+
+
@else
- @if ($isSharedVariable)
-
+ @if ($is_shared)
+
@else
-
-
- @if ($is_multiline === false)
-
+ @if ($isSharedVariable)
+
+ @else
+ @if (!$env->is_nixpacks)
+
+ @endif
+
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
+ @endif
@endif
@endif
@endif
@endif
- @endif
-
- @if ($isDisabled)
-
- Update
-
-
- Lock
-
-
- @else
-
- Update
-
-
- Lock
-
-
- @endif
+
+
+ @if ($isDisabled)
+ Update
+ Lock
+
+ @else
+ Update
+ Lock
+
+ @endif
+
@else
-
- @if (!$is_redis_credential)
- @if ($type === 'service')
-
-
-
- @else
- @if ($is_shared)
-
+
+
+ @if (!$is_redis_credential)
+ @if ($type === 'service')
+
+
+
@else
- @if ($isSharedVariable)
-
+ @if ($is_shared)
+
@else
-
-
- @if ($is_multiline === false)
-
+ @if ($isSharedVariable)
+
+ @else
+
+
+
+ @if ($is_multiline === false)
+
+ @endif
@endif
@endif
@endif
@endif
- @endif
-
+
@endcan
@endif
diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php
index 7fe208a9b..f980d6f3c 100644
--- a/resources/views/livewire/project/shared/execute-container-command.blade.php
+++ b/resources/views/livewire/project/shared/execute-container-command.blade.php
@@ -20,7 +20,11 @@
@if (count($containers) === 0)
No containers are running or terminal access is disabled on this server.
@else
-
@endif
@@ -243,6 +259,22 @@ class="w-full input opacity-50 cursor-not-allowed"
label="Enable Metrics (enable Sentinel first)" />
@endif
+ @if (isDev() && $server->isSentinelEnabled())
+
+
+
+ @endif
@if ($server->isSentinelEnabled())