Merge pull request #16 from coollabsio/next

Next
This commit is contained in:
peaklabs-dev
2024-09-23 18:50:19 +02:00
committed by GitHub
135 changed files with 2738 additions and 1589 deletions

View File

@@ -6,7 +6,7 @@ APP_KEY=
APP_URL=http://localhost APP_URL=http://localhost
APP_PORT=8000 APP_PORT=8000
APP_DEBUG=true APP_DEBUG=true
SSH_MUX_ENABLED=false SSH_MUX_ENABLED=true
# PostgreSQL Database Configuration # PostgreSQL Database Configuration
DB_DATABASE=coolify DB_DATABASE=coolify
@@ -19,11 +19,7 @@ DB_PORT=5432
# Set to true to enable Ray # Set to true to enable Ray
RAY_ENABLED=false RAY_ENABLED=false
# Set custom ray port # Set custom ray port
RAY_PORT= # RAY_PORT=
# Clockwork Configuration
CLOCKWORK_ENABLED=false
CLOCKWORK_QUEUE_COLLECT=true
# Enable Laravel Telescope for debugging # Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false TELESCOPE_ENABLED=false

View File

@@ -1 +1,13 @@
> Always use `next` branch as destination branch for PRs, not `main` ## Submit Checklist (REMOVE THIS SECTION BEFORE SUBMITTING)
- [ ] I have selected the `next` branch as the destination for my PR, not `main`.
- [ ] I have listed all changes in the `Changes` section.
- [ ] I have filled out the `Issues` section with the issue/discussion link(s) (if applicable).
- [ ] I have tested my changes.
- [ ] I have considered backwards compatibility.
- [ ] I have removed this checklist and any unused sections.
## Changes
-
## Issues
- fix #

View File

@@ -65,11 +65,14 @@ jobs:
} }
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
const { data: closedIssues } = await github.rest.search.issuesAndPullRequests({ const pr = context.payload.pull_request;
q: `repo:${owner}/${repo} is:issue is:closed linked:${context.payload.pull_request.number}`, if (pr.body) {
per_page: 100 const issueReferences = pr.body.match(/#(\d+)/g);
}); if (issueReferences) {
for (const issue of closedIssues.items) { for (const reference of issueReferences) {
await processIssue(issue.number); const issueNumber = parseInt(reference.substring(1));
await processIssue(issueNumber);
}
}
} }
} }

View File

@@ -2,6 +2,7 @@
namespace App\Actions\Application; namespace App\Actions\Application;
use App\Actions\Server\CleanupDocker;
use App\Models\Application; use App\Models\Application;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -9,44 +10,35 @@ class StopApplication
{ {
use AsAction; use AsAction;
public function handle(Application $application, bool $previewDeployments = false) public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
{ {
if ($application->destination->server->isSwarm()) { try {
instant_remote_process(["docker stack rm {$application->uuid}"], $application->destination->server); $server = $application->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
}
ray('Stopping application: '.$application->name);
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
return; return;
} }
$servers = collect([]); $containersToStop = $application->getContainersToStop($previewDeployments);
$servers->push($application->destination->server); $application->stopContainers($containersToStop, $server);
$application->additional_servers->map(function ($server) use ($servers) {
$servers->push($server);
});
foreach ($servers as $server) {
if (! $server->isFunctional()) {
return 'Server is not functional';
}
if ($previewDeployments) {
$containers = getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true);
} else {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
}
if ($containers->count() > 0) {
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
instant_remote_process(command: ["docker stop --time=30 $containerName"], server: $server, throwError: false);
instant_remote_process(command: ["docker rm $containerName"], server: $server, throwError: false);
instant_remote_process(command: ["docker rm -f {$containerName}"], server: $server, throwError: false);
}
}
}
if ($application->build_pack === 'dockercompose') { if ($application->build_pack === 'dockercompose') {
// remove network $application->delete_connected_networks($application->uuid);
$uuid = $application->uuid; }
instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$uuid}"], $server, false); if ($dockerCleanup) {
} CleanupDocker::dispatch($server, true);
}
} catch (\Exception $e) {
ray($e->getMessage());
return $e->getMessage();
} }
} }
} }

View File

@@ -10,6 +10,7 @@ use Illuminate\Process\ProcessResult;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
use App\Helpers\SshMultiplexingHelper;
class RunRemoteProcess class RunRemoteProcess
{ {
@@ -137,7 +138,7 @@ class RunRemoteProcess
$command = $this->activity->getExtraProperty('command'); $command = $this->activity->getExtraProperty('command');
$server = Server::whereUuid($server_uuid)->firstOrFail(); $server = Server::whereUuid($server_uuid)->firstOrFail();
return generateSshCommand($server, $command); return SshMultiplexingHelper::generateSshCommand($server, $command);
} }
protected function handleOutput(string $type, string $output) protected function handleOutput(string $type, string $output)

View File

@@ -2,6 +2,7 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Actions\Server\CleanupDocker;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
@@ -10,25 +11,65 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class StopDatabase class StopDatabase
{ {
use AsAction; use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true)
{ {
$server = $database->destination->server; $server = $database->destination->server;
if (! $server->isFunctional()) { if (! $server->isFunctional()) {
return 'Server is not functional'; return 'Server is not functional';
} }
instant_remote_process(command: ["docker stop --time=30 $database->uuid"], server: $server, throwError: false); $this->stopContainer($database, $database->uuid, 300);
instant_remote_process(command: ["docker rm $database->uuid"], server: $server, throwError: false); if (! $isDeleteOperation) {
instant_remote_process(command: ["docker rm -f $database->uuid"], server: $server, throwError: false); if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
}
}
if ($database->is_public) { if ($database->is_public) {
StopDatabaseProxy::run($database); StopDatabaseProxy::run($database);
} }
return 'Database stopped successfully';
}
private function stopContainer($database, string $containerName, int $timeout = 300): void
{
$server = $database->destination->server;
$process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
$startTime = time();
while ($process->running()) {
if (time() - $startTime >= $timeout) {
$this->forceStopContainer($containerName, $server);
break;
}
usleep(100000);
}
$this->removeContainer($containerName, $server);
}
private function forceStopContainer(string $containerName, $server): void
{
instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false);
}
private function removeContainer(string $containerName, $server): void
{
instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false);
}
private function deleteConnectedNetworks($uuid, $server)
{
instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$uuid}"], $server, false);
} }
} }

View File

@@ -26,7 +26,7 @@ class CheckProxy
if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) { if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) {
return false; return false;
} }
['uptime' => $uptime, 'error' => $error] = $server->validateConnection(); ['uptime' => $uptime, 'error' => $error] = $server->validateConnection(false);
if (! $uptime) { if (! $uptime) {
throw new \Exception($error); throw new \Exception($error);
} }

View File

@@ -2,6 +2,7 @@
namespace App\Actions\Service; namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker;
use App\Models\Service; use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -9,11 +10,11 @@ class DeleteService
{ {
use AsAction; use AsAction;
public function handle(Service $service) public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks)
{ {
try { try {
$server = data_get($service, 'server'); $server = data_get($service, 'server');
if ($server->isFunctional()) { if ($deleteVolumes && $server->isFunctional()) {
$storagesToDelete = collect([]); $storagesToDelete = collect([]);
$service->environment_variables()->delete(); $service->environment_variables()->delete();
@@ -33,13 +34,29 @@ class DeleteService
foreach ($storagesToDelete as $storage) { foreach ($storagesToDelete as $storage) {
$commands[] = "docker volume rm -f $storage->name"; $commands[] = "docker volume rm -f $storage->name";
} }
$commands[] = "docker rm -f $service->uuid";
instant_remote_process($commands, $server, false); // Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
if (! empty($commands)) {
foreach ($commands as $command) {
$result = instant_remote_process([$command], $server, false);
if ($result !== 0) {
ray("Failed to execute: $command");
} }
}
}
}
if ($deleteConnectedNetworks) {
$service->delete_connected_networks($service->uuid);
}
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \Exception($e->getMessage()); throw new \Exception($e->getMessage());
} finally { } finally {
if ($deleteConfigurations) {
$service->delete_configurations();
}
foreach ($service->applications()->get() as $application) { foreach ($service->applications()->get() as $application) {
$application->forceDelete(); $application->forceDelete();
} }
@@ -50,6 +67,11 @@ class DeleteService
$task->delete(); $task->delete();
} }
$service->tags()->detach(); $service->tags()->detach();
$service->forceDelete();
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@
namespace App\Actions\Service; namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker;
use App\Models\Service; use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -9,40 +10,27 @@ class StopService
{ {
use AsAction; use AsAction;
public function handle(Service $service) public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true)
{ {
try { try {
$server = $service->destination->server; $server = $service->destination->server;
if (! $server->isFunctional()) { if (! $server->isFunctional()) {
return 'Server is not functional'; return 'Server is not functional';
} }
ray('Stopping service: '.$service->name);
$applications = $service->applications()->get();
foreach ($applications as $application) {
if ($applications->count() < 6) {
instant_remote_process(command: ["docker stop --time=10 {$application->name}-{$service->uuid}"], server: $server, throwError: false);
}
instant_remote_process(command: ["docker rm {$application->name}-{$service->uuid}"], server: $server, throwError: false);
instant_remote_process(command: ["docker rm -f {$application->name}-{$service->uuid}"], server: $server, throwError: false);
$application->update(['status' => 'exited']);
}
$dbs = $service->databases()->get();
foreach ($dbs as $db) {
if ($dbs->count() < 6) {
instant_remote_process(command: ["docker stop --time=10 {$db->name}-{$service->uuid}"], server: $server, throwError: false); $containersToStop = $service->getContainersToStop();
$service->stopContainers($containersToStop, $server);
if (! $isDeleteOperation) {
$service->delete_connected_networks($service->uuid);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
} }
instant_remote_process(command: ["docker rm {$db->name}-{$service->uuid}"], server: $server, throwError: false);
instant_remote_process(command: ["docker rm -f {$db->name}-{$service->uuid}"], server: $server, throwError: false);
$db->update(['status' => 'exited']);
} }
instant_remote_process(["docker network disconnect {$service->uuid} coolify-proxy"], $service->server);
instant_remote_process(["docker network rm {$service->uuid}"], $service->server);
} catch (\Exception $e) { } catch (\Exception $e) {
ray($e->getMessage()); ray($e->getMessage());
return $e->getMessage(); return $e->getMessage();
} }
} }
} }

View File

@@ -43,6 +43,8 @@ class Kernel extends ConsoleKernel
$schedule->command('uploads:clear')->everyTwoMinutes(); $schedule->command('uploads:clear')->everyTwoMinutes();
$schedule->command('telescope:prune')->daily(); $schedule->command('telescope:prune')->daily();
$schedule->job(new PullHelperImageJob)->everyFiveMinutes()->onOneServer();
} else { } else {
// Instance Jobs // Instance Jobs
$schedule->command('horizon:snapshot')->everyFiveMinutes(); $schedule->command('horizon:snapshot')->everyFiveMinutes();
@@ -77,12 +79,12 @@ class Kernel extends ConsoleKernel
} }
})->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer(); })->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer();
} }
$schedule->job(new PullHelperImageJob($server)) }
$schedule->job(new PullHelperImageJob)
->cron($settings->update_check_frequency) ->cron($settings->update_check_frequency)
->timezone($settings->instance_timezone) ->timezone($settings->instance_timezone)
->onOneServer(); ->onOneServer();
} }
}
private function schedule_updates($schedule) private function schedule_updates($schedule)
{ {

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Helpers;
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Process;
class SshMultiplexingHelper
{
public static function serverSshConfiguration(Server $server)
{
$privateKey = PrivateKey::findOrFail($server->private_key_id);
$sshKeyLocation = $privateKey->getKeyLocation();
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [
'sshKeyLocation' => $sshKeyLocation,
'muxFilename' => $muxFilename,
];
}
public static function ensureMultiplexedConnection(Server $server)
{
if (! self::isMultiplexingEnabled()) {
// ray('SSH Multiplexing: DISABLED')->red();
return;
}
// ray('SSH Multiplexing: ENABLED')->green();
// ray('Ensuring multiplexed connection for server:', $server);
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$sshKeyLocation = $sshConfig['sshKeyLocation'];
self::validateSshKey($sshKeyLocation);
$checkCommand = "ssh -O check -o ControlPath=$muxSocket {$server->user}@{$server->ip}";
$process = Process::run($checkCommand);
if ($process->exitCode() !== 0) {
// ray('SSH Multiplexing: Existing connection check failed or not found')->orange();
// ray('Establishing new connection');
self::establishNewMultiplexedConnection($server);
} else {
// ray('SSH Multiplexing: Existing connection is valid')->green();
}
}
public static function establishNewMultiplexedConnection(Server $server)
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
// ray('Establishing new multiplexed connection')->blue();
// ray('SSH Key Location:', $sshKeyLocation);
// ray('Mux Socket:', $muxSocket);
$connectionTimeout = config('constants.ssh.connection_timeout');
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "
.self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval)
."{$server->user}@{$server->ip}";
// ray('Establish Command:', $establishCommand);
$establishProcess = Process::run($establishCommand);
// ray('Establish Process Exit Code:', $establishProcess->exitCode());
// ray('Establish Process Output:', $establishProcess->output());
// ray('Establish Process Error Output:', $establishProcess->errorOutput());
if ($establishProcess->exitCode() !== 0) {
// ray('Failed to establish multiplexed connection')->red();
throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput());
}
// ray('Successfully established multiplexed connection')->green();
// Check if the mux socket file was created
if (! file_exists($muxSocket)) {
// ray('Mux socket file not found after connection establishment')->orange();
}
}
public static function removeMuxFile(Server $server)
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip}";
$process = Process::run($closeCommand);
// ray('Closing multiplexed connection')->blue();
// ray('Close command:', $closeCommand);
// ray('Close process exit code:', $process->exitCode());
// ray('Close process output:', $process->output());
// ray('Close process error output:', $process->errorOutput());
if ($process->exitCode() !== 0) {
// ray('Failed to close multiplexed connection')->orange();
} else {
// ray('Successfully closed multiplexed connection')->green();
}
}
public static function generateScpCommand(Server $server, string $source, string $dest)
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$timeout = config('constants.ssh.command_timeout');
$scp_command = "timeout $timeout scp ";
if (self::isMultiplexingEnabled()) {
$muxPersistTime = config('constants.ssh.mux_persist_time');
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
self::ensureMultiplexedConnection($server);
}
self::addCloudflareProxyCommand($scp_command, $server);
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
return $scp_command;
}
public static function generateSshCommand(Server $server, string $command)
{
if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
}
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$timeout = config('constants.ssh.command_timeout');
$ssh_command = "timeout $timeout ssh ";
if (self::isMultiplexingEnabled()) {
$muxPersistTime = config('constants.ssh.mux_persist_time');
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
self::ensureMultiplexedConnection($server);
}
self::addCloudflareProxyCommand($ssh_command, $server);
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
$command = "PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:/host/sbin:/host/bin && $command";
$delimiter = Hash::make($command);
$command = str_replace($delimiter, '', $command);
$ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
return $ssh_command;
}
private static function isMultiplexingEnabled(): bool
{
return config('constants.ssh.mux_enabled') && ! config('coolify.is_windows_docker_desktop');
}
private static function validateSshKey(string $sshKeyLocation): void
{
$checkKeyCommand = "ls $sshKeyLocation 2>/dev/null";
$keyCheckProcess = Process::run($checkKeyCommand);
if ($keyCheckProcess->exitCode() !== 0) {
throw new \RuntimeException("SSH key file not accessible: $sshKeyLocation");
}
}
private static function addCloudflareProxyCommand(string &$command, Server $server): void
{
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" ';
}
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
."-o ServerAliveInterval=$serverInterval "
.'-o RequestTTY=no '
.'-o LogLevel=ERROR ';
// Bruh
if ($isScp) {
$options .= "-P {$server->port} ";
} else {
$options .= "-p {$server->port} ";
}
return $options;
}
}

View File

