diff --git a/.github/workflows/development-build.yml b/.github/workflows/development-build.yml index 681cbda3a..7d9b730c4 100644 --- a/.github/workflows/development-build.yml +++ b/.github/workflows/development-build.yml @@ -2,7 +2,7 @@ name: Development Build (v4) on: push: - branches: ["next"] + branches-ignore: ["main", "v3"] paths-ignore: - .github/workflows/coolify-helper.yml - docker/coolify-helper/Dockerfile @@ -29,51 +29,51 @@ jobs: file: docker/prod-ssu/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} aarch64: - runs-on: [self-hosted, arm64] - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v3 - - name: Login to ghcr.io - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v3 - with: - context: . - file: docker/prod-ssu/Dockerfile - platforms: linux/aarch64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next-aarch64 + runs-on: [self-hosted, arm64] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v3 + - name: Login to ghcr.io + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build image and push to registry + uses: docker/build-push-action@v3 + with: + context: . + file: docker/prod-ssu/Dockerfile + platforms: linux/aarch64 + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 merge-manifest: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - needs: [amd64, aarch64] - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Login to ghcr.io - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create & publish manifest - run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: [amd64, aarch64] + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to ghcr.io + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create & publish manifest + run: | + docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 32673abc9..ccefa8681 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -21,7 +21,10 @@ class CheckProxy $status = getContainerStatus($server, 'coolify-proxy_traefik'); $server->proxy->set('status', $status); $server->save(); - return false; + if ($status === 'running') { + return false; + } + return true; } else { $status = getContainerStatus($server, 'coolify-proxy'); if ($status === 'running') { diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e24d657a9..2a8e857e2 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -56,16 +56,20 @@ class Kernel extends ConsoleKernel $servers = Server::all()->whereNotNull('team.subscription')->where('team.subscription.stripe_trial_already_ended', false)->where('ip', '!=', '1.2.3.4'); $own = Team::find(0)->servers; $servers = $servers->merge($own); + $containerServers = $servers->where('settings.is_swarm_worker', false); } else { $servers = Server::all()->where('ip', '!=', '1.2.3.4'); + $containerServers = $servers->where('settings.is_swarm_worker', false); } - foreach ($servers as $server) { - $schedule->job(new ServerStatusJob($server))->everyTenMinutes()->onOneServer(); + foreach ($containerServers as $server) { $schedule->job(new ContainerStatusJob($server))->everyMinute()->onOneServer(); if ($server->isLogDrainEnabled()) { $schedule->job(new CheckLogDrainContainerJob($server))->everyMinute()->onOneServer(); } } + foreach ($servers as $server) { + $schedule->job(new ServerStatusJob($server))->everyTenMinutes()->onOneServer(); + } } private function instance_auto_update($schedule) { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 652fd2262..8f6a45578 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -75,6 +75,8 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted private $docker_compose_base64; private string $dockerfile_location = '/Dockerfile'; private string $docker_compose_location = '/docker-compose.yml'; + private ?string $docker_compose_custom_start_command = null; + private ?string $docker_compose_custom_build_command = null; private ?string $addHosts = null; private ?string $buildTarget = null; private Collection $saved_outputs; @@ -215,19 +217,8 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted if ($this->server->isProxyShouldRun()) { dispatch(new ContainerStatusJob($this->server)); } - if ($this->application->docker_registry_image_name && $this->application->build_pack !== 'dockerimage') { + if ($this->application->docker_registry_image_name && $this->application->build_pack !== 'dockerimage' && !$this->application->destination->server->isSwarm()) { $this->push_to_docker_registry(); - if ($this->server->isSwarm()) { - $this->application_deployment_queue->addLogEntry("Creating / updating stack."); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "cd {$this->workdir} && docker stack deploy --with-registry-auth -c docker-compose.yml {$this->application->uuid}") - ], - [ - "echo 'Stack deployed. It may take a few minutes to fully available in your swarm.'" - ] - ); - } } $this->next(ApplicationDeploymentStatus::FINISHED->value); $this->application->isConfigurationChanged(true); @@ -299,6 +290,9 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted "echo -n 'Image pushed to docker registry.'" ]); } catch (Exception $e) { + if ($this->application->destination->server->isSwarm()) { + throw $e; + } $this->execute_remote_command( ["echo -n 'Failed to push image to docker registry. Please check debug logs for more information.'"], ); @@ -432,6 +426,12 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted if (data_get($this->application, 'docker_compose_location')) { $this->docker_compose_location = $this->application->docker_compose_location; } + if (data_get($this->application, 'docker_compose_custom_start_command')) { + $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; + } + if (data_get($this->application, 'docker_compose_custom_build_command')) { + $this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command; + } if ($this->pull_request_id === 0) { $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name}."); } else { @@ -454,7 +454,18 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted ]); $this->save_environment_variables(); // Build new container to limit downtime. - $this->build_by_compose_file(); + $this->application_deployment_queue->addLogEntry("Pulling & building required images."); + + if ($this->docker_compose_custom_build_command) { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), "hidden" => true], + ); + } else { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build"), "hidden" => true], + ); + } + $this->stop_running_container(force: true); $networkId = $this->application->uuid; @@ -488,7 +499,17 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted ] ); } - $this->start_by_compose_file(); + // Start compose file + if ($this->docker_compose_custom_start_command) { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_start_command}"), "hidden" => true], + ); + } else { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"), "hidden" => true], + ); + } + $this->application_deployment_queue->addLogEntry("New container started."); } private function deploy_dockerfile_buildpack() { @@ -575,7 +596,16 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted private function rolling_update() { if ($this->server->isSwarm()) { - // Skip this. + if ($this->build_pack !== 'dockerimage') { + $this->push_to_docker_registry(); + } + $this->application_deployment_queue->addLogEntry("Rolling update started."); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "docker stack deploy --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}") + ], + ); + $this->application_deployment_queue->addLogEntry("Rolling update completed."); } else { if (count($this->application->ports_mappings_array) > 0) { $this->execute_remote_command( @@ -674,10 +704,20 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted $this->add_build_env_variables_to_dockerfile(); $this->build_image(); $this->stop_running_container(); - $this->execute_remote_command( - ["echo -n 'Starting preview deployment.'"], - [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} up -d"), "hidden" => true], - ); + if ($this->application->destination->server->isSwarm()) { + ray("{$this->workdir}{$this->docker_compose_location}"); + $this->push_to_docker_registry(); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "docker stack deploy --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}-{$this->pull_request_id}") + ], + ); + } else { + $this->execute_remote_command( + ["echo -n 'Starting preview deployment.'"], + [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} up -d"), "hidden" => true], + ); + } } private function create_workdir() { @@ -941,13 +981,8 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted data_forget($docker_compose, 'services.' . $this->container_name . '.cpu_shares'); $docker_compose['services'][$this->container_name]['deploy'] = [ - 'placement' => [ - 'constraints' => [ - 'node.role == worker' - ] - ], 'mode' => 'replicated', - 'replicas' => 1, + 'replicas' => data_get($this->application, 'swarm_replicas', 1), 'update_config' => [ 'order' => 'start-first' ], @@ -966,6 +1001,16 @@ class ApplicationDeploymentJob implements ShouldQueue, ShouldBeEncrypted ] ] ]; + if (data_get($this->application, 'settings.is_swarm_only_worker_nodes')) { + $docker_compose['services'][$this->container_name]['deploy']['placement'] = [ + 'constraints' => [ + 'node.role == worker' + ] + ]; + } + if ($this->pull_request_id !== 0) { + $docker_compose['services'][$this->container_name]['deploy']['replicas'] = 1; + } } else { $docker_compose['services'][$this->container_name]['labels'] = $labels; } @@ -1258,15 +1303,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} build"), "hidden" => true], ); } else { - if ($this->docker_compose_location) { - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build"), "hidden" => true], - ); - } else { - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} build"), "hidden" => true], - ); - } + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "docker compose --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build"), "hidden" => true], + ); } $this->application_deployment_queue->addLogEntry("New images built."); } diff --git a/app/Jobs/ContainerStatusJob.php b/app/Jobs/ContainerStatusJob.php index 9989ed8cb..15dad0a06 100644 --- a/app/Jobs/ContainerStatusJob.php +++ b/app/Jobs/ContainerStatusJob.php @@ -17,36 +17,40 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; -use Illuminate\Support\Sleep; class ContainerStatusJob implements ShouldQueue, ShouldBeEncrypted { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public $tries = 5; + public function backoff(): int + { + return isDev() ? 1 : 5; + } public function middleware(): array { - return [(new WithoutOverlapping($this->server->id))->dontRelease()]; + return [(new WithoutOverlapping($this->server->uuid))]; } public function uniqueId(): int { - return $this->server->id; + return $this->server->uuid; } public function __construct(public Server $server) { - $this->handle(); + // $this->handle(); } public function handle() { + if (!$this->server->isServerReady($this->tries)) { + throw new \RuntimeException('Server is not reachable.'); + }; try { - if (!$this->server->isServerReady()) { - return; - }; if ($this->server->isSwarm()) { $containers = instant_remote_process(["docker service inspect $(docker service ls -q) --format '{{json .}}'"], $this->server, false); - $containerReplicase = instant_remote_process(["docker service ls --format '{{json .}}'"], $this->server, false); + $containerReplicates = instant_remote_process(["docker service ls --format '{{json .}}'"], $this->server, false); } else { // Precheck for containers $containers = instant_remote_process(["docker container ls -q"], $this->server, false); @@ -54,15 +58,15 @@ class ContainerStatusJob implements ShouldQueue, ShouldBeEncrypted return; } $containers = instant_remote_process(["docker container inspect $(docker container ls -q) --format '{{json .}}'"], $this->server, false); - $containerReplicase = null; + $containerReplicates = null; } if (is_null($containers)) { return; } $containers = format_docker_command_output_to_json($containers); - if ($containerReplicase) { - $containerReplicase = format_docker_command_output_to_json($containerReplicase); - foreach ($containerReplicase as $containerReplica) { + if ($containerReplicates) { + $containerReplicates = format_docker_command_output_to_json($containerReplicates); + foreach ($containerReplicates as $containerReplica) { $name = data_get($containerReplica, 'Name'); $containers = $containers->map(function ($container) use ($name, $containerReplica) { if (data_get($container, 'Spec.Name') === $name) { diff --git a/app/Livewire/Destination/New/StandaloneDocker.php b/app/Livewire/Destination/New/Docker.php similarity index 53% rename from app/Livewire/Destination/New/StandaloneDocker.php rename to app/Livewire/Destination/New/Docker.php index 72516752a..3b76a7d13 100644 --- a/app/Livewire/Destination/New/StandaloneDocker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -4,29 +4,32 @@ namespace App\Livewire\Destination\New; use App\Models\Server; use App\Models\StandaloneDocker as ModelsStandaloneDocker; +use App\Models\SwarmDocker; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Str; use Livewire\Component; use Visus\Cuid2\Cuid2; -class StandaloneDocker extends Component +class Docker extends Component { public string $name; public string $network; public Collection $servers; public Server $server; - public int|null $server_id = null; + public ?int $server_id = null; + public bool $is_swarm = false; protected $rules = [ 'name' => 'required|string', 'network' => 'required|string', - 'server_id' => 'required|integer' + 'server_id' => 'required|integer', + 'is_swarm' => 'boolean' ]; protected $validationAttributes = [ 'name' => 'name', 'network' => 'network', - 'server_id' => 'server' + 'server_id' => 'server', + 'is_swarm' => 'swarm' ]; public function mount() @@ -43,13 +46,13 @@ class StandaloneDocker extends Component } else { $this->network = new Cuid2(7); } - $this->name = Str::kebab("{$this->servers->first()->name}-{$this->network}"); + $this->name = str("{$this->servers->first()->name}-{$this->network}")->kebab(); } public function generate_name() { $this->server = Server::find($this->server_id); - $this->name = Str::kebab("{$this->server->name}-{$this->network}"); + $this->name = str("{$this->server->name}-{$this->network}")->kebab(); } public function submit() @@ -57,17 +60,30 @@ class StandaloneDocker extends Component $this->validate(); try { $this->server = Server::find($this->server_id); - $found = $this->server->standaloneDockers()->where('network', $this->network)->first(); - if ($found) { - $this->createNetworkAndAttachToProxy(); - $this->dispatch('error', 'Network already added to this server.'); - return; + if ($this->is_swarm) { + $found = $this->server->swarmDockers()->where('network', $this->network)->first(); + if ($found) { + $this->dispatch('error', 'Network already added to this server.'); + return; + } else { + $docker = SwarmDocker::create([ + 'name' => $this->name, + 'network' => $this->network, + 'server_id' => $this->server_id, + ]); + } } else { - $docker = ModelsStandaloneDocker::create([ - 'name' => $this->name, - 'network' => $this->network, - 'server_id' => $this->server_id, - ]); + $found = $this->server->standaloneDockers()->where('network', $this->network)->first(); + if ($found) { + $this->dispatch('error', 'Network already added to this server.'); + return; + } else { + $docker = ModelsStandaloneDocker::create([ + 'name' => $this->name, + 'network' => $this->network, + 'server_id' => $this->server_id, + ]); + } } $this->createNetworkAndAttachToProxy(); return $this->redirectRoute('destination.show', $docker->uuid, navigate: true); diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 77495c83e..b9cbcc147 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -13,7 +13,11 @@ class Show extends Component public function scan() { - $alreadyAddedNetworks = $this->server->standaloneDockers; + if ($this->server->isSwarm()) { + $alreadyAddedNetworks = $this->server->swarmDockers; + } else { + $alreadyAddedNetworks = $this->server->standaloneDockers; + } $networks = instant_remote_process(['docker network ls --format "{{json .}}"'], $this->server, false); $this->networks = format_docker_command_output_to_json($networks)->filter(function ($network) { return $network['Name'] !== 'bridge' && $network['Name'] !== 'host' && $network['Name'] !== 'none'; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index abc091917..39cb15b27 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -64,6 +64,8 @@ class General extends Component 'application.custom_labels' => 'nullable', 'application.dockerfile_target_build' => 'nullable', 'application.settings.is_static' => 'boolean|required', + 'application.docker_compose_custom_start_command' => 'nullable', + 'application.docker_compose_custom_build_command' => 'nullable', ]; protected $validationAttributes = [ 'application.name' => 'name', @@ -94,6 +96,8 @@ class General extends Component 'application.custom_labels' => 'Custom labels', 'application.dockerfile_target_build' => 'Dockerfile target build', 'application.settings.is_static' => 'Is static', + 'application.docker_compose_custom_start_command' => 'Docker compose custom start command', + 'application.docker_compose_custom_build_command' => 'Docker compose custom build command', ]; public function mount() { @@ -109,7 +113,7 @@ class General extends Component $this->application->isConfigurationChanged(true); } $this->isConfigurationChanged = $this->application->isConfigurationChanged(); - $this->customLabels = $this->application->parseContainerLabels(); + $this->customLabels = $this->application->parseContainerLabels(); $this->initialDockerComposeLocation = $this->application->docker_compose_location; $this->checkLabelUpdates(); } @@ -195,7 +199,8 @@ class General extends Component public function submit($showToaster = true) { try { - if ($this->application->build_pack === 'dockercompose' && ($this->initialDockerComposeLocation !== $this->application->docker_compose_location || $this->initialDockerComposePrLocation !== $this->application->docker_compose_pr_location)) { + ray($this->initialDockerComposeLocation, $this->application->docker_compose_location); + if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) { $this->loadComposeFile(); } $this->validate(); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 3db5fc9c4..c8fbcca2b 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -73,6 +73,10 @@ class Heading extends Component $this->dispatch('error', 'Please load a Compose file first.'); return; } + if ($this->application->destination->server->isSwarm() && is_null($this->application->docker_registry_image_name)) { + $this->dispatch('error', 'Please set a Docker image name first.'); + return; + } $this->setDeploymentUuid(); queue_application_deployment( application_id: $this->application->id, diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 0fc1acf5e..31707aa3d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -72,10 +72,14 @@ class Previews extends Component public function stop(int $pull_request_id) { try { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); + if ($this->application->destination->server->isSwarm()) { + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + } else { + $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); + foreach ($containers as $container) { + $name = str_replace('/', '', $container['Names']); + instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); + } } ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first()->delete(); $this->application->refresh(); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php new file mode 100644 index 000000000..5f89f4934 --- /dev/null +++ b/app/Livewire/Project/Application/Swarm.php @@ -0,0 +1,51 @@ + 'required', + 'application.swarm_placement_constraints' => 'nullable', + 'application.settings.is_swarm_only_worker_nodes' => 'required', + ]; + public function mount() { + if ($this->application->swarm_placement_constraints) { + $this->swarm_placement_constraints = base64_decode($this->application->swarm_placement_constraints); + } + } + public function instantSave() { + try { + $this->validate(); + $this->application->settings->save(); + $this->dispatch('success', 'Swarm settings updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function submit() { + try { + $this->validate(); + if ($this->swarm_placement_constraints) { + $this->application->swarm_placement_constraints = base64_encode($this->swarm_placement_constraints); + } else { + $this->application->swarm_placement_constraints = null; + } + $this->application->save(); + + $this->dispatch('success', 'Swarm settings updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() + { + return view('livewire.project.application.swarm'); + } +} diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 856a9b9a9..80a41d7c7 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -6,22 +6,24 @@ use App\Models\Project; use App\Models\Server; use Countable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component { public $current_step = 'type'; - public ?int $server = null; + public ?Server $server = null; public string $type; public string $server_id; public string $destination_uuid; + public Countable|array|Server $allServers = []; public Countable|array|Server $servers = []; public Collection|array $standaloneDockers = []; public Collection|array $swarmDockers = []; public array $parameters; public Collection|array $services = []; public Collection|array $allServices = []; + public bool $isDatabase = false; + public bool $includeSwarm = true; public bool $loadingServices = true; public bool $loading = false; @@ -31,7 +33,7 @@ class Select extends Component public ?string $search = null; protected $queryString = [ - 'server', + 'server_id', 'search' ]; @@ -97,21 +99,45 @@ class Select extends Component $this->loadingServices = false; } } + public function instantSave() + { + if ($this->includeSwarm) { + $this->servers = $this->allServers; + } else { + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false); + } + } public function setType(string $type) { - $this->type = $type; if ($this->loading) return; $this->loading = true; + $this->type = $type; + switch ($type) { + case 'postgresql': + case 'mysql': + case 'mariadb': + case 'redis': + case 'mongodb': + $this->isDatabase = true; + $this->includeSwarm = false; + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false); + break; + } + if (str($type)->startsWith('one-click-service') || str($type)->startsWith('docker-compose-empty') || str($type)->startsWith('docker-image')) { + $this->isDatabase = true; + $this->includeSwarm = false; + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false); + } if ($type === "existing-postgresql") { $this->current_step = $type; return; } - if (count($this->servers) === 1) { - $server = $this->servers->first(); - $this->setServer($server); - } + // if (count($this->servers) === 1) { + // $server = $this->servers->first(); + // $this->setServer($server); + // } if (!is_null($this->server)) { - $foundServer = $this->servers->where('id', $this->server)->first(); + $foundServer = $this->servers->where('id', $this->server->id)->first(); if ($foundServer) { return $this->setServer($foundServer); } @@ -122,6 +148,7 @@ class Select extends Component public function setServer(Server $server) { $this->server_id = $server->id; + $this->server = $server; $this->standaloneDockers = $server->standaloneDockers; $this->swarmDockers = $server->swarmDockers; $this->current_step = 'destinations'; @@ -142,5 +169,6 @@ class Select extends Component public function loadServers() { $this->servers = Server::isUsable()->get(); + $this->allServers = $this->servers; } } diff --git a/app/Livewire/Project/Shared/Storages/Add.php b/app/Livewire/Project/Shared/Storages/Add.php index b1f467b34..e2e84c358 100644 --- a/app/Livewire/Project/Shared/Storages/Add.php +++ b/app/Livewire/Project/Shared/Storages/Add.php @@ -2,22 +2,26 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Models\Application; use Livewire\Component; class Add extends Component { public $uuid; public $parameters; + public $isSwarm = false; public string $name; public string $mount_path; - public string|null $host_path = null; + public ?string $host_path = null; - protected $listeners = ['clearAddStorage' => 'clear']; - protected $rules = [ + public $rules = [ 'name' => 'required|string', 'mount_path' => 'required|string', 'host_path' => 'string|nullable', ]; + + protected $listeners = ['clearAddStorage' => 'clear']; + protected $validationAttributes = [ 'name' => 'name', 'mount_path' => 'mount', @@ -27,17 +31,31 @@ class Add extends Component public function mount() { $this->parameters = get_route_parameters(); + $applicationUuid = $this->parameters['application_uuid']; + $application = Application::where('uuid', $applicationUuid)->first(); + if (!$application) { + abort(404); + } + if ($application->destination->server->isSwarm()) { + $this->isSwarm = true; + $this->rules['host_path'] = 'required|string'; + } } public function submit() { - $this->validate(); - $name = $this->uuid . '-' . $this->name; - $this->dispatch('addNewVolume', [ - 'name' => $name, - 'mount_path' => $this->mount_path, - 'host_path' => $this->host_path, - ]); + try { + $this->validate($this->rules); + $name = $this->uuid . '-' . $this->name; + $this->dispatch('addNewVolume', [ + 'name' => $name, + 'mount_path' => $this->mount_path, + 'host_path' => $this->host_path, + ]); + $this->dispatch('closeStorageModal'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function clear() diff --git a/app/Livewire/Server/Form.php b/app/Livewire/Server/Form.php index 480028ede..d81d69e8f 100644 --- a/app/Livewire/Server/Form.php +++ b/app/Livewire/Server/Form.php @@ -25,7 +25,7 @@ class Form extends Component 'server.settings.is_cloudflare_tunnel' => 'required|boolean', 'server.settings.is_reachable' => 'required', 'server.settings.is_swarm_manager' => 'required|boolean', - // 'server.settings.is_swarm_worker' => 'required|boolean', + 'server.settings.is_swarm_worker' => 'required|boolean', 'wildcard_domain' => 'nullable|url', ]; protected $validationAttributes = [ @@ -37,16 +37,13 @@ class Form extends Component 'server.settings.is_cloudflare_tunnel' => 'Cloudflare Tunnel', 'server.settings.is_reachable' => 'Is reachable', 'server.settings.is_swarm_manager' => 'Swarm Manager', - // 'server.settings.is_swarm_worker' => 'Swarm Worker', + 'server.settings.is_swarm_worker' => 'Swarm Worker', ]; public function mount() { $this->wildcard_domain = $this->server->settings->wildcard_domain; $this->cleanup_after_percentage = $this->server->settings->cleanup_after_percentage; - if (!$this->server->isFunctional()) { - $this->validateServer(); - } } public function serverRefresh($install = true) { diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 3470e27f8..1849cdfbc 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -22,7 +22,10 @@ class ByIp extends Component public string $user = 'root'; public int $port = 22; public bool $is_swarm_manager = false; + public bool $is_swarm_worker = false; + public $selected_swarm_cluster = null; + public $swarm_managers = []; protected $rules = [ 'name' => 'required|string', 'description' => 'nullable|string', @@ -30,6 +33,7 @@ class ByIp extends Component 'user' => 'required|string', 'port' => 'required|integer', 'is_swarm_manager' => 'required|boolean', + 'is_swarm_worker' => 'required|boolean', ]; protected $validationAttributes = [ 'name' => 'Name', @@ -38,12 +42,17 @@ class ByIp extends Component 'user' => 'User', 'port' => 'Port', 'is_swarm_manager' => 'Swarm Manager', + 'is_swarm_worker' => 'Swarm Worker', ]; public function mount() { $this->name = generate_random_name(); $this->private_key_id = $this->private_keys->first()->id; + $this->swarm_managers = Server::isUsable()->get()->where('settings.is_swarm_manager', true); + if ($this->swarm_managers->count() > 0) { + $this->selected_swarm_cluster = $this->swarm_managers->first()->id; + } } public function setPrivateKey(string $private_key_id) @@ -53,7 +62,7 @@ class ByIp extends Component public function instantSave() { - $this->dispatch('success', 'Application settings updated!'); + // $this->dispatch('success', 'Application settings updated!'); } public function submit() @@ -63,7 +72,7 @@ class ByIp extends Component if (is_null($this->private_key_id)) { return $this->dispatch('error', 'You must select a private key'); } - $server = Server::create([ + $payload = [ 'name' => $this->name, 'description' => $this->description, 'ip' => $this->ip, @@ -75,8 +84,13 @@ class ByIp extends Component "type" => ProxyTypes::TRAEFIK_V2->value, "status" => ProxyStatus::EXITED->value, ], - ]); + ]; + if ($this->is_swarm_worker) { + $payload['swarm_cluster'] = $this->selected_swarm_cluster; + } + $server = Server::create($payload); $server->settings->is_swarm_manager = $this->is_swarm_manager; + $server->settings->is_swarm_worker = $this->is_swarm_worker; $server->settings->save(); $server->addInitialNetwork(); return $this->redirectRoute('server.show', $server->uuid, navigate: true); diff --git a/app/Models/Server.php b/app/Models/Server.php index a5577f6c1..285ce01db 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -12,7 +12,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; -use Illuminate\Support\Sleep; use Spatie\SchemalessAttributes\Casts\SchemalessAttributes; use Spatie\SchemalessAttributes\SchemalessAttributesTrait; use Illuminate\Support\Str; @@ -72,7 +71,7 @@ class Server extends BaseModel static public function isUsable() { - return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true); + return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false); } static public function destinationsByServer(string $server_id) @@ -150,30 +149,35 @@ class Server extends BaseModel } return false; } - public function isServerReady() + public function isServerReady(int $tries = 3) { - $serverUptimeCheckNumber = $this->unreachable_count; - $serverUptimeCheckNumberMax = 8; + $serverUptimeCheckNumber = $this->unreachable_count + 1; + $serverUptimeCheckNumberMax = $tries; - $currentTime = now()->timestamp; - $runtime = 50; + ray('server: ' . $this->name); + ray('serverUptimeCheckNumber: ' . $serverUptimeCheckNumber); + ray('serverUptimeCheckNumberMax: ' . $serverUptimeCheckNumberMax); - $isReady = false; - // Run for 50 seconds max and check every 5 seconds for 8 times - while ($currentTime + $runtime > now()->timestamp) { - ray('serverUptimeCheckNumber: ' . $serverUptimeCheckNumber); + $result = $this->validateConnection(); + if ($result) { + if ($this->unreachable_notification_sent === true) { + $this->update(['unreachable_notification_sent' => false]); + } + return true; + } else { if ($serverUptimeCheckNumber >= $serverUptimeCheckNumberMax) { + // Reached max number of retries if ($this->unreachable_notification_sent === false) { ray('Server unreachable, sending notification...'); $this->team?->notify(new Unreachable($this)); $this->update(['unreachable_notification_sent' => true]); } - $this->settings()->update([ - 'is_reachable' => false, - ]); - $this->update([ - 'unreachable_count' => 0, - ]); + if ($this->settings->is_reachable === true) { + $this->settings()->update([ + 'is_reachable' => false, + ]); + } + foreach ($this->applications() as $application) { $application->update(['status' => 'exited']); } @@ -190,23 +194,13 @@ class Server extends BaseModel $db->update(['status' => 'exited']); } } - $isReady = false; - break; - } - $result = $this->validateConnection(); - // ray('validateConnection: ' . $result); - if (!$result) { - $serverUptimeCheckNumber++; + } else { $this->update([ - 'unreachable_count' => $serverUptimeCheckNumber, + 'unreachable_count' => $this->unreachable_count + 1, ]); - Sleep::for(5)->seconds(); - continue; } - $isReady = true; - break; + return false; } - return $isReady; } public function getDiskUsage() { @@ -380,9 +374,20 @@ class Server extends BaseModel { return data_get($this, 'settings.is_swarm_manager') || data_get($this, 'settings.is_swarm_worker'); } + public function isSwarmManager() + { + return data_get($this, 'settings.is_swarm_manager'); + } + public function isSwarmWorker() + { + return data_get($this, 'settings.is_swarm_worker'); + } public function validateConnection() { $server = Server::find($this->id); + if (!$server) { + return false; + } if ($server->skipServer()) { return false; } diff --git a/app/Notifications/Server/Revived.php b/app/Notifications/Server/Revived.php index 400ef8377..3e5a99425 100644 --- a/app/Notifications/Server/Revived.php +++ b/app/Notifications/Server/Revived.php @@ -54,7 +54,7 @@ class Revived extends Notification implements ShouldQueue public function toDiscord(): string { - $message = "Coolify: Server '{$this->server->name}' revived. All automations & integrations are turned on again!"; + $message = "Coolify: Server '{$this->server->name}' revived. All automations & integrations are turned on again!"; return $message; } public function toTelegram(): array diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index b738b5a9d..0ff3d1344 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -15,10 +15,16 @@ function get_proxy_path() } function connectProxyToNetworks(Server $server) { - // Standalone networks - $networks = collect($server->standaloneDockers)->map(function ($docker) { - return $docker['network']; - }); + if ($server->isSwarm()) { + $networks = collect($server->swarmDockers)->map(function ($docker) { + return $docker['network']; + }); + } else { + // Standalone networks + $networks = collect($server->standaloneDockers)->map(function ($docker) { + return $docker['network']; + }); + } // Service networks foreach ($server->services()->get() as $service) { $networks->push($service->networks()); @@ -41,16 +47,30 @@ function connectProxyToNetworks(Server $server) $networks->push($network); } $networks = collect($networks)->flatten()->unique(); - if ($networks->count() === 0) { - $networks = collect(['coolify']); + if ($server->isSwarm()) { + if ($networks->count() === 0) { + $networks = collect(['coolify-overlay']); + } + $commands = $networks->map(function ($network) { + return [ + "echo 'Connecting coolify-proxy to $network network...'", + "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null", + "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", + ]; + }); + } else { + if ($networks->count() === 0) { + $networks = collect(['coolify']); + } + $commands = $networks->map(function ($network) { + return [ + "echo 'Connecting coolify-proxy to $network network...'", + "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null", + "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", + ]; + }); } - $commands = $networks->map(function ($network) { - return [ - "echo 'Connecting coolify-proxy to $network network...'", - "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null", - "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", - ]; - }); + return $commands->flatten(); } function generate_default_proxy_configuration(Server $server) @@ -60,14 +80,18 @@ function generate_default_proxy_configuration(Server $server) $networks = collect($server->swarmDockers)->map(function ($docker) { return $docker['network']; })->unique(); + if ($networks->count() === 0) { + $networks = collect(['coolify-overlay']); + } } else { $networks = collect($server->standaloneDockers)->map(function ($docker) { return $docker['network']; })->unique(); + if ($networks->count() === 0) { + $networks = collect(['coolify']); + } } - if ($networks->count() === 0) { - $networks = collect(['coolify']); - } + $array_of_networks = collect([]); $networks->map(function ($network) use ($array_of_networks) { $array_of_networks[$network] = [ diff --git a/config/sentry.php b/config/sentry.php index 23c9af8bb..b24ec892f 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.164', + 'release' => '4.0.0-beta.165', // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index d9e28384f..a0b2aca88 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ string('docker_compose_custom_start_command')->nullable(); + $table->string('docker_compose_custom_build_command')->nullable(); + + + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('docker_compose_custom_start_command'); + $table->dropColumn('docker_compose_custom_build_command'); + }); + } +}; diff --git a/database/migrations/2023_12_18_093514_add_swarm_related_things.php b/database/migrations/2023_12_18_093514_add_swarm_related_things.php new file mode 100644 index 000000000..0f44f35ec --- /dev/null +++ b/database/migrations/2023_12_18_093514_add_swarm_related_things.php @@ -0,0 +1,36 @@ +integer('swarm_replicas')->default(1); + $table->text('swarm_placement_constraints')->nullable(); + }); + Schema::table('application_settings', function (Blueprint $table) { + $table->boolean('is_swarm_only_worker_nodes')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('swarm_replicas'); + $table->dropColumn('swarm_placement_constraints'); + }); + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('is_swarm_only_worker_nodes'); + }); + } +}; diff --git a/database/migrations/2023_12_19_124111_add_swarm_cluster_grouping.php b/database/migrations/2023_12_19_124111_add_swarm_cluster_grouping.php new file mode 100644 index 000000000..668acb6fe --- /dev/null +++ b/database/migrations/2023_12_19_124111_add_swarm_cluster_grouping.php @@ -0,0 +1,28 @@ +integer('swarm_cluster')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('swarm_cluster'); + }); + } +}; diff --git a/resources/views/components/applications/navbar.blade.php b/resources/views/components/applications/navbar.blade.php index 38bff68e2..a1b4af91d 100644 --- a/resources/views/components/applications/navbar.blade.php +++ b/resources/views/components/applications/navbar.blade.php @@ -19,24 +19,26 @@
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
Please load a Compose file.
- @elseif ($application->destination->server->isSwarm() && str($application->docker_registry_image_name)->isEmpty()) - Swarm Deployments requires a Docker Image in a Registry. @else - + @if (!$application->destination->server->isSwarm()) + + @endif @if ($application->status !== 'exited') - + @if (!$application->destination->server->isSwarm()) + + @endif @if ($application->build_pack !== 'dockercompose') - @if (isDev()) + {{-- @if (isDev()) - @endif + @endif --}} @endif - @if (isDev()) + {{-- @if (isDev()) - @endif + @endif --}} @endif @endif diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 8cff88f48..98141b044 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -2,7 +2,7 @@