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 @@
@if ($label) -
diff --git a/resources/views/livewire/images/registry/create.blade.php b/resources/views/livewire/images/registry/create.blade.php new file mode 100644 index 0000000000..204fb86b06 --- /dev/null +++ b/resources/views/livewire/images/registry/create.blade.php @@ -0,0 +1,23 @@ +
+ + + + @foreach ($this->registryTypes as $key => $value) + + @endforeach + + + @if ($type === 'custom') + + @endif + + + + + +
+ Save Registry +
+ diff --git a/resources/views/livewire/images/registry/index.blade.php b/resources/views/livewire/images/registry/index.blade.php new file mode 100644 index 0000000000..455b5c3b7d --- /dev/null +++ b/resources/views/livewire/images/registry/index.blade.php @@ -0,0 +1,18 @@ +
+ +
+

Registries

+ + + +
+
Configure registries to pull Docker images from.
+ + @forelse($registries as $registry) + + @empty +
+ No registries configured yet. Add one to get started. +
+ @endforelse +
diff --git a/resources/views/livewire/images/registry/show.blade.php b/resources/views/livewire/images/registry/show.blade.php new file mode 100644 index 0000000000..33133d15bc --- /dev/null +++ b/resources/views/livewire/images/registry/show.blade.php @@ -0,0 +1,31 @@ +
+
+
+ +
+ Update + +
+
+ +
+ + @foreach ($this->registryTypes as $key => $value) + + @endforeach + + + + + + + +
+
+
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 026b3b5796..1e2039d33d 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -110,36 +110,67 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" target="_blank">here
. @endif @endif -
- @if ($application->build_pack === 'dockerimage') - @if ($application->destination->server->isSwarm()) - - +
+
+ @if ($application->build_pack === 'dockerimage') + @if ($application->destination->server->isSwarm()) + + + @else + + + @endif @else - - + @if ( + $application->destination->server->isSwarm() || + $application->additional_servers->count() > 0 || + $application->settings->is_build_server_enabled) + + + @else + + + @endif @endif - @else - @if ( - $application->destination->server->isSwarm() || - $application->additional_servers->count() > 0 || - $application->settings->is_build_server_enabled) - - - @else - - +
+ + {{-- @if ($application->build_pack === 'dockerimage') --}} +
+
+ +
+ + + @if ($application->docker_use_custom_registry) +
+ + @foreach ($registries as $registry) + + @endforeach + +
+ @endif - @endif +
+ {{-- @endif --}}
@endif
diff --git a/resources/views/livewire/project/new/docker-image.blade.php b/resources/views/livewire/project/new/docker-image.blade.php index 4cc86710a3..6901fc2245 100644 --- a/resources/views/livewire/project/new/docker-image.blade.php +++ b/resources/views/livewire/project/new/docker-image.blade.php @@ -6,6 +6,23 @@

Docker Image

Save
- + + +
+ +
+ + @if ($useCustomRegistry) +
+ + @foreach ($registries as $registry) + + @endforeach + +
+ @endif
diff --git a/routes/api.php b/routes/api.php index b884f40071..62ff022edb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\ApplicationsController; use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\DeployController; +use App\Http\Controllers\Api\DockerController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController; @@ -127,8 +128,15 @@ Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + + Route::get('/docker/{server_uuid}/images', [DockerController::class, 'list_server_docker_images'])->middleware(['api.ability:read']); + Route::get('/docker/{server_uuid}/image/{id}', [DockerController::class, 'get_server_docker_image_details'])->middleware(['api.ability:read']); + Route::delete('/docker/{server_uuid}/images/delete', [DockerController::class, 'delete_server_docker_images'])->middleware(['api.ability:write']); + Route::patch('/docker/{server_uuid}/image/{id}/update', [DockerController::class, 'update_server_docker_image_tag'])->middleware(['api.ability:write']); }); + + Route::group([ 'prefix' => 'v1', ], function () { diff --git a/routes/web.php b/routes/web.php index 618e4e0900..51125bf073 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,8 @@ use App\Livewire\Project\Shared\Logs; use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; +use App\Livewire\Images\Registry\Index as RegistryIndex; +use App\Livewire\Images\Images\Index as ImagesIndex; use App\Livewire\Security\ApiTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; @@ -268,6 +270,8 @@ Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); + Route::get('/images/images', ImagesIndex::class)->name('images.images.index'); + Route::get('/images/registries', RegistryIndex::class)->name('images.registries.index'); }); Route::middleware(['auth'])->group(function () { @@ -346,13 +350,12 @@ fclose($stream); }, 200, [ 'Content-Type' => 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', + 'Content-Disposition' => 'attachment; filename="' . basename($filename) . '"', ]); } catch (\Throwable $e) { return response()->json(['message' => $e->getMessage()], 500); } })->name('download.backup'); - }); Route::any('/{any}', function () {