@@ -2529,6 +2529,131 @@ class ApplicationsController extends Controller
} }
#[OA\Post(
summary: 'Execute Command',
description: "Execute a command on the application's current container.",
path: '/applications/{uuid}/execute',
operationId: 'execute-command-application',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
requestBody: new OA\RequestBody(
required: true,
description: 'Command to execute.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'command' => ['type' => 'string', 'description' => 'Command to execute.'],
],
),
),
),
responses: [
new OA\Response(
response: 200,
description: "Execute a command on the application's current container.",
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Command executed.'],
'response' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function execute_command_by_uuid(Request $request)
{
// TODO: Need to review this from security perspective, to not allow arbitrary command execution
$allowedFields = ['command'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'command' => 'string|required',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail();
$status = getContainerStatus($application->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Application is not running.',
], 400);
}
$commands = collect([
executeInDocker($container['Names'], $request->command),
]);
$res = instant_remote_process(command: $commands, server: $application->destination->server);
return response()->json([
'message' => 'Command executed.',
'response' => $res,
]);
}
private function validateDataApplications(Request $request, Server $server) private function validateDataApplications(Request $request, Server $server)
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();

View File

@@ -29,6 +29,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Sleep; use Illuminate\Support\Sleep;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Process;
use RuntimeException; use RuntimeException;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Throwable; use Throwable;
@@ -210,7 +211,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} }
ray('New container name: ', $this->container_name)->green(); ray('New container name: ', $this->container_name)->green();
savePrivateKeyToFs($this->server);
$this->saved_outputs = collect(); $this->saved_outputs = collect();
// Set preview fqdn // Set preview fqdn
@@ -1442,21 +1442,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id !== 0) { if ($this->pull_request_id !== 0) {
$local_branch = "pull/{$this->pull_request_id}/head"; $local_branch = "pull/{$this->pull_request_id}/head";
} }
$private_key = data_get($this->application, 'private_key.private_key'); $private_key = $this->application->privateKey?->getKeyLocation();
if ($private_key) { if ($private_key) {
$private_key = base64_encode($private_key);
$this->execute_remote_command( $this->execute_remote_command(
[ [
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'), executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$private_key}\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
],
[
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
'hidden' => true, 'hidden' => true,
'save' => 'git_commit_sha', 'save' => 'git_commit_sha',
], ],
@@ -2211,20 +2201,40 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry('Building docker image completed.'); $this->application_deployment_queue->addLogEntry('Building docker image completed.');
} }
/** private function graceful_shutdown_container(string $containerName, int $timeout = 300)
* @param int $timeout in seconds
*/
private function graceful_shutdown_container(string $containerName, int $timeout = 30)
{ {
try { try {
$process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
$startTime = time();
while ($process->running()) {
if (time() - $startTime >= $timeout) {
$this->execute_remote_command( $this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true]
["docker rm $containerName", 'hidden' => true, 'ignore_errors' => true]
); );
} catch (\Exception $error) { break;
// report error if needed }
usleep(100000);
} }
$isRunning = $this->execute_remote_command(
["docker inspect -f '{{.State.Running}}' $containerName", 'hidden' => true, 'ignore_errors' => true]
) === 'true';
if ($isRunning) {
$this->execute_remote_command(
["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}
} catch (\Exception $error) {
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: " . $error->getMessage(), 'stderr');
}
$this->remove_container($containerName);
}
private function remove_container(string $containerName)
{
$this->execute_remote_command( $this->execute_remote_command(
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
); );

View File

@@ -21,11 +21,10 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
{ {
try { try {
ray('Cleaning up helper containers on '.$this->server->name); ray('Cleaning up helper containers on '.$this->server->name);
$containers = instant_remote_process(['docker container ps --filter "ancestor=ghcr.io/coollabsio/coolify-helper:next" --filter "ancestor=ghcr.io/coollabsio/coolify-helper:latest" --format \'{{json .}}\''], $this->server, false); $containers = instant_remote_process(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false);
$containers = format_docker_command_output_to_json($containers); $containerIds = collect(json_decode($containers))->pluck('ID');
if ($containers->count() > 0) { if ($containerIds->count() > 0) {
foreach ($containers as $container) { foreach ($containerIds as $containerId) {
$containerId = data_get($container, 'ID');
ray('Removing container '.$containerId); ray('Removing container '.$containerId);
instant_remote_process(['docker container rm -f '.$containerId], $this->server, false); instant_remote_process(['docker container rm -f '.$containerId], $this->server, false);
} }

View File

@@ -9,6 +9,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
class CleanupStaleMultiplexedConnections implements ShouldQueue class CleanupStaleMultiplexedConnections implements ShouldQueue
{ {
@@ -16,22 +18,64 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
public function handle() public function handle()
{ {
Server::chunk(100, function ($servers) { $this->cleanupStaleConnections();
foreach ($servers as $server) { $this->cleanupNonExistentServerConnections();
$this->cleanupStaleConnection($server);
}
});
} }
private function cleanupStaleConnection(Server $server) private function cleanupStaleConnections()
{ {
$muxSocket = "/tmp/mux_{$server->id}"; $muxFiles = Storage::disk('ssh-mux')->files();
$checkCommand = "ssh -O check -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null";
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
$server = Server::where('uuid', $serverUuid)->first();
if (!$server) {
$this->removeMultiplexFile($muxFile);
continue;
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null";
$checkProcess = Process::run($checkCommand); $checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) { if ($checkProcess->exitCode() !== 0) {
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; $this->removeMultiplexFile($muxFile);
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
$this->removeMultiplexFile($muxFile);
}
}
}
}
private function cleanupNonExistentServerConnections()
{
$muxFiles = Storage::disk('ssh-mux')->files();
$existingServerUuids = Server::pluck('uuid')->toArray();
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (!in_array($serverUuid, $existingServerUuids)) {
$this->removeMultiplexFile($muxFile);
}
}
}
private function extractServerUuidFromMuxFile($muxFile)
{
return substr($muxFile, 4);
}
private function removeMultiplexFile($muxFile)
{
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand); Process::run($closeCommand);
} Storage::disk('ssh-mux')->delete($muxFile);
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplication;
use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabase;
use App\Actions\Server\CleanupDocker;
use App\Actions\Service\DeleteService; use App\Actions\Service\DeleteService;
use App\Actions\Service\StopService; use App\Actions\Service\StopService;
use App\Models\Application; use App\Models\Application;
@@ -30,8 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
public function __construct( public function __construct(
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
public bool $deleteConfigurations = false, public bool $deleteConfigurations,
public bool $deleteVolumes = false) {} public bool $deleteVolumes,
public bool $dockerCleanup,
public bool $deleteConnectedNetworks
) {}
public function handle() public function handle()
{ {
@@ -51,11 +55,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
case 'standalone-dragonfly': case 'standalone-dragonfly':
case 'standalone-clickhouse': case 'standalone-clickhouse':
$persistentStorages = $this->resource?->persistentStorages()?->get(); $persistentStorages = $this->resource?->persistentStorages()?->get();
StopDatabase::run($this->resource); StopDatabase::run($this->resource, true);
break; break;
case 'service': case 'service':
StopService::run($this->resource); StopService::run($this->resource, true);
DeleteService::run($this->resource); DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks);
break; break;
} }
@@ -65,12 +69,31 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
if ($this->deleteConfigurations) { if ($this->deleteConfigurations) {
$this->resource?->delete_configurations(); $this->resource?->delete_configurations();
} }
$isDatabase = $this->resource instanceof StandalonePostgresql
|| $this->resource instanceof StandaloneRedis
|| $this->resource instanceof StandaloneMongodb
|| $this->resource instanceof StandaloneMysql
|| $this->resource instanceof StandaloneMariadb
|| $this->resource instanceof StandaloneKeydb
|| $this->resource instanceof StandaloneDragonfly
|| $this->resource instanceof StandaloneClickhouse;
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if (($this->dockerCleanup || $isDatabase) && $server) {
CleanupDocker::dispatch($server, true);
}
if ($this->deleteConnectedNetworks && ! $isDatabase) {
$this->resource?->delete_connected_networks($this->resource->uuid);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray($e->getMessage());
send_internal_notification('ContainerStoppingJob failed with: '.$e->getMessage()); send_internal_notification('ContainerStoppingJob failed with: '.$e->getMessage());
throw $e; throw $e;
} finally { } finally {
$this->resource->forceDelete(); $this->resource->forceDelete();
if ($this->dockerCleanup) {
CleanupDocker::dispatch($server, true);
}
Artisan::queue('cleanup:stucked-resources'); Artisan::queue('cleanup:stucked-resources');
} }
} }

View File

@@ -10,7 +10,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -26,16 +25,6 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
public function middleware(): array
{
return [new WithoutOverlapping($this->server->id)];
}
public function uniqueId(): int
{
return $this->server->id;
}
public function handle(): void public function handle(): void
{ {
try { try {

View File

@@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -19,17 +18,7 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
public $timeout = 1000; public $timeout = 1000;
public function middleware(): array public function __construct() {}
{
return [(new WithoutOverlapping($this->server->uuid))];
}
public function uniqueId(): string
{
return $this->server->uuid;
}
public function __construct(public Server $server) {}
public function handle(): void public function handle(): void
{ {

View File

@@ -93,7 +93,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
private function serverStatus() private function serverStatus()
{ {
['uptime' => $uptime] = $this->server->validateConnection(); ['uptime' => $uptime] = $this->server->validateConnection(false);
if ($uptime) { if ($uptime) {
if ($this->server->unreachable_notification_sent === true) { if ($this->server->unreachable_notification_sent === true) {
$this->server->update(['unreachable_notification_sent' => false]); $this->server->update(['unreachable_notification_sent' => false]);

View File

@@ -141,7 +141,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
if (! $this->createdServer) { if (! $this->createdServer) {
return $this->dispatch('error', 'Localhost server is not found. Something went wrong during installation. Please try to reinstall or contact support.'); return $this->dispatch('error', 'Localhost server is not found. Something went wrong during installation. Please try to reinstall or contact support.');
} }
$this->serverPublicKey = $this->createdServer->privateKey->publicKey(); $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
return $this->validateServer('localhost'); return $this->validateServer('localhost');
} elseif ($this->selectedServerType === 'remote') { } elseif ($this->selectedServerType === 'remote') {
@@ -175,7 +175,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
return; return;
} }
$this->selectedExistingPrivateKey = $this->createdServer->privateKey->id; $this->selectedExistingPrivateKey = $this->createdServer->privateKey->id;
$this->serverPublicKey = $this->createdServer->privateKey->publicKey(); $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails(); $this->updateServerDetails();
$this->currentState = 'validate-server'; $this->currentState = 'validate-server';
} }
@@ -231,17 +231,24 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
public function savePrivateKey() public function savePrivateKey()
{ {
$this->validate([ $this->validate([
'privateKeyName' => 'required', 'privateKeyName' => 'required|string|max:255',
'privateKey' => 'required', 'privateKeyDescription' => 'nullable|string|max:255',
'privateKey' => 'required|string',
]); ]);
$this->createdPrivateKey = PrivateKey::create([
try {
$privateKey = PrivateKey::createAndStore([
'name' => $this->privateKeyName, 'name' => $this->privateKeyName,
'description' => $this->privateKeyDescription, 'description' => $this->privateKeyDescription,
'private_key' => $this->privateKey, 'private_key' => $this->privateKey,
'team_id' => currentTeam()->id, 'team_id' => currentTeam()->id,
]); ]);
$this->createdPrivateKey->save();
$this->createdPrivateKey = $privateKey;
$this->currentState = 'create-server'; $this->currentState = 'create-server';
} catch (\Exception $e) {
$this->addError('privateKey', 'Failed to save private key: ' . $e->getMessage());
}
} }
public function saveServer() public function saveServer()

View File

@@ -30,7 +30,6 @@ class Dashboard extends Component
public function cleanup_queue() public function cleanup_queue()
{ {
$this->dispatch('success', 'Cleanup started.');
Artisan::queue('cleanup:application-deployment-queue', [ Artisan::queue('cleanup:application-deployment-queue', [
'--team-id' => currentTeam()->id, '--team-id' => currentTeam()->id,
]); ]);

View File

@@ -1,7 +1,6 @@
<?php <?php
namespace App\Livewire\Destination; namespace App\Livewire\Destination;
use Livewire\Component; use Livewire\Component;
class Form extends Component class Form extends Component
@@ -38,7 +37,7 @@ class Form extends Component
} }
$this->destination->delete(); $this->destination->delete();
return redirect()->route('dashboard'); return redirect()->route('destination.all');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View File

@@ -4,11 +4,25 @@ namespace App\Livewire;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class NavbarDeleteTeam extends Component class NavbarDeleteTeam extends Component
{ {
public function delete() public $team;
public function mount()
{ {
$this->team = currentTeam()->name;
}
public function delete($password)
{
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
$currentTeam = currentTeam(); $currentTeam = currentTeam();
$currentTeam->delete(); $currentTeam->delete();

View File

@@ -21,6 +21,8 @@ class Heading extends Component
protected string $deploymentUuid; protected string $deploymentUuid;
public bool $docker_cleanup = true;
public function getListeners() public function getListeners()
{ {
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
@@ -102,7 +104,7 @@ class Heading extends Component
public function stop() public function stop()
{ {
StopApplication::run($this->application); StopApplication::run($this->application, false, $this->docker_cleanup);
$this->application->status = 'exited'; $this->application->status = 'exited';
$this->application->save(); $this->application->save();
if ($this->application->additional_servers->count() > 0) { if ($this->application->additional_servers->count() > 0) {
@@ -135,4 +137,13 @@ class Heading extends Component
'environment_name' => $this->parameters['environment_name'], 'environment_name' => $this->parameters['environment_name'],
]); ]);
} }
public function render()
{
return view('livewire.project.application.heading', [
'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
],
]);
}
} }

View File

@@ -9,6 +9,8 @@ use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
use Illuminate\Process\InvokedProcess;
use Illuminate\Support\Facades\Process;
class Previews extends Component class Previews extends Component
{ {
@@ -184,17 +186,20 @@ class Previews extends Component
public function stop(int $pull_request_id) public function stop(int $pull_request_id)
{ {
try { try {
$server = $this->application->destination->server;
$timeout = 300;
if ($this->application->destination->server->isSwarm()) { if ($this->application->destination->server->isSwarm()) {
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server);
} else { } else {
$containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray();
foreach ($containers as $container) { $this->stopContainers($containers, $server, $timeout);
$name = str_replace('/', '', $container['Names']);
instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false);
} }
}
GetContainersStatus::dispatchSync($this->application->destination->server)->onQueue('high'); GetContainersStatus::run($server);
$this->dispatch('reloadWindow'); $this->application->refresh();
$this->dispatch('containerStatusUpdated');
$this->dispatch('success', 'Preview Deployment stopped.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@@ -203,16 +208,21 @@ class Previews extends Component
public function delete(int $pull_request_id) public function delete(int $pull_request_id)
{ {
try { try {
$server = $this->application->destination->server;
$timeout = 300;
if ($this->application->destination->server->isSwarm()) { if ($this->application->destination->server->isSwarm()) {
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server);
} else { } else {
$containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray();
foreach ($containers as $container) { $this->stopContainers($containers, $server, $timeout);
$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(); ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->first()
->delete();
$this->application->refresh(); $this->application->refresh();
$this->dispatch('update_links'); $this->dispatch('update_links');
$this->dispatch('success', 'Preview deleted.'); $this->dispatch('success', 'Preview deleted.');
@@ -220,4 +230,49 @@ class Previews extends Component
return handleError($e, $this); return handleError($e, $this);
} }
} }
private function stopContainers(array $containers, $server, int $timeout)
{
$processes = [];
foreach ($containers as $container) {
$containerName = str_replace('/', '', $container['Names']);
$processes[$containerName] = $this->stopContainer($containerName, $timeout);
}
$startTime = time();
while (count($processes) > 0) {
$finishedProcesses = array_filter($processes, function ($process) {
return !$process->running();
});
foreach (array_keys($finishedProcesses) as $containerName) {
unset($processes[$containerName]);
$this->removeContainer($containerName, $server);
}
if (time() - $startTime >= $timeout) {
$this->forceStopRemainingContainers(array_keys($processes), $server);
break;
}
usleep(100000);
}
}
private function stopContainer(string $containerName, int $timeout): InvokedProcess
{
return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
}
private function removeContainer(string $containerName, $server)
{
instant_remote_process(["docker rm -f $containerName"], $server, throwError: false);
}
private function forceStopRemainingContainers(array $containerNames, $server)
{
foreach ($containerNames as $containerName) {
instant_remote_process(["docker kill $containerName"], $server, throwError: false);
$this->removeContainer($containerName, $server);
}
}
} }

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
class BackupEdit extends Component class BackupEdit extends Component
{ {
@@ -12,6 +14,10 @@ class BackupEdit extends Component
public $s3s; public $s3s;
public bool $delete_associated_backups_locally = false;
public bool $delete_associated_backups_s3 = false;
public bool $delete_associated_backups_sftp = false;
public ?string $status = null; public ?string $status = null;
public array $parameters; public array $parameters;
@@ -46,10 +52,23 @@ class BackupEdit extends Component
} }
} }
public function delete() public function delete($password)
{ {
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
try { try {
if ($this->delete_associated_backups_locally) {
$this->deleteAssociatedBackupsLocally();
}
if ($this->delete_associated_backups_s3) {
$this->deleteAssociatedBackupsS3();
}
$this->backup->delete(); $this->backup->delete();
if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') {
$previousUrl = url()->previous(); $previousUrl = url()->previous();
$url = Url::fromString($previousUrl); $url = Url::fromString($previousUrl);
@@ -104,4 +123,66 @@ class BackupEdit extends Component
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
} }
} }
public function deleteAssociatedBackupsLocally()
{
$executions = $this->backup->executions;
$backupFolder = null;
foreach ($executions as $execution) {
if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') {
$server = $this->backup->database->service->destination->server;
} else {
$server = $this->backup->database->destination->server;
}
if (!$backupFolder) {
$backupFolder = dirname($execution->filename);
}
delete_backup_locally($execution->filename, $server);
$execution->delete();
}
if ($backupFolder) {
$this->deleteEmptyBackupFolder($backupFolder, $server);
}
}
public function deleteAssociatedBackupsS3()
{
//Add function to delete backups from S3
}
public function deleteAssociatedBackupsSftp()
{
//Add function to delete backups from SFTP
}
private function deleteEmptyBackupFolder($folderPath, $server)
{
$checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server);
if (trim($checkEmpty) === 'empty') {
instant_remote_process(["rmdir '$folderPath'"], $server);
$parentFolder = dirname($folderPath);
$checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server);
if (trim($checkParentEmpty) === 'empty') {
instant_remote_process(["rmdir '$parentFolder'"], $server);
}
}
}
public function render()
{
return view('livewire.project.database.backup-edit', [
'checkboxes' => [
['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from local storage.'],
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.']
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.']
]
]);
}
} }

View File

@@ -4,6 +4,9 @@ namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
class BackupExecutions extends Component class BackupExecutions extends Component
{ {
@@ -12,9 +15,12 @@ class BackupExecutions extends Component
public $executions = []; public $executions = [];
public $setDeletableBackup; public $setDeletableBackup;
public $delete_backup_s3 = true;
public $delete_backup_sftp = true;
public function getListeners() public function getListeners()
{ {
$userId = auth()->user()->id; $userId = Auth::id();
return [ return [
"echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions',
@@ -31,19 +37,34 @@ class BackupExecutions extends Component
} }
} }
public function deleteBackup($exeuctionId) #[On('deleteBackup')]
public function deleteBackup($executionId, $password)
{ {
$execution = $this->backup->executions()->where('id', $exeuctionId)->first(); if (!Hash::check($password, Auth::user()->password)) {
if (is_null($execution)) { $this->addError('password', 'The provided password is incorrect.');
$this->dispatch('error', 'Backup execution not found.');
return; return;
} }
$execution = $this->backup->executions()->where('id', $executionId)->first();
if (is_null($execution)) {
$this->dispatch('error', 'Backup execution not found.');
return;
}
if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') { if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') {
delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server); delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server);
} else { } else {
delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server); delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server);
} }
if ($this->delete_backup_s3) {
// Add logic to delete from S3
}
if ($this->delete_backup_sftp) {
// Add logic to delete from SFTP
}
$execution->delete(); $execution->delete();
$this->dispatch('success', 'Backup deleted.'); $this->dispatch('success', 'Backup deleted.');
$this->refreshBackupExecutions(); $this->refreshBackupExecutions();
@@ -106,4 +127,14 @@ class BackupExecutions extends Component
} }
return $dateObj->format('Y-m-d H:i:s T'); return $dateObj->format('Y-m-d H:i:s T');
} }
public function render()
{
return view('livewire.project.database.backup-executions', [
'checkboxes' => [
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'],
['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'],
]
]);
}
} }

View File

@@ -14,6 +14,8 @@ class Heading extends Component
public array $parameters; public array $parameters;
public $docker_cleanup = true;
public function getListeners() public function getListeners()
{ {
$userId = auth()->user()->id; $userId = auth()->user()->id;
@@ -54,7 +56,7 @@ class Heading extends Component
public function stop() public function stop()
{ {
StopDatabase::run($this->database); StopDatabase::run($this->database, false, $this->docker_cleanup);
$this->database->status = 'exited'; $this->database->status = 'exited';
$this->database->save(); $this->database->save();
$this->check_status(); $this->check_status();
@@ -71,4 +73,13 @@ class Heading extends Component
$activity = StartDatabase::run($this->database); $activity = StartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} }
public function render()
{
return view('livewire.project.database.heading', [
'checkboxes' => [
['id' => 'docker_cleanup', 'label' => 'Cleanup docker build cache and unused images (next deployment could take longer).'],
],
]);
}
} }

View File

@@ -13,9 +13,12 @@ class DeleteEnvironment extends Component
public bool $disabled = false; public bool $disabled = false;
public string $environmentName = '';
public function mount() public function mount()
{ {
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->environmentName = Environment::findOrFail($this->environment_id)->name;
} }
public function delete() public function delete()

View File

@@ -13,9 +13,12 @@ class DeleteProject extends Component
public bool $disabled = false; public bool $disabled = false;
public string $projectName = '';
public function mount() public function mount()
{ {
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->projectName = Project::findOrFail($this->project_id)->name;
} }
public function delete() public function delete()

View File

@@ -52,7 +52,7 @@ class Configuration extends Component
$application = $this->service->applications->find($id); $application = $this->service->applications->find($id);
if ($application) { if ($application) {
$application->restart(); $application->restart();
$this->dispatch('success', 'Application restarted successfully.'); $this->dispatch('success', 'Service application restarted successfully.');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -65,7 +65,7 @@ class Configuration extends Component
$database = $this->service->databases->find($id); $database = $this->service->databases->find($id);
if ($database) { if ($database) {
$database->restart(); $database->restart();
$this->dispatch('success', 'Database restarted successfully.'); $this->dispatch('success', 'Service database restarted successfully.');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);

View File

@@ -15,6 +15,8 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
class FileStorage extends Component class FileStorage extends Component
{ {
@@ -83,8 +85,13 @@ class FileStorage extends Component
} }
} }
public function delete() public function delete($password)
{ {
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
try { try {
$message = 'File deleted.'; $message = 'File deleted.';
if ($this->fileStorage->is_directory) { if ($this->fileStorage->is_directory) {
@@ -129,6 +136,13 @@ class FileStorage extends Component
public function render() public function render()
{ {
return view('livewire.project.service.file-storage'); return view('livewire.project.service.file-storage', [
'directoryDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'],
],
'fileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
]
]);
} }
} }

View File

@@ -20,6 +20,8 @@ class Navbar extends Component
public $isDeploymentProgress = false; public $isDeploymentProgress = false;
public $docker_cleanup = true;
public $title = 'Configuration'; public $title = 'Configuration';
public function mount() public function mount()
@@ -42,7 +44,7 @@ class Navbar extends Component
public function serviceStarted() public function serviceStarted()
{ {
$this->dispatch('success', 'Service status changed.'); // $this->dispatch('success', 'Service status changed.');
if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) {
$this->service->isConfigurationChanged(true); $this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
@@ -62,11 +64,6 @@ class Navbar extends Component
$this->dispatch('success', 'Service status updated.'); $this->dispatch('success', 'Service status updated.');
} }
public function render()
{
return view('livewire.project.service.navbar');
}
public function checkDeployments() public function checkDeployments()
{ {
try { try {
@@ -97,14 +94,9 @@ class Navbar extends Component
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} }
public function stop(bool $forceCleanup = false) public function stop()
{ {
StopService::run($this->service); StopService::run($this->service, false, $this->docker_cleanup);
if ($forceCleanup) {
$this->dispatch('success', 'Containers cleaned up.');
} else {
$this->dispatch('success', 'Service stopped.');
}
ServiceStatusChanged::dispatch(); ServiceStatusChanged::dispatch();
} }
@@ -123,4 +115,13 @@ class Navbar extends Component
$activity = StartService::run($this->service); $activity = StartService::run($this->service);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} }
public function render()
{
return view('livewire.project.service.navbar', [
'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
],
]);
}
} }

View File

@@ -3,6 +3,8 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component; use Livewire\Component;
class ServiceApplicationView extends Component class ServiceApplicationView extends Component
@@ -11,6 +13,10 @@ class ServiceApplicationView extends Component
public $parameters; public $parameters;
public $docker_cleanup = true;
public $delete_volumes = true;
protected $rules = [ protected $rules = [
'application.human_name' => 'nullable', 'application.human_name' => 'nullable',
'application.description' => 'nullable', 'application.description' => 'nullable',
@@ -23,11 +29,6 @@ class ServiceApplicationView extends Component
'application.is_stripprefix_enabled' => 'nullable|boolean', 'application.is_stripprefix_enabled' => 'nullable|boolean',
]; ];
public function render()
{
return view('livewire.project.service.service-application-view');
}
public function updatedApplicationFqdn() public function updatedApplicationFqdn()
{ {
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
@@ -56,8 +57,14 @@ class ServiceApplicationView extends Component
$this->dispatch('success', 'You need to restart the service for the changes to take effect.'); $this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} }
public function delete() public function delete($password)
{ {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
try { try {
$this->application->delete(); $this->application->delete();
$this->dispatch('success', 'Application deleted.'); $this->dispatch('success', 'Application deleted.');
@@ -91,4 +98,17 @@ class ServiceApplicationView extends Component
$this->dispatch('generateDockerCompose'); $this->dispatch('generateDockerCompose');
} }
} }
public function render()
{
return view('livewire.project.service.service-application-view', [
'checkboxes' => [
['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')],
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
// ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'],
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'],
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.']
],
]);
}
} }

View File

@@ -3,6 +3,11 @@
namespace App\Livewire\Project\Shared; namespace App\Livewire\Project\Shared;
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -10,6 +15,8 @@ class Danger extends Component
{ {
public $resource; public $resource;
public $resourceName;
public $projectUuid; public $projectUuid;
public $environmentName; public $environmentName;
@@ -18,22 +25,93 @@ class Danger extends Component
public bool $delete_volumes = true; public bool $delete_volumes = true;
public bool $docker_cleanup = true;
public bool $delete_connected_networks = true;
public ?string $modalId = null; public ?string $modalId = null;
public string $resourceDomain = '';
public function mount() public function mount()
{ {
$this->modalId = new Cuid2;
$parameters = get_route_parameters(); $parameters = get_route_parameters();
$this->modalId = new Cuid2;
$this->projectUuid = data_get($parameters, 'project_uuid'); $this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentName = data_get($parameters, 'environment_name'); $this->environmentName = data_get($parameters, 'environment_name');
if ($this->resource === null) {
if (isset($parameters['service_uuid'])) {
$this->resource = Service::where('uuid', $parameters['service_uuid'])->first();
} elseif (isset($parameters['stack_service_uuid'])) {
$this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first()
?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first();
}
} }
public function delete() if ($this->resource === null) {
$this->resourceName = 'Unknown Resource';
return;
}
if (! method_exists($this->resource, 'type')) {
$this->resourceName = 'Unknown Resource';
return;
}
switch ($this->resource->type()) {
case 'application':
$this->resourceName = $this->resource->name ?? 'Application';
break;
case 'standalone-postgresql':
case 'standalone-redis':
case 'standalone-mongodb':
case 'standalone-mysql':
case 'standalone-mariadb':
case 'standalone-keydb':
case 'standalone-dragonfly':
case 'standalone-clickhouse':
$this->resourceName = $this->resource->name ?? 'Database';
break;
case 'service':
$this->resourceName = $this->resource->name ?? 'Service';
break;
case 'service-application':
$this->resourceName = $this->resource->name ?? 'Service Application';
break;
case 'service-database':
$this->resourceName = $this->resource->name ?? 'Service Database';
break;
default:
$this->resourceName = 'Unknown Resource';
}
}
public function delete($password)
{ {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! $this->resource) {
$this->addError('resource', 'Resource not found.');
return;
}
try { try {
// $this->authorize('delete', $this->resource);
$this->resource->delete(); $this->resource->delete();
DeleteResourceJob::dispatch($this->resource, $this->delete_configurations, $this->delete_volumes); DeleteResourceJob::dispatch(
$this->resource,
$this->delete_configurations,
$this->delete_volumes,
$this->docker_cleanup,
$this->delete_connected_networks
);
return redirect()->route('project.resource.index', [ return redirect()->route('project.resource.index', [
'project_uuid' => $this->projectUuid, 'project_uuid' => $this->projectUuid,
@@ -43,4 +121,19 @@ class Danger extends Component
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function render()
{
return view('livewire.project.shared.danger', [
'checkboxes' => [
['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')],
['id' => 'delete_connected_networks', 'label' => __('resource.delete_connected_networks')],
['id' => 'delete_configurations', 'label' => __('resource.delete_configurations')],
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
// ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'],
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'],
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.']
],
]);
}
} }

View File

@@ -10,6 +10,8 @@ use App\Models\Server;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
class Destination extends Component class Destination extends Component
{ {
@@ -115,8 +117,13 @@ class Destination extends Component
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
} }
public function removeServer(int $network_id, int $server_id) public function removeServer(int $network_id, int $server_id, $password)
{ {
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
$this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); $this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.');

View File

@@ -17,6 +17,7 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Livewire\Component; use Livewire\Component;
use App\Helpers\SshMultiplexingHelper;
class GetLogs extends Component class GetLogs extends Component
{ {
@@ -108,14 +109,14 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else { } else {
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; $command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
if ($this->server->isNonRoot()) { if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} else { } else {
if ($this->server->isSwarm()) { if ($this->server->isSwarm()) {
@@ -124,14 +125,14 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else { } else {
$command = "docker logs -n {$this->numberOfLines} {$this->container}"; $command = "docker logs -n {$this->numberOfLines} {$this->container}";
if ($this->server->isNonRoot()) { if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} }
if ($refresh) { if ($refresh) {

View File

@@ -20,6 +20,8 @@ class Show extends Component
public string $type; public string $type;
public string $scheduledTaskName;
protected $rules = [ protected $rules = [
'task.enabled' => 'required|boolean', 'task.enabled' => 'required|boolean',
'task.name' => 'required|string', 'task.name' => 'required|string',
@@ -49,6 +51,7 @@ class Show extends Component
$this->modalId = new Cuid2; $this->modalId = new Cuid2;
$this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first();
$this->scheduledTaskName = $this->task->name;
} }
public function instantSave() public function instantSave()
@@ -75,9 +78,9 @@ class Show extends Component
$this->task->delete(); $this->task->delete();
if ($this->type == 'application') { if ($this->type == 'application') {
return redirect()->route('project.application.configuration', $this->parameters); return redirect()->route('project.application.configuration', $this->parameters, $this->scheduledTaskName);
} else { } else {
return redirect()->route('project.service.configuration', $this->parameters); return redirect()->route('project.service.configuration', $this->parameters, $this->scheduledTaskName);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e); return handleError($e);

View File

@@ -3,6 +3,8 @@
namespace App\Livewire\Project\Shared\Storages; namespace App\Livewire\Project\Shared\Storages;
use App\Models\LocalPersistentVolume; use App\Models\LocalPersistentVolume;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class Show extends Component class Show extends Component
@@ -36,8 +38,13 @@ class Show extends Component
$this->dispatch('success', 'Storage updated successfully'); $this->dispatch('success', 'Storage updated successfully');
} }
public function delete() public function delete($password)
{ {
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
$this->storage->delete(); $this->storage->delete();
$this->dispatch('refreshStorages'); $this->dispatch('refreshStorages');
} }

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared; namespace App\Livewire\Project\Shared;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server; use App\Models\Server;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
@@ -19,9 +20,9 @@ class Terminal extends Component
if ($status !== 'running') { if ($status !== 'running') {
return; return;
} }
$command = generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'");
} else { } else {
$command = generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); $command = SshMultiplexingHelper::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 // ssh command is sent back to frontend then to websocket

View File

@@ -3,22 +3,14 @@
namespace App\Livewire\Security\PrivateKey; namespace App\Livewire\Security\PrivateKey;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Livewire\Component; use Livewire\Component;
use phpseclib3\Crypt\PublicKeyLoader;
class Create extends Component class Create extends Component
{ {
use WithRateLimiting; public string $name = '';
public string $value = '';
public string $name;
public string $value;
public ?string $from = null; public ?string $from = null;
public ?string $description = null; public ?string $description = null;
public ?string $publicKey = null; public ?string $publicKey = null;
protected $rules = [ protected $rules = [
@@ -26,72 +18,69 @@ class Create extends Component
'value' => 'required|string', 'value' => 'required|string',
]; ];
protected $validationAttributes = [
'name' => 'name',
'value' => 'private Key',
];
public function generateNewRSAKey() public function generateNewRSAKey()
{ {
try { $this->generateNewKey('rsa');
$this->rateLimit(10);
$this->name = generate_random_name();
$this->description = 'Created by Coolify';
['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey();
} catch (\Throwable $e) {
return handleError($e, $this);
}
} }
public function generateNewEDKey() public function generateNewEDKey()
{ {
try { $this->generateNewKey('ed25519');
$this->rateLimit(10);
$this->name = generate_random_name();
$this->description = 'Created by Coolify';
['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey('ed25519');
} catch (\Throwable $e) {
return handleError($e, $this);
}
} }
public function updated($updateProperty) private function generateNewKey($type)
{ {
if ($updateProperty === 'value') { $keyData = PrivateKey::generateNewKeyPair($type);
try { $this->setKeyData($keyData);
$this->publicKey = PublicKeyLoader::load($this->$updateProperty)->getPublicKey()->toString('OpenSSH', ['comment' => '']);
} catch (\Throwable $e) {
if ($this->$updateProperty === '') {
$this->publicKey = '';
} else {
$this->publicKey = 'Invalid private key';
} }
public function updated($property)
{
if ($property === 'value') {
$this->validatePrivateKey();
} }
} }
$this->validateOnly($updateProperty);
}
public function createPrivateKey() public function createPrivateKey()
{ {
$this->validate(); $this->validate();
try { try {
$this->value = trim($this->value); $privateKey = PrivateKey::createAndStore([
if (! str_ends_with($this->value, "\n")) {
$this->value .= "\n";
}
$private_key = PrivateKey::create([
'name' => $this->name, 'name' => $this->name,
'description' => $this->description, 'description' => $this->description,
'private_key' => $this->value, 'private_key' => trim($this->value) . "\n",
'team_id' => currentTeam()->id, 'team_id' => currentTeam()->id,
]); ]);
if ($this->from === 'server') {
return redirect()->route('dashboard');
}
return redirect()->route('security.private-key.show', ['private_key_uuid' => $private_key->uuid]); return $this->redirectAfterCreation($privateKey);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
} }
private function setKeyData(array $keyData)
{
$this->name = $keyData['name'];
$this->description = $keyData['description'];
$this->value = $keyData['private_key'];
$this->publicKey = $keyData['public_key'];
}
private function validatePrivateKey()
{
$validationResult = PrivateKey::validateAndExtractPublicKey($this->value);
$this->publicKey = $validationResult['publicKey'];
if (!$validationResult['isValid']) {
$this->addError('value', 'Invalid private key');
}
}
private function redirectAfterCreation(PrivateKey $privateKey)
{
return $this->from === 'server'
? redirect()->route('dashboard')
: redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
}
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Security\PrivateKey;
use Livewire\Component;
use App\Models\PrivateKey;
class Index extends Component
{
public function render()
{
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description'])->get();
return view('livewire.security.private-key.index', [
'privateKeys' => $privateKeys,
])->layout('components.layout');
}
public function cleanupUnusedKeys()
{
PrivateKey::cleanupUnusedKeys();
$this->dispatch('success', 'Unused keys have been cleaned up.');
}
}

View File

@@ -29,25 +29,27 @@ class Show extends Component
try { try {
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail(); $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); abort(404);
} }
} }
public function loadPublicKey() public function loadPublicKey()
{ {
$this->public_key = $this->private_key->publicKey(); $this->public_key = $this->private_key->getPublicKey();
if ($this->public_key === 'Error loading private key') {
$this->dispatch('error', 'Failed to load public key. The private key may be invalid.');
}
} }
public function delete() public function delete()
{ {
try { try {
if ($this->private_key->isEmpty()) { $this->private_key->safeDelete();
$this->private_key->delete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirect()->route('security.private-key.index'); return redirect()->route('security.private-key.index');
} } catch (\Exception $e) {
$this->dispatch('error', 'This private key is in use and cannot be deleted. Please delete all servers, applications, and GitHub/GitLab apps that use this private key before deleting it.'); $this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@@ -56,8 +58,9 @@ class Show extends Component
public function changePrivateKey() public function changePrivateKey()
{ {
try { try {
$this->private_key->private_key = formatPrivateKey($this->private_key->private_key); $this->private_key->updatePrivateKey([
$this->private_key->save(); 'private_key' => formatPrivateKey($this->private_key->private_key),
]);
refresh_server_connection($this->private_key); refresh_server_connection($this->private_key);
$this->dispatch('success', 'Private key updated.'); $this->dispatch('success', 'Private key updated.');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -4,6 +4,8 @@ namespace App\Livewire\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
class Delete extends Component class Delete extends Component
{ {
@@ -11,8 +13,12 @@ class Delete extends Component
public $server; public $server;
public function delete() public function delete($password)
{ {
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
try { try {
$this->authorize('delete', $this->server); $this->authorize('delete', $this->server);
if ($this->server->hasDefinedResources()) { if ($this->server->hasDefinedResources()) {

View File

@@ -39,6 +39,7 @@ class Proxy extends Component
{ {
$this->server->proxy = null; $this->server->proxy = null;
$this->server->save(); $this->server->save();
$this->dispatch('proxyChanged');
} }
public function selectProxy($proxy_type) public function selectProxy($proxy_type)
@@ -47,7 +48,7 @@ class Proxy extends Component
$this->server->proxy->set('type', $proxy_type); $this->server->proxy->set('type', $proxy_type);
$this->server->save(); $this->server->save();
$this->selectedProxy = $this->server->proxy->type; $this->selectedProxy = $this->server->proxy->type;
if ($this->selectedProxy !== 'NONE') { if ($this->server->proxySet()) {
StartProxy::run($this->server, false); StartProxy::run($this->server, false);
} }
$this->dispatch('proxyStatusUpdated'); $this->dispatch('proxyStatusUpdated');

View File

@@ -7,6 +7,8 @@ use App\Actions\Proxy\StartProxy;
use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChanged;
use App\Models\Server; use App\Models\Server;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Process;
use Illuminate\Process\InvokedProcess;
class Deploy extends Component class Deploy extends Component
{ {
@@ -29,6 +31,7 @@ class Deploy extends Component
'serverRefresh' => 'proxyStatusUpdated', 'serverRefresh' => 'proxyStatusUpdated',
'checkProxy', 'checkProxy',
'startProxy', 'startProxy',
'proxyChanged' => 'proxyStatusUpdated',
]; ];
} }
@@ -94,21 +97,43 @@ class Deploy extends Component
public function stop(bool $forceStop = true) public function stop(bool $forceStop = true)
{ {
try { try {
if ($this->server->isSwarm()) { $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
instant_remote_process([ $timeout = 30;
'docker service rm coolify-proxy_traefik',
], $this->server); $process = $this->stopContainer($containerName, $timeout);
} else {
instant_remote_process([ $startTime = time();
'docker rm -f coolify-proxy', while ($process->running()) {
], $this->server); if (time() - $startTime >= $timeout) {
$this->forceStopContainer($containerName);
break;
} }
$this->server->proxy->status = 'exited'; usleep(100000);
$this->server->proxy->force_stop = $forceStop; }
$this->server->save();
$this->dispatch('proxyStatusUpdated'); $this->removeContainer($containerName);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} finally {
$this->server->proxy->force_stop = $forceStop;
$this->server->proxy->status = 'exited';
$this->server->save();
$this->dispatch('proxyStatusUpdated');
} }
} }
private function stopContainer(string $containerName, int $timeout): InvokedProcess
{
return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
}
private function forceStopContainer(string $containerName)
{
instant_remote_process(["docker kill $containerName"], $this->server, throwError: false);
}
private function removeContainer(string $containerName)
{
instant_remote_process(["docker rm -f $containerName"], $this->server, throwError: false);
}
} }

View File

@@ -11,7 +11,7 @@ class Show extends Component
public $parameters = []; public $parameters = [];
protected $listeners = ['proxyStatusUpdated']; protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated'];
public function proxyStatusUpdated() public function proxyStatusUpdated()
{ {

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use Livewire\Component; use Livewire\Component;
@@ -13,25 +14,15 @@ class ShowPrivateKey extends Component
public $parameters; public $parameters;
public function setPrivateKey($newPrivateKeyId) public function setPrivateKey($privateKeyId)
{ {
try { try {
$oldPrivateKeyId = $this->server->private_key_id; $privateKey = PrivateKey::findOrFail($privateKeyId);
refresh_server_connection($this->server->privateKey); $this->server->update(['private_key_id' => $privateKey->id]);
$this->server->update([
'private_key_id' => $newPrivateKeyId,
]);
$this->server->refresh(); $this->server->refresh();
refresh_server_connection($this->server->privateKey); $this->dispatch('success', 'Private key updated successfully.');
$this->checkConnection(); } catch (\Exception $e) {
} catch (\Throwable $e) { $this->dispatch('error', 'Failed to update private key: '.$e->getMessage());
$this->server->update([
'private_key_id' => $oldPrivateKeyId,
]);
$this->server->refresh();
refresh_server_connection($this->server->privateKey);
return handleError($e, $this);
} }
} }
@@ -43,7 +34,7 @@ class ShowPrivateKey extends Component
$this->dispatch('success', 'Server is reachable.'); $this->dispatch('success', 'Server is reachable.');
} else { } else {
ray($error); ray($error);
$this->dispatch('error', 'Server is not reachable.<br>Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.'); $this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
return; return;
} }

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Team;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Livewire\Component; use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AdminView extends Component class AdminView extends Component
{ {
@@ -73,8 +75,12 @@ class AdminView extends Component
$team->delete(); $team->delete();
} }
public function delete($id) public function delete($id, $password)
{ {
if (!Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! auth()->user()->isInstanceAdmin()) { if (! auth()->user()->isInstanceAdmin()) {
return $this->dispatch('error', 'You are not authorized to delete users'); return $this->dispatch('error', 'You are not authorized to delete users');
} }

View File

@@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Process;
use Illuminate\Process\InvokedProcess;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use RuntimeException; use RuntimeException;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
@@ -149,12 +151,64 @@ class Application extends BaseModel
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
} }
public function getContainersToStop(bool $previewDeployments = false): array
{
$containers = $previewDeployments
? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true)
: getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0);
return $containers->pluck('Names')->toArray();
}
public function stopContainers(array $containerNames, $server, int $timeout = 600)
{
$processes = [];
foreach ($containerNames as $containerName) {
$processes[$containerName] = $this->stopContainer($containerName, $server, $timeout);
}
$startTime = time();
while (count($processes) > 0) {
$finishedProcesses = array_filter($processes, function ($process) {
return !$process->running();
});
foreach ($finishedProcesses as $containerName => $process) {
unset($processes[$containerName]);
$this->removeContainer($containerName, $server);
}
if (time() - $startTime >= $timeout) {
$this->forceStopRemainingContainers(array_keys($processes), $server);
break;
}
usleep(100000);
}
}
public function stopContainer(string $containerName, $server, int $timeout): InvokedProcess
{
return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
}
public function removeContainer(string $containerName, $server)
{
instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false);
}
public function forceStopRemainingContainers(array $containerNames, $server)
{
foreach ($containerNames as $containerName) {
instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false);
$this->removeContainer($containerName, $server);
}
}
public function delete_configurations() public function delete_configurations()
{ {
$server = data_get($this, 'destination.server'); $server = data_get($this, 'destination.server');
$workdir = $this->workdir(); $workdir = $this->workdir();
if (str($workdir)->endsWith($this->uuid)) { if (str($workdir)->endsWith($this->uuid)) {
ray('Deleting workdir');
instant_remote_process(['rm -rf ' . $this->workdir()], $server, false); instant_remote_process(['rm -rf ' . $this->workdir()], $server, false);
} }
} }
@@ -176,6 +230,14 @@ class Application extends BaseModel
} }
} }
public function delete_connected_networks($uuid)
{
$server = data_get($this, 'destination.server');
instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$uuid}"], $server, false);
}
public function additional_servers() public function additional_servers()
{ {
return $this->belongsToMany(Server::class, 'additional_destinations') return $this->belongsToMany(Server::class, 'additional_destinations')
@@ -1166,7 +1228,6 @@ class Application extends BaseModel
} else { } else {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name."); throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
} }
} }
public function parseContainerLabels(?ApplicationPreview $preview = null) public function parseContainerLabels(?ApplicationPreview $preview = null)

View File

@@ -2,6 +2,9 @@
namespace App\Models; namespace App\Models;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
@@ -22,48 +25,144 @@ use phpseclib3\Crypt\PublicKeyLoader;
)] )]
class PrivateKey extends BaseModel class PrivateKey extends BaseModel
{ {
use WithRateLimiting;
protected $fillable = [ protected $fillable = [
'name', 'name',
'description', 'description',
'private_key', 'private_key',
'is_git_related', 'is_git_related',
'team_id', 'team_id',
'fingerprint',
];
protected $casts = [
'private_key' => 'encrypted',
]; ];
protected static function booted() protected static function booted()
{ {
static::saving(function ($key) { static::saving(function ($key) {
$privateKey = data_get($key, 'private_key'); $key->private_key = formatPrivateKey($key->private_key);
if (substr($privateKey, -1) !== "\n") {
$key->private_key = $privateKey."\n"; if (! self::validatePrivateKey($key->private_key)) {
throw ValidationException::withMessages([
'private_key' => ['The private key is invalid.'],
]);
}
$key->fingerprint = self::generateFingerprint($key->private_key);
if (self::fingerprintExists($key->fingerprint, $key->id)) {
throw ValidationException::withMessages([
'private_key' => ['This private key already exists.'],
]);
} }
}); });
static::deleted(function ($key) {
self::deleteFromStorage($key);
});
}
public function getPublicKey()
{
return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key';
} }
public static function ownedByCurrentTeam(array $select = ['*']) public static function ownedByCurrentTeam(array $select = ['*'])
{ {
$selectArray = collect($select)->concat(['id']); $selectArray = collect($select)->concat(['id']);
return PrivateKey::whereTeamId(currentTeam()->id)->select($selectArray->all()); return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
} }
public function publicKey() public static function validatePrivateKey($privateKey)
{ {
try { try {
return PublicKeyLoader::load($this->private_key)->getPublicKey()->toString('OpenSSH', ['comment' => '']); PublicKeyLoader::load($privateKey);
} catch (\Throwable $e) {
return 'Error loading private key';
}
}
public function isEmpty()
{
if ($this->servers()->count() === 0 && $this->applications()->count() === 0 && $this->githubApps()->count() === 0 && $this->gitlabApps()->count() === 0) {
return true; return true;
} catch (\Throwable $e) {
return false;
}
} }
return false; public static function createAndStore(array $data)
{
$privateKey = new self($data);
$privateKey->save();
$privateKey->storeInFileSystem();
return $privateKey;
}
public static function generateNewKeyPair($type = 'rsa')
{
try {
$instance = new self;
$instance->rateLimit(10);
$name = generate_random_name();
$description = 'Created by Coolify';
$keyPair = generateSSHKey($type === 'ed25519' ? 'ed25519' : 'rsa');
return [
'name' => $name,
'description' => $description,
'private_key' => $keyPair['private'],
'public_key' => $keyPair['public'],
];
} catch (\Throwable $e) {
throw new \Exception("Failed to generate new {$type} key: ".$e->getMessage());
}
}
public static function extractPublicKeyFromPrivate($privateKey)
{
try {
$key = PublicKeyLoader::load($privateKey);
return $key->getPublicKey()->toString('OpenSSH', ['comment' => '']);
} catch (\Throwable $e) {
return null;
}
}
public static function validateAndExtractPublicKey($privateKey)
{
$isValid = self::validatePrivateKey($privateKey);
$publicKey = $isValid ? self::extractPublicKeyFromPrivate($privateKey) : '';
return [
'isValid' => $isValid,
'publicKey' => $publicKey,
];
}
public function storeInFileSystem()
{
$filename = "ssh_key@{$this->uuid}";
Storage::disk('ssh-keys')->put($filename, $this->private_key);
return "/var/www/html/storage/app/ssh/keys/{$filename}";
}
public static function deleteFromStorage(self $privateKey)
{
$filename = "ssh_key@{$privateKey->uuid}";
Storage::disk('ssh-keys')->delete($filename);
}
public function getKeyLocation()
{
return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}";
}
public function updatePrivateKey(array $data)
{
$this->update($data);
$this->storeInFileSystem();
return $this;
} }
public function servers() public function servers()
@@ -85,4 +184,53 @@ class PrivateKey extends BaseModel
{ {
return $this->hasMany(GitlabApp::class); return $this->hasMany(GitlabApp::class);
} }
public function isInUse()
{
return $this->servers()->exists()
|| $this->applications()->exists()
|| $this->githubApps()->exists()
|| $this->gitlabApps()->exists();
}
public function safeDelete()
{
if (! $this->isInUse()) {
$this->delete();
return true;
}
return false;
}
public static function generateFingerprint($privateKey)
{
try {
$key = PublicKeyLoader::load($privateKey);
$publicKey = $key->getPublicKey();
return $publicKey->getFingerprint('sha256');
} catch (\Throwable $e) {
return null;
}
}
private static function fingerprintExists($fingerprint, $excludeId = null)
{
$query = self::where('fingerprint', $fingerprint);
if (! is_null($excludeId)) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
public static function cleanupUnusedKeys()
{
self::ownedByCurrentTeam()->each(function ($privateKey) {
$privateKey->safeDelete();
});
}
} }

View File

@@ -5,7 +5,6 @@ namespace App\Models;
use App\Actions\Server\InstallDocker; use App\Actions\Server\InstallDocker;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Jobs\PullSentinelImageJob; use App\Jobs\PullSentinelImageJob;
use App\Notifications\Server\Revived;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -156,11 +155,17 @@ class Server extends BaseModel
return $this->hasOne(ServerSetting::class); return $this->hasOne(ServerSetting::class);
} }
public function proxySet()
{
return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server;
}
public function setupDefault404Redirect() public function setupDefault404Redirect()
{ {
$dynamic_conf_path = $this->proxyPath().'/dynamic'; $dynamic_conf_path = $this->proxyPath().'/dynamic';
$proxy_type = $this->proxyType(); $proxy_type = $this->proxyType();
$redirect_url = $this->proxy->redirect_url; $redirect_url = $this->proxy->redirect_url;
ray($proxy_type);
if ($proxy_type === ProxyTypes::TRAEFIK->value) { if ($proxy_type === ProxyTypes::TRAEFIK->value) {
$default_redirect_file = "$dynamic_conf_path/default_redirect_404.yaml"; $default_redirect_file = "$dynamic_conf_path/default_redirect_404.yaml";
} elseif ($proxy_type === 'CADDY') { } elseif ($proxy_type === 'CADDY') {
@@ -833,9 +838,9 @@ $schema://$host {
$clickhouses = data_get($standaloneDocker, 'clickhouses', collect([])); $clickhouses = data_get($standaloneDocker, 'clickhouses', collect([]));
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses); return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
})->filter(function ($item) { })->flatten()->filter(function ($item) {
return data_get($item, 'name') !== 'coolify-db'; return data_get($item, 'name') !== 'coolify-db';
})->flatten(); });
} }
public function applications() public function applications()
@@ -879,6 +884,35 @@ $schema://$host {
return $this->hasMany(Service::class); return $this->hasMany(Service::class);
} }
public function port(): Attribute
{
return Attribute::make(
get: function ($value) {
return preg_replace('/[^0-9]/', '', $value);
}
);
}
public function user(): Attribute
{
return Attribute::make(
get: function ($value) {
$sanitizedValue = preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
return $sanitizedValue;
}
);
}
public function ip(): Attribute
{
return Attribute::make(
get: function ($value) {
return preg_replace('/[^0-9a-zA-Z.-]/', '', $value);
}
);
}
public function getIp(): Attribute public function getIp(): Attribute
{ {
return Attribute::make( return Attribute::make(
@@ -951,10 +985,9 @@ $schema://$host {
public function isFunctional() public function isFunctional()
{ {
$isFunctional = $this->settings->is_reachable && $this->settings->is_usable && ! $this->settings->force_disabled; $isFunctional = $this->settings->is_reachable && $this->settings->is_usable && ! $this->settings->force_disabled;
['private_key_filename' => $private_key_filename, 'mux_filename' => $mux_filename] = server_ssh_configuration($this);
if (! $isFunctional) { if (! $isFunctional) {
Storage::disk('ssh-keys')->delete($private_key_filename); Storage::disk('ssh-mux')->delete($this->muxFilename());
Storage::disk('ssh-mux')->delete($mux_filename);
} }
return $isFunctional; return $isFunctional;
@@ -1006,9 +1039,10 @@ $schema://$host {
return data_get($this, 'settings.is_swarm_worker'); return data_get($this, 'settings.is_swarm_worker');
} }
public function validateConnection() public function validateConnection($isManualCheck = true)
{ {
config()->set('constants.ssh.mux_enabled', false); config()->set('constants.ssh.mux_enabled', ! $isManualCheck);
// ray('Manual Check: ' . ($isManualCheck ? 'true' : 'false'));
$server = Server::find($this->id); $server = Server::find($this->id);
if (! $server) { if (! $server) {
@@ -1018,7 +1052,6 @@ $schema://$host {
return ['uptime' => false, 'error' => 'Server skipped.']; return ['uptime' => false, 'error' => 'Server skipped.'];
} }
try { try {
// EC2 does not have `uptime` command, lol
instant_remote_process(['ls /'], $server); instant_remote_process(['ls /'], $server);
$server->settings()->update([ $server->settings()->update([
'is_reachable' => true, 'is_reachable' => true,
@@ -1027,7 +1060,6 @@ $schema://$host {
'unreachable_count' => 0, 'unreachable_count' => 0,
]); ]);
if (data_get($server, 'unreachable_notification_sent') === true) { if (data_get($server, 'unreachable_notification_sent') === true) {
// $server->team?->notify(new Revived($server));
$server->update(['unreachable_notification_sent' => false]); $server->update(['unreachable_notification_sent' => false]);
} }
@@ -1156,4 +1188,24 @@ $schema://$host {
{ {
return $this->settings->is_build_server; return $this->settings->is_build_server;
} }
public static function createWithPrivateKey(array $data, PrivateKey $privateKey)
{
$server = new self($data);
$server->privateKey()->associate($privateKey);
$server->save();
return $server;
}
public function updateWithPrivateKey(array $data, ?PrivateKey $privateKey = null)
{
$this->update($data);
if ($privateKey) {
$this->privateKey()->associate($privateKey);
$this->save();
}
return $this;
}
} }

View File

@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use Illuminate\Process\InvokedProcess;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Spatie\Url\Url; use Spatie\Url\Url;
@@ -131,15 +133,80 @@ class Service extends BaseModel
return $this->morphToMany(Tag::class, 'taggable'); return $this->morphToMany(Tag::class, 'taggable');
} }
public function getContainersToStop(): array
{
$containersToStop = [];
$applications = $this->applications()->get();
foreach ($applications as $application) {
$containersToStop[] = "{$application->name}-{$this->uuid}";
}
$dbs = $this->databases()->get();
foreach ($dbs as $db) {
$containersToStop[] = "{$db->name}-{$this->uuid}";
}
return $containersToStop;
}
public function stopContainers(array $containerNames, $server, int $timeout = 300)
{
$processes = [];
foreach ($containerNames as $containerName) {
$processes[$containerName] = $this->stopContainer($containerName, $timeout);
}
$startTime = time();
while (count($processes) > 0) {
$finishedProcesses = array_filter($processes, function ($process) {
return !$process->running();
});
foreach (array_keys($finishedProcesses) as $containerName) {
unset($processes[$containerName]);
$this->removeContainer($containerName, $server);
}
if (time() - $startTime >= $timeout) {
$this->forceStopRemainingContainers(array_keys($processes), $server);
break;
}
usleep(100000);
}
}
public function stopContainer(string $containerName, int $timeout): InvokedProcess
{
return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
}
public function removeContainer(string $containerName, $server)
{
instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false);
}
public function forceStopRemainingContainers(array $containerNames, $server)
{
foreach ($containerNames as $containerName) {
instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false);
$this->removeContainer($containerName, $server);
}
}
public function delete_configurations() public function delete_configurations()
{ {
$server = data_get($this, 'server'); $server = data_get($this, 'destination.server');
$workdir = $this->workdir(); $workdir = $this->workdir();
if (str($workdir)->endsWith($this->uuid)) { if (str($workdir)->endsWith($this->uuid)) {
instant_remote_process(['rm -rf ' . $this->workdir()], $server, false); instant_remote_process(['rm -rf ' . $this->workdir()], $server, false);
} }
} }
public function delete_connected_networks($uuid)
{
$server = data_get($this, 'destination.server');
instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$uuid}"], $server, false);
}
public function status() public function status()
{ {
$applications = $this->applications; $applications = $this->applications;

View File

@@ -52,7 +52,7 @@ class ForceDisabled extends Notification implements ShouldQueue
public function toDiscord(): string public function toDiscord(): string
{ {
$message = "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions)."; $message = "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).";
return $message; return $message;
} }
@@ -60,7 +60,7 @@ class ForceDisabled extends Notification implements ShouldQueue
public function toTelegram(): array public function toTelegram(): array
{ {
return [ return [
'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions).", 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).",
]; ];
} }
} }

View File

@@ -7,6 +7,7 @@ use App\Models\Server;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use App\Helpers\SshMultiplexingHelper;
trait ExecuteRemoteCommand trait ExecuteRemoteCommand
{ {
@@ -42,7 +43,7 @@ trait ExecuteRemoteCommand
$command = parseLineForSudo($command, $this->server); $command = parseLineForSudo($command, $this->server);
} }
} }
$remote_command = generateSshCommand($this->server, $command); $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
$process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
$output = str($output)->trim(); $output = str($output)->trim();
if ($output->startsWith('╔')) { if ($output->startsWith('╔')) {

View File

@@ -3,6 +3,7 @@
use App\Actions\CoolifyTask\PrepareCoolifyTask; use App\Actions\CoolifyTask\PrepareCoolifyTask;
use App\Data\CoolifyTaskArgs; use App\Data\CoolifyTaskArgs;
use App\Enums\ActivityTypes; use App\Enums\ActivityTypes;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey; use App\Models\PrivateKey;
@@ -10,9 +11,8 @@ use App\Models\Server;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\Activitylog\Contracts\Activity; use Spatie\Activitylog\Contracts\Activity;
@@ -26,29 +26,28 @@ function remote_process(
$callEventOnFinish = null, $callEventOnFinish = null,
$callEventData = null $callEventData = null
): Activity { ): Activity {
if (is_null($type)) { $type = $type ?? ActivityTypes::INLINE->value;
$type = ActivityTypes::INLINE->value; $command = $command instanceof Collection ? $command->toArray() : $command;
}
if ($command instanceof Collection) {
$command = $command->toArray();
}
if ($server->isNonRoot()) { if ($server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $server); $command = parseCommandsByLineForSudo(collect($command), $server);
} }
$command_string = implode("\n", $command); $command_string = implode("\n", $command);
if (auth()->user()) {
$teams = auth()->user()->teams->pluck('id'); if (Auth::check()) {
$teams = Auth::user()->teams->pluck('id');
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
throw new \Exception('User is not part of the team that owns this server'); throw new \Exception('User is not part of the team that owns this server');
} }
} }
SshMultiplexingHelper::ensureMultiplexedConnection($server);
return resolve(PrepareCoolifyTask::class, [ return resolve(PrepareCoolifyTask::class, [
'remoteProcessArgs' => new CoolifyTaskArgs( 'remoteProcessArgs' => new CoolifyTaskArgs(
server_uuid: $server->uuid, server_uuid: $server->uuid,
command: <<<EOT command: $command_string,
{$command_string}
EOT,
type: $type, type: $type,
type_uuid: $type_uuid, type_uuid: $type_uuid,
model: $model, model: $model,
@@ -58,313 +57,67 @@ function remote_process(
), ),
])(); ])();
} }
function server_ssh_configuration(Server $server)
{
$uuid = data_get($server, 'uuid');
if (is_null($uuid)) {
throw new \Exception('Server does not have a uuid');
}
$private_key_filename = "id.root@{$server->uuid}";
$location = '/var/www/html/storage/app/ssh/keys/'.$private_key_filename;
$mux_filename = '/var/www/html/storage/app/ssh/mux/'.$server->muxFilename();
return [
'location' => $location,
'mux_filename' => $mux_filename,
'private_key_filename' => $private_key_filename,
];
}
function savePrivateKeyToFs(Server $server)
{
if (data_get($server, 'privateKey.private_key') === null) {
throw new \Exception("Server {$server->name} does not have a private key");
}
['location' => $location, 'private_key_filename' => $private_key_filename] = server_ssh_configuration($server);
Storage::disk('ssh-keys')->makeDirectory('.');
Storage::disk('ssh-mux')->makeDirectory('.');
Storage::disk('ssh-keys')->put($private_key_filename, $server->privateKey->private_key);
return $location;
}
function generateScpCommand(Server $server, string $source, string $dest)
{
$user = $server->user;
$port = $server->port;
$privateKeyLocation = savePrivateKeyToFs($server);
$timeout = config('constants.ssh.command_timeout');
$connectionTimeout = config('constants.ssh.connection_timeout');
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$scp_command = "timeout $timeout scp ";
$muxEnabled = config('constants.ssh.mux_enabled', true) && config('coolify.is_windows_docker_desktop') == false;
// ray('SSH Multiplexing Enabled:', $muxEnabled)->blue();
if ($muxEnabled) {
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}";
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
ensureMultiplexedConnection($server);
// ray('Using SSH Multiplexing')->green();
} else {
// ray('Not using SSH Multiplexing')->red();
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$scp_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" ';
}
$scp_command .= "-i {$privateKeyLocation} "
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
."-o ServerAliveInterval=$serverInterval "
.'-o RequestTTY=no '
.'-o LogLevel=ERROR '
."-P {$port} "
."{$source} "
."{$user}@{$server->ip}:{$dest}";
return $scp_command;
}
function instant_scp(string $source, string $dest, Server $server, $throwError = true) function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{ {
$timeout = config('constants.ssh.command_timeout'); $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$scp_command = generateScpCommand($server, $source, $dest); $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
$process = Process::timeout($timeout)->run($scp_command);
$output = trim($process->output()); $output = trim($process->output());
$exitCode = $process->exitCode(); $exitCode = $process->exitCode();
if ($exitCode !== 0) { if ($exitCode !== 0) {
if (! $throwError) { return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
return null;
} }
return excludeCertainErrors($process->errorOutput(), $exitCode); return $output === 'null' ? null : $output;
}
if ($output === 'null') {
$output = null;
}
return $output;
}
function generateSshCommand(Server $server, string $command)
{
if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
}
$user = $server->user;
$port = $server->port;
$privateKeyLocation = savePrivateKeyToFs($server);
$timeout = config('constants.ssh.command_timeout');
$connectionTimeout = config('constants.ssh.connection_timeout');
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$ssh_command = "timeout $timeout ssh ";
$muxEnabled = config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false;
// ray('SSH Multiplexing Enabled:', $muxEnabled)->blue();
if ($muxEnabled) {
// Always use multiplexing when enabled
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}";
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
ensureMultiplexedConnection($server);
// ray('Using SSH Multiplexing')->green();
} else {
// ray('Not using SSH Multiplexing')->red();
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$ssh_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" ';
}
$command = "PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:/host/sbin:/host/bin && $command";
$delimiter = Hash::make($command);
$command = str_replace($delimiter, '', $command);
$ssh_command .= "-i {$privateKeyLocation} "
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
."-o ServerAliveInterval=$serverInterval "
.'-o RequestTTY=no '
.'-o LogLevel=ERROR '
."-p {$port} "
."{$user}@{$server->ip} "
." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
return $ssh_command;
}
function ensureMultiplexedConnection(Server $server)
{
if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) {
return;
}
static $ensuredConnections = [];
if (isset($ensuredConnections[$server->id])) {
if (! shouldResetMultiplexedConnection($server)) {
// ray('Using Existing Multiplexed Connection')->green();
return;
}
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}";
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$checkCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" ';
}
$checkCommand .= " {$server->user}@{$server->ip}";
$process = Process::run($checkCommand);
if ($process->exitCode() === 0) {
// ray('Existing Multiplexed Connection is Valid')->green();
$ensuredConnections[$server->id] = [
'timestamp' => now(),
'muxSocket' => $muxSocket,
];
return;
}
// ray('Establishing New Multiplexed Connection')->orange();
$privateKeyLocation = savePrivateKeyToFs($server);
$connectionTimeout = config('constants.ssh.connection_timeout');
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" ';
}
$establishCommand .= "-i {$privateKeyLocation} "
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
."-o ServerAliveInterval=$serverInterval "
.'-o RequestTTY=no '
.'-o LogLevel=ERROR '
."-p {$server->port} "
."{$server->user}@{$server->ip}";
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput());
}
$ensuredConnections[$server->id] = [
'timestamp' => now(),
'muxSocket' => $muxSocket,
];
// ray('Established New Multiplexed Connection')->green();
}
function shouldResetMultiplexedConnection(Server $server)
{
if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) {
return false;
}
static $ensuredConnections = [];
if (! isset($ensuredConnections[$server->id])) {
return true;
}
$lastEnsured = $ensuredConnections[$server->id]['timestamp'];
$muxPersistTime = config('constants.ssh.mux_persist_time');
$resetInterval = strtotime($muxPersistTime) - time();
return $lastEnsured->addSeconds($resetInterval)->isPast();
}
function resetMultiplexedConnection(Server $server)
{
if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) {
return;
}
static $ensuredConnections = [];
if (isset($ensuredConnections[$server->id])) {
$muxSocket = $ensuredConnections[$server->id]['muxSocket'];
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip}";
Process::run($closeCommand);
unset($ensuredConnections[$server->id]);
}
} }
function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{ {
static $processCount = 0; $command = $command instanceof Collection ? $command->toArray() : $command;
$processCount++;
$timeout = config('constants.ssh.command_timeout');
if ($command instanceof Collection) {
$command = $command->toArray();
}
if ($server->isNonRoot() && ! $no_sudo) { if ($server->isNonRoot() && ! $no_sudo) {
$command = parseCommandsByLineForSudo(collect($command), $server); $command = parseCommandsByLineForSudo(collect($command), $server);
} }
$command_string = implode("\n", $command); $command_string = implode("\n", $command);
$start_time = microtime(true); // $start_time = microtime(true);
$sshCommand = generateSshCommand($server, $command_string); $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout($timeout)->run($sshCommand); $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
$end_time = microtime(true); // $end_time = microtime(true);
$execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
// ray('SSH command execution time:', $execution_time.' ms')->orange(); // ray('SSH command execution time:', $execution_time.' ms')->orange();
$output = trim($process->output()); $output = trim($process->output());
$exitCode = $process->exitCode(); $exitCode = $process->exitCode();
if ($exitCode !== 0) { if ($exitCode !== 0) {
if (! $throwError) { return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
return null;
} }
return excludeCertainErrors($process->errorOutput(), $exitCode); return $output === 'null' ? null : $output;
}
if ($output === 'null') {
$output = null;
} }
return $output;
}
function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
{ {
$ignoredErrors = collect([ $ignoredErrors = collect([
'Permission denied (publickey', 'Permission denied (publickey',
'Could not resolve hostname', 'Could not resolve hostname',
]); ]);
$ignored = false; $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error));
foreach ($ignoredErrors as $ignoredError) {
if (Str::contains($errorOutput, $ignoredError)) {
$ignored = true;
break;
}
}
if ($ignored) { if ($ignored) {
// TODO: Create new exception and disable in sentry // TODO: Create new exception and disable in sentry
throw new \RuntimeException($errorOutput, $exitCode); throw new \RuntimeException($errorOutput, $exitCode);
} }
throw new \RuntimeException($errorOutput, $exitCode); throw new \RuntimeException($errorOutput, $exitCode);
} }
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection
{ {
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
if (is_null($application_deployment_queue)) { if (is_null($application_deployment_queue)) {
return collect([]); return collect([]);
} }
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
try { try {
$decoded = json_decode( $decoded = json_decode(
data_get($application_deployment_queue, 'logs'), data_get($application_deployment_queue, 'logs'),
@@ -379,7 +132,8 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
if (! $is_debug_enabled) { if (! $is_debug_enabled) {
$formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false); $formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false);
} }
$formatted = $formatted
return $formatted
->sortBy(fn ($i) => data_get($i, 'order')) ->sortBy(fn ($i) => data_get($i, 'order'))
->map(function ($i) { ->map(function ($i) {
data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u')); data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u'));
@@ -421,36 +175,22 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
return $deploymentLogLines; return $deploymentLogLines;
}, collect()); }, collect());
return $formatted;
} }
function remove_iip($text) function remove_iip($text)
{ {
$text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
return preg_replace('/\x1b\[[0-9;]*m/', '', $text); return preg_replace('/\x1b\[[0-9;]*m/', '', $text);
} }
function remove_mux_and_private_key(Server $server)
{
$muxFilename = $server->muxFilename();
$privateKeyLocation = savePrivateKeyToFs($server);
$closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFilename);
Storage::disk('ssh-keys')->delete($privateKeyLocation);
}
function refresh_server_connection(?PrivateKey $private_key = null) function refresh_server_connection(?PrivateKey $private_key = null)
{ {
if (is_null($private_key)) { if (is_null($private_key)) {
return; return;
} }
foreach ($private_key->servers as $server) { foreach ($private_key->servers as $server) {
$muxFilename = $server->muxFilename(); SshMultiplexingHelper::removeMuxFile($server);
$closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFilename);
} }
} }
@@ -468,9 +208,8 @@ function checkRequiredCommands(Server $server)
break; break;
} }
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false); $commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);
if ($commandFound) { if (! $commandFound) {
continue;
}
break; break;
} }
} }
}

