mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-21 16:26:16 +00:00
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:
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<void> {
|
||||
await fetch(`/v5/applications/${application.id}/position`, {
|
||||
@@ -162,6 +163,7 @@ export default function Dashboard({
|
||||
const [pointerState, setPointerState] = useState<PointerState | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedNginxServerId, setSelectedNginxServerId] = useState<string>(nginxServers[0]?.id ?? '');
|
||||
const [nginxImage, setNginxImage] = useState<string>(DEFAULT_NGINX_IMAGE);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(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
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Nginx image"
|
||||
value={nginxImage}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addNginx()}
|
||||
|
||||
@@ -1078,9 +1078,7 @@ it('creates an nginx v5 application on the first installed team server', functio
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "nginx-container-id\n"),
|
||||
]);
|
||||
fakeSuccessfulNginxFluxDeployment();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
@@ -1109,6 +1107,46 @@ it('creates an nginx v5 application on the first installed team server', functio
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates an nginx v5 application with a custom image', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
fakeSuccessfulNginxFluxDeployment(image: 'docker.io/library/httpd:alpine');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->postJson('/v5/applications/nginx', [
|
||||
'image' => 'docker.io/library/httpd:alpine',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('application.image', 'docker.io/library/httpd:alpine');
|
||||
|
||||
expect(V5Application::query()
|
||||
->where('image', 'docker.io/library/httpd:alpine')
|
||||
->where('runtime_container_id', 'nginx-container-id')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates an nginx v5 application on the selected team server', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
@@ -1140,9 +1178,7 @@ it('creates an nginx v5 application on the selected team server', function () {
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "nginx-container-id\n"),
|
||||
]);
|
||||
fakeSuccessfulNginxFluxDeployment();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
@@ -1198,9 +1234,7 @@ it('places a new nginx v5 application next to existing canvas nodes', function (
|
||||
'canvas_y' => 0,
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "nginx-container-id\n"),
|
||||
]);
|
||||
fakeSuccessfulNginxFluxDeployment();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
@@ -1234,9 +1268,9 @@ it('marks an nginx v5 application failed when the launch command fails', functio
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(errorOutput: 'podman failed', exitCode: 1),
|
||||
]);
|
||||
$this->mock(FluxClient::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('pullImage')->once()->andThrow(new RuntimeException('podman failed'));
|
||||
});
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
@@ -4195,7 +4229,10 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
|
||||
->toContain('Add nginx')
|
||||
->toContain('Select nginx server')
|
||||
->toContain('selectedNginxServerId')
|
||||
->toContain('nginxImage')
|
||||
->toContain('docker.io/library/nginx:alpine')
|
||||
->toContain('server_id: selectedNginxServerId || null')
|
||||
->toContain('image: nginxImage.trim() || DEFAULT_NGINX_IMAGE')
|
||||
->toContain('Center')
|
||||
->toContain('Delete')
|
||||
->toContain('App configuration')
|
||||
@@ -4892,6 +4929,33 @@ function fakeFluxHealth(bool $available = true, string $message = 'Flux is runni
|
||||
}));
|
||||
}
|
||||
|
||||
function fakeSuccessfulNginxFluxDeployment(string $image = 'docker.io/library/nginx:alpine'): void
|
||||
{
|
||||
$mock = Mockery::mock(FluxClient::class, function (MockInterface $mock) use ($image): void {
|
||||
$mock->shouldReceive('pullImage')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $image)
|
||||
->andReturn('Image pulled.');
|
||||
$mock->shouldReceive('createContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), Mockery::on(fn (array $spec): bool => ($spec['image'] ?? null) === $image
|
||||
&& ($spec['networks'] ?? []) === ['coolify-default-mesh']
|
||||
&& ($spec['dns_search'] ?? []) === ['default.coolify.internal']
|
||||
&& in_array($spec['name'] ?? '', $spec['network_aliases'] ?? [], true)))
|
||||
->andReturn('nginx-container-id');
|
||||
$mock->shouldReceive('startContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'nginx-container-id')
|
||||
->andReturn('Container started.');
|
||||
$mock->shouldReceive('inspectContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'nginx-container-id')
|
||||
->andReturn(['State' => ['Running' => true]]);
|
||||
});
|
||||
|
||||
app()->instance(FluxClient::class, $mock);
|
||||
}
|
||||
|
||||
function createSharedUserAndTeamTables(): void
|
||||
{
|
||||
Schema::create('users', function ($table) {
|
||||
|
||||
@@ -271,6 +271,47 @@ it('dispatches container inventory through the containers list primitive', funct
|
||||
->not->toContain('list_containers');
|
||||
});
|
||||
|
||||
it('dispatches image pull and container lifecycle primitives for v5 apps', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responses = [
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Image pulled.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['id' => 'container-123']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container started.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['State' => ['Running' => true]]],
|
||||
];
|
||||
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
|
||||
|
||||
withFakeFluxSocketCapturingRequests($responses, $requestPath, function (): void {
|
||||
$fluxClient = new FluxClient;
|
||||
|
||||
expect($fluxClient->pullImage('100.64.0.10', 'docker.io/library/nginx:alpine'))->toBe('Image pulled.')
|
||||
->and($fluxClient->createContainer('100.64.0.10', [
|
||||
'name' => 'coolify-v5-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'networks' => ['coolify-default-mesh'],
|
||||
'network_aliases' => ['coolify-v5-nginx-test'],
|
||||
'dns' => ['10.210.0.1'],
|
||||
'dns_search' => ['default.coolify.internal'],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
]))->toBe('container-123')
|
||||
->and($fluxClient->startContainer('100.64.0.10', 'container-123'))->toBe('Container started.')
|
||||
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]]);
|
||||
});
|
||||
|
||||
$request = file_get_contents($requestPath) ?: '';
|
||||
@unlink($requestPath);
|
||||
|
||||
expect($request)->toContain('"type":"images.pull"')
|
||||
->toContain('"type":"containers.create"')
|
||||
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
|
||||
->toContain('"dns_search":["default.coolify.internal"]')
|
||||
->toContain('"type":"containers.start"')
|
||||
->toContain('"type":"containers.inspect"');
|
||||
});
|
||||
|
||||
it('dispatches firewall allow through the firewall allow primitive', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
@@ -339,6 +380,72 @@ it('dispatches firewall revoke through the firewall revoke primitive', function
|
||||
->toContain('"id":"rule-123"');
|
||||
});
|
||||
|
||||
function withFakeFluxSocketCapturingRequests(array $responsePayloads, string $requestPath, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$capturedRequests = '';
|
||||
|
||||
foreach ($responsePayloads as $payload) {
|
||||
$connection = stream_socket_accept($server, 5);
|
||||
|
||||
if ($connection === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
|
||||
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
|
||||
|
||||
while ($remaining > 0 && ! feof($connection)) {
|
||||
$chunk = fread($connection, $remaining);
|
||||
$request .= $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
$capturedRequests .= $request."\n---REQUEST---\n";
|
||||
$body = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n{$body}");
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
file_put_contents($requestPath, $capturedRequests);
|
||||
fclose($server);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($server);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.health_timeout_seconds', 1.0);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
@unlink($socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
function withFakeFluxSocketCapturingRequest(string $response, string $requestPath, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
@@ -2,31 +2,34 @@
|
||||
|
||||
use App\Actions\V5\Application\DeployNginxApplication;
|
||||
use App\Models\V5\Application;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('verifies nginx is running before marking the application running', function () {
|
||||
it('builds an nginx container spec for the coold mesh runtime', function () {
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'creating',
|
||||
]);
|
||||
|
||||
$action = new DeployNginxApplication;
|
||||
$method = new ReflectionMethod($action, 'remoteCommand');
|
||||
$action = new DeployNginxApplication(Mockery::mock(FluxClient::class));
|
||||
$method = new ReflectionMethod($action, 'containerSpec');
|
||||
$method->setAccessible(true);
|
||||
$remoteCommand = $method->invoke($action, $application);
|
||||
$spec = $method->invoke($action, $application);
|
||||
|
||||
expect($remoteCommand)
|
||||
->toContain('if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi')
|
||||
->toContain("--network 'coolify-default-mesh'")
|
||||
->toContain("--network-alias 'coolify-v5-nginx-test'")
|
||||
->toContain('$podman inspect')
|
||||
->not->toContain('docker run')
|
||||
->not->toContain('docker inspect')
|
||||
->toContain('.State.Running')
|
||||
->toContain('Container did not stay running')
|
||||
->toContain('exit 1');
|
||||
expect($spec)
|
||||
->toMatchArray([
|
||||
'name' => 'coolify-v5-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'networks' => ['coolify-default-mesh'],
|
||||
'network_aliases' => ['coolify-v5-nginx-test'],
|
||||
'dns_search' => ['default.coolify.internal'],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
])
|
||||
->not->toHaveKey('command')
|
||||
->not->toHaveKey('privileged');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user