v4.0.0-beta.467 (#8911)

This commit is contained in:
Andras Bacsai 2026-03-11 18:22:43 +01:00 committed by GitHub
commit ce076817d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1195 additions and 115 deletions

View file

@ -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;
}
} }

View file

@ -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);
} }
} }

View file

@ -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(
[ [

View file

@ -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(
[ [

View file

@ -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(
[ [

View file

@ -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)

View file

@ -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();

View file

@ -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.',

View file

@ -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 {

View file

@ -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)

View file

@ -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()

View file

@ -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();

View file

@ -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 {

View file

@ -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);
} }

View file

@ -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 {

View file

@ -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;
} }
} }

View file

@ -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);

View file

@ -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

View file

@ -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);
} }

View file

@ -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 {

View file

@ -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());
} }

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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();

View file

@ -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);

View file

@ -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;

View file

@ -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),

View file

@ -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');
});
}
}
};

View file

@ -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,
]);
} }
} }

View file

@ -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();
}
} }
} }

View file

@ -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": {

View file

@ -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.'

View file

@ -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"
} }
} }

View file

@ -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">

View file

@ -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>

View 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);
});

View 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');
});

View file

@ -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);

View 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']);
});

View 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);
});

View file

@ -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',

View 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();
});

View file

@ -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"