View File

@@ -2928,6 +2928,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$allMagicEnvironments = collect([]); $allMagicEnvironments = collect([]);
foreach ($services as $serviceName => $service) { foreach ($services as $serviceName => $service) {
$predefinedPort = null;
$magicEnvironments = collect([]); $magicEnvironments = collect([]);
$image = data_get_str($service, 'image'); $image = data_get_str($service, 'image');
$environment = collect(data_get($service, 'environment', [])); $environment = collect(data_get($service, 'environment', []));
@@ -2936,6 +2937,24 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$isDatabase = isDatabaseImage(data_get_str($service, 'image')); $isDatabase = isDatabaseImage(data_get_str($service, 'image'));
if ($isService) { if ($isService) {
$containerName = "$serviceName-{$resource->uuid}";
if ($serviceName === 'registry') {
$tempServiceName = 'docker-registry';
} else {
$tempServiceName = $serviceName;
}
if (str(data_get($service, 'image'))->contains('glitchtip')) {
$tempServiceName = 'glitchtip';
}
if ($serviceName === 'supabase-kong') {
$tempServiceName = 'supabase';
}
$serviceDefinition = data_get($allServices, $tempServiceName);
$predefinedPort = data_get($serviceDefinition, 'port');
if ($serviceName === 'plausible') {
$predefinedPort = '8000';
}
if ($isDatabase) { if ($isDatabase) {
$savedService = ServiceDatabase::firstOrCreate([ $savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
@@ -2987,8 +3006,10 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
if (substr_count(str($key)->value(), '_') === 3) { if (substr_count(str($key)->value(), '_') === 3) {
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
$port = $key->afterLast('_')->value();
} else { } else {
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
$port = null;
} }
if ($isApplication) { if ($isApplication) {
$fqdn = generateFqdn($server, "{$resource->name}-$uuid"); $fqdn = generateFqdn($server, "{$resource->name}-$uuid");
@@ -2999,19 +3020,24 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$fqdn = generateFqdn($server, "{$savedService->name}-$uuid"); $fqdn = generateFqdn($server, "{$savedService->name}-$uuid");
} }
} }
if ($value && get_class($value) === 'Illuminate\Support\Stringable' && $value->startsWith('/')) { if ($value && get_class($value) === 'Illuminate\Support\Stringable' && $value->startsWith('/')) {
$path = $value->value(); $path = $value->value();
if ($path !== '/') { if ($path !== '/') {
$fqdn = "$fqdn$path"; $fqdn = "$fqdn$path";
} }
} }
$fqdnWithPort = $fqdn;
if ($port) {
$fqdnWithPort = "$fqdn:$port";
}
if ($isApplication && is_null($resource->fqdn)) { if ($isApplication && is_null($resource->fqdn)) {
data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview'); data_forget($resource, 'environment_variables_preview');
$resource->fqdn = $fqdn; $resource->fqdn = $fqdnWithPort;
$resource->save(); $resource->save();
} elseif ($isService && is_null($savedService->fqdn)) { } elseif ($isService && is_null($savedService->fqdn)) {
$savedService->fqdn = $fqdn; $savedService->fqdn = $fqdnWithPort;
$savedService->save(); $savedService->save();
} }
@@ -3040,7 +3066,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
if ($magicEnvironments->count() > 0) { if ($magicEnvironments->count() > 0) {
foreach ($magicEnvironments as $key => $value) { foreach ($magicEnvironments as $key => $value) {
$key = str($key); $key = str($key);
@@ -3631,6 +3656,14 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
data_forget($service, 'volumes.*.is_directory'); data_forget($service, 'volumes.*.is_directory');
data_forget($service, 'exclude_from_hc'); data_forget($service, 'exclude_from_hc');
$volumesParsed = $volumesParsed->map(function ($volume) {
data_forget($volume, 'content');
data_forget($volume, 'is_directory');
data_forget($volume, 'isDirectory');
return $volume;
});
$payload = collect($service)->merge([ $payload = collect($service)->merge([
'container_name' => $containerName, 'container_name' => $containerName,
'restart' => $restart->value(), 'restart' => $restart->value(),
@@ -3661,6 +3694,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$parsedServices->put($serviceName, $payload); $parsedServices->put($serviceName, $payload);
} }
$topLevel->put('services', $parsedServices); $topLevel->put('services', $parsedServices);
$customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets']; $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
$topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) { $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {

View File

@@ -1,424 +0,0 @@
<?php
return [
/*
|------------------------------------------------------------------------------------------------------------------
| Enable Clockwork
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork is enabled by default only when your application is in debug mode. Here you can explicitly enable or
| disable Clockwork. When disabled, no data is collected and the api and web ui are inactive.
| Unless explicitly enabled, Clockwork only runs on localhost, *.local, *.test and *.wip domains.
|
*/
'enable' => env('CLOCKWORK_ENABLE', null),
/*
|------------------------------------------------------------------------------------------------------------------
| Features
|------------------------------------------------------------------------------------------------------------------
|
| You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query
| threshold for database queries).
|
*/
'features' => [
// Cache usage stats and cache queries including results
'cache' => [
'enabled' => env('CLOCKWORK_CACHE_ENABLED', true),
// Collect cache queries
'collect_queries' => env('CLOCKWORK_CACHE_QUERIES', true),
// Collect values from cache queries (high performance impact with a very high number of queries)
'collect_values' => env('CLOCKWORK_CACHE_COLLECT_VALUES', false)
],
// Database usage stats and queries
'database' => [
'enabled' => env('CLOCKWORK_DATABASE_ENABLED', true),
// Collect database queries (high performance impact with a very high number of queries)
'collect_queries' => env('CLOCKWORK_DATABASE_COLLECT_QUERIES', true),
// Collect details of models updates (high performance impact with a lot of model updates)
'collect_models_actions' => env('CLOCKWORK_DATABASE_COLLECT_MODELS_ACTIONS', true),
// Collect details of retrieved models (very high performance impact with a lot of models retrieved)
'collect_models_retrieved' => env('CLOCKWORK_DATABASE_COLLECT_MODELS_RETRIEVED', false),
// Query execution time threshold in milliseconds after which the query will be marked as slow
'slow_threshold' => env('CLOCKWORK_DATABASE_SLOW_THRESHOLD'),
// Collect only slow database queries
'slow_only' => env('CLOCKWORK_DATABASE_SLOW_ONLY', false),
// Detect and report duplicate queries
'detect_duplicate_queries' => env('CLOCKWORK_DATABASE_DETECT_DUPLICATE_QUERIES', false)
],
// Dispatched events
'events' => [
'enabled' => env('CLOCKWORK_EVENTS_ENABLED', true),
// Ignored events (framework events are ignored by default)
'ignored_events' => [
// App\Events\UserRegistered::class,
// 'user.registered'
],
],
// Laravel log (you can still log directly to Clockwork with laravel log disabled)
'log' => [
'enabled' => env('CLOCKWORK_LOG_ENABLED', true)
],
// Sent notifications
'notifications' => [
'enabled' => env('CLOCKWORK_NOTIFICATIONS_ENABLED', true),
],
// Performance metrics
'performance' => [
// Allow collecting of client metrics. Requires separate clockwork-browser npm package.
'client_metrics' => env('CLOCKWORK_PERFORMANCE_CLIENT_METRICS', true)
],
// Dispatched queue jobs
'queue' => [
'enabled' => env('CLOCKWORK_QUEUE_ENABLED', true)
],
// Redis commands
'redis' => [
'enabled' => env('CLOCKWORK_REDIS_ENABLED', true)
],
// Routes list
'routes' => [
'enabled' => env('CLOCKWORK_ROUTES_ENABLED', false),
// Collect only routes from particular namespaces (only application routes by default)
'only_namespaces' => [ 'App' ]
],
// Rendered views
'views' => [
'enabled' => env('CLOCKWORK_VIEWS_ENABLED', true),
// Collect views including view data (high performance impact with a high number of views)
'collect_data' => env('CLOCKWORK_VIEWS_COLLECT_DATA', false),
// Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does
// not support collecting view data)
'use_twig_profiler' => env('CLOCKWORK_VIEWS_USE_TWIG_PROFILER', false)
]
],
/*
|------------------------------------------------------------------------------------------------------------------
| Enable web UI
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork comes with a web UI accessible via http://your.app/clockwork. Here you can enable or disable this
| feature. You can also set a custom path for the web UI.
|
*/
'web' => env('CLOCKWORK_WEB', true),
/*
|------------------------------------------------------------------------------------------------------------------
| Enable toolbar
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature.
| Requires a separate clockwork-browser npm library.
| For installation instructions see https://underground.works/clockwork/#docs-viewing-data
|
*/
'toolbar' => env('CLOCKWORK_TOOLBAR', true),
/*
|------------------------------------------------------------------------------------------------------------------
| HTTP requests collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected.
|
*/
'requests' => [
// With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you
// manually pass a "clockwork-profile" cookie or get/post data key.
// Optionally you can specify a "secret" that has to be passed as the value to enable profiling.
'on_demand' => env('CLOCKWORK_REQUESTS_ON_DEMAND', false),
// Collect only errors (requests with HTTP 4xx and 5xx responses)
'errors_only' => env('CLOCKWORK_REQUESTS_ERRORS_ONLY', false),
// Response time threshold in milliseconds after which the request will be marked as slow
'slow_threshold' => env('CLOCKWORK_REQUESTS_SLOW_THRESHOLD'),
// Collect only slow requests
'slow_only' => env('CLOCKWORK_REQUESTS_SLOW_ONLY', false),
// Sample the collected requests (e.g. set to 100 to collect only 1 in 100 requests)
'sample' => env('CLOCKWORK_REQUESTS_SAMPLE', false),
// List of URIs that should not be collected
'except' => [
'/horizon/.*', // Laravel Horizon requests
'/telescope/.*', // Laravel Telescope requests
'/_tt/.*', // Laravel Telescope toolbar
'/_debugbar/.*', // Laravel DebugBar requests
],
// List of URIs that should be collected, any other URI will not be collected if not empty
'only' => [
// '/api/.*'
],
// Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest
'except_preflight' => env('CLOCKWORK_REQUESTS_EXCEPT_PREFLIGHT', true)
],
/*
|------------------------------------------------------------------------------------------------------------------
| Artisan commands collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands
| should be collected.
|
*/
'artisan' => [
// Enable or disable collection of executed Artisan commands
'collect' => env('CLOCKWORK_ARTISAN_COLLECT', false),
// List of commands that should not be collected (built-in commands are not collected by default)
'except' => [
// 'inspire'
],
// List of commands that should be collected, any other command will not be collected if not empty
'only' => [
// 'inspire'
],
// Enable or disable collection of command output
'collect_output' => env('CLOCKWORK_ARTISAN_COLLECT_OUTPUT', false),
// Enable or disable collection of built-in Laravel commands
'except_laravel_commands' => env('CLOCKWORK_ARTISAN_EXCEPT_LARAVEL_COMMANDS', true)
],
/*
|------------------------------------------------------------------------------------------------------------------
| Queue jobs collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should
| be collected.
|
*/
'queue' => [
// Enable or disable collection of executed queue jobs
'collect' => env('CLOCKWORK_QUEUE_COLLECT', false),
// List of queue jobs that should not be collected
'except' => [
// App\Jobs\ExpensiveJob::class
],
// List of queue jobs that should be collected, any other queue job will not be collected if not empty
'only' => [
// App\Jobs\BuggyJob::class
]
],
/*
|------------------------------------------------------------------------------------------------------------------
| Tests collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed tests. Here you can enable and configure which tests should be
| collected.
|
*/
'tests' => [
// Enable or disable collection of ran tests
'collect' => env('CLOCKWORK_TESTS_COLLECT', false),
// List of tests that should not be collected
'except' => [
// Tests\Unit\ExampleTest::class
]
],
/*
|------------------------------------------------------------------------------------------------------------------
| Enable data collection when Clockwork is disabled
|------------------------------------------------------------------------------------------------------------------
|
| You can enable this setting to collect data even when Clockwork is disabled, e.g. for future analysis.
|
*/
'collect_data_always' => env('CLOCKWORK_COLLECT_DATA_ALWAYS', false),
/*
|------------------------------------------------------------------------------------------------------------------
| Metadata storage
|------------------------------------------------------------------------------------------------------------------
|
| Configure how is the metadata collected by Clockwork stored. Three options are available:
| - files - A simple fast storage implementation storing data in one-per-request files.
| - sql - Stores requests in a sql database. Supports MySQL, PostgreSQL and SQLite. Requires PDO.
| - redis - Stores requests in redis. Requires phpredis.
*/
'storage' => env('CLOCKWORK_STORAGE', 'files'),
// Path where the Clockwork metadata is stored
'storage_files_path' => env('CLOCKWORK_STORAGE_FILES_PATH', storage_path('clockwork')),
// Compress the metadata files using gzip, trading a little bit of performance for lower disk usage
'storage_files_compress' => env('CLOCKWORK_STORAGE_FILES_COMPRESS', false),
// SQL database to use, can be a name of database configured in database.php or a path to a SQLite file
'storage_sql_database' => env('CLOCKWORK_STORAGE_SQL_DATABASE', storage_path('clockwork.sqlite')),
// SQL table name to use, the table is automatically created and updated when needed
'storage_sql_table' => env('CLOCKWORK_STORAGE_SQL_TABLE', 'clockwork'),
// Redis connection, name of redis connection or cluster configured in database.php
'storage_redis' => env('CLOCKWORK_STORAGE_REDIS', 'default'),
// Redis prefix for Clockwork keys ("clockwork" if not set)
'storage_redis_prefix' => env('CLOCKWORK_STORAGE_REDIS_PREFIX', 'clockwork'),
// Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable
'storage_expiration' => env('CLOCKWORK_STORAGE_EXPIRATION', 60 * 24 * 7),
/*
|------------------------------------------------------------------------------------------------------------------
| Authentication
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can be configured to require authentication before allowing access to the collected data. This might be
| useful when the application is publicly accessible. Setting to true will enable a simple authentication with a
| pre-configured password. You can also pass a class name of a custom implementation.
|
*/
'authentication' => env('CLOCKWORK_AUTHENTICATION', false),
// Password for the simple authentication
'authentication_password' => env('CLOCKWORK_AUTHENTICATION_PASSWORD', 'VerySecretPassword'),
/*
|------------------------------------------------------------------------------------------------------------------
| Stack traces collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set
| whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting
| long stack traces considerably increases metadata size.
|
*/
'stack_traces' => [
// Enable or disable collecting of stack traces
'enabled' => env('CLOCKWORK_STACK_TRACES_ENABLED', true),
// Limit the number of frames to be collected
'limit' => env('CLOCKWORK_STACK_TRACES_LIMIT', 10),
// List of vendor names to skip when determining caller, common vendors are automatically added
'skip_vendors' => [
// 'phpunit'
],
// List of namespaces to skip when determining caller
'skip_namespaces' => [
// 'Laravel'
],
// List of class names to skip when determining caller
'skip_classes' => [
// App\CustomLog::class
]
],
/*
|------------------------------------------------------------------------------------------------------------------
| Serialization
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects
| of serialization. Serialization has a large effect on the cpu time and memory usage.
|
*/
// Maximum depth of serialized multi-level arrays and objects
'serialization_depth' => env('CLOCKWORK_SERIALIZATION_DEPTH', 10),
// A list of classes that will never be serialized (e.g. a common service container class)
'serialization_blackbox' => [
\Illuminate\Container\Container::class,
\Illuminate\Foundation\Application::class,
\Laravel\Lumen\Application::class
],
/*
|------------------------------------------------------------------------------------------------------------------
| Register helpers
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to
| access the Clockwork instance.
|
*/
'register_helpers' => env('CLOCKWORK_REGISTER_HELPERS', true),
/*
|------------------------------------------------------------------------------------------------------------------
| Send headers for AJAX request
|------------------------------------------------------------------------------------------------------------------
|
| When trying to collect data, the AJAX method can sometimes fail if it is missing required headers. For example, an
| API might require a version number using Accept headers to route the HTTP request to the correct codebase.
|
*/
'headers' => [
// 'Accept' => 'application/vnd.com.whatever.v1+json',
],
/*
|------------------------------------------------------------------------------------------------------------------
| Server timing
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics
| in a cross-browser way. E.g. in Chrome, your app, database and timeline event timings will be shown in the Dev
| Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false
| will disable the feature.
|
*/
'server_timing' => env('CLOCKWORK_SERVER_TIMING', 10)
];

View File

@@ -6,9 +6,8 @@ return [
'contact' => 'https://coolify.io/docs/contact', 'contact' => 'https://coolify.io/docs/contact',
], ],
'ssh' => [ 'ssh' => [
// Using MUX 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)),
'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true), true), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600),
'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', '1h'),
'connection_timeout' => 10, 'connection_timeout' => 10,
'server_interval' => 20, 'server_interval' => 20,
'command_timeout' => 7200, 'command_timeout' => 7200,

View File

@@ -7,7 +7,7 @@ return [
// The release version of your application // The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
'release' => '4.0.0-beta.341', 'release' => '4.0.0-beta.342',
// When left empty or `null` the Laravel environment will be used // When left empty or `null` the Laravel environment will be used
'environment' => config('app.env'), 'environment' => config('app.env'),

View File

@@ -1,3 +1,3 @@
<?php <?php
return '4.0.0-beta.341'; return '4.0.0-beta.342';

View File

@@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
class EncryptExistingPrivateKeys extends Migration
{
public function up()
{
DB::table('private_keys')->chunkById(100, function ($keys) {
foreach ($keys as $key) {
DB::table('private_keys')
->where('id', $key->id)
->update(['private_key' => Crypt::encryptString($key->private_key)]);
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
use App\Models\PrivateKey;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Storage;
class PopulateSshKeysAndClearMuxDirectory extends Migration
{
public function up()
{
// Storage::disk('ssh-keys')->deleteDirectory('');
// Storage::disk('ssh-keys')->makeDirectory('');
// Storage::disk('ssh-mux')->deleteDirectory('');
// Storage::disk('ssh-mux')->makeDirectory('');
// PrivateKey::chunk(100, function ($keys) {
// foreach ($keys as $key) {
// $key->storeInFileSystem();
// if ($key->id === 0) {
// Storage::disk('ssh-keys')->put('id.root@host.docker.internal', $key->private_key);
// }
// }
// });
}
}

View File

@@ -0,0 +1,31 @@
<?php
use App\Models\PrivateKey;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSshKeyFingerprintToPrivateKeysTable extends Migration
{
public function up()
{
Schema::table('private_keys', function (Blueprint $table) {
$table->string('fingerprint')->after('private_key')->nullable();
});
PrivateKey::whereNull('fingerprint')->each(function ($key) {
$fingerprint = PrivateKey::generateFingerprint($key->private_key);
if ($fingerprint) {
$key->fingerprint = $fingerprint;
$key->save();
}
});
}
public function down()
{
Schema::table('private_keys', function (Blueprint $table) {
$table->dropColumn('fingerprint');
});
}
}

View File

@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
UserSeeder::class, UserSeeder::class,
TeamSeeder::class, TeamSeeder::class,
PrivateKeySeeder::class, PrivateKeySeeder::class,
PopulateSshKeysDirectorySeeder::class,
ServerSeeder::class, ServerSeeder::class,
ServerSettingSeeder::class, ServerSettingSeeder::class,
ProjectSeeder::class, ProjectSeeder::class,

View File

@@ -31,7 +31,7 @@ class GithubAppSeeder extends Seeder
'client_id' => 'Iv1.220e564d2b0abd8c', 'client_id' => 'Iv1.220e564d2b0abd8c',
'client_secret' => '116d1d80289f378410dd70ab4e4b81dd8d2c52b6', 'client_secret' => '116d1d80289f378410dd70ab4e4b81dd8d2c52b6',
'webhook_secret' => '326a47b49054f03288f800d81247ec9414d0abf3', 'webhook_secret' => '326a47b49054f03288f800d81247ec9414d0abf3',
'private_key_id' => 1, 'private_key_id' => 2,
'team_id' => 0, 'team_id' => 0,
]); ]);
} }

View File

@@ -20,19 +20,5 @@ class GitlabAppSeeder extends Seeder
'is_public' => true, 'is_public' => true,
'team_id' => 0, 'team_id' => 0,
]); ]);
GitlabApp::create([
'id' => 2,
'name' => 'coolify-laravel-development-private-gitlab',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'app_id' => 1234,
'app_secret' => '1234',
'oauth_id' => 1234,
'deploy_key_id' => '1234',
'public_key' => 'dfjasiourj',
'webhook_token' => '4u3928u4y392',
'private_key_id' => 2,
'team_id' => 0,
]);
} }
} }

View File

@@ -0,0 +1,35 @@
<?php
namespace Database\Seeders;
use App\Models\PrivateKey;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PopulateSshKeysDirectorySeeder extends Seeder
{
public function run()
{
Storage::disk('ssh-keys')->deleteDirectory('');
Storage::disk('ssh-keys')->makeDirectory('');
Storage::disk('ssh-mux')->deleteDirectory('');
Storage::disk('ssh-mux')->makeDirectory('');
PrivateKey::chunk(100, function ($keys) {
foreach ($keys as $key) {
echo 'Storing key: '.$key->name."\n";
$key->storeInFileSystem();
}
});
if (isDev()) {
$user = env('PUID').':'.env('PGID');
Process::run("chown -R $user ".storage_path('app/ssh/keys'));
Process::run("chown -R $user ".storage_path('app/ssh/mux'));
} else {
Process::run('chown -R 9999:9999 '.storage_path('app/ssh/keys'));
Process::run('chown -R 9999:9999 '.storage_path('app/ssh/mux'));
}
}
}

View File

@@ -13,9 +13,8 @@ class PrivateKeySeeder extends Seeder
public function run(): void public function run(): void
{ {
PrivateKey::create([ PrivateKey::create([
'id' => 0,
'team_id' => 0, 'team_id' => 0,
'name' => 'Testing-host', 'name' => 'Testing Host Key',
'description' => 'This is a test docker container', 'description' => 'This is a test docker container',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
@@ -25,10 +24,9 @@ AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY----- -----END OPENSSH PRIVATE KEY-----
', ',
]); ]);
PrivateKey::create([ PrivateKey::create([
'id' => 1,
'team_id' => 0, 'team_id' => 0,
'name' => 'development-github-app', 'name' => 'development-github-app',
'description' => 'This is the key for using the development GitHub app', 'description' => 'This is the key for using the development GitHub app',
@@ -61,12 +59,5 @@ a1C8EDKapCw5hAhizEFOUQKOygL8Ipn+tmEUkORYdZ8Q8cWFCv9nIw==
-----END RSA PRIVATE KEY-----', -----END RSA PRIVATE KEY-----',
'is_git_related' => true, 'is_git_related' => true,
]); ]);
PrivateKey::create([
'id' => 2,
'team_id' => 0,
'name' => 'development-gitlab-app',
'description' => 'This is the key for using the development Gitlab app',
'private_key' => 'asdf',
]);
} }
} }

View File

@@ -64,31 +64,6 @@ class ProductionSeeder extends Seeder
'team_id' => 0, 'team_id' => 0,
]); ]);
} }
if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) {
echo "Checking localhost key.\n";
// Save SSH Keys for the Coolify Host
$coolify_key_name = 'id.root@host.docker.internal';
$coolify_key = Storage::disk('ssh-keys')->get("{$coolify_key_name}");
if ($coolify_key) {
PrivateKey::updateOrCreate(
[
'id' => 0,
'team_id' => 0,
],
[
'name' => 'localhost\'s key',
'description' => 'The private key for the Coolify host machine (localhost).',
'private_key' => $coolify_key,
]
);
} else {
echo "No SSH key found for the Coolify host machine (localhost).\n";
echo "Please generate one and save it in /data/coolify/ssh/keys/{$coolify_key_name}\n";
echo "Then try to install again.\n";
exit(1);
}
// Add Coolify host (localhost) as Server if it doesn't exist // Add Coolify host (localhost) as Server if it doesn't exist
if (Server::find(0) == null) { if (Server::find(0) == null) {
$server_details = [ $server_details = [
@@ -122,6 +97,49 @@ class ProductionSeeder extends Seeder
'server_id' => 0, 'server_id' => 0,
]); ]);
} }
if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) {
echo "Checking localhost key.\n";
$coolify_key_name = '@host.docker.internal';
$ssh_keys_directory = Storage::disk('ssh-keys')->files();
$coolify_key = collect($ssh_keys_directory)->firstWhere(fn ($item) => str($item)->contains($coolify_key_name));
$found = PrivateKey::find(0);
if ($found) {
echo 'Private Key found in database.\n';
if ($coolify_key) {
echo "SSH key found for the Coolify host machine (localhost).\n";
}
} else {
if ($coolify_key) {
$coolify_key = Storage::disk('ssh-keys')->get($coolify_key);
$user = str($coolify_key)->before('@')->after('id.');
PrivateKey::create([
'id' => 0,
'team_id' => 0,
'name' => 'localhost\'s key',
'description' => 'The private key for the Coolify host machine (localhost).',
'private_key' => $coolify_key,
]);
$server->update(['user' => $user]);
echo "SSH key found for the Coolify host machine (localhost).\n";
} else {
PrivateKey::create(
[
'id' => 0,
'team_id' => 0,
'name' => 'localhost\'s key',
'description' => 'The private key for the Coolify host machine (localhost).',
'private_key' => 'Paste here you private key!!',
]
);
echo "No SSH key found for the Coolify host machine (localhost).\n";
echo "Please read the following documentation (point 3) to fix it: https://coolify.io/docs/knowledge-base/server/openssh/\n";
echo "Your localhost connection won't work until then.";
}
}
} }
if (config('coolify.is_windows_docker_desktop')) { if (config('coolify.is_windows_docker_desktop')) {
PrivateKey::updateOrCreate( PrivateKey::updateOrCreate(
@@ -179,8 +197,8 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
get_public_ips(); get_public_ips();
$oauth_settings_seeder = new OauthSettingSeeder; $this->call(OauthSettingSeeder::class);
$oauth_settings_seeder->run(); $this->call(PopulateSshKeysDirectorySeeder::class);
} }
} }

