diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 343915d9c..b560595b3 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -2,17 +2,15 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Server\RunCommand; use App\Models\Application; use App\Models\Server; use App\Models\Service; use Illuminate\Support\Collection; +use Livewire\Attributes\On; use Livewire\Component; class ExecuteContainerCommand extends Component { - public string $command; - public string $container; public Collection $containers; @@ -23,8 +21,6 @@ class ExecuteContainerCommand extends Component public string $type; - public string $workDir = ''; - public Server $server; public Collection $servers; @@ -33,7 +29,6 @@ class ExecuteContainerCommand extends Component 'server' => 'required', 'container' => 'required', 'command' => 'required', - 'workDir' => 'nullable', ]; public function mount() @@ -115,7 +110,8 @@ class ExecuteContainerCommand extends Component } } - public function runCommand() + #[On('connectToContainer')] + public function connectToContainer() { try { if (data_get($this->parameters, 'application_uuid')) { @@ -132,14 +128,13 @@ class ExecuteContainerCommand extends Component if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } - $cmd = "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; ".str_replace("'", "'\''", $this->command)."'"; - if (! empty($this->workDir)) { - $exec = "docker exec -w {$this->workDir} {$container_name} {$cmd}"; - } else { - $exec = "docker exec {$container_name} {$cmd}"; - } - $activity = RunCommand::run(server: $server, command: $exec); - $this->dispatch('activityMonitor', $activity->id); + + $this->dispatch('send-terminal-command', + true, + $container_name, + $server->uuid, + ); + } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php new file mode 100644 index 000000000..70b8fd18f --- /dev/null +++ b/app/Livewire/Project/Shared/Terminal.php @@ -0,0 +1,51 @@ +whereUuid($serverUuid)->firstOrFail(); + + // if (auth()->user()) { + // $teams = auth()->user()->teams->pluck('id'); + // if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { + // throw new \Exception('User is not part of the team that owns this server'); + // } + // } + + if ($isContainer) { + ray($identifier); + $status = getContainerStatus($server, $identifier); + ray($status); + if ($status !== 'running') { + return handleError(new \Exception('Container is not running'), $this); + } + $command = generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + } else { + $command = generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + } + + // ssh command is sent back to frontend then to websocket + // this is done because the websocket connection is not available here + // a better solution would be to remove websocket on NodeJS and work with something like + // 1. Laravel Pusher/Echo connection (not possible without a sdk) + // 2. Ratchet / Revolt / ReactPHP / Event Loop (possible but hard to implement and huge dependencies) + // 3. Just found out about this https://github.com/sirn-se/websocket-php, perhaps it can be used + // 4. Follow-up discussions here: + // - https://github.com/coollabsio/coolify/issues/2298 + // - https://github.com/coollabsio/coolify/discussions/3362 + $this->dispatch('send-back-command', $command); + } + + public function render() + { + return view('livewire.project.shared.terminal'); + } +} diff --git a/app/Livewire/RunCommand.php b/app/Livewire/RunCommand.php index c2d3adeea..449ab1ea9 100644 --- a/app/Livewire/RunCommand.php +++ b/app/Livewire/RunCommand.php @@ -2,42 +2,96 @@ namespace App\Livewire; -use App\Actions\Server\RunCommand as ServerRunCommand; -use App\Models\Server; +use Livewire\Attributes\On; use Livewire\Component; class RunCommand extends Component { - public string $command; - - public $server; + public $selected_uuid; public $servers = []; - protected $rules = [ - 'server' => 'required', - 'command' => 'required', - ]; - - protected $validationAttributes = [ - 'server' => 'server', - 'command' => 'command', - ]; + public $containers = []; public function mount($servers) { $this->servers = $servers; - $this->server = $servers[0]->uuid; + $this->selected_uuid = $servers[0]->uuid; + $this->containers = $this->getAllActiveContainers(); } - public function runCommand() + private function getAllActiveContainers() { - $this->validate(); - try { - $activity = ServerRunCommand::run(server: Server::where('uuid', $this->server)->first(), command: $this->command); - $this->dispatch('activityMonitor', $activity->id); - } catch (\Throwable $e) { - return handleError($e, $this); - } + return collect($this->servers)->flatMap(function ($server) { + if (! $server->isFunctional()) { + return []; + } + + return $server->definedResources() + ->filter(function ($resource) { + $status = method_exists($resource, 'realStatus') ? $resource->realStatus() : (method_exists($resource, 'status') ? $resource->status() : 'exited'); + + return str_starts_with($status, 'running:'); + }) + ->map(function ($resource) use ($server) { + if (isDev()) { + if (data_get($resource, 'name') === 'coolify-db') { + $container_name = 'coolify-db'; + + return [ + 'name' => $resource->name, + 'connection_name' => $container_name, + 'uuid' => $resource->uuid, + 'status' => 'running', + 'server' => $server, + 'server_uuid' => $server->uuid, + ]; + } + } + + if (class_basename($resource) === 'Application') { + if (! $server->isSwarm()) { + $current_containers = getCurrentApplicationContainerStatus($server, $resource->id, includePullrequests: true); + } + $status = $resource->status; + } elseif (class_basename($resource) === 'Service') { + $current_containers = getCurrentServiceContainerStatus($server, $resource->id); + $status = $resource->status(); + } else { + $status = getContainerStatus($server, $resource->uuid); + if ($status === 'running') { + $current_containers = collect([ + 'Names' => $resource->name, + ]); + } + } + if ($server->isSwarm()) { + $container_name = $resource->uuid.'_'.$resource->uuid; + } else { + $container_name = data_get($current_containers->first(), 'Names'); + } + + return [ + 'name' => $resource->name, + 'connection_name' => $container_name, + 'uuid' => $resource->uuid, + 'status' => $status, + 'server' => $server, + 'server_uuid' => $server->uuid, + ]; + }); + }); + } + + #[On('connectToContainer')] + public function connectToContainer() + { + $container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid); + + $this->dispatch('send-terminal-command', + isset($container), + $container['connection_name'] ?? $this->selected_uuid, + $container['server_uuid'] ?? $this->selected_uuid + ); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 65d70083f..1f173604d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -305,6 +305,13 @@ respond 404 'service' => 'coolify-realtime', 'rule' => "Host(`{$host}`) && PathPrefix(`/app`)", ], + 'coolify-terminal-ws' => [ + 'entryPoints' => [ + 0 => 'http', + ], + 'service' => 'coolify-terminal', + 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal`)", + ], ], 'services' => [ 'coolify' => [ @@ -325,6 +332,15 @@ respond 404 ], ], ], + 'coolify-terminal' => [ + 'loadBalancer' => [ + 'servers' => [ + 0 => [ + 'url' => 'http://coolify-realtime:6002', + ], + ], + ], + ], ], ], ]; @@ -354,6 +370,16 @@ respond 404 'certresolver' => 'letsencrypt', ], ]; + $traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [ + 'entryPoints' => [ + 0 => 'https', + ], + 'service' => 'coolify-terminal', + 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal`)", + 'tls' => [ + 'certresolver' => 'letsencrypt', + ], + ]; } $yaml = Yaml::dump($traefik_dynamic_conf, 12, 2); $yaml = @@ -387,6 +413,9 @@ $schema://$host { handle /app/* { reverse_proxy coolify-realtime:6001 } + handle /terminal/* { + reverse_proxy coolify-realtime:6002 + } reverse_proxy coolify:80 }"; $base64 = base64_encode($caddy_file); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 90093deb8..825668743 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -40,6 +40,20 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul return $containers; } +function getCurrentServiceContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + $containers = $containers->filter(); + + return $containers; + } + + return $containers; +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 718d52d04..750ad45d4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -44,10 +44,17 @@ services: - /data/coolify/_volumes/redis/:/data # - coolify-redis-data-dev:/data soketi: + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile env_file: - .env ports: - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js environment: SOKETI_DEBUG: "false" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 65a708acb..8387817f7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -110,18 +110,28 @@ services: retries: 10 timeout: 2s soketi: + image: 'ghcr.io/coollabsio/coolify-realtime:latest' ports: - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./docker/soketi-entrypoint/soketi-entrypoint.sh:/soketi-entrypoint.sh + - ./package.json:/terminal/package.json + - ./package-lock.json:/terminal/package-lock.json + - ./terminal-server.js:/terminal/terminal-server.js + - ./storage:/var/www/html/storage + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] environment: SOKETI_DEBUG: "${SOKETI_DEBUG:-false}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: wget -qO- http://127.0.0.1:6001/ready || exit 1 + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] interval: 5s retries: 10 timeout: 2s + volumes: coolify-db: name: coolify-db diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 3f45b62cd..149bc22c8 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -103,7 +103,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'quay.io/soketi/soketi:1.6-16-alpine' + image: 'ghcr.io/coollabsio/coolify-realtime:latest' pull_policy: always container_name: coolify-realtime restart: always @@ -111,16 +111,25 @@ services: - .env ports: - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./docker/soketi-entrypoint/soketi-entrypoint.sh:/soketi-entrypoint.sh + - ./package.json:/terminal/package.json + - ./package-lock.json:/terminal/package-lock.json + - ./terminal-server.js:/terminal/terminal-server.js + - ./storage:/var/www/html/storage + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] environment: SOKETI_DEBUG: "${SOKETI_DEBUG:-false}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: wget -qO- http://localhost:6001/ready || exit 1 + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] interval: 5s retries: 10 timeout: 2s + volumes: coolify-db: name: coolify-db diff --git a/docker-compose.yml b/docker-compose.yml index 930c0a6b9..7d71ba8e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,6 @@ services: networks: - coolify soketi: - image: 'quay.io/soketi/soketi:1.6-16-alpine' container_name: coolify-realtime restart: always networks: