diff --git a/app/Actions/Docker/DeleteServerDockerImages.php b/app/Actions/Docker/DeleteServerDockerImages.php
new file mode 100644
index 0000000000..e3e256bdf9
--- /dev/null
+++ b/app/Actions/Docker/DeleteServerDockerImages.php
@@ -0,0 +1,17 @@
+ 'Invalid JSON returned from Docker inspect', 'raw_output' => $imageDetailsRaw];
+ }
+
+ $containerCountRaw = instant_remote_process(["docker ps -q --filter ancestor={$imageId} | wc -l"], $server);
+ $containerCount = intval(trim($containerCountRaw));
+
+ $imageDetails['ContainerCount'] = $containerCount;
+
+ return $imageDetails;
+ }
+}
diff --git a/app/Actions/Docker/ListServerDockerImages.php b/app/Actions/Docker/ListServerDockerImages.php
new file mode 100644
index 0000000000..899f58884c
--- /dev/null
+++ b/app/Actions/Docker/ListServerDockerImages.php
@@ -0,0 +1,64 @@
+where('docker_registry_image_name', $name)
+ ->where('docker_registry_image_tag', $tag)
+ ->get();
+
+ array_push($images, $imageCopy);
+ }
+ }
+ }
+
+ return $images;
+ }
+}
diff --git a/app/Actions/Docker/UpdateServerDockerImageTag.php b/app/Actions/Docker/UpdateServerDockerImageTag.php
new file mode 100644
index 0000000000..b6b5b57cb6
--- /dev/null
+++ b/app/Actions/Docker/UpdateServerDockerImageTag.php
@@ -0,0 +1,19 @@
+where('uuid', $server_uuid)->first();
+ if (!$server) {
+ return response()->json(['error' => 'server not found'], 404);
+ }
+
+ $isReachable = (bool) $server->settings->is_reachable;
+ // If the server is reachable, send the reachable notification if it was sent before
+ if ($isReachable !== true) {
+ return response()->json(['error' => 'server is not reachable.'], 403);
+ }
+
+ return ListServerDockerImages::run($server);
+ }
+
+ public function get_server_docker_image_details($server_uuid, $id)
+ {
+ $query = Server::query();
+
+ $server = $query->where('uuid', $server_uuid)->first();
+ if (!$server) {
+ return response()->json(['error' => 'server not found'], 404);
+ }
+
+ $isReachable = (bool) $server->settings->is_reachable;
+ // If the server is reachable, send the reachable notification if it was sent before
+ if ($isReachable !== true) {
+ return response()->json(['error' => 'server is not reachable.'], 403);
+ }
+
+ return response()->json(GetServerDockerImageDetails::run($server, $id));
+ }
+
+ public function update_server_docker_image_tag($server_uuid, $id, Request $request)
+ {
+ $query = Server::query();
+
+ $server = $query->where('uuid', $server_uuid)->first();
+ if (!$server) {
+ return response()->json(['error' => 'server not found'], 404);
+ }
+
+ $isReachable = (bool) $server->settings->is_reachable;
+ // If the server is reachable, send the reachable notification if it was sent before
+ if ($isReachable !== true) {
+ return response()->json(['error' => 'server is not reachable.'], 403);
+ }
+
+ $validatedData = $request->validate([
+ 'tag' => 'required|string|min:1'
+ ]);
+
+ return response()->json(UpdateServerDockerImageTag::run($server, $id, $validatedData['tag']));
+ }
+
+ public function delete_server_docker_images($server_uuid, Request $request)
+ {
+ $query = Server::query();
+
+ $server = $query->where('uuid', $server_uuid)->first();
+ if (!$server) {
+ return response()->json(['error' => 'server not found'], 404);
+ }
+
+ $isReachable = (bool) $server->settings->is_reachable;
+ // If the server is reachable, send the reachable notification if it was sent before
+ if ($isReachable !== true) {
+ return response()->json(['error' => 'server is not reachable.'], 403);
+ }
+
+ $validatedData = $request->validate([
+ 'ids' => ['nullable', 'array'],
+ 'ids.*' => ['string', 'distinct'],
+ ]);
+
+ $message = DeleteServerDockerImages::run($server, $validatedData['ids']);
+
+ return response()->json(['message' => $message]);
+ }
+}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index a6d3bc1a25..9e01c06051 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -9,6 +9,7 @@
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
+use App\Models\DockerRegistry;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
@@ -211,8 +212,8 @@ public function __construct(public int $application_deployment_queue_id)
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
- $this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/');
- $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
+ $this->workdir = "{$this->basedir}" . rtrim($this->application->base_directory, '/');
+ $this->configuration_dir = application_configuration_dir() . "/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
@@ -339,6 +340,11 @@ public function handle(): void
private function decide_what_to_do()
{
+ // login if use custom registry
+ if ($this->application->docker_use_custom_registry) {
+ $this->handleRegistryAuth();
+ }
+
if ($this->restart_only) {
$this->just_restart();
@@ -382,7 +388,9 @@ private function deploy_simple_dockerfile()
$this->server = $this->build_server;
}
$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();
$this->execute_remote_command(
[
@@ -400,17 +408,32 @@ private function deploy_simple_dockerfile()
private function deploy_dockerimage_buildpack()
{
- $this->dockerImage = $this->application->docker_registry_image_name;
- if (str($this->application->docker_registry_image_tag)->isEmpty()) {
- $this->dockerImageTag = 'latest';
- } else {
- $this->dockerImageTag = $this->application->docker_registry_image_tag;
+ try {
+ // setup
+ $this->dockerImage = $this->application->docker_registry_image_name;
+ if (str($this->application->docker_registry_image_tag)->isEmpty()) {
+ $this->dockerImageTag = 'latest';
+ } else {
+ $this->dockerImageTag = $this->application->docker_registry_image_tag;
+ }
+
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
+
+ $this->generate_image_names();
+ $this->prepare_builder_image();
+ $this->generate_compose_file();
+ $this->rolling_update();
+ } catch (Exception $e) {
+ throw $e;
+ } finally {
+ if ($this->application->docker_use_custom_registry) {
+ $this->application_deployment_queue->addLogEntry('Logging out from registry...');
+ $this->execute_remote_command([
+ 'docker logout',
+ 'hidden' => true
+ ]);
+ }
}
- $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
- $this->generate_image_names();
- $this->prepare_builder_image();
- $this->generate_compose_file();
- $this->rolling_update();
}
private function deploy_docker_compose_buildpack()
@@ -421,13 +444,13 @@ private function deploy_docker_compose_buildpack()
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
- $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
+ $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory ' . $this->workdir)->value();
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
- $this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
+ $this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory ' . $this->workdir)->value();
}
}
if ($this->pull_request_id === 0) {
@@ -441,7 +464,7 @@ private function deploy_docker_compose_buildpack()
if ($this->preserveRepository) {
foreach ($this->application->fileStorages as $fileStorage) {
$path = $fileStorage->fs_path;
- $saveName = 'file_stat_'.$fileStorage->id;
+ $saveName = 'file_stat_' . $fileStorage->id;
$realPathInGit = str($path)->replace($this->application->workdir(), $this->workdir)->value();
// check if the file is a directory or a file inside the repository
$this->execute_remote_command(
@@ -936,12 +959,12 @@ private function save_environment_variables()
$real_value = $env->real_value;
} else {
if ($env->is_literal || $env->is_multiline) {
- $real_value = '\''.$real_value.'\'';
+ $real_value = '\'' . $real_value . '\'';
} else {
$real_value = escapeEnvVariables($env->real_value);
}
}
- $envs->push($env->key.'='.$real_value);
+ $envs->push($env->key . '=' . $real_value);
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
@@ -995,12 +1018,12 @@ private function save_environment_variables()
$real_value = $env->real_value;
} else {
if ($env->is_literal || $env->is_multiline) {
- $real_value = '\''.$real_value.'\'';
+ $real_value = '\'' . $real_value . '\'';
} else {
$real_value = escapeEnvVariables($env->real_value);
}
}
- $envs->push($env->key.'='.$real_value);
+ $envs->push($env->key . '=' . $real_value);
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
@@ -1410,7 +1433,7 @@ private function deploy_to_additional_destinations()
destination: $destination,
no_questions_asked: true,
);
- $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [
+ $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: " . route('project.application.deployment.show', [
'project_uuid' => data_get($this->application, 'environment.project.uuid'),
'application_uuid' => data_get($this->application, 'uuid'),
'deployment_uuid' => $deployment_uuid,
@@ -1751,27 +1774,27 @@ private function generate_compose_file()
'CMD-SHELL',
$this->generate_healthcheck_commands(),
],
- 'interval' => $this->application->health_check_interval.'s',
- 'timeout' => $this->application->health_check_timeout.'s',
+ 'interval' => $this->application->health_check_interval . 's',
+ 'timeout' => $this->application->health_check_timeout . 's',
'retries' => $this->application->health_check_retries,
- 'start_period' => $this->application->health_check_start_period.'s',
+ 'start_period' => $this->application->health_check_start_period . 's',
];
if (! is_null($this->application->limits_cpuset)) {
- data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
+ data_set($docker_compose, 'services.' . $this->container_name . '.cpuset', $this->application->limits_cpuset);
}
if ($this->server->isSwarm()) {
- data_forget($docker_compose, 'services.'.$this->container_name.'.container_name');
- data_forget($docker_compose, 'services.'.$this->container_name.'.expose');
- data_forget($docker_compose, 'services.'.$this->container_name.'.restart');
-
- data_forget($docker_compose, 'services.'.$this->container_name.'.mem_limit');
- data_forget($docker_compose, 'services.'.$this->container_name.'.memswap_limit');
- data_forget($docker_compose, 'services.'.$this->container_name.'.mem_swappiness');
- data_forget($docker_compose, 'services.'.$this->container_name.'.mem_reservation');
- data_forget($docker_compose, 'services.'.$this->container_name.'.cpus');
- data_forget($docker_compose, 'services.'.$this->container_name.'.cpuset');
- data_forget($docker_compose, 'services.'.$this->container_name.'.cpu_shares');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.container_name');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.expose');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.restart');
+
+ data_forget($docker_compose, 'services.' . $this->container_name . '.mem_limit');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.memswap_limit');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.mem_swappiness');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.mem_reservation');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.cpus');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.cpuset');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.cpu_shares');
$docker_compose['services'][$this->container_name]['deploy'] = [
'mode' => 'replicated',
@@ -1833,20 +1856,20 @@ private function generate_compose_file()
}
}
if ($this->application->isHealthcheckDisabled()) {
- data_forget($docker_compose, 'services.'.$this->container_name.'.healthcheck');
+ data_forget($docker_compose, 'services.' . $this->container_name . '.healthcheck');
}
if (count($this->application->ports_mappings_array) > 0 && $this->pull_request_id === 0) {
$docker_compose['services'][$this->container_name]['ports'] = $this->application->ports_mappings_array;
}
if (count($persistent_storages) > 0) {
- if (! data_get($docker_compose, 'services.'.$this->container_name.'.volumes')) {
+ if (! data_get($docker_compose, 'services.' . $this->container_name . '.volumes')) {
$docker_compose['services'][$this->container_name]['volumes'] = [];
}
$docker_compose['services'][$this->container_name]['volumes'] = array_merge($docker_compose['services'][$this->container_name]['volumes'], $persistent_storages);
}
if (count($persistent_file_volumes) > 0) {
- if (! data_get($docker_compose, 'services.'.$this->container_name.'.volumes')) {
+ if (! data_get($docker_compose, 'services.' . $this->container_name . '.volumes')) {
$docker_compose['services'][$this->container_name]['volumes'] = [];
}
$docker_compose['services'][$this->container_name]['volumes'] = array_merge($docker_compose['services'][$this->container_name]['volumes'], $persistent_file_volumes->map(function ($item) {
@@ -1914,9 +1937,9 @@ 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 = $volume_name . '-pr-' . $this->pull_request_id;
}
- $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
+ $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path;
}
return $local_persistent_volumes;
@@ -1932,7 +1955,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 = $name . '-pr-' . $this->pull_request_id;
}
$local_persistent_volumes_names[$name] = [
@@ -2220,7 +2243,7 @@ private function graceful_shutdown_container(string $containerName, int $timeout
);
}
} catch (\Exception $error) {
- $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
+ $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: " . $error->getMessage(), 'stderr');
}
$this->remove_container($containerName);
@@ -2243,7 +2266,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') !== $this->container_name . '-pr-' . $this->pull_request_id;
});
}
$containers->each(function ($container) {
@@ -2347,8 +2370,8 @@ private function run_pre_deployment_command()
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
- if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
- $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
+ if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container . '-' . $this->application->uuid)) {
+ $cmd = "sh -c '" . str_replace("'", "'\''", $this->application->pre_deployment_command) . "'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
[
@@ -2374,8 +2397,8 @@ private function run_post_deployment_command()
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
- if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
- $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
+ if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container . '-' . $this->application->uuid)) {
+ $cmd = "sh -c '" . str_replace("'", "'\''", $this->application->post_deployment_command) . "'";
$exec = "docker exec {$containerName} {$cmd}";
try {
$this->execute_remote_command(
@@ -2447,4 +2470,38 @@ public function failed(Throwable $exception): void
}
}
}
+
+ private function handleRegistryAuth()
+ {
+ $registries = $this->application->registries;
+
+ if ($registries->isEmpty()) {
+ throw new Exception('No registries found.');
+ }
+
+ foreach ($registries as $registry) {
+ $token = escapeshellarg($registry->token);
+ $username = escapeshellarg($registry->username);
+
+ $url = match ($registry->type) {
+ 'docker_hub' => '',
+ 'custom' => escapeshellarg($registry->url),
+ default => escapeshellarg($registry->url)
+ };
+
+ $this->application_deployment_queue->addLogEntry("Attempting to log into registry {$registry->name}");
+
+ $command = $registry->type === 'docker_hub'
+ ? "echo {{secrets.token}} | docker login -u {$username} --password-stdin"
+ : "echo {{secrets.token}} | docker login {$url} -u {$username} --password-stdin";
+
+ $this->execute_remote_command([
+ 'command' => $command,
+ 'secrets' => [
+ 'token' => $token,
+ ],
+ 'hidden' => true,
+ ]);
+ }
+ }
}
diff --git a/app/Livewire/Images/Images/Index.php b/app/Livewire/Images/Images/Index.php
new file mode 100644
index 0000000000..c40dded3ff
--- /dev/null
+++ b/app/Livewire/Images/Images/Index.php
@@ -0,0 +1,342 @@
+servers = Server::isReachable()->get();
+ $this->serverImages = collect([]);
+ }
+
+ /**
+ * Whenever user picks a new server, load images & reset selection
+ */
+ public function updatedSelectedUuid()
+ {
+ $this->loadServerImages();
+ $this->resetSelection();
+ }
+
+ /**
+ * "Select all" checkbox toggled
+ */
+ public function updatedSelectAll($value)
+ {
+ // If selectAll is true, grab all filtered images' IDs
+ // If false, clear out selectedImages
+ $this->selectedImages = $value
+ ? $this->filteredImages->pluck('Id')->toArray()
+ : [];
+ }
+
+ /**
+ * Clears out selections (helper function)
+ */
+ protected function resetSelection()
+ {
+ $this->selectedImages = [];
+ $this->selectAll = false;
+ }
+ /**
+ * Whenever we search or change "dangling only", reset selection
+ */
+ public function updatedSearchQuery()
+ {
+ $this->resetSelection();
+ }
+
+ public function updatedShowOnlyDangling()
+ {
+ $this->resetSelection();
+ }
+
+ /**
+ * Load images for the chosen server
+ */
+ public function loadServerImages()
+ {
+ $this->isLoadingImages = true;
+ $this->imageDetails = null;
+
+ if ($this->selected_uuid === 'default') {
+ return;
+ }
+
+ try {
+ $server = $this->servers->firstWhere('uuid', $this->selected_uuid);
+ if (!$server) {
+ return;
+ }
+
+ $images = collect(ListServerDockerImages::run($server));
+
+ // Format sizes for the list
+ $this->serverImages = $images->map(function ($image) {
+ $image['FormattedSize'] = $this->formatBytes($image['Size'] ?? 0);
+ return $image;
+ });
+ } catch (\Exception $e) {
+ $this->dispatch('error', "Error loading docker images: " . $e->getMessage());
+ } finally {
+ $this->isLoadingImages = false;
+ }
+ }
+
+ /**
+ * Fetch details for a specific image
+ */
+ public function getImageDetails($imageId)
+ {
+ try {
+ $server = $this->servers->firstWhere('uuid', $this->selected_uuid);
+ if (!$server) {
+ return;
+ }
+
+ $details = GetServerDockerImageDetails::run($server, $imageId);
+
+ // Add some nicely formatted fields
+ $details['FormattedSize'] = isset($details['Size'])
+ ? $this->formatBytes($details['Size'])
+ : 'N/A';
+
+ if (isset($details['VirtualSize'])) {
+ $details['FormattedVirtualSize'] = $this->formatBytes($details['VirtualSize']);
+ }
+
+ if (isset($details['Created'])) {
+ $details['FormattedCreated'] = \Carbon\Carbon::parse($details['Created'])->diffForHumans();
+ } else {
+ $details['FormattedCreated'] = 'N/A';
+ }
+
+ $this->imageDetails = $details;
+ } catch (\Exception $e) {
+ $this->dispatch('error', "Error loading image details: " . $e->getMessage());
+ }
+ }
+
+ public function confirmDelete($imageId = null)
+ {
+ // You can open a modal here or similar
+ // dd($imageId);
+ $this->showDeleteConfirmation = true;
+ }
+
+ public function deleteImages($imageId = null)
+ {
+ // If a single ID was passed, we delete just that
+ // Otherwise, we delete the entire selection
+ $this->imagesToDelete = $imageId
+ ? [$imageId]
+ : $this->selectedImages;
+
+ if (empty($this->imagesToDelete)) {
+ $this->dispatch('error', 'No images selected for deletion');
+ return;
+ }
+
+ try {
+ $server = $this->servers->firstWhere('uuid', $this->selected_uuid);
+ if (!$server) {
+ return;
+ }
+
+ DeleteServerDockerImages::run($server, $this->imagesToDelete);
+
+ // Reset states
+ $this->showDeleteConfirmation = false;
+ $this->imagesToDelete = [];
+ $this->resetSelection();
+ $this->imageDetails = null;
+ $this->confirmationText = '';
+
+ // Reload images
+ $this->loadServerImages();
+
+ $this->dispatch('success', 'Images deleted successfully.');
+ } catch (\Exception $e) {
+ $this->dispatch('error', "Error deleting images: " . $e->getMessage());
+ }
+ }
+
+ /**
+ * Delete all dangling images
+ */
+ public function deleteUnusedImages()
+ {
+ try {
+ $server = $this->servers->firstWhere('uuid', $this->selected_uuid);
+ if (!$server) {
+ return;
+ }
+
+ $unusedIds = $this->filteredImages
+ ->filter(fn($image) => ($image['Status'] ?? '') === 'unused')
+ ->pluck('Id')
+ ->toArray();
+
+ DeleteServerDockerImages::run($server, $unusedIds);
+
+ $this->loadServerImages();
+ $this->dispatch('success', 'Unused images deleted successfully.');
+ } catch (\Exception $e) {
+ $this->dispatch('error', "Error deleting unused images: " . $e->getMessage());
+ }
+ }
+
+ /**
+ * Prune (delete) *dangling* images
+ */
+ public function pruneUnused()
+ {
+ try {
+ $server = $this->servers->firstWhere('uuid', $this->selected_uuid);
+ if (!$server) {
+ return;
+ }
+
+ DeleteAllDanglingServerDockerImages::run($server);
+ $this->loadServerImages();
+ } catch (\Exception $e) {
+ $this->dispatch('error', "Error pruning images: " . $e->getMessage());
+ }
+ }
+
+ /**
+ * Dynamically filter images based on search/dangling
+ */
+ public function getFilteredImagesProperty()
+ {
+ return $this->serverImages
+ ->when($this->searchQuery, function ($collection) {
+ return $collection->filter(function ($image) {
+ $tags = is_array($image['RepoTags'])
+ ? $image['RepoTags']
+ : [$image['RepoTags']];
+
+ $tags = array_filter($tags); // remove null or empty
+
+ // search by any tag or the ID
+ $matchTag = collect($tags)->some(function ($tag) {
+ return str_contains(strtolower($tag), strtolower($this->searchQuery));
+ });
+
+ $matchId = str_contains(strtolower($image['Id'] ?? ''), strtolower($this->searchQuery));
+
+ return $matchTag || $matchId;
+ });
+ })
+ ->when($this->showOnlyDangling, function ($collection) {
+ return $collection->filter(function ($image) {
+ return $image['Dangling'] ?? false;
+ });
+ })
+ ->values();
+ }
+
+ /**
+ * Return how many images are "unused"
+ */
+ public function getUnusedImagesCountProperty()
+ {
+ return $this->serverImages
+ ->filter(fn($image) => ($image['Status'] ?? '') === 'unused')
+ ->count();
+ }
+
+ /**
+ * Convert bytes to a human-readable format
+ */
+ protected function formatBytes($bytes, $precision = 2)
+ {
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+
+ $bytes = max($bytes, 0);
+ $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
+ $pow = min($pow, count($units) - 1);
+
+ $bytes /= (1 << (10 * $pow)); // same as pow(1024, $pow)
+
+ return round($bytes, $precision) . ' ' . $units[$pow];
+ }
+
+ public function render()
+ {
+ return view('livewire.images.images.index', [
+ 'filteredImages' => $this->filteredImages,
+ ]);
+ }
+
+ public function startEditingTag($imageId)
+ {
+ $this->editingImageId = $imageId;
+ $image = $this->serverImages->firstWhere('Id', $imageId);
+ if ($image && isset($image['RepoTags'])) {
+ $tag = is_array($image['RepoTags']) ? $image['RepoTags'][0] : $image['RepoTags'];
+ $this->newTag = explode(':', $tag)[1] ?? '';
+ $this->newRepo = explode(':', $tag)[0] ?? '';
+ }
+ }
+
+ public function updateTag()
+ {
+ try {
+ $server = $this->servers->firstWhere('uuid', $this->selected_uuid);
+ if (!$server) {
+ return;
+ }
+
+ UpdateServerDockerImageTag::run($server, $this->editingImageId, $this->newRepo, $this->newTag);
+
+ // Reset states
+ $this->editingImageId = null;
+ $this->newTag = '';
+ $this->newRepo = '';
+
+
+ // Reload images
+ $this->loadServerImages();
+ $this->dispatch('success', 'Image tag updated successfully.');
+ } catch (\Exception $e) {
+ $this->dispatch('error', "Error updating tag: " . $e->getMessage());
+ }
+ }
+
+ public function cancelEditTag()
+ {
+ $this->editingImageId = null;
+ $this->newTag = '';
+ $this->newRepo = '';
+ }
+}
diff --git a/app/Livewire/Images/Registry/Create.php b/app/Livewire/Images/Registry/Create.php
new file mode 100644
index 0000000000..3b13613d87
--- /dev/null
+++ b/app/Livewire/Images/Registry/Create.php
@@ -0,0 +1,53 @@
+validate();
+
+ DockerRegistry::create([
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'url' => $this->type === 'custom' ? $this->url : 'docker.io',
+ 'username' => $this->username,
+ 'token' => $this->token,
+ ]);
+
+ $this->dispatch('registry-added');
+ $this->dispatch('success', 'Registry added successfully.');
+ $this->dispatch('close-modal');
+ }
+
+ public function render()
+ {
+ return view('livewire.images.registry.create');
+ }
+};
diff --git a/app/Livewire/Images/Registry/Index.php b/app/Livewire/Images/Registry/Index.php
new file mode 100644
index 0000000000..f612ebee20
--- /dev/null
+++ b/app/Livewire/Images/Registry/Index.php
@@ -0,0 +1,18 @@
+ '$refresh'];
+
+ public function render()
+ {
+ return view('livewire.images.registry.index', [
+ 'registries' => DockerRegistry::all()
+ ]);
+ }
+}
diff --git a/app/Livewire/Images/Registry/Show.php b/app/Livewire/Images/Registry/Show.php
new file mode 100644
index 0000000000..e7f464a217
--- /dev/null
+++ b/app/Livewire/Images/Registry/Show.php
@@ -0,0 +1,86 @@
+registry = $registry;
+ $this->name = $registry->name;
+ $this->type = $registry->type;
+ $this->url = $registry->url;
+ $this->username = $registry->username;
+ $this->token = $registry->token;
+ }
+
+ public function getRegistryTypesProperty()
+ {
+ return DockerRegistry::getTypes();
+ }
+
+ public function updateRegistry()
+ {
+ // Validation is automatically applied based on attributes
+ $this->validate();
+
+ $this->registry->update([
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'url' => $this->type === 'custom' ? $this->url : 'docker.io',
+ 'username' => $this->username,
+ 'token' => $this->token,
+ ]);
+
+ $this->dispatch('success', 'Registry updated successfully.');
+ }
+
+ public function delete()
+ {
+ // Update all applications using this registry
+ $this->registry->applications()
+ ->update([
+ 'docker_registry_id' => null,
+ 'docker_use_custom_registry' => false,
+ ]);
+
+ $this->registry->delete();
+ $this->dispatch('registry-added');
+ $this->dispatch('success', 'Registry deleted successfully.');
+ }
+
+ public function render()
+ {
+ return view('livewire.images.registry.show');
+ }
+
+ public function getIsFormDirtyProperty(): bool
+ {
+ return $this->name !== $this->registry->name
+ || $this->type !== $this->registry->type
+ || $this->url !== $this->registry->url
+ || $this->username !== $this->registry->username
+ || $this->token !== $this->registry->token;
+ }
+};
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index f8e28d2164..7b678263ba 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -4,6 +4,7 @@
use App\Actions\Application\GenerateConfig;
use App\Models\Application;
+use App\Models\DockerRegistry;
use Illuminate\Support\Collection;
use Livewire\Component;
use Spatie\Url\Url;
@@ -29,6 +30,8 @@ class General extends Component
public string $build_pack;
+ public array $selectedRegistries = [];
+
public ?string $ports_exposes = null;
public bool $is_preserve_repository_enabled = false;
@@ -71,6 +74,7 @@ class General extends Component
'application.dockerfile' => 'nullable',
'application.docker_registry_image_name' => 'nullable',
'application.docker_registry_image_tag' => 'nullable',
+ 'application.docker_use_custom_registry' => 'boolean',
'application.dockerfile_location' => 'nullable',
'application.docker_compose_location' => 'nullable',
'application.docker_compose' => 'nullable',
@@ -92,6 +96,8 @@ class General extends Component
'application.settings.is_preserve_repository_enabled' => 'boolean|required',
'application.watch_paths' => 'nullable',
'application.redirect' => 'string|required',
+ 'selectedRegistries' => 'required_if:application.docker_use_custom_registry,true|array',
+ 'selectedRegistries.*' => 'exists:docker_registries,id',
];
protected $validationAttributes = [
@@ -113,6 +119,7 @@ class General extends Component
'application.dockerfile' => 'Dockerfile',
'application.docker_registry_image_name' => 'Docker registry image name',
'application.docker_registry_image_tag' => 'Docker registry image tag',
+ 'application.docker_use_custom_registry' => 'Use private registry',
'application.dockerfile_location' => 'Dockerfile location',
'application.docker_compose_location' => 'Docker compose location',
'application.docker_compose' => 'Docker compose',
@@ -130,6 +137,7 @@ class General extends Component
'application.settings.is_preserve_repository_enabled' => 'Is preserve repository enabled',
'application.watch_paths' => 'Watch paths',
'application.redirect' => 'Redirect',
+ 'selectedRegistries' => 'Registries',
];
public function mount()
@@ -148,6 +156,10 @@ public function mount()
$this->application->fqdn = null;
$this->application->settings->save();
}
+
+ if ($this->application->docker_use_custom_registry) {
+ $this->selectedRegistries = $this->application->registries->pluck('id')->toArray();
+ }
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
$this->ports_exposes = $this->application->ports_exposes;
$this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled;
@@ -330,7 +342,7 @@ public function checkFqdns($showToaster = true)
public function setRedirect()
{
try {
- $has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
+ $has_www = collect($this->application->fqdns)->filter(fn($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.
Please add www to your domain list and as an A DNS record (if applicable).');
@@ -389,6 +401,8 @@ public function submit($showToaster = true)
if (data_get($this->application, 'build_pack') === 'dockerimage') {
$this->validate([
'application.docker_registry_image_name' => 'required',
+ 'selectedRegistries' => 'required_if:application.docker_use_custom_registry,true|array',
+ 'selectedRegistries.*' => 'exists:docker_registries,id',
]);
}
@@ -424,6 +438,12 @@ public function submit($showToaster = true)
}
}
$this->application->custom_labels = base64_encode($this->customLabels);
+ if ($this->application->docker_use_custom_registry) {
+ $this->application->registries()->sync($this->selectedRegistries);
+ } else {
+ $this->application->registries()->detach();
+ }
+
$this->application->save();
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
@@ -448,7 +468,14 @@ public function downloadConfig()
echo $config;
}, $fileName, [
'Content-Type' => 'application/json',
- 'Content-Disposition' => 'attachment; filename='.$fileName,
+ 'Content-Disposition' => 'attachment; filename=' . $fileName,
+ ]);
+ }
+
+ public function render()
+ {
+ return view('livewire.project.application.general', [
+ 'registries' => DockerRegistry::all(),
]);
}
}
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 7d68ce0684..b453d75ac3 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\New;
use App\Models\Application;
+use App\Models\DockerRegistry;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
@@ -13,11 +14,17 @@
class DockerImage extends Component
{
public string $dockerImage = '';
-
+ public bool $useCustomRegistry = false;
+ public array $selectedRegistries = [];
public array $parameters;
-
public array $query;
+ protected $rules = [
+ 'dockerImage' => 'required|string',
+ 'selectedRegistries' => 'required_if:useCustomRegistry,true|array',
+ 'selectedRegistries.*' => 'exists:docker_registries,id'
+ ];
+
public function mount()
{
$this->parameters = get_route_parameters();
@@ -28,6 +35,8 @@ public function submit()
{
$this->validate([
'dockerImage' => 'required',
+ 'selectedRegistries' => 'required_if:useCustomRegistry,true|array',
+ 'selectedRegistries.*' => 'exists:docker_registries,id'
]);
$parser = new DockerImageParser;
@@ -46,7 +55,7 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$application = Application::create([
- 'name' => 'docker-image-'.new Cuid2,
+ 'name' => 'docker-image-' . new Cuid2,
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
@@ -58,11 +67,17 @@ public function submit()
'destination_id' => $destination->id,
'destination_type' => $destination_class,
'health_check_enabled' => false,
+ 'docker_use_custom_registry' => $this->useCustomRegistry,
]);
+ if ($this->useCustomRegistry && !empty($this->selectedRegistries)) {
+ $application->registries()->sync($this->selectedRegistries);
+ }
+
+ error_log($application->uuid);
$fqdn = generateFqdn($destination->server, $application->uuid);
$application->update([
- 'name' => 'docker-image-'.$application->uuid,
+ 'name' => 'docker-image-' . $application->uuid,
'fqdn' => $fqdn,
]);
@@ -75,6 +90,8 @@ public function submit()
public function render()
{
- return view('livewire.project.new.docker-image');
+ return view('livewire.project.new.docker-image', [
+ 'registries' => DockerRegistry::all()
+ ]);
}
}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 3913ce37a3..1f7f4af3a7 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -38,6 +38,8 @@
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
+ 'docker_use_custom_registry' => ['type' => 'boolean', 'description' => 'Use custom registry.'],
+ 'docker_registry_id' => ['type' => 'integer', 'nullable' => true, 'description' => 'Docker registry identifier.'],
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']],
'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'],
'install_command' => ['type' => 'string', 'description' => 'Install command.'],
@@ -111,6 +113,11 @@ class Application extends BaseModel
private static $parserVersion = '4';
+ protected $casts = [
+ 'docker_use_custom_registry' => 'boolean',
+ 'selectedRegistries' => 'array',
+ ];
+
protected $guarded = [];
protected $appends = ['server_status'];
@@ -250,7 +257,7 @@ public function delete_configurations()
$server = data_get($this, 'destination.server');
$workdir = $this->workdir();
if (str($workdir)->endsWith($this->uuid)) {
- instant_remote_process(['rm -rf '.$this->workdir()], $server, false);
+ instant_remote_process(['rm -rf ' . $this->workdir()], $server, false);
}
}
@@ -384,7 +391,7 @@ public function type()
public function publishDirectory(): Attribute
{
return Attribute::make(
- set: fn ($value) => $value ? '/'.ltrim($value, '/') : null,
+ set: fn($value) => $value ? '/' . ltrim($value, '/') : null,
);
}
@@ -466,7 +473,7 @@ public function gitCommitLink($link): string
$git_repository = str_replace('.git', '', $this->git_repository);
$url = Url::fromString($git_repository);
$url = $url->withUserInfo('');
- $url = $url->withPath($url->getPath().'/commits/'.$link);
+ $url = $url->withPath($url->getPath() . '/commits/' . $link);
return $url->__toString();
}
@@ -519,21 +526,21 @@ public function dockerComposeLocation(): Attribute
public function baseDirectory(): Attribute
{
return Attribute::make(
- set: fn ($value) => '/'.ltrim($value, '/'),
+ set: fn($value) => '/' . ltrim($value, '/'),
);
}
public function portsMappings(): Attribute
{
return Attribute::make(
- set: fn ($value) => $value === '' ? null : $value,
+ set: fn($value) => $value === '' ? null : $value,
);
}
public function portsMappingsArray(): Attribute
{
return Attribute::make(
- get: fn () => is_null($this->ports_mappings)
+ get: fn() => is_null($this->ports_mappings)
? []
: explode(',', $this->ports_mappings),
@@ -651,15 +658,15 @@ public function status(): Attribute
public function customNginxConfiguration(): Attribute
{
return Attribute::make(
- set: fn ($value) => base64_encode($value),
- get: fn ($value) => base64_decode($value),
+ set: fn($value) => base64_encode($value),
+ get: fn($value) => base64_decode($value),
);
}
public function portsExposesArray(): Attribute
{
return Attribute::make(
- get: fn () => is_null($this->ports_exposes)
+ get: fn() => is_null($this->ports_exposes)
? []
: explode(',', $this->ports_exposes)
);
@@ -892,7 +899,7 @@ public function isHealthcheckDisabled(): bool
public function workdir()
{
- return application_configuration_dir()."/{$this->uuid}";
+ return application_configuration_dir() . "/{$this->uuid}";
}
public function isLogDrainEnabled()
@@ -902,7 +909,7 @@ 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);
+ $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);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
} else {
@@ -942,7 +949,7 @@ public function generateBaseDir(string $uuid)
public function dirOnServer()
{
- return application_configuration_dir()."/{$this->uuid}";
+ return application_configuration_dir() . "/{$this->uuid}";
}
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
@@ -1182,7 +1189,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1190,14 +1197,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" ".$this->buildGitCheckoutCommand($commit);
+ $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" " . $this->buildGitCheckoutCommand($commit);
}
}
@@ -1226,7 +1233,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1234,14 +1241,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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 fetch origin $branch && " . $this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" ".$this->buildGitCheckoutCommand($commit);
+ $git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" " . $this->buildGitCheckoutCommand($commit);
}
}
@@ -1294,7 +1301,7 @@ public function oldRawParser()
}
if ($source->startsWith('.')) {
$source = $source->after('.');
- $source = $workdir.$source;
+ $source = $workdir . $source;
}
$commands->push("mkdir -p $source > /dev/null 2>&1 || true");
}
@@ -1305,7 +1312,7 @@ public function oldRawParser()
$labels->push('coolify.managed=true');
}
if (! $labels->contains('coolify.applicationId')) {
- $labels->push('coolify.applicationId='.$this->id);
+ $labels->push('coolify.applicationId=' . $this->id);
}
if (! $labels->contains('coolify.type')) {
$labels->push('coolify.type=application');
@@ -1458,7 +1465,7 @@ public function parseContainerLabels(?ApplicationPreview $preview = null)
public function fqdns(): Attribute
{
return Attribute::make(
- get: fn () => is_null($this->fqdn)
+ get: fn() => is_null($this->fqdn)
? []
: explode(',', $this->fqdn),
);
@@ -1519,10 +1526,10 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false
continue;
}
if (isset($healthcheckCommand) && str_contains($trimmedLine, '\\')) {
- $healthcheckCommand .= ' '.trim($trimmedLine, '\\ ');
+ $healthcheckCommand .= ' ' . trim($trimmedLine, '\\ ');
}
if (isset($healthcheckCommand) && ! str_contains($trimmedLine, '\\') && ! empty($healthcheckCommand)) {
- $healthcheckCommand .= ' '.$trimmedLine;
+ $healthcheckCommand .= ' ' . $trimmedLine;
break;
}
}
@@ -1697,4 +1704,9 @@ public function setConfig($config)
throw new \Exception('Failed to update application settings');
}
}
+
+ public function registries()
+ {
+ return $this->belongsToMany(DockerRegistry::class, 'application_docker_registry', 'application_id');
+ }
}
diff --git a/app/Models/DockerRegistry.php b/app/Models/DockerRegistry.php
new file mode 100644
index 0000000000..3c0ac3a6f4
--- /dev/null
+++ b/app/Models/DockerRegistry.php
@@ -0,0 +1,28 @@
+ 'boolean',
+ 'token' => 'encrypted',
+ ];
+
+ public static function getTypes(): array
+ {
+ return [
+ 'docker_hub' => 'Docker Hub',
+ 'gcr' => 'Google Container Registry',
+ 'ghcr' => 'GitHub Container Registry',
+ 'quay' => 'Quay.io',
+ 'custom' => 'Custom Registry'
+ ];
+ }
+}
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index f8ccee9db7..2bebec587b 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -23,7 +23,7 @@ public function execute_remote_command(...$commands)
} else {
$commandsText = collect($commands);
}
- if ($this->server instanceof Server === false) {
+ if (! $this->server instanceof Server) {
throw new \RuntimeException('Server is not set or is not an instance of Server model');
}
$commandsText->each(function ($single_command) {
@@ -36,6 +36,10 @@ public function execute_remote_command(...$commands)
$ignore_errors = data_get($single_command, 'ignore_errors', false);
$append = data_get($single_command, 'append', true);
$this->save = data_get($single_command, 'save');
+ $secrets = data_get($single_command, 'secrets', []);
+ if (!empty($secrets)) {
+ $command = $this->replaceSecrets($command, $secrets);
+ }
if ($this->server->isNonRoot()) {
if (str($command)->startsWith('docker exec')) {
$command = str($command)->replace('docker exec', 'sudo docker exec');
@@ -44,10 +48,14 @@ public function execute_remote_command(...$commands)
}
}
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
- $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
+ $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $secrets, $hidden, $customType, $append) {
$output = str($output)->trim();
- if ($output->startsWith('╔')) {
- $output = "\n".$output;
+ if (!empty($secrets)) {
+ $output = $this->maskSecrets($output, $secrets);
+ $command = $this->maskSecrets($command, $secrets);
+ }
+ if (str($output)->startsWith('╔')) {
+ $output = "\n" . $output;
}
$new_log_entry = [
'command' => remove_iip($command),
@@ -60,7 +68,11 @@ public function execute_remote_command(...$commands)
if (! $this->application_deployment_queue->logs) {
$new_log_entry['order'] = 1;
} else {
- $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
+ $previous_logs = json_decode(
+ $this->application_deployment_queue->logs,
+ associative: true,
+ flags: JSON_THROW_ON_ERROR
+ );
$new_log_entry['order'] = count($previous_logs) + 1;
}
$previous_logs[] = $new_log_entry;
@@ -93,4 +105,25 @@ public function execute_remote_command(...$commands)
}
});
}
+
+ private function replaceSecrets(string $text, array $secrets): string
+ {
+ return preg_replace_callback(
+ '/\{\{secrets\.(\w+)\}\}/',
+ fn($match) => $secrets[$match[1]] ?? $match[0],
+ $text
+ );
+ }
+
+ private function maskSecrets(string $text, array $secrets): string
+ {
+ // Sort by length to prevent partial matches
+ $sortedSecrets = collect($secrets)->sortByDesc(fn($value) => strlen($value));
+ foreach ($sortedSecrets as $value) {
+ if (!empty($value)) {
+ $text = str_replace($value, '******', $text);
+ }
+ }
+ return $text;
+ }
}
diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php
index dd5ba66b7d..e5a3ab19a2 100644
--- a/app/View/Components/Forms/Select.php
+++ b/app/View/Components/Forms/Select.php
@@ -19,6 +19,7 @@ public function __construct(
public ?string $label = null,
public ?string $helper = null,
public bool $required = false,
+ public bool $multiple = false,
public string $defaultClass = 'select'
) {
//
@@ -33,11 +34,13 @@ public function render(): View|Closure|string
$this->id = new Cuid2;
}
if (is_null($this->name)) {
- $this->name = $this->id;
+ $this->name = $this->multiple ? "{$this->id}[]" : $this->id;
}
$this->label = Str::title($this->label);
- return view('components.forms.select');
+ return view('components.forms.select', [
+ 'multiple' => $this->multiple,
+ ]);
}
}
diff --git a/database/migrations/2024_11_08_084443_add_registry_to_applications_table.php b/database/migrations/2024_11_08_084443_add_registry_to_applications_table.php
new file mode 100644
index 0000000000..c84bd56cb8
--- /dev/null
+++ b/database/migrations/2024_11_08_084443_add_registry_to_applications_table.php
@@ -0,0 +1,21 @@
+boolean('docker_use_custom_registry')->default(false);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->dropColumn('docker_use_custom_registry');
+ });
+ }
+};
diff --git a/database/migrations/2024_11_7_203115_create_registries_table.php b/database/migrations/2024_11_7_203115_create_registries_table.php
new file mode 100644
index 0000000000..0cfa8874e4
--- /dev/null
+++ b/database/migrations/2024_11_7_203115_create_registries_table.php
@@ -0,0 +1,25 @@
+id();
+ $table->string('name')->unique();
+ $table->string('type'); // docker_hub, gcr, ghcr, quay, custom
+ $table->string('url')->nullable();
+ $table->string('username')->nullable();
+ $table->text('token')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('docker_registries');
+ }
+};
diff --git a/database/migrations/2024_12_03_184606_create_application_docker_registry_table.php b/database/migrations/2024_12_03_184606_create_application_docker_registry_table.php
new file mode 100644
index 0000000000..dedae70675
--- /dev/null
+++ b/database/migrations/2024_12_03_184606_create_application_docker_registry_table.php
@@ -0,0 +1,24 @@
+id();
+ $table->unsignedBigInteger('application_id');
+ $table->unsignedBigInteger('docker_registry_id');
+ $table->foreign('application_id')->references('id')->on('applications')->cascadeOnDelete();
+ $table->foreign('docker_registry_id')->references('id')->on('docker_registries')->cascadeOnDelete();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists('application_docker_registry');
+ }
+};
diff --git a/resources/css/app.css b/resources/css/app.css
index f89d65d802..fcf5a7fe81 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -13,6 +13,10 @@ body {
@apply text-sm antialiased scrollbar min-h-screen;
}
+option[selected] {
+ @apply bg-coollabs;
+}
+
.apexcharts-tooltip {
@apply dark:text-white dark:border-coolgray-300 dark:bg-coolgray-200 shadow-none !important;
}
diff --git a/resources/views/components/forms/select.blade.php b/resources/views/components/forms/select.blade.php
index 4da9eca1be..f572802957 100644
--- a/resources/views/components/forms/select.blade.php
+++ b/resources/views/components/forms/select.blade.php
@@ -1,6 +1,7 @@