View File

@@ -2,6 +2,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\Server; use App\Models\Server;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -15,7 +17,11 @@ class ServerSeeder extends Seeder
'description' => 'This is a test docker container in development mode', 'description' => 'This is a test docker container in development mode',
'ip' => 'coolify-testing-host', 'ip' => 'coolify-testing-host',
'team_id' => 0, 'team_id' => 0,
'private_key_id' => 0, 'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]); ]);
} }
} }

View File

@@ -26,5 +26,11 @@
"input.code": "One-time code", "input.code": "One-time code",
"input.recovery_code": "Recovery code", "input.recovery_code": "Recovery code",
"button.save": "Save", "button.save": "Save",
"repository.url": "<span class='text-helper'>Examples</span><br>For Public repositories, use <span class='text-helper'>https://...</span>.<br>For Private repositories, use <span class='text-helper'>git@...</span>.<br><br>https://github.com/coollabsio/coolify-examples <span class='text-helper'>main</span> branch will be selected<br>https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify <span class='text-helper'>nodejs-fastify</span> branch will be selected.<br>https://gitea.com/sedlav/expressjs.git <span class='text-helper'>main</span> branch will be selected.<br>https://gitlab.com/andrasbacsai/nodejs-example.git <span class='text-helper'>main</span> branch will be selected." "repository.url": "<span class='text-helper'>Examples</span><br>For Public repositories, use <span class='text-helper'>https://...</span>.<br>For Private repositories, use <span class='text-helper'>git@...</span>.<br><br>https://github.com/coollabsio/coolify-examples <span class='text-helper'>main</span> branch will be selected<br>https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify <span class='text-helper'>nodejs-fastify</span> branch will be selected.<br>https://gitea.com/sedlav/expressjs.git <span class='text-helper'>main</span> branch will be selected.<br>https://gitlab.com/andrasbacsai/nodejs-example.git <span class='text-helper'>main</span> branch will be selected.",
"service.stop": "This service will be stopped.",
"resource.docker_cleanup": "Run Docker Cleanup (remove unused images and builder cache).",
"resource.non_persistent": "All non-persistent data will be deleted.",
"resource.delete_volumes": "All volumes associated with this resource will be permanently deleted.",
"resource.delete_connected_networks": "All non-predefined networks associated with this resource will be permanently deleted.",
"resource.delete_configurations": "All configuration files will be permanently deleted from the server."
} }

