v4.0.0-beta.467 (#8911)
This commit is contained in:
commit
ce076817d2
44 changed files with 1195 additions and 115 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\ProxyDashboardCacheService;
|
use App\Services\ProxyDashboardCacheService;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class GetProxyConfiguration
|
class GetProxyConfiguration
|
||||||
|
|
@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
||||||
return 'OK';
|
return 'OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
$proxy_path = $server->proxyPath();
|
|
||||||
$proxy_configuration = null;
|
$proxy_configuration = null;
|
||||||
|
|
||||||
// If not forcing regeneration, try to read existing configuration
|
|
||||||
if (! $forceRegenerate) {
|
if (! $forceRegenerate) {
|
||||||
$payload = [
|
// Primary source: database
|
||||||
"mkdir -p $proxy_path",
|
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
|
||||||
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
|
||||||
];
|
// Backfill: existing servers may not have DB config yet — read from disk once
|
||||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
if (empty(trim($proxy_configuration ?? ''))) {
|
||||||
|
$proxy_configuration = $this->backfillFromDisk($server);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate default configuration if:
|
// Generate default configuration as last resort
|
||||||
// 1. Force regenerate is requested
|
|
||||||
// 2. Configuration file doesn't exist or is empty
|
|
||||||
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
||||||
// Extract custom commands from existing config before regenerating
|
|
||||||
$custom_commands = [];
|
$custom_commands = [];
|
||||||
if (! empty(trim($proxy_configuration ?? ''))) {
|
if (! empty(trim($proxy_configuration ?? ''))) {
|
||||||
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
|
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log::warning('Proxy configuration regenerated to defaults', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found',
|
||||||
|
]);
|
||||||
|
|
||||||
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
|
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,4 +54,30 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
||||||
|
|
||||||
return $proxy_configuration;
|
return $proxy_configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill: read config from disk for servers that predate DB storage.
|
||||||
|
* Stores the result in the database so future reads skip SSH entirely.
|
||||||
|
*/
|
||||||
|
private function backfillFromDisk(Server $server): ?string
|
||||||
|
{
|
||||||
|
$proxy_path = $server->proxyPath();
|
||||||
|
$result = instant_remote_process([
|
||||||
|
"mkdir -p $proxy_path",
|
||||||
|
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
||||||
|
], $server, false);
|
||||||
|
|
||||||
|
if (! empty(trim($result ?? ''))) {
|
||||||
|
$server->proxy->last_saved_proxy_configuration = $result;
|
||||||
|
$server->save();
|
||||||
|
|
||||||
|
Log::info('Proxy config backfilled to database from disk', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,41 @@ class SaveProxyConfiguration
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
|
private const MAX_BACKUPS = 10;
|
||||||
|
|
||||||
public function handle(Server $server, string $configuration): void
|
public function handle(Server $server, string $configuration): void
|
||||||
{
|
{
|
||||||
$proxy_path = $server->proxyPath();
|
$proxy_path = $server->proxyPath();
|
||||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||||
|
$new_hash = str($docker_compose_yml_base64)->pipe('md5')->value;
|
||||||
|
|
||||||
// Update the saved settings hash
|
// Only create a backup if the configuration actually changed
|
||||||
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
|
$old_hash = $server->proxy->get('last_saved_settings');
|
||||||
|
$config_changed = $old_hash && $old_hash !== $new_hash;
|
||||||
|
|
||||||
|
// Update the saved settings hash and store full config as database backup
|
||||||
|
$server->proxy->last_saved_settings = $new_hash;
|
||||||
|
$server->proxy->last_saved_proxy_configuration = $configuration;
|
||||||
$server->save();
|
$server->save();
|
||||||
|
|
||||||
// Transfer the configuration file to the server
|
$backup_path = "$proxy_path/backups";
|
||||||
instant_remote_process([
|
|
||||||
"mkdir -p $proxy_path",
|
// Transfer the configuration file to the server, with backup if changed
|
||||||
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
|
$commands = ["mkdir -p $proxy_path"];
|
||||||
], $server);
|
|
||||||
|
if ($config_changed) {
|
||||||
|
$short_hash = substr($old_hash, 0, 8);
|
||||||
|
$timestamp = now()->format('Y-m-d_H-i-s');
|
||||||
|
$backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml";
|
||||||
|
$commands[] = "mkdir -p $backup_path";
|
||||||
|
// Skip backup if a file with the same hash already exists (identical content)
|
||||||
|
$commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true";
|
||||||
|
// Prune old backups, keep only the most recent ones
|
||||||
|
$commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
|
||||||
|
$commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null";
|
||||||
|
|
||||||
|
instant_remote_process($commands, $server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3670,6 +3670,15 @@ public function action_deploy(Request $request)
|
||||||
type: 'string',
|
type: 'string',
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'docker_cleanup',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
responses: [
|
responses: [
|
||||||
new OA\Response(
|
new OA\Response(
|
||||||
|
|
@ -3718,7 +3727,8 @@ public function action_stop(Request $request)
|
||||||
|
|
||||||
$this->authorize('deploy', $application);
|
$this->authorize('deploy', $application);
|
||||||
|
|
||||||
StopApplication::dispatch($application);
|
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||||
|
StopApplication::dispatch($application, false, $dockerCleanup);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -2602,6 +2602,15 @@ public function action_deploy(Request $request)
|
||||||
type: 'string',
|
type: 'string',
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'docker_cleanup',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
responses: [
|
responses: [
|
||||||
new OA\Response(
|
new OA\Response(
|
||||||
|
|
@ -2653,7 +2662,9 @@ public function action_stop(Request $request)
|
||||||
if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) {
|
if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) {
|
||||||
return response()->json(['message' => 'Database is already stopped.'], 400);
|
return response()->json(['message' => 'Database is already stopped.'], 400);
|
||||||
}
|
}
|
||||||
StopDatabase::dispatch($database);
|
|
||||||
|
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||||
|
StopDatabase::dispatch($database, $dockerCleanup);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1676,6 +1676,15 @@ public function action_deploy(Request $request)
|
||||||
type: 'string',
|
type: 'string',
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'docker_cleanup',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
responses: [
|
responses: [
|
||||||
new OA\Response(
|
new OA\Response(
|
||||||
|
|
@ -1727,7 +1736,9 @@ public function action_stop(Request $request)
|
||||||
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
|
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
|
||||||
return response()->json(['message' => 'Service is already stopped.'], 400);
|
return response()->json(['message' => 'Service is already stopped.'], 400);
|
||||||
}
|
}
|
||||||
StopService::dispatch($service);
|
|
||||||
|
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||||
|
StopService::dispatch($service, false, $dockerCleanup);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -2196,7 +2196,7 @@ private function clone_repository()
|
||||||
$this->create_workdir();
|
$this->create_workdir();
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit)." --pretty=%B"),
|
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'),
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
'save' => 'commit_message',
|
'save' => 'commit_message',
|
||||||
]
|
]
|
||||||
|
|
@ -2462,7 +2462,9 @@ private function generate_env_variables()
|
||||||
|
|
||||||
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||||
$coolify_envs->each(function ($value, $key) {
|
$coolify_envs->each(function ($value, $key) {
|
||||||
$this->env_args->put($key, $value);
|
if (! is_null($value) && $value !== '') {
|
||||||
|
$this->env_args->put($key, $value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// For build process, include only environment variables where is_buildtime = true
|
// For build process, include only environment variables where is_buildtime = true
|
||||||
|
|
@ -2777,9 +2779,10 @@ private function generate_healthcheck_commands()
|
||||||
{
|
{
|
||||||
// Handle CMD type healthcheck
|
// Handle CMD type healthcheck
|
||||||
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
||||||
$this->full_healthcheck_url = $this->application->health_check_command;
|
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
|
||||||
|
$this->full_healthcheck_url = $command;
|
||||||
|
|
||||||
return $this->application->health_check_command;
|
return $command;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP type healthcheck (default)
|
// HTTP type healthcheck (default)
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ public function mount()
|
||||||
$this->team = currentTeam()->name;
|
$this->team = currentTeam()->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentTeam = currentTeam();
|
$currentTeam = currentTeam();
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,8 @@ protected function rules(): array
|
||||||
'dockerfile' => 'nullable',
|
'dockerfile' => 'nullable',
|
||||||
'dockerRegistryImageName' => 'nullable',
|
'dockerRegistryImageName' => 'nullable',
|
||||||
'dockerRegistryImageTag' => 'nullable',
|
'dockerRegistryImageTag' => 'nullable',
|
||||||
'dockerfileLocation' => 'nullable',
|
'dockerfileLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||||
'dockerComposeLocation' => 'nullable',
|
'dockerComposeLocation' => ['nullable', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||||
'dockerCompose' => 'nullable',
|
'dockerCompose' => 'nullable',
|
||||||
'dockerComposeRaw' => 'nullable',
|
'dockerComposeRaw' => 'nullable',
|
||||||
'dockerfileTargetBuild' => 'nullable',
|
'dockerfileTargetBuild' => 'nullable',
|
||||||
|
|
@ -231,6 +231,8 @@ protected function messages(): array
|
||||||
return array_merge(
|
return array_merge(
|
||||||
ValidationPatterns::combinedMessages(),
|
ValidationPatterns::combinedMessages(),
|
||||||
[
|
[
|
||||||
|
'dockerfileLocation.regex' => 'The Dockerfile location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.',
|
||||||
|
'dockerComposeLocation.regex' => 'The Docker Compose location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, and slashes.',
|
||||||
'name.required' => 'The Name field is required.',
|
'name.required' => 'The Name field is required.',
|
||||||
'gitRepository.required' => 'The Git Repository field is required.',
|
'gitRepository.required' => 'The Git Repository field is required.',
|
||||||
'gitBranch.required' => 'The Git Branch field is required.',
|
'gitBranch.required' => 'The Git Branch field is required.',
|
||||||
|
|
|
||||||
|
|
@ -146,12 +146,12 @@ public function syncData(bool $toModel = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
$this->authorize('manageBackups', $this->backup->database);
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,10 @@ public function cleanupDeleted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteBackup($executionId, $password)
|
public function deleteBackup($executionId, $password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||||
|
|
@ -96,7 +96,11 @@ public function deleteBackup($executionId, $password)
|
||||||
$this->refreshBackupExecutions();
|
$this->refreshBackupExecutions();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function download_file($exeuctionId)
|
public function download_file($exeuctionId)
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,12 @@ public function convertToFile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -158,6 +158,8 @@ public function delete($password)
|
||||||
} finally {
|
} finally {
|
||||||
$this->dispatch('refreshStorages');
|
$this->dispatch('refreshStorages');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
|
||||||
|
|
@ -194,13 +194,13 @@ public function refreshFileStorages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteDatabase($password)
|
public function deleteDatabase($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->serviceDatabase);
|
$this->authorize('delete', $this->serviceDatabase);
|
||||||
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->serviceDatabase->delete();
|
$this->serviceDatabase->delete();
|
||||||
|
|
@ -398,13 +398,13 @@ public function instantSaveApplicationAdvanced()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteApplication($password)
|
public function deleteApplication($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->serviceApplication);
|
$this->authorize('delete', $this->serviceApplication);
|
||||||
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->serviceApplication->delete();
|
$this->serviceApplication->delete();
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,21 @@ public function mount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->resource) {
|
if (! $this->resource) {
|
||||||
$this->addError('resource', 'Resource not found.');
|
return 'Resource not found.';
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
if (! empty($selectedActions)) {
|
||||||
|
$this->delete_volumes = in_array('delete_volumes', $selectedActions);
|
||||||
|
$this->delete_connected_networks = in_array('delete_connected_networks', $selectedActions);
|
||||||
|
$this->delete_configurations = in_array('delete_configurations', $selectedActions);
|
||||||
|
$this->docker_cleanup = in_array('docker_cleanup', $selectedActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,11 @@ public function addServer(int $network_id, int $server_id)
|
||||||
$this->dispatch('refresh');
|
$this->dispatch('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeServer(int $network_id, int $server_id, $password)
|
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|
@ -152,6 +152,8 @@ public function removeServer(int $network_id, int $server_id, $password)
|
||||||
$this->loadData();
|
$this->loadData();
|
||||||
$this->dispatch('refresh');
|
$this->dispatch('refresh');
|
||||||
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
|
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,6 @@ class Show extends Component
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public string $task_uuid;
|
public string $task_uuid;
|
||||||
|
|
||||||
public function getListeners()
|
|
||||||
{
|
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null)
|
public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,17 @@ public function submit()
|
||||||
$this->dispatch('success', 'Storage updated successfully');
|
$this->dispatch('success', 'Storage updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->storage->delete();
|
$this->storage->delete();
|
||||||
$this->dispatch('refreshStorages');
|
$this->dispatch('refreshStorages');
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,14 @@ public function mount(string $server_uuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($selectedActions)) {
|
||||||
|
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->server);
|
$this->authorize('delete', $this->server);
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ public function mount()
|
||||||
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
||||||
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
||||||
$this->syncData(false);
|
$this->syncData(false);
|
||||||
|
$this->loadProxyConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncData(bool $toModel = false): void
|
private function syncData(bool $toModel = false): void
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ public function mount(string $server_uuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleTerminal($password)
|
public function toggleTerminal($password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->server);
|
$this->authorize('update', $this->server);
|
||||||
|
|
@ -43,7 +43,7 @@ public function toggleTerminal($password)
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle the terminal setting
|
// Toggle the terminal setting
|
||||||
|
|
@ -55,6 +55,8 @@ public function toggleTerminal($password)
|
||||||
|
|
||||||
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
|
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
|
||||||
$this->dispatch('success', "Terminal access has been {$status}.");
|
$this->dispatch('success', "Terminal access has been {$status}.");
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,22 @@ public function startHetznerServer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function refreshServerMetadata(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->server);
|
||||||
|
$result = $this->server->gatherServerMetadata();
|
||||||
|
if ($result) {
|
||||||
|
$this->server->refresh();
|
||||||
|
$this->dispatch('success', 'Server details refreshed.');
|
||||||
|
} else {
|
||||||
|
$this->dispatch('error', 'Could not fetch server details. Is the server reachable?');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,14 @@ public function getUsers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($id, $password)
|
public function delete($id, $password, $selectedActions = [])
|
||||||
{
|
{
|
||||||
if (! isInstanceAdmin()) {
|
if (! isInstanceAdmin()) {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
return;
|
return 'The provided password is incorrect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! auth()->user()->isInstanceAdmin()) {
|
if (! auth()->user()->isInstanceAdmin()) {
|
||||||
|
|
@ -71,6 +71,8 @@ public function delete($id, $password)
|
||||||
try {
|
try {
|
||||||
$user->delete();
|
$user->delete();
|
||||||
$this->getUsers();
|
$this->getUsers();
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $this->dispatch('error', $e->getMessage());
|
return $this->dispatch('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1163,14 +1163,15 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
||||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||||
} else {
|
} else {
|
||||||
$github_access_token = generateGithubInstallationToken($this->source);
|
$github_access_token = generateGithubInstallationToken($this->source);
|
||||||
|
$encodedToken = rawurlencode($github_access_token);
|
||||||
|
|
||||||
if ($exec_in_docker) {
|
if ($exec_in_docker) {
|
||||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
|
||||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||||
$fullRepoUrl = $repoUrl;
|
$fullRepoUrl = $repoUrl;
|
||||||
} else {
|
} else {
|
||||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}";
|
||||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||||
$fullRepoUrl = $repoUrl;
|
$fullRepoUrl = $repoUrl;
|
||||||
|
|
@ -1189,6 +1190,62 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
||||||
'fullRepoUrl' => $fullRepoUrl,
|
'fullRepoUrl' => $fullRepoUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) {
|
||||||
|
$gitlabSource = $this->source;
|
||||||
|
$private_key = data_get($gitlabSource, 'privateKey.private_key');
|
||||||
|
|
||||||
|
if ($private_key) {
|
||||||
|
$fullRepoUrl = $customRepository;
|
||||||
|
$private_key = base64_encode($private_key);
|
||||||
|
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||||
|
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||||
|
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
|
||||||
|
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands = collect([
|
||||||
|
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||||
|
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||||
|
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$commands = collect([
|
||||||
|
'mkdir -p /root/.ssh',
|
||||||
|
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||||
|
'chmod 600 /root/.ssh/id_rsa',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||||
|
} else {
|
||||||
|
$commands->push($base_command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'commands' => $commands->implode(' && '),
|
||||||
|
'branch' => $branch,
|
||||||
|
'fullRepoUrl' => $fullRepoUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
|
||||||
|
$fullRepoUrl = $customRepository;
|
||||||
|
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||||
|
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||||
|
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||||
|
} else {
|
||||||
|
$commands->push($base_command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'commands' => $commands->implode(' && '),
|
||||||
|
'branch' => $branch,
|
||||||
|
'fullRepoUrl' => $fullRepoUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->deploymentType() === 'deploy_key') {
|
if ($this->deploymentType() === 'deploy_key') {
|
||||||
|
|
@ -1301,13 +1358,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$github_access_token = generateGithubInstallationToken($this->source);
|
$github_access_token = generateGithubInstallationToken($this->source);
|
||||||
|
$encodedToken = rawurlencode($github_access_token);
|
||||||
if ($exec_in_docker) {
|
if ($exec_in_docker) {
|
||||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
|
||||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||||
$fullRepoUrl = $repoUrl;
|
$fullRepoUrl = $repoUrl;
|
||||||
} else {
|
} else {
|
||||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}";
|
||||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||||
$fullRepoUrl = $repoUrl;
|
$fullRepoUrl = $repoUrl;
|
||||||
|
|
@ -1339,6 +1397,77 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
||||||
'fullRepoUrl' => $fullRepoUrl,
|
'fullRepoUrl' => $fullRepoUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) {
|
||||||
|
$gitlabSource = $this->source;
|
||||||
|
$private_key = data_get($gitlabSource, 'privateKey.private_key');
|
||||||
|
|
||||||
|
if ($private_key) {
|
||||||
|
$fullRepoUrl = $customRepository;
|
||||||
|
$private_key = base64_encode($private_key);
|
||||||
|
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||||
|
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||||
|
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||||
|
if ($only_checkout) {
|
||||||
|
$git_clone_command = $git_clone_command_base;
|
||||||
|
} else {
|
||||||
|
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit);
|
||||||
|
}
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands = collect([
|
||||||
|
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||||
|
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||||
|
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$commands = collect([
|
||||||
|
'mkdir -p /root/.ssh',
|
||||||
|
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||||
|
'chmod 600 /root/.ssh/id_rsa',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pull_request_id !== 0) {
|
||||||
|
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||||
|
} else {
|
||||||
|
$commands->push("echo 'Checking out $branch'");
|
||||||
|
}
|
||||||
|
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||||
|
} else {
|
||||||
|
$commands->push($git_clone_command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'commands' => $commands->implode(' && '),
|
||||||
|
'branch' => $branch,
|
||||||
|
'fullRepoUrl' => $fullRepoUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
|
||||||
|
$fullRepoUrl = $customRepository;
|
||||||
|
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||||
|
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||||
|
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||||
|
|
||||||
|
if ($exec_in_docker) {
|
||||||
|
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||||
|
} else {
|
||||||
|
$commands->push($git_clone_command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'commands' => $commands->implode(' && '),
|
||||||
|
'branch' => $branch,
|
||||||
|
'fullRepoUrl' => $fullRepoUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($this->deploymentType() === 'deploy_key') {
|
if ($this->deploymentType() === 'deploy_key') {
|
||||||
$fullRepoUrl = $customRepository;
|
$fullRepoUrl = $customRepository;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Stringable;
|
use Illuminate\Support\Stringable;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
@ -231,6 +232,7 @@ public static function flushIdentityMap(): void
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'proxy' => SchemalessAttributes::class,
|
'proxy' => SchemalessAttributes::class,
|
||||||
'traefik_outdated_info' => 'array',
|
'traefik_outdated_info' => 'array',
|
||||||
|
'server_metadata' => 'array',
|
||||||
'logdrain_axiom_api_key' => 'encrypted',
|
'logdrain_axiom_api_key' => 'encrypted',
|
||||||
'logdrain_newrelic_license_key' => 'encrypted',
|
'logdrain_newrelic_license_key' => 'encrypted',
|
||||||
'delete_unused_volumes' => 'boolean',
|
'delete_unused_volumes' => 'boolean',
|
||||||
|
|
@ -258,6 +260,7 @@ public static function flushIdentityMap(): void
|
||||||
'is_validating',
|
'is_validating',
|
||||||
'detected_traefik_version',
|
'detected_traefik_version',
|
||||||
'traefik_outdated_info',
|
'traefik_outdated_info',
|
||||||
|
'server_metadata',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
@ -1074,6 +1077,55 @@ public function validateOS(): bool|Stringable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function gatherServerMetadata(): ?array
|
||||||
|
{
|
||||||
|
if (! $this->isFunctional()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$output = instant_remote_process([
|
||||||
|
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s',
|
||||||
|
], $this, false);
|
||||||
|
|
||||||
|
if (! $output) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sections = [];
|
||||||
|
$currentKey = null;
|
||||||
|
foreach (explode("\n", trim($output)) as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (preg_match('/^---(\w+)---$/', $line, $m)) {
|
||||||
|
$currentKey = $m[1];
|
||||||
|
} elseif ($currentKey) {
|
||||||
|
$sections[$currentKey] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'os' => $sections['PRETTY_NAME'] ?? 'Unknown',
|
||||||
|
'arch' => $sections['ARCH'] ?? 'Unknown',
|
||||||
|
'kernel' => $sections['KERNEL'] ?? 'Unknown',
|
||||||
|
'cpus' => (int) ($sections['CPUS'] ?? 0),
|
||||||
|
'memory_bytes' => (int) ($sections['MEMORY'] ?? 0),
|
||||||
|
'uptime_since' => $sections['UPTIME_SINCE'] ?? null,
|
||||||
|
'collected_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->update(['server_metadata' => $metadata]);
|
||||||
|
|
||||||
|
return $metadata;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::debug('Failed to gather server metadata', [
|
||||||
|
'server_id' => $this->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function isTerminalEnabled()
|
public function isTerminalEnabled()
|
||||||
{
|
{
|
||||||
return $this->settings->is_terminal_enabled ?? false;
|
return $this->settings->is_terminal_enabled ?? false;
|
||||||
|
|
|
||||||
|
|
@ -1875,8 +1875,9 @@ function serviceParser(Service $resource): Collection
|
||||||
$serviceExists->fqdn = $url;
|
$serviceExists->fqdn = $url;
|
||||||
$serviceExists->save();
|
$serviceExists->save();
|
||||||
}
|
}
|
||||||
// Create FQDN variable
|
// Create FQDN variable (use firstOrCreate to avoid overwriting values
|
||||||
$resource->environment_variables()->updateOrCreate([
|
// already set by direct template declarations or updateCompose)
|
||||||
|
$resource->environment_variables()->firstOrCreate([
|
||||||
'key' => $key->value(),
|
'key' => $key->value(),
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
|
|
@ -1888,7 +1889,7 @@ function serviceParser(Service $resource): Collection
|
||||||
|
|
||||||
// Also create the paired SERVICE_URL_* variable
|
// Also create the paired SERVICE_URL_* variable
|
||||||
$urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
|
$urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
|
||||||
$resource->environment_variables()->updateOrCreate([
|
$resource->environment_variables()->firstOrCreate([
|
||||||
'key' => $urlKey,
|
'key' => $urlKey,
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
|
|
@ -1918,8 +1919,9 @@ function serviceParser(Service $resource): Collection
|
||||||
$serviceExists->fqdn = $url;
|
$serviceExists->fqdn = $url;
|
||||||
$serviceExists->save();
|
$serviceExists->save();
|
||||||
}
|
}
|
||||||
// Create URL variable
|
// Create URL variable (use firstOrCreate to avoid overwriting values
|
||||||
$resource->environment_variables()->updateOrCreate([
|
// already set by direct template declarations or updateCompose)
|
||||||
|
$resource->environment_variables()->firstOrCreate([
|
||||||
'key' => $key->value(),
|
'key' => $key->value(),
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
|
|
@ -1931,7 +1933,7 @@ function serviceParser(Service $resource): Collection
|
||||||
|
|
||||||
// Also create the paired SERVICE_FQDN_* variable
|
// Also create the paired SERVICE_FQDN_* variable
|
||||||
$fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
|
$fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
|
||||||
$resource->environment_variables()->updateOrCreate([
|
$resource->environment_variables()->firstOrCreate([
|
||||||
'key' => $fqdnKey,
|
'key' => $fqdnKey,
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use App\Enums\ProxyTypes;
|
use App\Enums\ProxyTypes;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
|
||||||
}
|
}
|
||||||
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
|
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
|
||||||
{
|
{
|
||||||
|
Log::info('Generating default proxy configuration', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'custom_commands_count' => count($custom_commands),
|
||||||
|
'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
$proxy_path = $server->proxyPath();
|
$proxy_path = $server->proxyPath();
|
||||||
$proxy_type = $server->proxyType();
|
$proxy_type = $server->proxyType();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,9 +275,9 @@ function remove_iip($text)
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
|
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
|
||||||
|
|
||||||
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.)
|
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.)
|
||||||
// (protocol://user:password@host → protocol://user:<REDACTED>@host)
|
// (protocol://user:password@host → protocol://user:<REDACTED>@host)
|
||||||
$text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text);
|
$text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text);
|
||||||
|
|
||||||
// Email addresses
|
// Email addresses
|
||||||
$text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text);
|
$text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use App\Models\EnvironmentVariable;
|
use App\Models\EnvironmentVariable;
|
||||||
use App\Models\GithubApp;
|
use App\Models\GithubApp;
|
||||||
|
use App\Models\GitlabApp;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\LocalFileVolume;
|
use App\Models\LocalFileVolume;
|
||||||
use App\Models\LocalPersistentVolume;
|
use App\Models\LocalPersistentVolume;
|
||||||
|
|
@ -3522,7 +3523,7 @@ function defaultNginxConfiguration(string $type = 'static'): string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array
|
function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array
|
||||||
{
|
{
|
||||||
$repository = $gitRepository;
|
$repository = $gitRepository;
|
||||||
$providerInfo = [
|
$providerInfo = [
|
||||||
|
|
@ -3542,6 +3543,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp
|
||||||
// Let's try and fix that for known Git providers
|
// Let's try and fix that for known Git providers
|
||||||
switch ($source->getMorphClass()) {
|
switch ($source->getMorphClass()) {
|
||||||
case \App\Models\GithubApp::class:
|
case \App\Models\GithubApp::class:
|
||||||
|
case \App\Models\GitlabApp::class:
|
||||||
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
|
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
|
||||||
$providerInfo['port'] = $source->custom_port;
|
$providerInfo['port'] = $source->custom_port;
|
||||||
$providerInfo['user'] = $source->custom_user;
|
$providerInfo['user'] = $source->custom_user;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'coolify' => [
|
'coolify' => [
|
||||||
'version' => '4.0.0-beta.466',
|
'version' => '4.0.0-beta.467',
|
||||||
'helper_version' => '1.0.12',
|
'helper_version' => '1.0.12',
|
||||||
'realtime_version' => '1.0.11',
|
'realtime_version' => '1.0.11',
|
||||||
'self_hosted' => env('SELF_HOSTED', true),
|
'self_hosted' => env('SELF_HOSTED', true),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('servers', 'server_metadata')) {
|
||||||
|
Schema::table('servers', function (Blueprint $table) {
|
||||||
|
$table->json('server_metadata')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('servers', 'server_metadata')) {
|
||||||
|
Schema::table('servers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('server_metadata');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\GithubApp;
|
use App\Models\GithubApp;
|
||||||
|
use App\Models\GitlabApp;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
|
@ -98,5 +99,36 @@ public function run(): void
|
||||||
CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"]
|
CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"]
|
||||||
',
|
',
|
||||||
]);
|
]);
|
||||||
|
Application::create([
|
||||||
|
'uuid' => 'gitlab-deploy-key',
|
||||||
|
'name' => 'GitLab Deploy Key Example',
|
||||||
|
'fqdn' => 'http://gitlab-deploy-key.127.0.0.1.sslip.io',
|
||||||
|
'git_repository' => 'git@gitlab.com:coollabsio/php-example.git',
|
||||||
|
'git_branch' => 'main',
|
||||||
|
'build_pack' => 'nixpacks',
|
||||||
|
'ports_exposes' => '80',
|
||||||
|
'environment_id' => 1,
|
||||||
|
'destination_id' => 0,
|
||||||
|
'destination_type' => StandaloneDocker::class,
|
||||||
|
'source_id' => 1,
|
||||||
|
'source_type' => GitlabApp::class,
|
||||||
|
'private_key_id' => 1,
|
||||||
|
]);
|
||||||
|
Application::create([
|
||||||
|
'uuid' => 'gitlab-public-example',
|
||||||
|
'name' => 'GitLab Public Example',
|
||||||
|
'fqdn' => 'http://gitlab-public.127.0.0.1.sslip.io',
|
||||||
|
'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git',
|
||||||
|
'base_directory' => '/astro/static',
|
||||||
|
'publish_directory' => '/dist',
|
||||||
|
'git_branch' => 'main',
|
||||||
|
'build_pack' => 'nixpacks',
|
||||||
|
'ports_exposes' => '80',
|
||||||
|
'environment_id' => 1,
|
||||||
|
'destination_id' => 0,
|
||||||
|
'destination_type' => StandaloneDocker::class,
|
||||||
|
'source_id' => 1,
|
||||||
|
'source_type' => GitlabApp::class,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,12 @@ public function run(): void
|
||||||
$application_1 = Application::find(1)->load(['settings']);
|
$application_1 = Application::find(1)->load(['settings']);
|
||||||
$application_1->settings->is_debug_enabled = false;
|
$application_1->settings->is_debug_enabled = false;
|
||||||
$application_1->settings->save();
|
$application_1->settings->save();
|
||||||
|
|
||||||
|
$gitlabPublic = Application::where('uuid', 'gitlab-public-example')->first();
|
||||||
|
if ($gitlabPublic) {
|
||||||
|
$gitlabPublic->load(['settings']);
|
||||||
|
$gitlabPublic->settings->is_static = true;
|
||||||
|
$gitlabPublic->settings->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
openapi.json
27
openapi.json
|
|
@ -3339,6 +3339,15 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "docker_cleanup",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Perform docker cleanup (prune networks, volumes, etc.).",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -5864,6 +5873,15 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "docker_cleanup",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Perform docker cleanup (prune networks, volumes, etc.).",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -10561,6 +10579,15 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "docker_cleanup",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Perform docker cleanup (prune networks, volumes, etc.).",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
21
openapi.yaml
21
openapi.yaml
|
|
@ -2111,6 +2111,13 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
-
|
||||||
|
name: docker_cleanup
|
||||||
|
in: query
|
||||||
|
description: 'Perform docker cleanup (prune networks, volumes, etc.).'
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 'Stop application.'
|
description: 'Stop application.'
|
||||||
|
|
@ -3806,6 +3813,13 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
-
|
||||||
|
name: docker_cleanup
|
||||||
|
in: query
|
||||||
|
description: 'Perform docker cleanup (prune networks, volumes, etc.).'
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 'Stop database.'
|
description: 'Stop database.'
|
||||||
|
|
@ -6645,6 +6659,13 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
-
|
||||||
|
name: docker_cleanup
|
||||||
|
in: query
|
||||||
|
description: 'Perform docker cleanup (prune networks, volumes, etc.).'
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 'Stop service.'
|
description: 'Stop service.'
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
{
|
{
|
||||||
"coolify": {
|
"coolify": {
|
||||||
"v4": {
|
"v4": {
|
||||||
"version": "4.0.0-beta.463"
|
"version": "4.0.0-beta.467"
|
||||||
},
|
},
|
||||||
"nightly": {
|
"nightly": {
|
||||||
"version": "4.0.0-beta.464"
|
"version": "4.0.0-beta.468"
|
||||||
},
|
},
|
||||||
"helper": {
|
"helper": {
|
||||||
"version": "1.0.12"
|
"version": "1.0.12"
|
||||||
},
|
},
|
||||||
"realtime": {
|
"realtime": {
|
||||||
"version": "1.0.10"
|
"version": "1.0.11"
|
||||||
},
|
},
|
||||||
"sentinel": {
|
"sentinel": {
|
||||||
"version": "0.0.18"
|
"version": "0.0.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"traefik": {
|
"traefik": {
|
||||||
|
|
@ -26,4 +26,4 @@
|
||||||
"v3.0": "3.0.4",
|
"v3.0": "3.0.4",
|
||||||
"v2.11": "2.11.32"
|
"v2.11": "2.11.32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
@php use App\Enums\ProxyTypes; @endphp
|
@php use App\Enums\ProxyTypes; @endphp
|
||||||
<div>
|
<div>
|
||||||
@if ($server->proxyType())
|
@if ($server->proxyType())
|
||||||
<div x-init="$wire.loadProxyConfiguration">
|
<div>
|
||||||
@if ($selectedProxy !== 'NONE')
|
@if ($selectedProxy !== 'NONE')
|
||||||
<form wire:submit='submit'>
|
<form wire:submit='submit'>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -55,24 +55,19 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3>{{ $proxyTitle }}</h3>
|
<h3>{{ $proxyTitle }}</h3>
|
||||||
@can('update', $server)
|
@can('update', $server)
|
||||||
<div wire:loading wire:target="loadProxyConfiguration">
|
@if ($proxySettings)
|
||||||
<x-forms.button disabled>Reset Configuration</x-forms.button>
|
<x-modal-confirmation title="Reset Proxy Configuration?"
|
||||||
</div>
|
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
|
||||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
:actions="[
|
||||||
@if ($proxySettings)
|
'Reset proxy configuration to default settings',
|
||||||
<x-modal-confirmation title="Reset Proxy Configuration?"
|
'All custom configurations will be lost',
|
||||||
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
|
'Custom ports and entrypoints will be removed',
|
||||||
:actions="[
|
]" confirmationText="{{ $server->name }}"
|
||||||
'Reset proxy configuration to default settings',
|
confirmationLabel="Please confirm by entering the server name below"
|
||||||
'All custom configurations will be lost',
|
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
|
||||||
'Custom ports and entrypoints will be removed',
|
:confirmWithPassword="false" :confirmWithText="true">
|
||||||
]" confirmationText="{{ $server->name }}"
|
</x-modal-confirmation>
|
||||||
confirmationLabel="Please confirm by entering the server name below"
|
@endif
|
||||||
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
|
|
||||||
:confirmWithPassword="false" :confirmWithText="true">
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endcan
|
@endcan
|
||||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||||
<button type="button" x-show="traefikWarningsDismissed"
|
<button type="button" x-show="traefikWarningsDismissed"
|
||||||
|
|
@ -134,19 +129,14 @@ class="underline text-white">Traefik changelog</a> to understand breaking change
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div wire:loading wire:target="loadProxyConfiguration" class="pt-4">
|
@if ($proxySettings)
|
||||||
<x-loading text="Loading proxy configuration..." />
|
<div class="flex flex-col gap-2 pt-2">
|
||||||
</div>
|
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
|
||||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
monacoEditorLanguage="yaml"
|
||||||
@if ($proxySettings)
|
label="Configuration file ( {{ $this->configurationFilePath }} )"
|
||||||
<div class="flex flex-col gap-2 pt-2">
|
name="proxySettings" id="proxySettings" rows="30" />
|
||||||
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
|
</div>
|
||||||
monacoEditorLanguage="yaml"
|
@endif
|
||||||
label="Configuration file ( {{ $this->configurationFilePath }} )"
|
|
||||||
name="proxySettings" id="proxySettings" rows="30" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
@elseif($selectedProxy === 'NONE')
|
@elseif($selectedProxy === 'NONE')
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,58 @@ class="w-full input opacity-50 cursor-not-allowed"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@if ($server->isFunctional())
|
||||||
|
<div class="pt-6">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<h3>Server Details</h3>
|
||||||
|
@if ($server->server_metadata)
|
||||||
|
<button wire:click="refreshServerMetadata" wire:loading.attr="disabled"
|
||||||
|
wire:target="refreshServerMetadata" title="Refresh server details"
|
||||||
|
class="dark:hover:fill-white fill-black dark:fill-warning">
|
||||||
|
<svg wire:loading.remove wire:target="refreshServerMetadata" class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
||||||
|
</svg>
|
||||||
|
<svg wire:loading wire:target="refreshServerMetadata" class="w-4 h-4 animate-spin"
|
||||||
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if ($server->server_metadata)
|
||||||
|
@php $meta = $server->server_metadata; @endphp
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm lg:grid-cols-3">
|
||||||
|
<div><span class="font-medium dark:text-neutral-400">OS:</span>
|
||||||
|
{{ $meta['os'] ?? 'N/A' }}</div>
|
||||||
|
<div><span class="font-medium dark:text-neutral-400">Arch:</span>
|
||||||
|
{{ $meta['arch'] ?? 'N/A' }}</div>
|
||||||
|
<div><span class="font-medium dark:text-neutral-400">Kernel:</span>
|
||||||
|
{{ $meta['kernel'] ?? 'N/A' }}</div>
|
||||||
|
<div><span class="font-medium dark:text-neutral-400">CPU Cores:</span>
|
||||||
|
{{ $meta['cpus'] ?? 'N/A' }}</div>
|
||||||
|
<div><span class="font-medium dark:text-neutral-400">RAM:</span>
|
||||||
|
{{ isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A' }}
|
||||||
|
</div>
|
||||||
|
<div><span class="font-medium dark:text-neutral-400">Up Since:</span>
|
||||||
|
{{ $meta['uptime_since'] ?? 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
@if (isset($meta['collected_at']))
|
||||||
|
<p class="mt-2 text-xs dark:text-neutral-500">Last updated:
|
||||||
|
{{ \Carbon\Carbon::parse($meta['collected_at'])->diffForHumans() }}</p>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<x-forms.button wire:click="refreshServerMetadata" canGate="update"
|
||||||
|
:canResource="$server">
|
||||||
|
<span wire:loading.remove wire:target="refreshServerMetadata">Fetch Server
|
||||||
|
Details</span>
|
||||||
|
<span wire:loading wire:target="refreshServerMetadata">Fetching...</span>
|
||||||
|
</x-forms.button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||||
<div class="pt-6">
|
<div class="pt-6">
|
||||||
<h3>Link to Hetzner Cloud</h3>
|
<h3>Link to Hetzner Cloud</h3>
|
||||||
|
|
|
||||||
96
tests/Feature/ServerMetadataTest.php
Normal file
96
tests/Feature/ServerMetadataTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$user->teams()->attach($this->team);
|
||||||
|
$this->actingAs($user);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
|
$this->server = Server::factory()->create([
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('casts server_metadata as array', function () {
|
||||||
|
$metadata = [
|
||||||
|
'os' => 'Ubuntu 22.04.3 LTS',
|
||||||
|
'arch' => 'x86_64',
|
||||||
|
'kernel' => '5.15.0-91-generic',
|
||||||
|
'cpus' => 4,
|
||||||
|
'memory_bytes' => 8589934592,
|
||||||
|
'uptime_since' => '2024-01-15 10:30:00',
|
||||||
|
'collected_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->server->update(['server_metadata' => $metadata]);
|
||||||
|
$this->server->refresh();
|
||||||
|
|
||||||
|
expect($this->server->server_metadata)->toBeArray()
|
||||||
|
->and($this->server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS')
|
||||||
|
->and($this->server->server_metadata['cpus'])->toBe(4)
|
||||||
|
->and($this->server->server_metadata['memory_bytes'])->toBe(8589934592);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores null server_metadata by default', function () {
|
||||||
|
expect($this->server->server_metadata)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes server_metadata in fillable', function () {
|
||||||
|
$this->server->fill(['server_metadata' => ['os' => 'Test']]);
|
||||||
|
|
||||||
|
expect($this->server->server_metadata)->toBe(['os' => 'Test']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists and retrieves full server metadata structure', function () {
|
||||||
|
$metadata = [
|
||||||
|
'os' => 'Debian GNU/Linux 12 (bookworm)',
|
||||||
|
'arch' => 'aarch64',
|
||||||
|
'kernel' => '6.1.0-17-arm64',
|
||||||
|
'cpus' => 8,
|
||||||
|
'memory_bytes' => 17179869184,
|
||||||
|
'uptime_since' => '2024-03-01 08:00:00',
|
||||||
|
'collected_at' => '2024-03-10T12:00:00+00:00',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->server->update(['server_metadata' => $metadata]);
|
||||||
|
$this->server->refresh();
|
||||||
|
|
||||||
|
expect($this->server->server_metadata)
|
||||||
|
->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at'])
|
||||||
|
->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)')
|
||||||
|
->and($this->server->server_metadata['arch'])->toBe('aarch64')
|
||||||
|
->and($this->server->server_metadata['cpus'])->toBe(8)
|
||||||
|
->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null from gatherServerMetadata when server is not functional', function () {
|
||||||
|
$this->server->settings->update([
|
||||||
|
'is_reachable' => false,
|
||||||
|
'is_usable' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->server->refresh();
|
||||||
|
|
||||||
|
expect($this->server->gatherServerMetadata())->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can overwrite server_metadata with new values', function () {
|
||||||
|
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]);
|
||||||
|
$this->server->refresh();
|
||||||
|
|
||||||
|
expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04');
|
||||||
|
|
||||||
|
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]);
|
||||||
|
$this->server->refresh();
|
||||||
|
|
||||||
|
expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04')
|
||||||
|
->and($this->server->server_metadata['cpus'])->toBe(4);
|
||||||
|
});
|
||||||
171
tests/Feature/ServiceMagicVariableOverwriteTest.php
Normal file
171
tests/Feature/ServiceMagicVariableOverwriteTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature tests to verify that magic (referenced) SERVICE_URL_/SERVICE_FQDN_
|
||||||
|
* variables do not overwrite values set by direct template declarations or updateCompose().
|
||||||
|
*
|
||||||
|
* This tests the fix for GitHub issue #8912 where generic SERVICE_URL and SERVICE_FQDN
|
||||||
|
* variables remained stale after changing a service domain in the UI, while
|
||||||
|
* port-specific variants updated correctly.
|
||||||
|
*
|
||||||
|
* Root cause: The magic variables section in serviceParser() used updateOrCreate()
|
||||||
|
* which overwrote values from direct template declarations with auto-generated FQDNs.
|
||||||
|
* Fix: Changed to firstOrCreate() so magic references don't overwrite existing values.
|
||||||
|
*
|
||||||
|
* IMPORTANT: These tests require database access and must be run inside Docker:
|
||||||
|
* docker exec coolify php artisan test --filter ServiceMagicVariableOverwriteTest
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceApplication;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('generic SERVICE_URL/FQDN vars update after domain change when referenced by other services', function () {
|
||||||
|
$server = Server::factory()->create([
|
||||||
|
'name' => 'test-server',
|
||||||
|
'ip' => '127.0.0.1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Compose template where:
|
||||||
|
// - nginx directly declares SERVICE_FQDN_NGINX_8080 (Section 1)
|
||||||
|
// - backend references ${SERVICE_URL_NGINX} and ${SERVICE_FQDN_NGINX} (Section 2 - magic)
|
||||||
|
$template = <<<'YAML'
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
environment:
|
||||||
|
- SERVICE_FQDN_NGINX_8080
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
backend:
|
||||||
|
image: node:20-alpine
|
||||||
|
environment:
|
||||||
|
- PUBLIC_URL=${SERVICE_URL_NGINX}
|
||||||
|
- PUBLIC_FQDN=${SERVICE_FQDN_NGINX}
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'name' => 'test-service',
|
||||||
|
'docker_compose_raw' => $template,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serviceApp = ServiceApplication::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'name' => 'nginx',
|
||||||
|
'fqdn' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Initial parse - generates auto FQDNs
|
||||||
|
$service->parse();
|
||||||
|
|
||||||
|
$baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first();
|
||||||
|
$baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first();
|
||||||
|
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first();
|
||||||
|
$portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first();
|
||||||
|
|
||||||
|
// All four variables should exist after initial parse
|
||||||
|
expect($baseUrl)->not->toBeNull('SERVICE_URL_NGINX should exist');
|
||||||
|
expect($baseFqdn)->not->toBeNull('SERVICE_FQDN_NGINX should exist');
|
||||||
|
expect($portUrl)->not->toBeNull('SERVICE_URL_NGINX_8080 should exist');
|
||||||
|
expect($portFqdn)->not->toBeNull('SERVICE_FQDN_NGINX_8080 should exist');
|
||||||
|
|
||||||
|
// Now simulate user changing domain via UI (EditDomain::submit flow)
|
||||||
|
$serviceApp->fqdn = 'https://my-nginx.example.com:8080';
|
||||||
|
$serviceApp->save();
|
||||||
|
|
||||||
|
// updateCompose() runs first (sets correct values)
|
||||||
|
updateCompose($serviceApp);
|
||||||
|
|
||||||
|
// Then parse() runs (should NOT overwrite the correct values)
|
||||||
|
$service->parse();
|
||||||
|
|
||||||
|
// Reload all variables
|
||||||
|
$baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first();
|
||||||
|
$baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first();
|
||||||
|
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first();
|
||||||
|
$portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first();
|
||||||
|
|
||||||
|
// ALL variables should reflect the custom domain
|
||||||
|
expect($baseUrl->value)->toBe('https://my-nginx.example.com')
|
||||||
|
->and($baseFqdn->value)->toBe('my-nginx.example.com')
|
||||||
|
->and($portUrl->value)->toBe('https://my-nginx.example.com:8080')
|
||||||
|
->and($portFqdn->value)->toBe('my-nginx.example.com:8080');
|
||||||
|
})->skip('Requires database - run in Docker');
|
||||||
|
|
||||||
|
test('magic variable references do not overwrite direct template declarations on initial parse', function () {
|
||||||
|
$server = Server::factory()->create([
|
||||||
|
'name' => 'test-server',
|
||||||
|
'ip' => '127.0.0.1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Backend references the port-specific variable via magic syntax
|
||||||
|
$template = <<<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: nginx:latest
|
||||||
|
environment:
|
||||||
|
- SERVICE_FQDN_APP_3000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
worker:
|
||||||
|
image: node:20-alpine
|
||||||
|
environment:
|
||||||
|
- API_URL=${SERVICE_URL_APP_3000}
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'name' => 'test-service',
|
||||||
|
'docker_compose_raw' => $template,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceApplication::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'name' => 'app',
|
||||||
|
'fqdn' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse the service
|
||||||
|
$service->parse();
|
||||||
|
|
||||||
|
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_APP_3000')->first();
|
||||||
|
$portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_APP_3000')->first();
|
||||||
|
|
||||||
|
// Port-specific vars should have port as a URL port suffix (:3000),
|
||||||
|
// NOT baked into the subdomain (app-3000-uuid.sslip.io)
|
||||||
|
expect($portUrl)->not->toBeNull();
|
||||||
|
expect($portFqdn)->not->toBeNull();
|
||||||
|
expect($portUrl->value)->toContain(':3000');
|
||||||
|
// The domain should NOT have 3000 in the subdomain
|
||||||
|
$urlWithoutPort = str($portUrl->value)->before(':3000')->value();
|
||||||
|
expect($urlWithoutPort)->not->toContain('3000');
|
||||||
|
})->skip('Requires database - run in Docker');
|
||||||
|
|
||||||
|
test('parsers.php uses firstOrCreate for magic variable references', function () {
|
||||||
|
$parsersFile = file_get_contents(base_path('bootstrap/helpers/parsers.php'));
|
||||||
|
|
||||||
|
// Find the magic variables section (Section 2) which processes ${SERVICE_*} references
|
||||||
|
// It should use firstOrCreate, not updateOrCreate, to avoid overwriting values
|
||||||
|
// set by direct template declarations (Section 1) or updateCompose()
|
||||||
|
|
||||||
|
// Look for the specific pattern: the magic variables section creates FQDN and URL pairs
|
||||||
|
// after the "Also create the paired SERVICE_URL_*" and "Also create the paired SERVICE_FQDN_*" comments
|
||||||
|
|
||||||
|
// Extract the magic variables section (between "$magicEnvironments->count()" and the end of the foreach)
|
||||||
|
$magicSectionStart = strpos($parsersFile, '$magicEnvironments->count() > 0');
|
||||||
|
expect($magicSectionStart)->not->toBeFalse('Magic variables section should exist');
|
||||||
|
|
||||||
|
$magicSection = substr($parsersFile, $magicSectionStart, 5000);
|
||||||
|
|
||||||
|
// Count updateOrCreate vs firstOrCreate in the magic section
|
||||||
|
$updateOrCreateCount = substr_count($magicSection, 'updateOrCreate');
|
||||||
|
$firstOrCreateCount = substr_count($magicSection, 'firstOrCreate');
|
||||||
|
|
||||||
|
// Magic section should use firstOrCreate for SERVICE_URL/FQDN variables
|
||||||
|
expect($firstOrCreateCount)->toBeGreaterThanOrEqual(4, 'Magic variables section should use firstOrCreate for SERVICE_URL/FQDN pairs')
|
||||||
|
->and($updateOrCreateCount)->toBe(0, 'Magic variables section should not use updateOrCreate for SERVICE_URL/FQDN variables');
|
||||||
|
});
|
||||||
|
|
@ -236,6 +236,48 @@
|
||||||
expect($envArgs)->toBe('');
|
expect($envArgs)->toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters out null coolify env variables from env_args used in nixpacks plan JSON', function () {
|
||||||
|
// This test verifies the fix for GitHub issue #6830:
|
||||||
|
// When application->fqdn is null, COOLIFY_FQDN/COOLIFY_URL get set to null
|
||||||
|
// in generate_coolify_env_variables(). The generate_env_variables() method
|
||||||
|
// merges these into env_args which become the nixpacks plan JSON "variables".
|
||||||
|
// Nixpacks requires all variable values to be strings, so null causes:
|
||||||
|
// "Error: Failed to parse Nixpacks config file - invalid type: null, expected a string"
|
||||||
|
|
||||||
|
// Simulate the coolify env collection with null values (as produced when fqdn is null)
|
||||||
|
$coolify_envs = collect([
|
||||||
|
'COOLIFY_URL' => null,
|
||||||
|
'COOLIFY_FQDN' => null,
|
||||||
|
'COOLIFY_BRANCH' => 'main',
|
||||||
|
'COOLIFY_RESOURCE_UUID' => 'abc123',
|
||||||
|
'COOLIFY_CONTAINER_NAME' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Apply the same filtering logic used in generate_env_variables()
|
||||||
|
$env_args = collect([]);
|
||||||
|
$coolify_envs->each(function ($value, $key) use ($env_args) {
|
||||||
|
if (! is_null($value) && $value !== '') {
|
||||||
|
$env_args->put($key, $value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Null values must NOT be present — they cause nixpacks JSON parse errors
|
||||||
|
expect($env_args->has('COOLIFY_URL'))->toBeFalse();
|
||||||
|
expect($env_args->has('COOLIFY_FQDN'))->toBeFalse();
|
||||||
|
expect($env_args->has('COOLIFY_CONTAINER_NAME'))->toBeFalse();
|
||||||
|
|
||||||
|
// Non-null values must be preserved
|
||||||
|
expect($env_args->get('COOLIFY_BRANCH'))->toBe('main');
|
||||||
|
expect($env_args->get('COOLIFY_RESOURCE_UUID'))->toBe('abc123');
|
||||||
|
|
||||||
|
// The resulting array must be safe for json_encode into nixpacks config
|
||||||
|
$json = json_encode(['variables' => $env_args->toArray()], JSON_PRETTY_PRINT);
|
||||||
|
$parsed = json_decode($json, true);
|
||||||
|
foreach ($parsed['variables'] as $value) {
|
||||||
|
expect($value)->toBeString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves environment variables with zero values', function () {
|
it('preserves environment variables with zero values', function () {
|
||||||
// Mock application with nixpacks build pack
|
// Mock application with nixpacks build pack
|
||||||
$mockApplication = Mockery::mock(Application::class);
|
$mockApplication = Mockery::mock(Application::class);
|
||||||
|
|
|
||||||
91
tests/Unit/GitlabSourceCommandsTest.php
Normal file
91
tests/Unit/GitlabSourceCommandsTest.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\GitlabApp;
|
||||||
|
use App\Models\PrivateKey;
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Mockery::close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates ls-remote commands for GitLab source with private key', function () {
|
||||||
|
$deploymentUuid = 'test-deployment-uuid';
|
||||||
|
|
||||||
|
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||||
|
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||||
|
|
||||||
|
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||||
|
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||||
|
|
||||||
|
$application = Mockery::mock(Application::class)->makePartial();
|
||||||
|
$application->git_branch = 'main';
|
||||||
|
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||||
|
$application->shouldReceive('customRepository')->andReturn([
|
||||||
|
'repository' => 'git@gitlab.com:user/repo.git',
|
||||||
|
'port' => 22,
|
||||||
|
]);
|
||||||
|
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||||
|
$application->source = $gitlabSource;
|
||||||
|
|
||||||
|
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||||
|
|
||||||
|
expect($result)->toBeArray();
|
||||||
|
expect($result)->toHaveKey('commands');
|
||||||
|
expect($result['commands'])->toContain('git ls-remote');
|
||||||
|
expect($result['commands'])->toContain('id_rsa');
|
||||||
|
expect($result['commands'])->toContain('mkdir -p /root/.ssh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates ls-remote commands for GitLab source without private key', function () {
|
||||||
|
$deploymentUuid = 'test-deployment-uuid';
|
||||||
|
|
||||||
|
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||||
|
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||||
|
|
||||||
|
$application = Mockery::mock(Application::class)->makePartial();
|
||||||
|
$application->git_branch = 'main';
|
||||||
|
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||||
|
$application->shouldReceive('customRepository')->andReturn([
|
||||||
|
'repository' => 'https://gitlab.com/user/repo.git',
|
||||||
|
'port' => 22,
|
||||||
|
]);
|
||||||
|
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||||
|
$application->source = $gitlabSource;
|
||||||
|
|
||||||
|
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||||
|
|
||||||
|
expect($result)->toBeArray();
|
||||||
|
expect($result)->toHaveKey('commands');
|
||||||
|
expect($result['commands'])->toContain('git ls-remote');
|
||||||
|
expect($result['commands'])->toContain('https://gitlab.com/user/repo.git');
|
||||||
|
// Should NOT contain SSH key setup
|
||||||
|
expect($result['commands'])->not->toContain('id_rsa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return null for GitLab source type', function () {
|
||||||
|
$deploymentUuid = 'test-deployment-uuid';
|
||||||
|
|
||||||
|
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||||
|
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||||
|
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||||
|
|
||||||
|
$application = Mockery::mock(Application::class)->makePartial();
|
||||||
|
$application->git_branch = 'main';
|
||||||
|
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||||
|
$application->shouldReceive('customRepository')->andReturn([
|
||||||
|
'repository' => 'https://gitlab.com/user/repo.git',
|
||||||
|
'port' => 22,
|
||||||
|
]);
|
||||||
|
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||||
|
$application->source = $gitlabSource;
|
||||||
|
|
||||||
|
$lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||||
|
expect($lsRemoteResult)->not->toBeNull();
|
||||||
|
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
|
||||||
|
});
|
||||||
109
tests/Unit/ProxyConfigRecoveryTest.php
Normal file
109
tests/Unit/ProxyConfigRecoveryTest.php
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Proxy\GetProxyConfiguration;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Spatie\SchemalessAttributes\SchemalessAttributes;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Log::spy();
|
||||||
|
Cache::spy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockServerWithDbConfig(?string $savedConfig): object
|
||||||
|
{
|
||||||
|
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
|
||||||
|
$proxyAttributes->shouldReceive('get')
|
||||||
|
->with('last_saved_proxy_configuration')
|
||||||
|
->andReturn($savedConfig);
|
||||||
|
|
||||||
|
$server = Mockery::mock('App\Models\Server');
|
||||||
|
$server->shouldIgnoreMissing();
|
||||||
|
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
|
||||||
|
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||||
|
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
|
||||||
|
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||||
|
$server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy');
|
||||||
|
|
||||||
|
return $server;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns OK for NONE proxy type without reading config', function () {
|
||||||
|
$server = Mockery::mock('App\Models\Server');
|
||||||
|
$server->shouldIgnoreMissing();
|
||||||
|
$server->shouldReceive('proxyType')->andReturn('NONE');
|
||||||
|
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads proxy configuration from database', function () {
|
||||||
|
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||||
|
$server = mockServerWithDbConfig($savedConfig);
|
||||||
|
|
||||||
|
// ProxyDashboardCacheService is called at the end — mock it
|
||||||
|
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||||
|
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe($savedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves full custom config including labels, env vars, and custom commands', function () {
|
||||||
|
$customConfig = <<<'YAML'
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.5
|
||||||
|
command:
|
||||||
|
- '--entrypoints.http.address=:80'
|
||||||
|
- '--metrics.prometheus=true'
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'waf.custom.middleware=true'
|
||||||
|
environment:
|
||||||
|
CF_API_EMAIL: user@example.com
|
||||||
|
CF_API_KEY: secret-key
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$server = mockServerWithDbConfig($customConfig);
|
||||||
|
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe($customConfig)
|
||||||
|
->and($result)->toContain('waf.custom.middleware=true')
|
||||||
|
->and($result)->toContain('CF_API_EMAIL')
|
||||||
|
->and($result)->toContain('metrics.prometheus=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs warning when regenerating defaults', function () {
|
||||||
|
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
// No DB config, no disk config — will try to regenerate
|
||||||
|
$server = mockServerWithDbConfig(null);
|
||||||
|
|
||||||
|
// backfillFromDisk will be called — we need instant_remote_process to return empty
|
||||||
|
// Since it's a global function we can't easily mock it, so test the logging via
|
||||||
|
// the force regenerate path instead
|
||||||
|
try {
|
||||||
|
GetProxyConfiguration::run($server, forceRegenerate: true);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// generateDefaultProxyConfiguration may fail without full server setup
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults'))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read from disk when DB config exists', function () {
|
||||||
|
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||||
|
$server = mockServerWithDbConfig($savedConfig);
|
||||||
|
|
||||||
|
// If disk were read, instant_remote_process would be called.
|
||||||
|
// Since we're not mocking it and the test passes, it proves DB is used.
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe($savedConfig);
|
||||||
|
});
|
||||||
|
|
@ -153,6 +153,22 @@
|
||||||
expect($result)->toContain('aws_secret_access_key='.REDACTED);
|
expect($result)->toContain('aws_secret_access_key='.REDACTED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes HTTPS basic auth passwords from git URLs', function () {
|
||||||
|
$testCases = [
|
||||||
|
'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED,
|
||||||
|
'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED,
|
||||||
|
'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testCases as $input => $notExpected) {
|
||||||
|
$result = sanitizeLogsForExport($input);
|
||||||
|
// The password should be redacted
|
||||||
|
expect($result)->not->toContain('glpat-xxxxxxxxxxxx');
|
||||||
|
expect($result)->not->toContain('my-secret-token');
|
||||||
|
expect($result)->not->toContain('token123');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('removes generic URL passwords', function () {
|
it('removes generic URL passwords', function () {
|
||||||
$testCases = [
|
$testCases = [
|
||||||
'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path',
|
'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path',
|
||||||
|
|
|
||||||
81
tests/v4/Feature/DangerDeleteResourceTest.php
Normal file
81
tests/v4/Feature/DangerDeleteResourceTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Project\Shared\Danger;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
InstanceSettings::create(['id' => 0]);
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create([
|
||||||
|
'password' => Hash::make('test-password'),
|
||||||
|
]);
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->destination = StandaloneDocker::factory()->create([
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'network' => 'test-network-'.fake()->unique()->word(),
|
||||||
|
]);
|
||||||
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||||
|
|
||||||
|
$this->application = Application::factory()->create([
|
||||||
|
'environment_id' => $this->environment->id,
|
||||||
|
'destination_id' => $this->destination->id,
|
||||||
|
'destination_type' => $this->destination->getMorphClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
|
// Bind route parameters so get_route_parameters() works in the Danger component
|
||||||
|
$route = Route::get('/test/{project_uuid}/{environment_uuid}', fn () => '')->name('test.danger');
|
||||||
|
$request = Request::create("/test/{$this->project->uuid}/{$this->environment->uuid}");
|
||||||
|
$route->bind($request);
|
||||||
|
app('router')->setRoutes(app('router')->getRoutes());
|
||||||
|
Route::dispatch($request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete returns error string when password is incorrect', function () {
|
||||||
|
Livewire::test(Danger::class, ['resource' => $this->application])
|
||||||
|
->call('delete', 'wrong-password')
|
||||||
|
->assertReturned('The provided password is incorrect.');
|
||||||
|
|
||||||
|
// Resource should NOT be deleted
|
||||||
|
expect(Application::find($this->application->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete succeeds with correct password and redirects', function () {
|
||||||
|
Livewire::test(Danger::class, ['resource' => $this->application])
|
||||||
|
->call('delete', 'test-password')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
// Resource should be soft-deleted
|
||||||
|
expect(Application::find($this->application->id))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete applies selectedActions from checkbox state', function () {
|
||||||
|
$component = Livewire::test(Danger::class, ['resource' => $this->application])
|
||||||
|
->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']);
|
||||||
|
|
||||||
|
expect($component->get('delete_volumes'))->toBeFalse();
|
||||||
|
expect($component->get('delete_connected_networks'))->toBeFalse();
|
||||||
|
expect($component->get('delete_configurations'))->toBeTrue();
|
||||||
|
expect($component->get('docker_cleanup'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"coolify": {
|
"coolify": {
|
||||||
"v4": {
|
"v4": {
|
||||||
"version": "4.0.0-beta.466"
|
"version": "4.0.0-beta.467"
|
||||||
},
|
},
|
||||||
"nightly": {
|
"nightly": {
|
||||||
"version": "4.0.0-beta.467"
|
"version": "4.0.0-beta.468"
|
||||||
},
|
},
|
||||||
"helper": {
|
"helper": {
|
||||||
"version": "1.0.12"
|
"version": "1.0.12"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue