diff --git a/app/Actions/V5/Application/DeployNginxApplication.php b/app/Actions/V5/Application/DeployNginxApplication.php index 4dcd550c7..1974d6be9 100644 --- a/app/Actions/V5/Application/DeployNginxApplication.php +++ b/app/Actions/V5/Application/DeployNginxApplication.php @@ -2,95 +2,69 @@ namespace App\Actions\V5\Application; -use App\Models\PrivateKey; use App\Models\V5\Application; -use Illuminate\Contracts\Process\ProcessResult; -use Illuminate\Support\Facades\Process; +use App\Services\Flux\FluxClient; use Lorisleiva\Actions\Concerns\AsAction; class DeployNginxApplication { use AsAction; + public function __construct(private readonly FluxClient $fluxClient) {} + public function handle(Application $application): Application { - $application->loadMissing('server.privateKey'); + $application->loadMissing('server'); $server = $application->server; if ($server === null) { return $this->markFailed($application, 'No server is attached to this application.'); } - if (! $server->privateKey instanceof PrivateKey) { - return $this->markFailed($application, 'No private key is attached to this server.'); + $hostId = $server->wireguard_management_ip ?: $server->node_address ?: $server->host; + + if (! is_string($hostId) || $hostId === '') { + return $this->markFailed($application, 'No Flux host ID is available for this server.'); } - $keyLocation = $this->writeTemporaryPrivateKey($server->privateKey); - try { - $result = Process::timeout(120)->run([ - 'ssh', - '-o', - 'BatchMode=yes', - '-o', - 'LogLevel=ERROR', - '-o', - 'StrictHostKeyChecking=no', - '-o', - 'UserKnownHostsFile=/dev/null', - '-o', - 'ConnectTimeout=10', - '-o', - 'IdentitiesOnly=yes', - '-i', - $keyLocation, - '-p', - (string) $server->ssh_port, - "{$server->ssh_user}@{$server->host}", - $this->remoteCommand($application), - ]); + $this->fluxClient->pullImage($hostId, $application->image); + $containerId = $this->fluxClient->createContainer($hostId, $this->containerSpec($application)); + $this->fluxClient->startContainer($hostId, $containerId); + $inspect = $this->fluxClient->inspectContainer($hostId, $containerId); - if (! $result->successful()) { - return $this->markFailed($application, $this->processOutput($result)); + if (! $this->isContainerRunning($inspect)) { + return $this->markFailed($application, 'Container did not stay running.'); } - $containerId = trim($result->output()); - $application->update([ 'status' => 'running', 'status_message' => 'Container started.', - 'runtime_container_id' => $containerId !== '' ? $containerId : null, + 'runtime_container_id' => $containerId, ]); return $application->refresh()->load('server'); } catch (\Throwable $e) { return $this->markFailed($application, $e->getMessage()); - } finally { - @unlink($keyLocation); } } - private function remoteCommand(Application $application): string + /** + * @return array + */ + private function containerSpec(Application $application): array { - $image = escapeshellarg($application->image); - $containerName = escapeshellarg($application->container_name); - $network = escapeshellarg($this->meshNetwork($application)); + $network = $this->meshNetwork($application); + $containerName = $application->container_name; - return implode(PHP_EOL, [ - 'set -e', - 'if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi', - 'if ! $podman --version >/dev/null 2>&1; then echo "Rootful Podman is required for v5 mesh applications." >&2; exit 1; fi', - "if ! \$podman network exists {$network}; then echo 'Mesh network {$network} does not exist. Bootstrap this server into the v5 mesh first.' >&2; exit 1; fi", - "container_id=\$(\$podman run -d --replace --name {$containerName} --network {$network} --network-alias {$containerName} {$image})", - 'sleep 1', - "is_running=$(\$podman inspect -f '{{.State.Running}}' {$containerName} 2>/dev/null || printf false)", - 'if [ "$is_running" != "true" ]; then', - " echo 'Container did not stay running.' >&2", - " \$podman ps -a --filter name={$containerName} >&2 || true", - ' exit 1', - 'fi', - 'printf %s "$container_id"', - ]); + return [ + 'name' => $containerName, + 'image' => $application->image, + 'networks' => [$network], + 'network_aliases' => [$containerName], + 'dns_search' => [$this->meshDnsSearchDomain($application)], + 'restart_policy' => 'unless-stopped', + ]; } private function meshNetwork(Application $application): string @@ -100,11 +74,25 @@ class DeployNginxApplication return "coolify-{$namespace}-mesh"; } - private function processOutput(ProcessResult $result): string + private function meshDnsSearchDomain(Application $application): string { - $output = trim($result->output()."\n".$result->errorOutput()); + $namespace = $application->mesh_namespace ?: 'default'; - return $output !== '' ? $output : 'Could not start nginx container.'; + return "{$namespace}.coolify.internal"; + } + + /** + * @param array $inspect + */ + private function isContainerRunning(array $inspect): bool + { + $state = $inspect['State'] ?? []; + + if (is_array($state) && ($state['Running'] ?? null) === true) { + return true; + } + + return is_string($inspect['state'] ?? null) && $inspect['state'] === 'running'; } private function markFailed(Application $application, string $message): Application @@ -116,22 +104,4 @@ class DeployNginxApplication return $application->refresh()->load('server'); } - - private function writeTemporaryPrivateKey(PrivateKey $privateKey): string - { - $keyDirectory = storage_path('app/ssh/keys'); - if (! is_dir($keyDirectory)) { - mkdir($keyDirectory, 0700, true); - } - - $keyLocation = tempnam($keyDirectory, 'v5_nginx_key_'); - if ($keyLocation === false) { - throw new \RuntimeException('Could not create a temporary SSH key file.'); - } - - file_put_contents($keyLocation, $privateKey->private_key); - chmod($keyLocation, 0600); - - return $keyLocation; - } } diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index fa9710abc..28de0accf 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -36,6 +36,8 @@ use Inertia\Response; class DashboardController extends Controller { + private const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine'; + private const CANVAS_CARD_WIDTH = 320; private const CANVAS_CARD_HEIGHT = 144; @@ -196,7 +198,9 @@ class DashboardController extends Controller $validated = $request->validate([ 'server_id' => ['nullable', 'integer'], + 'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'], ]); + $image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE; $server = V5Server::query() ->where('team_id', $currentTeam->id) @@ -224,7 +228,7 @@ class DashboardController extends Controller 'server_id' => $server->id, 'created_by_user_id' => $request->user()->id, 'name' => 'nginx-test', - 'image' => 'docker.io/library/nginx:alpine', + 'image' => $image, 'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()), 'status' => 'creating', 'status_message' => 'Starting nginx container.', diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php index 3e2224879..bc5453ee9 100644 --- a/app/Services/Flux/FluxClient.php +++ b/app/Services/Flux/FluxClient.php @@ -21,6 +21,59 @@ class FluxClient return is_array($data) ? $data : []; } + public function pullImage(string $hostId, string $image): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'images.pull', + 'reference' => $image, + ]); + + return $this->output($payload, 'Image pulled.'); + } + + /** + * @param array $spec + */ + public function createContainer(string $hostId, array $spec): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'containers.create', + ...$spec, + ]); + $data = $payload['data'] ?? []; + $id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : ''; + + if ($id === '') { + throw new RuntimeException('Flux did not return a container id.'); + } + + return $id; + } + + public function startContainer(string $hostId, string $id): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'containers.start', + 'id' => $id, + ]); + + return $this->output($payload, 'Container started.'); + } + + /** + * @return array + */ + public function inspectContainer(string $hostId, string $id): array + { + $payload = $this->dispatch($hostId, [ + 'type' => 'containers.inspect', + 'id' => $id, + ]); + $data = $payload['data'] ?? []; + + return is_array($data) ? $data : []; + } + /** * @param array $apps */ diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index 7c52f7204..e994edfa4 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -106,6 +106,7 @@ const MIN_CANVAS_ZOOM = 0.5; const MAX_CANVAS_ZOOM = 2; const CANVAS_ZOOM_STEP = 0.1; const PINCH_CANVAS_ZOOM_STEP = 0.03; +const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine'; async function persistApplicationPosition(application: V5Application): Promise { await fetch(`/v5/applications/${application.id}/position`, { @@ -162,6 +163,7 @@ export default function Dashboard({ const [pointerState, setPointerState] = useState(null); const [isCreating, setIsCreating] = useState(false); const [selectedNginxServerId, setSelectedNginxServerId] = useState(nginxServers[0]?.id ?? ''); + const [nginxImage, setNginxImage] = useState(DEFAULT_NGINX_IMAGE); const [isRefreshing, setIsRefreshing] = useState(false); const [notice, setNotice] = useState(null); const [ingressModal, setIngressModal] = useState(null); @@ -845,6 +847,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection }, body: JSON.stringify({ server_id: selectedNginxServerId || null, + image: nginxImage.trim() || DEFAULT_NGINX_IMAGE, }), }); const payload = (await response.json()) as { application?: V5Application; message?: string }; @@ -1245,6 +1248,14 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection )) )} + setNginxImage(event.target.value)} + disabled={isCreating} + className="w-72 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition disabled:cursor-not-allowed disabled:opacity-60" + />