View File

@@ -19,11 +19,7 @@ DB_PORT=5432
# Set to true to enable Ray # Set to true to enable Ray
RAY_ENABLED=false RAY_ENABLED=false
# Set custom ray port # Set custom ray port
RAY_PORT= # RAY_PORT=
# Clockwork Configuration
CLOCKWORK_ENABLED=false
CLOCKWORK_QUEUE_COLLECT=true
# Enable Laravel Telescope for debugging # Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false TELESCOPE_ENABLED=false

View File

@@ -10,6 +10,8 @@ DATE=$(date +"%Y%m%d-%H%M%S")
VERSION="1.5" VERSION="1.5"
DOCKER_VERSION="26.0" DOCKER_VERSION="26.0"
# TODO: Ask for a user
CURRENT_USER=$USER
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,metrics,logs} mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,metrics,logs}
mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/ssh/{keys,mux}
@@ -23,7 +25,7 @@ INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1 exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1
getAJoke() { getAJoke() {
JOKES=$(curl -s --max-time 2 https://v2.jokeapi.dev/joke/Programming?format=txt&type=single&amount=1 || true) JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true)
if [ "$JOKES" != "" ]; then if [ "$JOKES" != "" ]; then
echo -e " - Until then, here's a joke for you:\n" echo -e " - Until then, here's a joke for you:\n"
echo -e "$JOKES\n" echo -e "$JOKES\n"
@@ -477,7 +479,17 @@ syncSshKeys() {
fi fi
} }
syncSshKeys || true set +e
IS_COOLIFY_VOLUME_EXISTS=$(docker volume ls | grep coolify-db | wc -l)
set -e
if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then
echo " - Generating SSH key."
ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify
chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal
sed -i "/coolify/d" ~/.ssh/authorized_keys
cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >> ~/.ssh/authorized_keys
rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub
fi
chown -R 9999:root /data/coolify chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify chmod -R 700 /data/coolify

View File

@@ -1,4 +1,15 @@
@props([
'id',
'label' => null,
'helper' => null,
'disabled' => false,
'instantSave' => false,
'value' => null,
'hideLabel' => false,
])
<div class="flex flex-row items-center gap-4 px-2 py-1 form-control min-w-fit dark:hover:bg-coolgray-100"> <div class="flex flex-row items-center gap-4 px-2 py-1 form-control min-w-fit dark:hover:bg-coolgray-100">
@if(!$hideLabel)
<label class="flex gap-4 px-0 min-w-fit label"> <label class="flex gap-4 px-0 min-w-fit label">
<span class="flex gap-2"> <span class="flex gap-2">
@if ($label) @if ($label)
@@ -11,6 +22,7 @@
@endif @endif
</span> </span>
</label> </label>
@endif
<span class="flex-grow"></span> <span class="flex-grow"></span>
<input @disabled($disabled) type="checkbox" {{ $attributes->merge(['class' => $defaultClass]) }} <input @disabled($disabled) type="checkbox" {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($instantSave) wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' @if ($instantSave) wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}'

View File

@@ -1,14 +1,105 @@
@props([ @props([
'title' => 'Are you sure?', 'title' => 'Are you sure?',
'isErrorButton' => false, 'isErrorButton' => false,
'buttonTitle' => 'REWRITE THIS BUTTON TITLE PLEASSSSEEEE', 'buttonTitle' => 'Confirm Action',
'buttonFullWidth' => false, 'buttonFullWidth' => false,
'customButton' => null, 'customButton' => null,
'disabled' => false, 'disabled' => false,
'action' => 'delete', 'submitAction' => 'delete',
'content' => null, 'content' => null,
'checkboxes' => [],
'actions' => [],
'confirmWithText' => true,
'confirmationText' => 'Confirm Deletion',
'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below',
'shortConfirmationLabel' => 'Name',
'confirmWithPassword' => true,
'step1ButtonText' => 'Continue',
'step2ButtonText' => 'Continue',
'step3ButtonText' => 'Confirm',
'dispatchEvent' => false,
'dispatchEventType' => 'success',
'dispatchEventMessage' => '',
]) ])
<div x-data="{ modalOpen: false }" @keydown.escape.window="modalOpen = false" :class="{ 'z-40': modalOpen }"
<div x-data="{
modalOpen: false,
step: {{ empty($checkboxes) ? 2 : 1 }},
initialStep: {{ empty($checkboxes) ? 2 : 1 }},
finalStep: {{ $confirmWithPassword ? 3 : 2 }},
deleteText: '',
password: '',
actions: @js($actions),
confirmationText: @js($confirmationText),
userConfirmationText: '',
confirmWithText: @js($confirmWithText),
confirmWithPassword: @js($confirmWithPassword),
copied: false,
submitAction: @js($submitAction),
passwordError: '',
selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()),
dispatchEvent: @js($dispatchEvent),
dispatchEventType: @js($dispatchEventType),
dispatchEventMessage: @js($dispatchEventMessage),
resetModal() {
this.step = this.initialStep;
this.deleteText = '';
this.password = '';
this.userConfirmationText = '';
this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all());
$wire.$refresh();
},
step1ButtonText: @js($step1ButtonText),
step2ButtonText: @js($step2ButtonText),
step3ButtonText: @js($step3ButtonText),
validatePassword() {
if (this.confirmWithPassword && !this.password) {
return 'Password is required.';
}
return '';
},
submitForm() {
if (this.confirmWithPassword) {
this.passwordError = this.validatePassword();
if (this.passwordError) {
return Promise.resolve(this.passwordError);
}
}
const methodName = this.submitAction.split('(')[0];
const paramsMatch = this.submitAction.match(/\((.*?)\)/);
const params = paramsMatch ? paramsMatch[1].split(',').map(param => param.trim()) : [];
if (this.confirmWithPassword) {
params.push(this.password);
}
params.push(this.selectedActions);
return $wire[methodName](...params)
.then(result => {
if (result === true) {
return true;
} else if (typeof result === 'string') {
return result;
}
});
},
copyConfirmationText() {
navigator.clipboard.writeText(this.confirmationText);
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
},
toggleAction(id) {
const index = this.selectedActions.indexOf(id);
if (index > -1) {
this.selectedActions.splice(index, 1);
} else {
this.selectedActions.push(id);
}
}
}" @keydown.escape.window="modalOpen = false; resetModal()" :class="{ 'z-40': modalOpen }"
class="relative w-auto h-auto"> class="relative w-auto h-auto">
@if ($customButton) @if ($customButton)
@if ($buttonFullWidth) @if ($buttonFullWidth)
@@ -60,11 +151,9 @@
@endif @endif
@endif @endif
<template x-teleport="body"> <template x-teleport="body">
<div x-show="modalOpen" <div x-show="modalOpen" @click.away="modalOpen = false; resetModal()"
class="fixed top-0 lg:pt-10 left-0 z-[99] flex items-start justify-center w-screen h-screen" x-cloak> class="fixed top-0 lg:pt-10 left-0 z-[99] flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" <div x-show="modalOpen" @click="modalOpen = false; resetModal()"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black bg-opacity-20 backdrop-blur-sm"></div> class="absolute inset-0 w-full h-full bg-black bg-opacity-20 backdrop-blur-sm"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -72,56 +161,186 @@
x-transition:leave="ease-in duration-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95" x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded min-w-full lg:min-w-[36rem] max-w-fit bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300"> class="relative w-full py-6 border rounded min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex items-center justify-between pb-3"> <div class="flex items-center justify-between pb-3">
<h3 class="text-2xl font-bold">{{ $title }}</h3> <h3 class="text-2xl font-bold pr-8">{{ $title }}</h3>
{{-- <button @click="modalOpen=false" <button @click="modalOpen = false; resetModal()"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-coolgray-300"> class="absolute top-2 right-2 flex items-center justify-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor"> stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> --}} </button>
</div> </div>
<div class="relative w-auto pb-8"> <div class="relative w-auto pb-8">
{{ $slot }} @if (!empty($checkboxes))
<!-- Step 1: Select actions -->
<div x-show="step === 1">
<div class="flex justify-between items-center">
<h4>Actions</h4>
</div>
@foreach ($checkboxes as $index => $checkbox)
<div class="flex items-center justify-between mb-2">
<label for="{{ $checkbox['id'] }}"
class="text-sm leading-5 text-gray-700 dark:text-gray-300 flex-grow pr-4">
{{ $checkbox['label'] }}
</label>
<x-forms.checkbox :id="$checkbox['id']" :wire:model="$checkbox['id']"
x-on:change="toggleAction('{{ $checkbox['id'] }}')" :checked="$this->{$checkbox['id']}"
x-bind:checked="selectedActions.includes('{{ $checkbox['id'] }}')"
class="flex-shrink-0" :hideLabel="true" />
</div>
@endforeach
</div> </div>
<div class="flex flex-row justify-end space-x-2">
<x-forms.button @click="modalOpen=false"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
</x-forms.button>
<div class="flex-1"></div>
@if ($attributes->whereStartsWith('wire:click')->first())
@if ($isErrorButton)
<x-forms.button @click="modalOpen=false" class="w-24" isError type="button"
wire:click.prevent="{{ $attributes->get('wire:click') }}">Continue
</x-forms.button>
@else
<x-forms.button @click="modalOpen=false" class="w-24" isHighlighted type="button"
wire:click.prevent="{{ $attributes->get('wire:click') }}">Continue
</x-forms.button>
@endif @endif
@elseif ($attributes->whereStartsWith('@click')->first())
@if ($isErrorButton) <!-- Step 2: Confirm deletion -->
<x-forms.button class="w-24" isError type="button" <div x-show="step === 2">
@click="modalOpen=false;{{ $attributes->get('@click') }}">Continue <div class="bg-error border-l-4 border-red-500 text-white p-4 mb-4" role="alert">
</x-forms.button> <p class="font-bold">Warning</p>
@else <p>This operation is permanent and cannot be undone. Please think again before proceeding!
<x-forms.button class="w-24" isHighlighted type="button" </p>
@click="modalOpen=false;{{ $attributes->get('@click') }}">Continue </div>
</x-forms.button> <div class="mb-4">The following actions will be performed:</div>
<ul class="mb-4 space-y-2">
@foreach ($actions as $action)
<li class="flex items-center text-red-500">
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span>{{ $action }}</span>
</li>
@endforeach
@foreach ($checkboxes as $checkbox)
<template x-if="selectedActions.includes('{{ $checkbox['id'] }}')">
<li class="flex items-center text-red-500">
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span>{{ $checkbox['label'] }}</span>
</li>
</template>
@endforeach
</ul>
@if ($confirmWithText)
<div class="mb-4">
<h4 class="text-lg font-semibold mb-2">Confirm Actions</h4>
<p class="text-sm mb-2">{{ $confirmationLabel }}</p>
<div class="relative mb-2">
<input type="text" x-model="confirmationText"
class="w-full p-2 pr-10 rounded text-black input cursor-text" readonly>
<button @click="copyConfirmationText()"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
title="Copy confirmation text" x-ref="copyButton">
<template x-if="!copied">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path
d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
</template>
<template x-if="copied">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
</template>
</button>
</div>
<label for="userConfirmationText"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mt-4">
{{ $shortConfirmationLabel }}
</label>
<input type="text" x-model="userConfirmationText"
class="w-full p-2 rounded text-black input mt-1">
</div>
@endif @endif
@elseif ($action) </div>
@if ($isErrorButton)
<x-forms.button @click="modalOpen=false" class="w-24" isError type="button" <!-- Step 3: Password confirmation -->
wire:click.prevent="{{ $action }}">Continue <div x-show="step === 3 && confirmWithPassword">
<div class="bg-error border-l-4 border-red-500 text-white p-4 mb-4" role="alert">
<p class="font-bold">Final Confirmation</p>
<p>Please enter your password to confirm this destructive action.</p>
</div>
<div class="flex flex-col gap-2 mb-4">
<label for="password-confirm"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Your Password
</label>
<input type="password" id="password-confirm" x-model="password" class="input w-full"
placeholder="Enter your password">
<p x-show="passwordError" x-text="passwordError" class="text-red-500 text-sm mt-1"></p>
@error('password')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<!-- Navigation buttons -->
<div class="flex flex-wrap gap-2 justify-between mt-4">
<template x-if="step > initialStep">
<x-forms.button @click="step--" class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Back
</x-forms.button> </x-forms.button>
@else </template>
<x-forms.button @click="modalOpen=false" class="w-24" isHighlighted type="button" <template x-if="step === initialStep">
wire:click.prevent="{{ $action }}">Continue <x-forms.button @click="modalOpen = false; resetModal()"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Cancel
</x-forms.button> </x-forms.button>
@endif </template>
@endif
<template x-if="step === 1">
<x-forms.button @click="step++" class="w-auto" isError>
<span x-text="step1ButtonText"></span>
</x-forms.button>
</template>
<template x-if="step === 2">
<x-forms.button x-bind:disabled="confirmWithText && userConfirmationText !== confirmationText"
class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
if (confirmWithPassword) {
step++;
} else {
modalOpen = false;
resetModal();
submitForm();
}">
<span x-text="step2ButtonText"></span>
</x-forms.button>
</template>
<template x-if="step === 3 && confirmWithPassword">
<x-forms.button x-bind:disabled="!password" class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
submitForm().then((result) => {
if (result === true) {
modalOpen = false;
resetModal();
} else {
passwordError = result;
}
});
">
<span x-text="step3ButtonText"></span>
</x-forms.button>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,9 @@
<livewire:server.proxy.modal :server="$server" /> <livewire:server.proxy.modal :server="$server" />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h1>Server</h1> <h1>Server</h1>
@if ($server->proxySet())
<livewire:server.proxy.status :server="$server" /> <livewire:server.proxy.status :server="$server" />
@endif
</div> </div>
<div class="subtitle">{{ data_get($server, 'name') }}.</div> <div class="subtitle">{{ data_get($server, 'name') }}.</div>
<div class="navbar-main"> <div class="navbar-main">

View File

@@ -1,20 +1,18 @@
@if ($server->proxySet())
<div class="flex h-full pr-4"> <div class="flex h-full pr-4">
<div class="flex flex-col w-48 gap-4 min-w-fit"> <div class="flex flex-col w-48 gap-4 min-w-fit">
<a class="{{ request()->routeIs('server.proxy') ? 'dark:text-white' : '' }}" <a class="{{ request()->routeIs('server.proxy') ? 'dark:text-white' : '' }}"
href="{{ route('server.proxy', $parameters) }}"> href="{{ route('server.proxy', $parameters) }}">
<button>Configuration</button> <button>Configuration</button>
</a> </a>
@if ($server->proxyType() !== 'NONE')
{{-- @if ($server->proxyType() === 'TRAEFIK') --}}
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'dark:text-white' : '' }}" <a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'dark:text-white' : '' }}"
href="{{ route('server.proxy.dynamic-confs', $parameters) }}"> href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
<button>Dynamic Configurations</button> <button>Dynamic Configurations</button>
</a> </a>
{{-- @endif --}}
<a class="{{ request()->routeIs('server.proxy.logs') ? 'dark:text-white' : '' }}" <a class="{{ request()->routeIs('server.proxy.logs') ? 'dark:text-white' : '' }}"
href="{{ route('server.proxy.logs', $parameters) }}"> href="{{ route('server.proxy.logs', $parameters) }}">
<button>Logs</button> <button>Logs</button>
</a> </a>
</div>
</div>
@endif @endif
</div>
</div>

