feat(v5): deploy nginx applications through Flux

Create nginx containers through Flux image and container primitives instead of SSH, and allow selecting a custom nginx image from the dashboard.
This commit is contained in:
Andras Bacsai committed 2026-06-21 22:39:16 +02:00
1 parent ec3e5dc9eb
commit e543d2b376
7 files changed
+315 -103

No files matched your search

@@ -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<string, mixed>
*/
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<string, mixed> $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;
}
}
@@ -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.',
+53
View File
@@ -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<string, mixed> $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<string, mixed>
*/
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<int, array{name: string, config: string}> $apps
*/