View File

@@ -1,5 +1,5 @@
<x-emails.layout> <x-emails.layout>
Your server ({{ $name }}) disabled because it is not paid! All automations and integrations are stopped. Your server ({{ $name }}) disabled because it is not paid! All automations and integrations are stopped.
Please update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions). Please update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).
</x-emails.layout> </x-emails.layout>

View File

@@ -122,9 +122,19 @@
@if (count($deployments_per_server) > 0) @if (count($deployments_per_server) > 0)
<x-loading /> <x-loading />
@endif @endif
<x-modal-confirmation isErrorButton action="cleanup_queue" buttonTitle="Cleanup Queues"> <x-modal-confirmation
This will clean up the deployment queue. <br>Please think again. title="Confirm Cleanup Queues?"
</x-modal-confirmation> buttonTitle="Cleanup Queues"
isErrorButton
submitAction="cleanup_queue"
:actions="['All running Deployment Queues will be cleaned up.']"
:confirmWithText="false"
:confirmWithPassword="false"
step2ButtonText="Permanently Cleanup Deployment Queues"
:dispatchEvent="true"
dispatchEventType="success"
dispatchEventMessage="Deployment Queues cleanup started."
/>
</div> </div>
<div wire:poll.3000ms="get_deployments" class="grid grid-cols-1"> <div wire:poll.3000ms="get_deployments" class="grid grid-cols-1">
@forelse ($deployments_per_server as $server_name => $deployments) @forelse ($deployments_per_server as $server_name => $deployments)
@@ -170,4 +180,6 @@
} }
</script> </script>
{{-- <x-forms.button wire:click='getIptables'>Get IPTABLES</x-forms.button> --}} {{-- <x-forms.button wire:click='getIptables'>Get IPTABLES</x-forms.button> --}}
</div> </div>

View File

@@ -6,9 +6,10 @@
Save Save
</x-forms.button> </x-forms.button>
@if ($destination->network !== 'coolify') @if ($destination->network !== 'coolify')
<x-modal-confirmation isErrorButton buttonTitle="Delete Destination"> <x-modal-confirmation title="Confirm Destination Deletion?" buttonTitle="Delete Destination" isErrorButton
This destination will be deleted. It is not reversible. <br>Please think again. submitAction="delete" :actions="['This will delete the selected destination/network.']" confirmationText="{{ $destination->name }}"
</x-modal-confirmation> confirmationLabel="Please confirm the execution of the actions by entering the Destination Name below"
shortConfirmationLabel="Destination Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endif @endif
</div> </div>

View File

@@ -1,5 +1,13 @@
<div> <div>
<x-modal-confirmation buttonFullWidth isErrorButton buttonTitle="Delete Team"> <x-modal-confirmation
This team be deleted. It is not reversible. <br>Please think again. title="Confirm Team Deletion?"
</x-modal-confirmation> buttonTitle="Delete Team"
isErrorButton
submitAction="delete"
:actions="['The current Team will be permanently deleted.']"
confirmationText="{{ $team }}"
confirmationLabel="Please confirm the execution of the actions by entering the Team Name below"
shortConfirmationLabel="Team Name"
step3ButtonText="Permanently Delete"
/>
</div> </div>

View File

@@ -68,11 +68,20 @@
<option value="www">Redirect to www.</option> <option value="www">Redirect to www.</option>
<option value="non-www">Redirect to non-www.</option> <option value="non-www">Redirect to non-www.</option>
</x-forms.select> </x-forms.select>
<x-modal-confirmation action="set_redirect"> <x-modal-confirmation
title="Confirm Redirection Setting?"
buttonTitle="Set Direction"
submitAction="set_redirect"
:actions="['All traffic will be redirected to the selected direction.']"
confirmationText="{{ $application->fqdn . '/' }}"
confirmationLabel="Please confirm the execution of the action by entering the Application URL below"
shortConfirmationLabel="Application URL"
:confirmWithPassword="false"
step2ButtonText="Set Direction"
>
<x-slot:customButton> <x-slot:customButton>
<div class="w-[7.2rem]">Set Direction</div> <div class="w-[7.2rem]">Set Direction</div>
</x-slot:customButton> </x-slot:customButton>
This will reset the container labels. Are you sure?
</x-modal-confirmation> </x-modal-confirmation>
</div> </div>
@endif @endif
@@ -302,12 +311,18 @@
helper="If you know what are you doing, you can enable this to edit the labels directly. Coolify won't update labels automatically. <br><br>Be careful, it could break the proxy configuration after you restart the container." helper="If you know what are you doing, you can enable this to edit the labels directly. Coolify won't update labels automatically. <br><br>Be careful, it could break the proxy configuration after you restart the container."
id="application.settings.is_container_label_readonly_enabled" instantSave></x-forms.checkbox> id="application.settings.is_container_label_readonly_enabled" instantSave></x-forms.checkbox>
</div> </div>
<x-modal-confirmation buttonFullWidth action="resetDefaultLabels" <x-modal-confirmation
buttonTitle="Reset to Coolify Generated Labels"> title="Confirm Labels Reset to Coolify Defaults?"
Are you sure you want to reset the labels to Coolify generated labels? <br>It could break the proxy buttonTitle="Reset Labels to Coolify Defaults"
configuration after you restart the container. buttonFullWidth
</x-modal-confirmation> submitAction="resetDefaultLabels"
:actions="['All your custom proxy labels will be lost.', 'Proxy labels (traefik, caddy, etc) will be reset to the coolify defaults.']"
confirmationText="{{ $application->fqdn . '/' }}"
confirmationLabel="Please confirm the execution of the actions by entering the Application URL below"
shortConfirmationLabel="Application URL"
:confirmWithPassword="false"
step2ButtonText="Permanently Reset Labels"
/>
@endif @endif
<h3 class="pt-8">Pre/Post Deployment Commands</h3> <h3 class="pt-8">Pre/Post Deployment Commands</h3>

View File

@@ -72,7 +72,13 @@
</x-forms.button> </x-forms.button>
@endif @endif
@endif @endif
<x-modal-confirmation @click="$wire.dispatch('stopEvent')"> <x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
submitAction="stop" :checkboxes="$checkboxes" :actions="[
'This application will be stopped.',
'All non-persistent data of this application will be deleted.',
]" :confirmWithText="false" :confirmWithPassword="false"
step1ButtonText="Continue" step2ButtonText="Confirm" :dispatchEvent="true"
dispatchEventType="stopEvent">
<x-slot:button-title> <x-slot:button-title>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
@@ -87,7 +93,6 @@
</svg> </svg>
Stop Stop
</x-slot:button-title> </x-slot:button-title>
This application will be stopped. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
@else @else
<x-forms.button wire:click='deploy'> <x-forms.button wire:click='deploy'>

View File

@@ -152,8 +152,15 @@
@endif @endif
</x-forms.button> </x-forms.button>
@if (data_get($preview, 'status') !== 'exited') @if (data_get($preview, 'status') !== 'exited')
<x-modal-confirmation isErrorButton <x-modal-confirmation
action="stop({{ data_get($preview, 'pull_request_id') }})"> title="Confirm Preview Deployment Stopping?"
buttonTitle="Stop"
submitAction="stop({{ data_get($preview, 'pull_request_id') }})"
:actions="['This preview deployment will be stopped.', 'If the preview deployment is currently in use data could be lost.', 'All non-persistent data of this preview deployment (containers, networks, unused images) will be deleted (don\'t worry, no data is lost and you can start the preview deployment again).']"
:confirmWithText="false"
:confirmWithPassword="false"
step2ButtonText="Stop Preview Deployment"
>
<x-slot:customButton> <x-slot:customButton>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
@@ -168,14 +175,19 @@
</svg> </svg>
Stop Stop
</x-slot:customButton> </x-slot:customButton>
This will stop the preview deployment. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
@endif @endif
<x-modal-confirmation isErrorButton <x-modal-confirmation
action="delete({{ data_get($preview, 'pull_request_id') }})" buttonTitle="Delete"> title="Confirm Preview Deployment Deletion?"
This will delete the preview deployment. <br>Please think again. buttonTitle="Delete"
</x-modal-confirmation> isErrorButton
submitAction="delete({{ data_get($preview, 'pull_request_id') }})"
:actions="['All containers of this preview deployment will be stopped and permanently deleted.']"
confirmationText="{{ data_get($preview, 'fqdn'). '/' }}"
confirmationLabel="Please confirm the execution of the actions by entering the Preview Deployment name below"
shortConfirmationLabel="Preview Deployment Name"
:confirmWithPassword="false"
/>
</div> </div>
</div> </div>
@endforeach @endforeach

View File

@@ -8,12 +8,17 @@
<livewire:project.database.backup-now :backup="$backup" /> <livewire:project.database.backup-now :backup="$backup" />
@endif @endif
@if ($backup->database_id !== 0) @if ($backup->database_id !== 0)
<x-modal-confirmation isErrorButton> <x-modal-confirmation
<x-slot:button-title> title="Confirm Backup Schedule Deletion?"
Delete buttonTitle="Delete Backups and Schedule"
</x-slot:button-title> isErrorButton
This will stop the scheduled backup for this database.<br>Please think again. submitAction="delete"
</x-modal-confirmation> :checkboxes="$checkboxes"
:actions="['The selected backup schedule will be deleted.', 'Scheduled backups for this database will be stopped (if this is the only backup schedule for this database).']"
confirmationText="{{ $backup->database->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below"
shortConfirmationLabel="Database Name"
/>
@endif @endif
</div> </div>
<div class="w-48 pb-2"> <div class="w-48 pb-2">

View File

@@ -45,12 +45,20 @@
<x-forms.button class="dark:hover:bg-coolgray-400" <x-forms.button class="dark:hover:bg-coolgray-400"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button> x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
@endif @endif
<x-modal-confirmation isErrorButton action="deleteBackup({{ data_get($execution, 'id') }})"> <x-modal-confirmation
<x-slot:button-title> title="Confirm Backup Deletion?"
Delete buttonTitle="Delete"
</x-slot:button-title> isErrorButton
This will delete this backup. It is not reversible.<br>Please think again. submitAction="deleteBackup({{ data_get($execution, 'id') }})"
</x-modal-confirmation> {{-- :checkboxes="$checkboxes" --}}
:actions="[
'This backup will be permanently deleted from local storage.'
]"
confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename"
step3ButtonText="Permanently Delete"
/>
</div> </div>
</div> </div>
@empty @empty

View File

@@ -35,7 +35,12 @@
</nav> </nav>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
@if (!str($database->status)->startsWith('exited')) @if (!str($database->status)->startsWith('exited'))
<x-modal-confirmation @click="$wire.dispatch('restartEvent')"> <x-modal-confirmation title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restart"
:actions="[
'This database will be unavailable during the restart.',
'If the database is currently in use data could be lost.',
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Database"
:dispatchEvent="true" dispatchEventType="restartEvent">
<x-slot:button-title> <x-slot:button-title>
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
@@ -46,9 +51,14 @@
</svg> </svg>
Restart Restart
</x-slot:button-title> </x-slot:button-title>
This database will be restarted. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
<x-modal-confirmation @click="$wire.dispatch('stopEvent')"> <x-modal-confirmation title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop"
:checkboxes="$checkboxes" :actions="[
'This database will be stopped.',
'If the database is currently in use data could be lost.',
'All non-persistent data of this database (containers, networks, unused images) will be deleted (don\'t worry, no data is lost and you can start the database again).',
]" :confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue"
step2ButtonText="Stop Database" :dispatchEvent="true" dispatchEventType="stopEvent">
<x-slot:button-title> <x-slot:button-title>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
@@ -61,7 +71,6 @@
</svg> </svg>
Stop Stop
</x-slot:button-title> </x-slot:button-title>
This database will be stopped. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
@else @else
<button @click="$wire.dispatch('startEvent')" class="gap-2 button"> <button @click="$wire.dispatch('startEvent')" class="gap-2 button">

View File

@@ -2,9 +2,13 @@
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<x-forms.input id="filename" label="Filename" /> <x-forms.input id="filename" label="Filename" />
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
<x-modal-confirmation isErrorButton buttonTitle="Delete"> <x-modal-confirmation title="Confirm init-script deletion?" buttonTitle="Delete" isErrorButton
This script will be deleted. It is not reversible. <br>Please think again. submitAction="delete" :actions="[
</x-modal-confirmation> 'The init-script of this database will be permanently deleted.',
'If you are actively using this init-script, it could cause errors on redeployment.',
]" confirmationText="{{ $filename }}"
confirmationLabel="Please confirm the execution of the actions by entering the init-script name below"
shortConfirmationLabel="Init-script Name" :confirmWithPassword=false step2ButtonText="Permanently Delete" />
</div> </div>
<x-forms.textarea id="content" label="Content" /> <x-forms.textarea id="content" label="Content" />
</form> </form>

View File

@@ -1,3 +1,5 @@
<x-modal-confirmation isErrorButton buttonTitle="Delete Environment" disabled="{{ $disabled }}"> <x-modal-confirmation title="Confirm Environment Deletion?" buttonTitle="Delete Environment" isErrorButton
This environment will be deleted. It is not reversible. <br>Please think again. submitAction="delete" :actions="['This will delete the selected environment.']"
</x-modal-confirmation> confirmationLabel="Please confirm the execution of the actions by entering the Environment Name below"
shortConfirmationLabel="Environment Name" confirmationText="{{ $environmentName }}" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />

View File

@@ -1,3 +1,7 @@
<x-modal-confirmation isErrorButton buttonTitle="Delete Project" disabled="{{ $disabled }}"> <x-modal-confirmation title="Confirm Project Deletion?" buttonTitle="Delete Project" isErrorButton submitAction="delete"
This project will be deleted. It is not reversible. <br>Please think again. :actions="[
</x-modal-confirmation> 'This will delete the selected project',
'All Environments inside the project will be deleted as well.',
]" confirmationLabel="Please confirm the execution of the actions by entering the Project Name below"
shortConfirmationLabel="Project Name" confirmationText="{{ $projectName }}" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />

View File

@@ -491,7 +491,9 @@
</div> </div>
<div class="pb-4 text-xs">Trademarks Policy: The respective trademarks mentioned here are owned by the <div class="pb-4 text-xs">Trademarks Policy: The respective trademarks mentioned here are owned by the
respective respective
companies, and use of them does not imply any affiliation or endorsement.</div> companies, and use of them does not imply any affiliation or endorsement.<br>Find more services <a
class="dark:text-white underline" target="_blank"
href="https://coolify.io/docs/services">here</a>.</div>
<input class="input" autofocus wire:model.live.debounce.200ms="search" autofocus <input class="input" autofocus wire:model.live.debounce.200ms="search" autofocus
placeholder="Search..."> placeholder="Search...">
@if ($loadingServices) @if ($loadingServices)

View File

@@ -107,11 +107,15 @@
Settings Settings
</a> </a>
@if (str($application->status)->contains('running')) @if (str($application->status)->contains('running'))
<x-modal-confirmation action="restartApplication({{ $application->id }})" <x-modal-confirmation
isErrorButton buttonTitle="Restart"> title="Confirm Service Application Restart?"
This application will be unavailable during the restart. <br>Please think buttonTitle="Restart"
again. submitAction="restartApplication({{ $application->id }})"
</x-modal-confirmation> :actions="['The selected service application will be unavailable during the restart.', 'If the service application is currently in use data could be lost.']"
:confirmWithText="false"
:confirmWithPassword="false"
step2ButtonText="Restart Service Container"
/>
@endif @endif
</div> </div>
</div> </div>
@@ -151,11 +155,15 @@
Settings Settings
</a> </a>
@if (str($database->status)->contains('running')) @if (str($database->status)->contains('running'))
<x-modal-confirmation action="restartDatabase({{ $database->id }})" <x-modal-confirmation
isErrorButton buttonTitle="Restart"> title="Confirm Service Database Restart?"
This database will be unavailable during the restart. <br>Please think buttonTitle="Restart"
again. submitAction="restartDatabase({{ $database->id }})"
</x-modal-confirmation> :actions="['This service database will be unavailable during the restart.', 'If the service database is currently in use data could be lost.']"
:confirmWithText="false"
:confirmWithPassword="false"
step2ButtonText="Restart Database"
/>
@endif @endif
</div> </div>
</div> </div>

View File

@@ -15,18 +15,32 @@
<form wire:submit='submit' class="flex flex-col gap-2"> <form wire:submit='submit' class="flex flex-col gap-2">
<div class="flex gap-2"> <div class="flex gap-2">
@if ($fileStorage->is_directory) @if ($fileStorage->is_directory)
<x-modal-confirmation action="convertToFile" buttonTitle="Convert to file"> <x-modal-confirmation title="Confirm Directory Conversion to File?" buttonTitle="Convert to file"
<div>This will delete all files in this directory. It is not reversible. <strong submitAction="convertToFile" :actions="[
class="text-error">Please think 'All files in this directory will be permanently deleted and an empty file will be created in its place.',
again.</strong><br><br></div> ]" confirmationText="{{ $fs_path }}"
</x-modal-confirmation> confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
@else @else
<x-modal-confirmation action="convertToDirectory" buttonTitle="Convert to directory"> <x-modal-confirmation title="Confirm File Conversion to Directory?" buttonTitle="Convert to directory"
<div>This will delete the file and make a directory instead. It is not reversible. submitAction="convertToDirectory" :actions="[
<strong class="text-error">Please think 'The selected file will be permanently deleted and an empty directory will be created in its place.',
again.</strong><br><br> ]" confirmationText="{{ $fs_path }}"
</div> confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
</x-modal-confirmation> shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to directory" />
@endif
@if ($fileStorage->is_directory)
<x-modal-confirmation title="Confirm Directory Deletion?" buttonTitle="Delete Directory" isErrorButton
submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
]" confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" step3ButtonText="Permanently Delete" />
@else
<x-modal-confirmation title="Confirm File Deletion?" buttonTitle="Delete File" isErrorButton
submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']" confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" step3ButtonText="Permanently Delete" />
@endif @endif
@if (!$fileStorage->is_based_on_git) @if (!$fileStorage->is_based_on_git)

View File

@@ -32,7 +32,9 @@
</svg> </svg>
Pull Latest Images & Restart Pull Latest Images & Restart
</button> </button>
<x-modal-confirmation @click="$wire.dispatch('stopEvent')"> <x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" submitAction="stop"
:checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]" :confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue"
step2ButtonText="Stop Service" :dispatchEvent="true" dispatchEventType="stopEvent">
<x-slot:button-title> <x-slot:button-title>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
@@ -45,7 +47,6 @@
</svg> </svg>
Stop Stop
</x-slot:button-title> </x-slot:button-title>
This service will be stopped. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
@elseif (str($service->status())->contains('degraded')) @elseif (str($service->status())->contains('degraded'))
<button @click="$wire.dispatch('startEvent')" class="gap-2 button"> <button @click="$wire.dispatch('startEvent')" class="gap-2 button">
@@ -58,7 +59,10 @@
</svg> </svg>
Restart Degraded Services Restart Degraded Services
</button> </button>
<x-modal-confirmation @click="$wire.dispatch('stopEvent')"> <x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" submitAction="stop"
:checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]" :confirmWithText="false" :confirmWithPassword="false"
step1ButtonText="Continue" step2ButtonText="Stop Service" :dispatchEvent="true"
dispatchEventType="stopEvent">
<x-slot:button-title> <x-slot:button-title>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
@@ -71,7 +75,6 @@
</svg> </svg>
Stop Stop
</x-slot:button-title> </x-slot:button-title>
This service will be stopped. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
@elseif (str($service->status())->contains('exited')) @elseif (str($service->status())->contains('exited'))
<button wire:click='stop(true)' class="gap-2 button"> <button wire:click='stop(true)' class="gap-2 button">
@@ -92,7 +95,10 @@
Deploy Deploy
</button> </button>
@else @else
<x-modal-confirmation @click="$wire.dispatch('stopEvent')"> <x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" submitAction="stop"
:checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]" :confirmWithText="false" :confirmWithPassword="false"
step1ButtonText="Continue" step2ButtonText="Stop Service" :dispatchEvent="true"
dispatchEventType="stopEvent">
<x-slot:button-title> <x-slot:button-title>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
@@ -105,7 +111,6 @@
</svg> </svg>
Stop Stop
</x-slot:button-title> </x-slot:button-title>
This service will be stopped. <br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
<button @click="$wire.dispatch('startEvent')" class="gap-2 button"> <button @click="$wire.dispatch('startEvent')" class="gap-2 button">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"

View File

@@ -7,12 +7,11 @@
<h2>{{ Str::headline($application->name) }}</h2> <h2>{{ Str::headline($application->name) }}</h2>
@endif @endif
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
<x-modal-confirmation isErrorButton> <x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete" isErrorButton
<x-slot:button-title> submitAction="delete" {{-- :checkboxes="$checkboxes" --}} :actions="['The selected service application container will be stopped and permanently deleted.']"
Delete confirmationText="{{ Str::headline($application->name) }}"
</x-slot:button-title> confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
This will delete this service application. It is not reversible.<br>Please think again. shortConfirmationLabel="Service Application Name" step3ButtonText="Permanently Delete" />
</x-modal-confirmation>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex gap-2"> <div class="flex gap-2">

View File

@@ -2,15 +2,10 @@
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
<div class="">Woah. I hope you know what are you doing.</div> <div class="">Woah. I hope you know what are you doing.</div>
<h4 class="pt-4">Delete Resource</h4> <h4 class="pt-4">Delete Resource</h4>
<div class="pb-4">This will stop your containers, delete all related data, etc. Beware! There is no coming <div class="pb-4">This will stop your containers, delete all related data, etc. Beware! There is no coming back!
back!
</div> </div>
<x-modal-confirmation isErrorButton buttonTitle="Delete" confirm={{ $confirm }}> <x-modal-confirmation title="Confirm Resource Deletion?" buttonTitle="Delete" isErrorButton submitAction="delete"
<div class="px-2">This resource will be deleted. It is not reversible. <strong class="text-error">Please think buttonTitle="Delete" :checkboxes="$checkboxes" :actions="['All containers of this resource will be stopped and permanently deleted.']" confirmationText="{{ $resourceName }}"
again.</strong><br><br></div> confirmationLabel="Please confirm the execution of the actions by entering the NAME of the resource below"
<h4>Actions</h4> shortConfirmationLabel="Resource Name" step3ButtonText="Delete Permanently" />
<x-forms.checkbox id="delete_configurations"
label="Permanently delete configuration files from the server?"></x-forms.checkbox>
<x-forms.checkbox id="delete_volumes" label="Permanently delete associated volumes?"></x-forms.checkbox>
</x-modal-confirmation>
</div> </div>

View File

@@ -63,11 +63,16 @@
wire:click="stop('{{ data_get($destination, 'server.id') }}')">Stop</x-forms.button> wire:click="stop('{{ data_get($destination, 'server.id') }}')">Stop</x-forms.button>
@endif @endif
<x-modal-confirmation <x-modal-confirmation
action="removeServer({{ data_get($destination, 'id') }},{{ data_get($destination, 'server.id') }})" title="Confirm server removal?"
isErrorButton buttonTitle="Remove Server"> isErrorButton
This will stop the running application in this server and remove it as a deployment buttonTitle="Remove Server"
destination.<br><br>Please think again. submitAction="removeServer({{ data_get($destination, 'id') }},{{ data_get($destination, 'server.id') }})"
</x-modal-confirmation> :actions="['This will stop the all running applications on this server and remove it as a deployment destination.']"
confirmationText="{{ data_get($destination, 'server.name') }}"
confirmationLabel="Please confirm the execution of the actions by entering the Server Name below"
shortConfirmationLabel="Server Name"
step3ButtonText="Permanently Remove Server"
/>
</div> </div>
</div> </div>
@endforeach @endforeach

View File

@@ -4,7 +4,9 @@
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value" <x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
x-bind:label="$wire.is_multiline === false && 'Value'" required /> x-bind:label="$wire.is_multiline === false && 'Value'" required />
@if (data_get($parameters, 'application_uuid')) @if (data_get($parameters, 'application_uuid'))
<x-forms.checkbox id="is_build_time" label="Build Variable?" /> <x-forms.checkbox id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Build Variable?" />
@endif @endif
<x-forms.checkbox id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox id="is_multiline" label="Is Multiline?" />
@if (!$shared) @if (!$shared)

View File

@@ -11,10 +11,11 @@
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" /> <path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" />
</g> </g>
</svg> </svg>
<x-modal-confirmation isErrorButton buttonTitle="Delete"> <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton buttonTitle="Delete"
You will delete environment variable <span submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']" confirmationText="{{ $env->key }}"
class="font-bold dark:text-warning text-coollabs">{{ $env->key }}</span>. confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
</x-modal-confirmation> shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
</div> </div>
@else @else
@if ($isDisabled) @if ($isDisabled)
@@ -41,10 +42,14 @@
@endif @endif
<div class="flex flex-col w-full gap-2 lg:flex-row"> <div class="flex flex-col w-full gap-2 lg:flex-row">
@if ($type === 'service') @if ($type === 'service')
<x-forms.checkbox instantSave id="env.is_build_time" label="Build Variable?" /> <x-forms.checkbox instantSave id="env.is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Build Variable?" />
@else @else
@if ($env->is_shared) @if ($env->is_shared)
<x-forms.checkbox instantSave id="env.is_build_time" label="Build Variable?" /> <x-forms.checkbox instantSave id="env.is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Build Variable?" />
<x-forms.checkbox instantSave id="env.is_literal" <x-forms.checkbox instantSave id="env.is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" /> label="Is Literal?" />
@@ -52,7 +57,9 @@
@if ($isSharedVariable) @if ($isSharedVariable)
<x-forms.checkbox instantSave id="env.is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="env.is_multiline" label="Is Multiline?" />
@else @else
<x-forms.checkbox instantSave id="env.is_build_time" label="Build Variable?" /> <x-forms.checkbox instantSave id="env.is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Build Variable?" />
<x-forms.checkbox instantSave id="env.is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="env.is_multiline" label="Is Multiline?" />
@if (!data_get($env, 'is_multiline')) @if (!data_get($env, 'is_multiline'))
<x-forms.checkbox instantSave id="env.is_literal" <x-forms.checkbox instantSave id="env.is_literal"
@@ -70,10 +77,12 @@
<x-forms.button wire:click='lock'> <x-forms.button wire:click='lock'>
Lock Lock
</x-forms.button> </x-forms.button>
<x-modal-confirmation isErrorButton buttonTitle="Delete"> <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
You will delete environment variable <span buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
class="font-bold dark:text-warning">{{ $env->key }}</span>. confirmationText="{{ $env->key }}"
</x-modal-confirmation> confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else @else
<x-forms.button type="submit"> <x-forms.button type="submit">
Update Update
@@ -81,10 +90,12 @@
<x-forms.button wire:click='lock'> <x-forms.button wire:click='lock'>
Lock Lock
</x-forms.button> </x-forms.button>
<x-modal-confirmation buttonFullWidth isErrorButton buttonTitle="Delete"> <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
You will delete environment variable <span buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
class="font-bold dark:text-warning">{{ $env->key }}</span>. confirmationText="{{ $env->key }}"
</x-modal-confirmation> confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endif @endif
</div> </div>
@endif @endif

Some files were not shown because too many files have changed in this diff Show More